Skip to content

feat(provider-opencode-go): add OpenCode Go Chat Completions provider worker - #1

Open
faramirezs wants to merge 165 commits into
mainfrom
feat/provider-opencode-go
Open

feat(provider-opencode-go): add OpenCode Go Chat Completions provider worker#1
faramirezs wants to merge 165 commits into
mainfrom
feat/provider-opencode-go

Conversation

@faramirezs

Copy link
Copy Markdown
Owner

What

New Rust worker provider-opencode-go: an LLM provider worker behind
llm-router speaking the OpenCode Go API — Chat Completions
(https://opencode.ai/zen/go/v1/chat/completions), SSE streaming, live model
discovery, auth/error taxonomy, reasoning-effort mapping, tool calling, and
structured output. Registers provider::opencode_go::stream / refresh_models
/ abort with the router, binds identity via registration token (state scope
provider-opencode-go), and reads OPENCODE_GO_API_KEY as credential.

Why

The stack has no way to route chat completions to an OpenCode Go subscription.
The OpenCode Go API is Chat Completions compatible, so the existing
provider-openai worker ports directly — same protocol, same relay/pump
scaffold, same error taxonomy, with the OpenAI-only surfaces (embeddings,
Responses API) dropped as dead code.

How it works

  • Credentials: resolved per request via router::provider::resolve
    (config slice → OPENCODE_GO_API_KEY env on the router → none); sent as
    Authorization: Bearer.
  • Wire / streaming: Chat Completions SSE → AssistantMessageEvent frames
    into a router-owned channel; ping ≥ every 30s of silence; a failed channel
    write (router::abort / caller gone) drops the SSE receiver and aborts the
    in-flight HTTP request. Stream-path tracing::debug for provider-side
    observability.
  • Catalog / discovery: live GET /v1/models supplies bare ids; each is
    enriched from a hardcoded curated metadata table (src/curated.rs) prepared
    from models.dev (2026-08-03) — context window, reasoning support/effort
    levels, tool-call and structured-output capability for the maintainer's
    curated model set (24 models.dev entries + hy3-preview). Ids outside the
    table keep conservative defaults (128K, no thinking, tools on) — same
    pattern as provider-openai's curated.rs.
  • Reasoning: thinking_level maps to the upstream reasoning_effort only
    when the model's curated effort list accepts the level (e.g. grok-4.5
    accepts low/medium/high; deepseek-v4-flash and glm-5.2 accept
    high/max; hy3 accepts none/low/high); toggle-only models and
    unknown ids stream without the field.
  • Registration: self-declares via router::provider::register with
    backoff until acked, re-declares on router::ready; registration_token
    persisted in iii-state (scope provider-opencode-go).

Scope / caveat

  • Port of provider-openai: mechanical renames only in most files; the
    OpenAI-only surface (ApiMode, embeddings, Responses-API event handlers and
    thinking deltas, curated reasoning-fallback ladder, luna guard) is dropped —
    OpenCode Go has no such surface.
  • Not a replacement or rewrite of the Node opencode/ worker —
    different role (CLI wrapper vs provider), no overlap; both install side by
    side.
  • hy3-preview is listed by the live GET /v1/models but the chat endpoint
    currently returns ModelNotFound — an upstream inconsistency; the curated
    row keeps conservative defaults and the provider surfaces the upstream error
    cleanly.
  • External contributor: no Linear ticket — no-ticket label applied.

Repo wiring

  • Root README.md Modules row added for provider-opencode-go
    (alphabetical, between provider-openai and provider-xai)
  • Release wiring: create-tag.yml, release.yml, alpha-release.yml, and
    .github/scripts/discover_changed_workers.py
  • llm-router/README.md reference note (same structure as provider-openai)

Verification

  • cargo fmt --check and cargo clippy --all-targets --all-features -- -D warnings clean
  • cargo test --all-features: 67 pass — 57 lib unit + 2 bin unit + 4
    schema/golden + 4 integration
  • Integration tests run against the real engine (v0.22.0) + llm-router with a
    stubbed upstream: chat stream end-to-end (incl. cache_read usage),
    401 → auth_expired error frame, refresh_models → catalog from the
    curated table, re-declare on router::ready
  • cargo build --release OK
  • Live smoke (real engine + llm-router 1.4.0 + OpenCode Go subscription):
    refresh_models → 25 models; router::models::list → 25 curated entries
    with metadata; router::complete served on glm-5, deepseek-v4-flash
    (incl. thinking_level: highreasoning_effort), and hy3

Test plan

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test — 67 pass
  • III_ENGINE_BIN=$(which iii) cargo test --test integration -- --test-threads=1
  • Live: iii worker add provider-opencode-gorefresh_models
    router::models::listrouter::complete

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

skill-check — worker

0 verified, 60 skipped (no docs/).

Layer Result
structure
vale
render

Three for three. Nicely done.

OpenCode Go Chat Completions provider behind llm-router. Implements the
provider protocol: stream (SSE chunks to AssistantMessageEvent frames),
abort, refresh_models (live GET /v1/models enriched with models.dev
metadata: context window, reasoning efforts, tool/structured-output
capability), and re-declaration on router::ready. Chat Completions wire
format only, max_completion_tokens, strict json_schema structured output,
reasoning_effort low/medium/high for deepseek-/kimi-k2.7- families.

Wired into create-tag/release workflows and the harness worker deps.
README to the provider family structure (Behavior/Tests/Running), manifest
tags+description to the canonical form, release wiring in alpha-release.yml
and discover_changed_workers.py, llm-router README reference note.
Upstream MOT-4335 rewrote all provider identity prompts to teach the live
surface (register_trigger, harness::spawn, orchestrator: true) and stripped
orchestration-process doctrine. The fork-PR merge with the new main runs the
harness prompts sweep over every shipped prompt, which failed on our pre-
rewrite copy ("delegation is one-way" etc.). Absorb the rewritten prompt,
update the register.rs identity assertions, and apply the iii-state -> state
rename.
@faramirezs
faramirezs force-pushed the feat/provider-opencode-go branch from 099ac93 to 20201db Compare August 4, 2026 11:10
@faramirezs faramirezs added the no-ticket PR deliberately has no Linear ticket (bump/typo/CI-only) label Aug 4, 2026
ytallo and others added 21 commits August 4, 2026 08:51
…pulation (iii-hq#689)

* fix(dashboard): filter efficiency sparklines to the comparable cohort

The sparklines summed raw per-scenario averages across every reported
scenario, so a missing report or a newly added scenario moved the line for
structural reasons while the delta chip on the same card honestly compared
only the comparable cohort. Sum the same cohort the chip uses and skip
executions that lack any cohort contract instead of fabricating a dip.

* fix(dashboard): make efficiency cards read one coherent population

The stat tiles mixed three quantities: the headline summed every reported
scenario, the delta chip compared only the comparable cohort, and the
sparkline plotted raw sums, so the three elements of one card could
contradict each other. Value, delta, and trend now all read the comparable
cohort; the sparkline gains a dashed baseline-median reference and
per-point hover values, the delta names its baseline, and the section copy
states the population once.

* feat(dashboard): show the baseline value and enrich sparkline hover

Each efficiency card now prints its baseline median under the delta chip,
the dashed baseline line carries a tooltip explaining the 7-run median, and
each sparkline point's hover shows run id, date, value, and its delta
against the baseline.
* chore: make E2E score advisory

* docs(harness): document empty reports as CI-blocking

has_ci_blocking_failure() also blocks an empty report; state that alongside
hard-gate and technical failures in the CI summary and README so operators
do not misread a missing result as an advisory quality failure.
- iii-permissions.yaml: deny provider::opencode_go::abort (agents must not
  cancel router-owned streams; matches provider-claude-code)
- sse.rs: relay delta.reasoning_content as thinking blocks (the OpenCode Go
  wire emits it, live-verified); bound tool-call index to 64 (malformed
  upstream could grow the vec unboundedly)
- upstream.rs: data_line per SSE spec — accept data: without a space and
  join repeated data: lines instead of silently dropping output
- curated.rs: per-model max_output_tokens from models.dev limit.output;
  4096 stays the unknown-id fallback
- reasoning.rs: Minimal maps to minimal then none (gpt-5.6-luna floor)
- register.rs: log router::ready trigger registration failures
- router_client.rs: narrow module doc claim
- README: repair split provider-openai table row; III_WS_URL -> III_URL
  (code + engine convention); thinking-delta relay note
…ii-hq#693)

The provider-deepseek worker (iii-hq#691) resolves its credential from the
DEEPSEEK_API_KEY env on the router, but the E2E workflows only forwarded
the anthropic/openai/zai secrets, so a deepseek subject or judge could
never discover models in CI. Declare the optional secret in the reusable
workflow, forward it from every caller (including the release pipeline's
E2E gate from iii-hq#692), add the provider to the Rust cache workspaces, and
export it in both run steps.
…le llm-router config UI (iii-hq#695)

* (MOT-4353) feat(console): standalone call cards, trigger/notification cards with terminal tabs

Every function call renders as its own card (accordion group removed);
consecutive call-like cards stack tight. Collapsed headers carry an args
preview; errored calls say 'failed' (gate denials stay verb-less).
Trigger-fired and notification rows share the card language: verb-first
headers, TERMINAL/RAW JSON tabs, registration recovery (harness rows ->
transcript register call, entry-id-exact notification correlation),
CONDITIONS met chip, copy on every JSON pane. RegisterTriggerView renders
gating conditions as an ONLY IF section. Assistant header renamed 'agent'.

* (MOT-4353) feat(llm-router): injectable console config form with env-var key guidance

Console-injected configuration UI (docs/sops/injectable-console-ui.md):
per-provider credential cards with plain-text api_key detection steering
to ${ENV_VAR} references (one-click fix chip, partial-reference warning,
env refs unmasked / secrets masked), console Select for provider pickers,
stream budgets with human-readable echoes, and a system-prompt override
flow that shows the provider-declared prompt. Assets built by build.rs
(esbuild, SKIP_UI_BUILD escape) and embedded in the binary via the shared
iii-console-ui crate.

* (MOT-4353) fix(console): size the worker-config dialog to its content column

1180px dialog around a max-w-3xl form was ~400px of dead margin; 880px
fits the column + padding, and narrow viewports keep the 100vw-2rem cap.
…c stale worker locks (iii-hq#697)

* chore: regenerate provider lock files for llm-router 1.4.1

The llm-router 1.4.1 bump (531b140) updated only the router's own
manifest, leaving every provider worker's Cargo.lock pinning a stale
llm-router path-dependency version (1.4.0, and 1.3.2 for llamacpp/xai).
The Harness E2E build compiles providers with --locked, so the first
E2E run on main after the bump failed before any scenario started:
'cannot update the lock file provider-deepseek/Cargo.lock'.

Sync all nine provider locks; each resolves cleanly under --locked.

* fix(llm-router): gate the console UI behind a default-on feature

llm-router 1.4.1's build.rs shells out to pnpm to bundle the injectable
console UI, so every worker that path-depends on the router library
(all nine providers) started requiring a Node toolchain just to compile
llm_router::types — which broke their CI lint/test and boot-smoke jobs,
whose runners only carry Rust.

Introduce a default-on 'console-ui' feature carrying ui.rs, the
iii-console-ui dependency, and the build.rs bundle step. Providers
depend on the router with default-features = false, dropping pnpm from
their build graphs entirely. The router's own binary is unaffected.

Also sync the lock files left stale by today's version bumps:
the nine provider locks (llm-router 1.4.1) and approval-gate
(harness 1.7.0).
…terface gate (iii-hq#698)

The harness 1.7.0 candidate smoke failed on untyped schemas from
iii-stream — an engine-hosted builtin the harness install enables for
console streaming, which landed in the workers-baseline diff and was
gated as if it were part of the released surface. Engine builtins are
not installable from the registry and their schemas are not this repo's
to fix: exclude them from target-worker resolution.

Also run the smoke's interface-gate scripts from the pipeline ref
instead of the release tag, so a dispatched re-run of an already
published candidate (release SOP § re-run) picks up gate fixes without
re-tagging. On tag pushes both refs coincide, so tag-triggered runs are
unchanged.
…ld (iii-hq#700)

The harness 1.7.1 release died in the deployed-E2E validation packaging
harness-e2e-stack.tar.gz: the registry-mode stack build fills a standard
runner's 14GB of free disk (ENOSPC, reproduced on rerun). Drop the
preinstalled toolchains this job never uses (~30GB: dotnet, Android,
GHC, CodeQL, docker images) before building.
…runner (iii-hq#701)

Registry mode builds only harness/target/release/harness-e2e; nothing
creates the repo-root target/ directory the packaging step writes into
(source mode gets it from the engine install). Both harness release
attempts died on this step: 1.7.1 masked as ENOSPC by the full runner
disk, 1.7.2 as a clean ENOENT once iii-hq#700 freed the space. The registry
E2E path has never executed past this point.
ytallo and others added 30 commits August 12, 2026 11:31
* fix(ci): coerce registry E2E runs input

* fix(ci): decouple E2E validator from source ref

* fix(ci): serialize registry E2E provisioning
… drop the console trigger poll (iii-hq#775)

* (MOT-4401) feat(harness,console): push trigger-binding change events, drop the console trigger poll

Every open console chat tab polled harness::triggers::list on a 5s
interval (each call a full-scope harness::state::list on the state
worker). Replace the poll with a harness::triggers-changed doorbell —
the same pattern iii-hq#452 used to kill the status poll:

- harness: 5th event trigger type; {session_id, timestamp} emitted from
  the four BindingStore CAS primitives (reserve, attach_trigger_id,
  claim_fire, delete_if_unchanged), so every mutation path incl. the
  out-of-turn ones (expiry sweep, GC, other-tab unregister, deferred
  release) rings from one layer.
- console: onTriggersChanged backend hook (shape of onQueuedMessage);
  ChatView swaps the interval for initial fetch + doorbell subscription
  + visibilitychange catch-up. Always-on for the selected conversation,
  unlike the streaming-gated queued subscription.

Version skew is graceful both ways: an old harness parks the unknown
trigger-type registration (console degrades to mount/visibility/post-
unregister refetches); an old console keeps polling a new harness.

* (MOT-4401) fix(console): coalesce trigger refetches, close subscription gaps

Review fixes on iii-hq#775:

- serialRefresh: doorbells arrive at-least-once and burst on rapid fires;
  refetches now serialize behind one in-flight list read with a single
  trailing rerun, so an older snapshot can never resolve after and
  overwrite a newer one. reset() discards in-flight responses on
  conversation switch/unmount (also fixes the pre-existing late-write
  into a switched conversation).
- ChatView subscribes to the doorbell BEFORE the initial snapshot, so a
  mutation in the setup gap rings instead of being missed.
- realOnTriggersChanged reseeds on every 'connected' transition: the SDK
  replays trigger registrations on reconnect but outage-window doorbells
  are gone (same repair TracesV2 uses). The reseed is ordered after the
  SDK's synchronous registration replay, so it also covers the first
  connect.
…q#771)

* feat(code-runner): run untrusted Node.js and Python in-process

One worker exposing `sandbox-code-runner`'s API — `run` /
`register_function` / `teardown` / `inject-guidance` — over two in-process
engines: untrusted JavaScript in deno_core V8 isolates, and untrusted
Python as CPython compiled to WebAssembly inside wasmtime. No microVM and
no /dev/kvm, which is the reason it exists.

The engine internals are two new bus-free libraries, `crates/node-core`
(`iii-node-core`) and `crates/python-core` (`iii-python-core`). Neither
depends on `iii-sdk` at all — not feature-gated, dependency absent — so
neither can collide with the pin the workers carry. `code-runner` owns the
only bus seam and translates core errors into its own taxonomy.

The load-bearing semantic, inherited from `sandbox-code-runner`: a failing
script is a RESPONSE, not an error. A tenant exception returns a resolved
call with `exit_code: 1` and the traceback in `stderr`; errors are reserved
for infrastructure. Host-derived kill signals (`timed_out`, memory,
disk) are checked BEFORE any guest-written byte is parsed, because a tenant
controls every byte under its output directory and could otherwise forge
past an infrastructure kill.

Both languages get a capped private working directory and a guest `iii`
global. Node reaches the host through ops; Python cannot — `python.wasm`
imports one module and exports only `_start` — so its bridge rides
`fd_write`/`fd_read` with sentinel framing, and the host publishes
registered handlers on the guest's behalf. A kept Python runtime persists
its interpreter as well as its files: the wrapper parks on stdin between
calls rather than letting `_start` return, so globals stay bound and
modules stay imported.

Function ids from both engines are claimed in ONE registry, and not under
the bare namespace — that is node's own pre-runtime placeholder owner, and
a python id claimed there is silently reclaimed by node and then aborts the
process from `op_iii_register`'s non-unwinding V8 callback.

366 tests (node-core 217, python-core 86, code-runner 63), fmt and clippy
clean, plus a 24-case e2e suite that runs both languages against a real
engine. Every containment test names the mutation that makes it fail.

* feat(code-runner): injectable console UI for the three ops

Ships two assets into any running console — `code-runner/page.js` over
`console:script` and `code-runner/styles.css` over `console:style` — built
from `ui/` by esbuild and embedded in the binary, so there is nothing to
install and nothing to serve separately. One function-trigger renderer per
op replaces the console's raw-JSON card, which turns `code` into a single
escaped line and buries the verdict.

Ported from `sandbox-code-runner/ui`, which serves the same API, with three
deliberate divergences for this worker's wire:

**`result` gets its own block.** The completion value is the field this
worker's wire adds, and a null one is information rather than an absence
(run.rs never skips it), so it renders explicitly with the engine's return
convention beside it — node code is a function body (`return 2 + 2`),
python code is a module (assign `result`). That mismatch is the usual
reason a call "worked" and came back null, and it was previously invisible.

**No network chip.** `sandbox-code-runner`'s `network` is a real create-time
flag; this worker has no such field because neither engine has any network.
An "off" chip would imply a knob exists, so there is none — asserted by a
test, since the port would otherwise carry it silently.

**In-process wording throughout.** Teardown disposes a V8 isolate or a
CPython interpreter, not a microVM; a python namespace's interpreter is
pinned, so teardown-by-namespace is the only thing that reclaims it.

`runtime_id` is never rendered in full — it is a capability, so it appears
only as a truncated click-to-copy chip, and every other string is filtered
first: stdout, stderr, the error messages that quote it by design, the
submitted source, the completion value, and the `raw json` tab the console
mounts regardless of what a card does.

Adding the UI reopened a process-abort hole, found by probing rather than
assumed: `ConsoleUi` publishes `code-runner::ui-content`, which was not in
`STATIC_IDS` and so was not seeded into the id registry. Because
`register_function` lets a caller choose any namespace — `code-runner::`
included — an unseeded worker id is claimable, and the claim reaches the
SDK's `register_function` on an already-registered id, whose duplicate-id
panic aborts the process from a non-unwinding V8 callback.
`register_function("code-runner::ui-content", …)` returned
`registered: true` before the fix. There is now a `seeded_ids()` that is
strictly larger than `STATIC_IDS`, used by both production and the test
harness so they cannot diverge, and a test that walks every owned id.

72 worker tests (up from 64) and 11 vitest cases; the mutations for the
seeding fix, the result redaction, the null-result block and the
convention hint were each verified to fail their test. fmt, clippy, both CI
validators and biome on `ui/` all clean.

* fix(code-runner): stop showing guests and callers the retired node-engine name

Observed live: an agent wrote Node-idiom `global.counter`, got
`ReferenceError: global is not defined`, and the stack frame said
`[node-engine:eval]` — a worker name that no longer exists on the bus.
Sweeping that defect class off every user-visible surface:

- guidance: name the `global`/`globalThis` trap (`lang: "node"` primes
  the Node mental model, and 'NOT Node' alone didn't transfer), and quote
  the REAL id-clash wire code — `code-runner::invalid_request`, not the
  `id_taken` code this worker's taxonomy never had
- script origins: `[code-runner:eval]`/`:prelude`/`:invoke`/`:namespace`,
  with the definition-error frame matcher renamed in lockstep (mutation
  re-verified: a leaking formatCause fails the test on the NEW names)
- prelude: `iii.shutdown()` now points at `code-runner::teardown` (the
  old message named a function nobody can call), `String(iii)` introduces
  a code-runner host client, registerFunction's http refusal ditto
- default namespace mint: `code-runner::<runtime_id>::` — guests were
  publishing live bus ids under the retired prefix
- error seams: NodeEngineError::message() is now pub, and ops + translate
  surface it instead of Display — callers saw double-coded messages like
  `code-runner::invalid_request: node-engine::id_taken: …`; new test pins
  that re-coded messages never carry the core's own prefix
- tenant log stream: the truncation notice, thread names, tempdir prefix
  and tracing target follow

The core's internal `node-engine::<code>` tags stay: after the seam fix
they reach no user surface, and re-coding is the worker's job.

* docs(code-runner): RUN_DESC no longer claims register_function is node-only

Stale since the python registration path landed; the catalog description
steered agents away from a working feature. Golden regenerated — the one
description string, no schema drift.

* feat(code-runner): register_function carries request/response schemas to the catalog

engine::functions::info showed REQUEST/RESPONSE "any" for every dynamic
registration: the SDK auto-extracts schemas from the handler's types, and a
dynamic handler is Value → Value. The SDK's escape hatch already existed —
RegisterFunction::request_format/response_format override the extraction —
nothing here reached it.

Two optional wire fields, `request_format`/`response_format` (a superset
of sandbox-code-runner's contract, like `result`), threaded to that
builder:

- python: one hop — the host publishes directly through `Engine::register`,
  which gains the two params (IIIEngine, FakeEngine, TestBus follow)
- node: the whole guest chain — wire → `wrap_register` embeds them as JSON
  literals in the generated `__def` → the prelude's options object →
  `op_iii_register` (two new args, "" = absent) → `Engine::register`
- byproduct: guest code gets the same surface for free —
  `iii.registerFunction(id, h, {request_format, response_format})`

One validation rule at both trust boundaries (wire::register::
validate_format, shared so they cannot drift): a JSON OBJECT carrying at
least one schema-defining keyword (an empty object IS the "any" being
replaced), 16 KiB serialized cap. The worker wire checks before anything is
claimed or booted; the op re-checks because the prelude's own check runs on
tenant-replaceable builtins.

The op's redeploy early-return is extended: it skipped the bus write
whenever the DESCRIPTION was absent, which would silently discard formats —
re-registration is a wholesale metadata replacement, never a merge, and the
comment now says so.

Mutations run for real: node arm dropping the fields from core_req → the
both-languages capture test fails; the swap-branch condition left on
description alone → the redeploy-republishes test fails. The final hop
(IIIEngine's builder calls) has no unit seam — the new e2e case covers it
against a live engine by reading functions::info back.

node-core 227 tests (was 217), code-runner 68+7 (was 66+7); goldens
regenerated (new request fields + REGISTER_DESC sentence, no other drift);
guidance, README and worker catalog updated.

* docs(code-runner): say schemas are not enforced; teach both return conventions up front

Two dogfooding findings, both documentation holes:

- request_format/response_format read as a contract but are catalog
  metadata: nothing on this bus validates payloads against them, and an
  agent that reads the schema in functions::info skips its own input
  checks. REGISTER_DESC and the guidance now say so and tell handlers to
  validate.
- the return-vs-result convention was taught only reactively (the null
  card, python's SyntaxError in stderr): the injected guidance mentioned
  python ZERO times, and RUN_DESC never stated either convention, so an
  agent's first python run was written blind. RUN_DESC now names both
  (node = function body, return x; python = module, result = x) and the
  guidance gains its first python clause — lang, conventions, sync iii,
  /work.

Guidance needles pin both clauses; goldens regenerated, descriptions only.

* feat(code-runner): max_result_bytes / max_stream_bytes config knobs

* feat(code-runner): pure output-cap primitives (result marker, stream head+tail)

* feat(code-runner): cap result/stdout/stderr echoes at the source

* feat(code-runner): serve config from the configuration worker (Tier 1)

The committed config.yaml is gone; the configuration worker's code-runner
entry is the authoritative runtime config (--config is a one-time seed).
Output caps and timeouts are load()ed per call from an ArcSwap snapshot the
configuration:updated trigger swaps, so they hot-apply; the engine-structural
fields stay boot-captured and the reload handler warns that they apply at the
next restart. on-config-change is seeded into the runtime-id registry and
denied to agents.

* feat(code-runner): custom console form for the configuration entry

host.configForms.register('code-runner', …) replaces the generic
schema-driven form on the Workers tab: sections carry the reload split
(output caps + timeouts hot-apply on save, runtime/memory/scratch apply at
the next restart), byte fields show KiB/token hints, and the scratch section
computes the worst-case host footprint.

* docs(code-runner): shrink run/register_function catalog descriptions

The injected harness guidance already teaches the full surface; the catalog
description now carries contract essentials only (~1/3 the tokens per read)
and no longer points at other workers — the sandbox-code-runner mentions are
gone from RUN_DESC and the Lang schema doc.

* feat(code-runner): inject_guidance config knob gates the guidance hook

inject_guidance (default true, hot-apply) silences the pre-generate guidance
hook: disabled, the handler answers with the no-op mutation so the harness
prompt is untouched. The hook stays bound — one no-op roundtrip per
generation while off. Toggle ships in the console config form under a new
agent-guidance section.

* test(python-core): load-tolerant margins for the per-turn budget test

each_turn_is_budgeted_on_its_own_timeout gave the first turn 600ms, which a
trivial turn on a slow shared CI runner blows (3/3 CI failures; passes on a
fast local box). First-turn budget 600ms -> 3s and the second turn's sleep
1.5s -> 4s, preserving the discrimination: the sleep still exceeds the
first-turn budget, so a leaked budget still trips the assertion.

* fix(code-runner): address CodeRabbit review findings on PR iii-hq#771

Registration + reload correctness:
- py namespaces track in-flight registrations: a failing registration no
  longer destroys the interpreter a concurrent sibling is still defining
  on (deterministic regression test, mutation-verified), and a register
  racing a teardown errors cleanly instead of panicking on the map entry
- py_handler loads default_timeout_ms per invocation so registered
  handlers honour hot config swaps, per the Manager struct contract
- one post-registration refresh closes the boot window where
  configuration:updated could fire before the trigger existed; refreshes
  serialize fetch->store so an older get cannot overwrite a newer snapshot
- manifest default_config is serialized from the struct (the hand list
  had drifted by four keys); its test now compares keysets to the schema

Core crates:
- python clamp_timeout/clamp_memory floor a zero ceiling instead of
  panicking (u64::clamp with min > max); Semaphore::new(0) hang guarded
- wrapper.py: allow_nan=False so NaN/inf results become null instead of
  poisoning the envelope (bridge frames too); the traceback cap moved
  into write_error; malformed iii.json degrades to no-bridge
- cache_root falls back to the temp dir when HOME/XDG_CACHE_HOME are
  unset instead of panicking on the boot path
- capture_streams counts the appended newline against MAX_LOG_BYTES
- run_with inlined into run (sole caller, work_dir always None)
- wire docs: node-engine::<runtime_id>:: / node-engine::teardown ->
  code-runner names (goldens regenerated)

Docs/catalog contract:
- run.code and register.function_id descriptions state the real model:
  async fn body vs module, iii.files vs /work, and one runtime per
  (namespace, lang) — the language gate they described was rolled back
- scratch_* documented as node-only (python /work is a fixed engine
  budget) in the schema docs, README, and config form

Console UI:
- CSS.escape on the focusField deep-link before querySelector, so a
  hostile fragment cannot unmount the form
- errorInfo keeps message total when error is undefined
- configForms remover joins the teardown list; copyText removes its
  textarea in finally; pulse honours prefers-reduced-motion
- build.rs watches packages/console-ui so edits there rebuild page.js

Tests/tooling:
- run-tests.sh: --filter without a value exits 2 (was an infinite loop);
  a missing HARNESS_DONE reaches its diagnostic under set -e
- stream-cap e2e asserts the real 16 KiB max_stream_bytes default (was
  <=2 MiB); the register e2e 'any' assertion is de-tautologized; the
  truncate UTF-8 test asserts the observable contract; the react import
  assertion matches the full specifier

Verified: fmt/clippy clean; node-core 227, python-core 76, code-runner
90 tests; UI tsc + vitest 11/11; e2e suite 25/25.
…ii-hq#781)

* feat(canvas): diagram worker with live console rendering

New canvas worker: diagrams stored as editable source under stable ids (canvas::create/get/list/update/delete on the state bus), a per-family mermaid syntax primer (canvas::syntax), pre-render validation (canvas::validate), and injected console UI - a canvas page with Monaco editor, live mermaid preview, pan/zoom, SVG/PNG export and a freeform whiteboard mode, plus chat cards that render diagrams inline for canvas function calls. MOT-4408.

* chore(canvas): retrigger ci

* feat(canvas): live streaming, draggable panes, hand-drawn look

The page now streams agent-side changes over the state worker's own state trigger (scope canvas, tab-scoped Message-path binding): the sidebar and any open canvas update in place, own saves are identity-suppressed so the editor never resets, and an externally deleted open canvas clears with a note. The sidebar and the editor|preview split get drag handles (persisted). Mermaid renders with the hand-drawn look and the colorful redux theme pair via one shared init used by both the page and chat cards.

* feat(canvas): export options, draw-on animation, auto-open

Exports gain background on/off and light/dark options on both panes (mermaid re-renders under the export theme; freeform maps them to exportBackground/exportWithDarkMode), mermaid drops htmlLabels so PNG rasterization stops blanking labels, every fresh render sketches itself in with a staggered stroke sweep (reduced-motion respected), and a canvas created by an agent auto-opens on the page when nothing is selected.

* feat(canvas): element-level live drawing on freeform canvases

Four new functions let an agent draw one shape at a time: canvas::element::add/update/delete/list operate on individual elements of a freeform canvas (stable element ids, verbatim scene storage, per-canvas caps, mutations serialized). The open console whiteboard applies each call in place through the editor's updateScene with remote strokes kept out of the user's undo stack, so drawings appear as they happen. Chat shows one-line cards per drawing step. Also folds in review feedback: case-insensitive family aliases, a mutation guard closing the update lost-write window, explicit syntax table arms, CI golden guard, watch-mode vendor prebuild, date and relative-time hardening, save-echo suppression for freeform, shared mermaid init, split-persist cleanup, and test dedupe. Refs MOT-4408.

* fix(canvas): never auto-save an unrendered scene, normalize skeletons

Opening a freeform canvas the renderer failed to restore could serialize an empty scene and save it back, destroying the stored elements. Saves and dirty-tracking are now gated on a real pointer or key interaction with the board this mount, so merely opening a canvas can never write. Stored elements are normalized on mount and on live-apply: full excalidraw elements pass through, agent skeletons run through the converter in per-run batches, and an unconvertible batch is dropped instead of sinking the whole scene. Refs MOT-4408.

* fix(canvas): unique element ids, honest list, parked remote applies

canvas::element::add regenerates caller-supplied ids that collide with the scene or repeat within one request; element::list returns one summary per stored element with placeholders for malformed entries so counts stay comparable; the add response counts before saving instead of re-parsing; a remote scene update that arrives mid-edit is parked and applied on the next clean transition instead of being dropped; SKILL.md states the element family is freeform-only; boundary test pins the element cap. Refs MOT-4408.

* docs(canvas): current README, skill, and modules row

README documents the element-by-element drawing flow with a worked example, the streaming canvas page, exports, and the freeform normalization; SKILL.md gains the draw-while-watching flow and the element list; the modules table row covers the element family. Refs MOT-4408.

* fix(canvas): regenerate pnpm-lock.yaml after main merge

The merge left a duplicated mapping key in pnpm-lock.yaml (stacked react
specifiers), which broke every job that pre-builds the frontend bundles
with --frozen-lockfile. Regenerated from main's lockfile reconciled
against the workspace so canvas/ui resolves cleanly.
…cense, abort + discovery fixes

- add provider::opencode_go::count_tokens (local tiktoken estimator, golden schema) — parity with openai/anthropic/codex
- iii.worker.yaml: license Apache-2.0; state pin ^0.22.0
- iii-permissions.yaml: deny !provider::opencode_go::count_tokens
- discovery: models_url returns Option, skips discovery when the configured
  URL has no /chat/completions suffix — never polls a guessed host (CodeRabbit iii-hq#3)
- integration: abort lands mid-stream, cancels the in-flight upstream request,
  terminal done(aborted) frame, idempotent second abort (CodeRabbit C)
The stream trigger type the console's live views subscribe through was
missing; add iii-stream to the harness engine stack (matches the
integration/e2e stack config pattern).
…der-PR scope

Repo pattern (provider-deepseek/openrouter/github-copilot): provider PRs
ship the provider dir + root README row only; no provider touches
harness/. Remove the harness/Makefile BASE_STACK entry (added during the
main merge) and the harness/iii.worker.yaml dependency (incidental in the
original commit; the PR body's wiring section does not list it). The
worker still installs via `iii worker add` like the other providers.
cargo test ran every target incl. the integration suite, which self-runs
again via the explicit III_ENGINE_BIN integration command when iii is on
PATH. Scope the first command to the pure targets (lib + golden schema
checks), leave the integration line unchanged. (CodeRabbit review
4929864279)
…native diff surface (iii-hq#791)

* feat(shell): stream workspace changes into the explorer

The explorer page subscribes to the editor worker's editor::changed push channel (it observes every filesystem-touching harness call through the post-trigger hook): agent writes refresh the tree and git views live, and the active file reloads in place when the agent wrote it, with a dirty buffer always keeping the user's edits. Events coalesce in a short window; the page degrades to load-once behavior when the editor worker is absent. Refs MOT-4407.

* feat(shell): system-level workspace watch behind shell::changed

Reworked per review direction: instead of observing harness calls, the worker owns a real OS directory watch (FSEvents/inotify via notify). A surface binds the shell::changed trigger type with config: { path } and the worker starts one recursive watcher per binding, coalesces the raw event storm per path, filters .git internals, and fans out {path, kind, root} — so agent calls, shell::exec side effects, and edits made entirely outside the engine all stream. No harness coupling, no other-worker dependency. The explorer binds its browsed root per tab (re-binding on root change), refreshes tree and git live, reloads a clean active buffer when its file changes on disk, and follows the last visible write into a preview tab, with hidden and system paths never stealing focus. Refs MOT-4407.

* feat(shell): live follow opens as a diff, deep noise filter

The live follow now shows the change the way the editor feed did — through the console's shared FileDiff renderer: git baseline when the root is a repo, empty baseline for created files, and the last content this page saw for modified files outside a repo (DiffPane grows an optional baseline override). The file still opens as the preview tab underneath, and an open diff tracks further writes to its file live. The follow filter also got depth-aware: noise directories (target, node_modules, dist, build, Library, ...) are rejected at any path depth, not just the first segment, and build-artifact extensions (.o, .rlib, .lock, .log, ...) never steal the view — a cargo build under a nested worktree was follow-opening object files. Refs MOT-4407.

* fix(shell): created files reach the live diff as creations

macOS reports a create and the write that fills it as separate events; latest-kind-wins coalescing collapsed that to modified, so a brand-new file outside a repo arrived with no baseline and the live view fell back to dumping raw content instead of the all-added diff. Kinds now merge toward the visible outcome within a window (create+modify stays created, deletion supersedes, create-after-delete is a creation), and the page keeps its own guard: a followed file absent from the previous tree render is treated as created even when the OS kind says otherwise. Refs MOT-4407.

* fix(shell): temp artifacts never steal the live follow

Session scratch files (.output, .tmp, .swp, .part, .pid, .sock) join the follow exclusion list — watching a shared directory like /private/tmp streams every process's temp churn, and those writes belong in the tree refresh, not the preview. Refs MOT-4407.

* docs(shell): document the shell::changed trigger type

README gains a Live change feed section — binding config, payload vocabulary, coalescing semantics, and what the explorer page does with the events — and the agent skill documents the trigger type alongside the function surfaces. Refs MOT-4407.

* feat(shell): review feed, lazy deep folders, nested-repo diffs, noise-free watch

Four explorer gaps from live use on a home-sized root. The watcher no longer reports reads or bare metadata touches as modifications (notify Access and Modify::Metadata events are dropped at the fold) — a cat or chmod is not a workspace change, and content writes carry their own Data events. A new changes side tab keeps the last 200 visible events (kind glyph, tail-ellipsized path, age) so a fast burst stays reviewable after the auto-follow has moved on; clicking a row reopens that file's diff. Folders the budgeted tree snapshot never reached now fetch their listing on expand and splice it into the rendered tree, with fetched-and-empty markers preventing refetch loops and live changes under a fetched subtree dropping it for refresh. And diffs now find a file's OWN repository when the browsed root sits above it — git -C from the file's directory auto-discovers upward — so a worktree under the home directory shows real baselines instead of 'not a git repository'; the git tab message explains its root scope. Refs MOT-4407.

* feat(shell): line-count chips and totals in the changes feed

Each feed row now carries the +N/−M of its change and the header sums the session — git diff HEAD --numstat probed from the file's own directory (nested repos included), untracked files counted whole, non-repo files measured against the page's last-seen content with a prefix/suffix line delta (chip-grade arithmetic, exact for contiguous edits). Stats fill in asynchronously and are bounded per burst; a row without a chip still opens its diff. Refs MOT-4407.

* feat(shell): burst-grouped changes feed

The feed now keeps each coalesced burst as one group with its own file count, +N/−M totals, and age — an agent turn that edits four files reads as one reviewable unit, and the same file edited across bursts keeps its history instead of collapsing to a single row. Oldest groups fall off whole past 300 rows. Refs MOT-4407.

* feat(shell): own diff renderer with folds, syntax color, and dir-aware events

The diff pane is now shell's own renderer instead of the console's shared component: a Myers line diff with unmodified context folded behind expandable 'N unmodified lines' rows, dual line-number gutters, lightweight per-line syntax coloring (strings, comments, numbers, keywords per family), intraline change emphasis on replaced line pairs, and +N/−M totals in the header. shell::changed events gain a dir flag — a burst's last event was sometimes the directory creation itself, and following it into coder::read-file returned C210 raw into the pane; directories now refresh the tree but never open, and never enter the feed. The page also stops chasing its own tail: config/shell-ui.yaml (its persisted UI state) is excluded from follow — every tab change writes it, so the live view kept replacing real diffs with a -0 +0 of its own state file. splitLines treats a trailing newline as a terminator, matching git's line counts. Verified live headless: created files render all-added with syntax color and counts, the active buffer follows disk writes, and the state-file loop is gone. Refs MOT-4407.

* fix(shell): own atomic-write temps never stream

coder::update-file and the fs backend write through sibling temp files (.coder-tmp-, .iii-tmp-, .tmp.<uuid>) that exist for a moment between write and rename — the watcher reported them, the live view followed one, and the read raced the rename into a raw C211 in the pane. They're this worker's own write machinery, not workspace changes, and the rename lands as an event on the real path regardless; the fold now drops them (suffix-shape matched, so a user's notes.tmp.md still streams). The diff pane also says 'file no longer exists on disk' instead of echoing a raw handler error when a followed file vanishes between event and read. Verified against the live coder::create-file path: only the final file delivers. Refs MOT-4407.

* feat(shell): diff pane matches the reference rendering

Studied the reference TUI's diff renderer source directly and ported its remaining decisions: one gutter (the new line number for additions and context, the old one for deletions — the sign column disambiguates), a verb-led header (Edited path (+A −R)), deleted-side syntax dimmed under its tint, and a row-count guard that drops per-line tokenizing on huge diffs so the pane stays instant. Verified live headless against a nested repo under a non-repo browsed root: folds above and below the hunk, intraline emphasis on the changed span, syntax coloring both sides. Refs MOT-4407.

* feat(shell): consume the open-in-shell deep link

The explorer handles #/ext/shell/open/<encoded-abs>[:line] — the chat's open-in menu (console iii-hq#792) navigates here. The request is captured and stripped from the URL immediately, then applied once the root has resolved: the file opens pinned when it lives under the browsed root, and the explorer re-roots to the file's own folder when it doesn't (the effect refires on the new root and opens it there). Refs MOT-4407.

* fix(shell): jailed watch paths, unstallable fan-out, bounded diffs

Addresses the review findings. The watch path now goes through the coder surface's PathResolver (jail containment, operator denylist, canonicalization, hot-reload aware) — watching a tree is a read of every filename under it, so a path you can't read is a path you can't watch; README documents the policy and a test pins the jail. Deliveries ride their own tasks so one hung send can't stop the pump from draining the channel and silently dropping events. The Myers backtrack gets an edit-distance budget (memory is O(D*(N+M)) from the per-step frontier snapshots) with the coarse-replace fallback, plus a regression test. Also: poisoned-mutex recovery instead of silently skipping registration bookkeeping, literal git pathspecs for filenames containing glob metacharacters, a guarded decodeURIComponent in the deep-link capture, the feed surviving a hidden-filter toggle (root changes still clear it), inaccessible lazy folders recorded as fetched-and-empty instead of refetching every burst, malformed events without a root dropped at the guard, image previews no longer overwritten by text reloads, pointer-cancel handling on the sidebar resize handle, a preview-sized ceiling on the base64 image read, and bidi-safe tail ellipsis on feed paths. Refs MOT-4407.

* (MOT-4407) feat(shell): polish live explorer review UI

* (MOT-4407) feat(shell): add Codex-style workspace review

* (MOT-4407) feat(shell): edit worktree diffs safely

* (MOT-4407) docs(shell): add post-merge live review screenshot

* (MOT-4407) fix(shell): keep explorer synced to chat root

* (MOT-4407) fix(shell): retain exact working directory roots

* (MOT-4407) fix(shell): avoid replaying collapse all
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-ticket PR deliberately has no Linear ticket (bump/typo/CI-only)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants