feat(opencode): local LAN provider discovery + auto-discover models - #27554
feat(opencode): local LAN provider discovery + auto-discover models#27554androidand wants to merge 204 commits into
Conversation
|
The following comment was made by an LLM, it may be inaccurate: Based on the search results, here are the potentially related PRs: Most Related:
Related by Pattern:
Note: PR #27554 (the current PR) appears as the top result in all searches, which is expected. The most directly related duplicate candidate is #26756, which already implements discovery from |
Auto-discover models from any provider using @ai-sdk/openai-compatible with a baseURL set by calling GET /v1/models at startup. Reads id, name, context_length / max_context_length, and max_output_tokens from the response. Manually configured models always win (non-destructive merge). Discovery is skipped when discoverModels:false is set, when the provider has no baseURL, or when it uses a non-compatible npm package. All discovery calls run in parallel (Promise.all) so N offline providers add at most one 2-second timeout to startup rather than N x 2 seconds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Add a new Local Providers entry to the provider connect dialog that scans the LAN via mDNS for llama-swap instances (_llamaswap._tcp) and lets users add or remove them as openai-compatible providers in the global config without editing JSON by hand. Backend: - packages/opencode/src/local/mdns.ts: bonjour-service mDNS scan - groups/local.ts: HttpApi schema (GET /local/scan, POST /local/connect, DELETE /local/connect/:providerID) - handlers/local.ts: Effect handlers reading/writing global config via Config.Service.getGlobal()/updateGlobal(); scan annotates each result with configuredProviderID so the UI can show current state - sdk regenerated with Local.scan/connect/disconnect UI: - dialog-local-discovery.tsx: scan button, per-instance Add/Remove, Add-all, optimistic local state, restart-required toast - dialog-select-provider.tsx: "Local providers" entry opens discovery Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…figured connect handler now checks if any existing provider already has the same normalised baseURL (e.g. written by sync-opencode under a different key) and reuses that key rather than creating a duplicate entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a 'Local (LAN)' option to the TUI /connect provider dialog. When selected, scans the local network via mDNS and probes localhost ports for llama-swap instances, then presents them in a selection list. Already-configured instances are shown with a checkmark and disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap bonjour init in try/catch so socket errors fall back to localhost probe silently. Use JSON.stringify on error body in TUI toast. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DialogSelect filters out disabled:true items entirely, so already-added providers were invisible. Remove disabled flag; show checkmark gutter for configured providers and an info toast if user selects one.
…ubnet backends mDNS doesn't work across VLANs/subnets, so remote fleet backends (proxmox, rocky, m5) were invisible in /connect → Local (LAN) scan. Fix by reading all @ai-sdk/openai-compatible providers already in the global config and probing them directly — they're already known from sync-opencode, just not reachable by mDNS. Also fix tsgo type error (p as unknown) in configuredByURL loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace fetch+AbortController with net.Socket TCP probes for LAN scanning — Bun's fetch does not reliably abort connections to unreachable hosts, causing all workers to stall permanently. net.Socket.destroy() cleans up the fd even before ARP resolves. - Wrap probeHost and probeModelIDs in withTimeout so localhost probes on ports that accept TCP but never respond to HTTP cannot block the whole scan (Promise.race with explicit setTimeout always resolves). - Raise LAN concurrency to 192 workers at 200ms each so the full /24 subnet (253 × 3 ports = 759 tasks) completes in ~800ms — previously 64 workers only reached .128 before the budget fired, missing hosts at .218 and .219. - Add reverse-DNS hostname resolution on the handful of live hosts found after TCP+HTTP probes (safe: only ~3-4 hosts, 500ms timeout). - Replace single-select connect dialog with a multi-select DialogLocalProviders component (toggle with space, add with ctrl+ret) matching the MCP server dialog pattern. - Fix maxOutputTokens to derive a reasonable limit from context_length when output limit is 0 (unknown), instead of returning OUTPUT_TOKEN_MAX unconditionally; add test coverage.
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Two bugs from the click-to-toggle work, and a server-side one underneath the second that was the more serious of the three. The picker opened on mouse-DOWN, and the dialog backdrop dismisses on mouse-UP. So the same physical click opened the dialog and then immediately closed it — it just flashed. Both the mode indicator and the loop position now open on mouse-up. Auto reverted to Manual on the next prompt because the toggle and the app were reading two different config views: the toggle wrote the global file and read back `global.config.get()`, while everything else reads `config.get()` — the effective merged config for the workspace. Sending a prompt triggers a bootstrap that re-reads the second, overwriting the first. Now the toggle reads back the same view the app reads, so the two cannot disagree. Underneath that: `Config.invalidate()` only dropped the global cache, but `get()` returns a per-instance snapshot of the merged config built when the instance loads, and nothing rebuilt it. A setting toggled at runtime therefore landed on disk and in `getGlobal()` while every `get()` kept answering with the old value — so auto mode was not only reverting in the indicator, it was never reaching `AutoMode.isEnabled()` on the server either. Writing config now invalidates the instance state so the next read recomputes from the file that was just written. test/loop 116/116, TUI 213/213, session suite unchanged against its known pre-existing failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Manual" being an option inside a menu titled "Auto mode" is a contradiction, and the contradiction was in the framing rather than the option. This is a scale of how much the agent does without asking; Auto is simply its far end. - Retitled to "Agent autonomy — how much it does without asking". - Options now name their trade-off rather than their flag: "Manual — you are in the loop", "Skip-ask — stops asking, still stops working", "Continue — keeps going, still asks", "Auto — works the backlog unattended". - `/mode` opens it, with `/auto` kept as an alias so the old word still works. - Dropped the "toggle auto mode" palette entry: it set both flags on or off, which is exactly picking Auto or Manual, so it was a fourth way to do something there were already three ways to do. Its keybind entry went with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"What does skip-ask mean and how does it differ from loop, will it continue forever?" was not a misunderstanding; the model was incoherent. It was two independent switches presented as four states, and one combination was self-defeating: keep working after a turn, but still stop to ask permission. Unattended, that parks on the first prompt with nobody there to answer it. Nothing should offer it. It is now a ladder where each rung strictly includes the one below: Manual — asks, stops after every turn Skip-ask — no prompts, still stops after every turn Continue — no prompts, keeps working on YOUR prompt, ignores the backlog Auto — no prompts, never stops, takes the next openspec change itself Continuing therefore implies not asking, and working the backlog implies continuing. `auto_queue` joins `auto_mode`/`auto_continue` in config so the rung is derivable without inspecting live runs, and an off-ladder config (hand edited, or written before this) rounds to the nearest real rung rather than leaving the indicator naming nothing. The pill and the picker now share one derivation, so they cannot name different rungs, and the single-switch palette toggles step along the ladder instead of producing a combination it does not contain. Answering the question directly, in the UI rather than in a reply: skip-ask stops asking but still stops working, which is why no loop appears and why it does not run forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Feedback, and it is correct: after all of this the modes were still unusable and the loop command was the only thing being used. The reason is now obvious — two of the four "modes" were invisible duplicates of commands that already exist: "Continue" mode == /loop <prompt> (keep working on my prompt) "Auto" mode == /queue (find planned work and do it) So the picker offered as settings the two things you already do as verbs, and the verb won every time. Only one thing here was ever a real setting, and it is not worth a four-state ladder. Deleted: the autonomy picker and its dialog, the cycle keybind, both single-switch toggles, /mode, the mode indicator, and the post-turn auto-continue behaviour (which is what /loop does, explicitly). `auto_continue` and `auto_queue` stay parseable so existing config files keep loading, marked deprecated and no longer read. Renamed /queue to /auto, keeping /queue as an alias. Auto is your definition: loop + do not ask + find the planned work itself, done when no planned task is left. Calling it "queue" was my framing leaking into the UI. The surface goes from 3 commands, 4 keybinds, 6 palette entries and 3 config flags down to: /loop <prompt> keep working on this /auto work all planned tasks until none are left /loop see and control what is running /btw ask without disturbing the run (unchanged) ctrl+x j same as bare /loop The pill no longer names a mode, because there is no mode to name: it shows what is running, and nothing when nothing is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sh the cross-repo spec Completes the outstanding items from the decluttering discussion. Config staleness, properly this time. `get()` answers from a per-instance snapshot whose rebuild resolves plugins through npm — which is why invalidating it on every write hung the app earlier today. Instead a runtime overlay covers the gap for the keys actually written while running (`auto_mode` today, still written by the permission dialog's "Allow + stop asking"). It is an allowlist rather than "whatever was written": overlaying a structural key like `provider` would shadow the per-project merge loadInstanceState performed, a subtler bug than the staleness being fixed. Tests pin both halves, including that structural keys deliberately stay snapshot-scoped. The running-work indicator is clickable again, via the file's existing `ClickText` idiom rather than the hand-rolled handler that fought the root box's copy-on-select. Copy: the work dialog no longer references `/loop --queue` or calls itself "Loops"; it explains `/loop` and `/auto` in the terms the commands now use. Spec: `workspace-queue` renamed to `auto-across-repos` and rewritten to the shape the discussion landed on. It no longer proposes a driver with discovery, priority and concurrency configuration — that duplicated scheduling the tracker already does, which was the objection. Work and its order come from specsync, which owns openspec ↔ provider translation and is where cross-repo aggregation belongs; opencode resolves each item to a repository and change and runs the existing per-repo engine. Adds a requirement that "no planned task remains" is reported distinctly from "nothing was ever found", since conflating them is a bug this codebase has already shipped once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"There is absolutely no output" on first trying /auto — correct, and my fault. Two compounding mistakes. /auto created its own session, so starting it from your session left you watching a session where nothing happens. And iterations ran in a fresh child session each time, which moved even /loop's work out of view. The result was an agent working hard somewhere you were not looking. Both undone. /auto runs in the session you are in, like any other turn, and iterations run in that same session — so the work appears as ordinary turns and any subagents appear as ordinary subagent parts, which is how everything else in this TUI already behaves. The child sessions were for a clean context window per iteration, which is a real benefit, but it traded away the more important one: work you cannot see is work you cannot supervise. Context growth is what compaction is for. The foreign-turn guard is kept and matters more now that the session is shared, and the tool-call count is bounded by the turn's start rather than by session identity. I first tried to fix this by navigating the view to the run and following each iteration. That was the wrong instinct — the answer is not to move the user around, it is for the work to happen where they already are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it carries Two things, and the second was a live safety hole I created an hour ago. Your suggestion is right and it is what this now does: one isolated run per task, not per iteration and not one shared session for everything. Per iteration was invisible and wasteful — a new session every few minutes, none of them findable. One shared session drags every change's history into every later change. The part I had not noticed: a shared session cannot carry the authority ceiling, because it is the session you are typing in. When I collapsed everything into your session to fix the "no output" problem, `permission: QueueDenyRules` was still being passed but nothing consumed it any more. So /auto had NO deny profile — it could push — and, because the ceiling is also what marks a session unattended, it would have stopped to ask permission on the first risky tool. Both silent. A session per change fixes all three at once: clean context for that change, a boundary that is navigable from the session you started in (child sessions, and their permission prompts and questions already surface to the parent), and its own deny profile — which is also what keeps it from stopping to ask. Your own session never receives the ceiling; the test asserts that too, along with one session per change, parented correctly, each denying push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You said it plainly: the main agent must be visible in the main window at all times. A child session is not that. It does not matter that it is only ctrl+x ↓ away — an unattended run you have to go find is a still, empty screen, which is exactly the complaint that started this. So iterations run in your session again. I tried the per-change split an hour ago because a child session was carrying something real, and this time that thing comes along explicitly rather than by accident: The authority ceiling (QueueDenyRules) is granted to your session for the duration of the run and handed back when it ends. It is applied in `create`, right next to the fiber that owns the run's lifetime, and released with `ensuring` — so draining, halting, cancelling, erroring and dying all restore your session exactly as they found it. Prior rules are preserved and re-applied, not clobbered. Putting it deeper in the driver would mean auditing every return path one at a time. That ruleset is doing two jobs, and the second is easy to lose: it denies push, AND it is what marks the session unattended so the run never stops to ask. Drop it and /auto both gains push and regains the permission prompt. What you give up is a clean context per change. Compaction handles that, and it is the cheaper thing to give up. The test now asserts the lifecycle rather than the shape: every iteration runs in your session, the ceiling is present while the run is live, and it is gone afterwards. 117/117 loop tests pass; typecheck clean. tasks.md records the round-trip and what the child session was carrying, so the next attempt does not silently re-open the hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cy (fleet-model-gallery 3.4) Task 3.4 asks to confirm opencode web and TUI model management works while Skein is stopped. A one-off manual check with Skein's process killed would only prove "nothing called Skein on that path, that time" — it would pass just as happily the day after someone adds a Skein import, as long as the exercised branch missed it. So the confirmation is structural and repo-wide instead: there is no code in this package that can contact a Skein supervisor at all, therefore no model-management path can depend on one. Verified, and now locked in by test: - every import specifier in src/ containing "skein" is either llama-skein's generated client or skein-loading (llama-skein's SSE loading-theme marker); zero Skein supervisor imports - nothing spawns a skein executable - the model-management entry points (mdns, capacity, placement, the local HTTP API handlers) each reach llama-skein directly - the web (app/dialog-local-discovery) and TUI (tui/dialog-provider) surfaces drive model management through the server's local API rather than reaching around it Corroborating evidence at the time of writing: Skein exposes no HTTP API at all (its own inventory for this change records "No HTTP API surface" — CLI and MCP only), so there is no Skein endpoint to depend on even in principle; the two programs share only on-disk conventions (openspec/changes/*/.skein), which are files, not a running service. The full test/local suite passes (189 tests), including live mDNS discovery of 5 llama-skein hosts. The guard was mutation-checked: adding a Skein client import to src/ makes it fail, so it is not passing vacuously. It also asserts the source scan is non-empty for the same reason — a suite whose value is finding nothing has to distinguish "found nothing" from "looked nowhere". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`/auto` could always delegate — `task` gives a subagent its own session, its own derived permission ruleset, and placement onto an idle fleet node. It had nobody worth delegating to. Two reasons, and the first was silently true for weeks: `.opencode/agent/` held ten symlinks into /Users/andreas/dev/opencode/.skein/agents/, a path that stopped existing when this repo was renamed. Every link dangled. opencode has been loading exactly two agents from disk and ignoring the other ten. The symlink was the wrong shape regardless. A `.skein/` directory is local per-repo state — that repo's changes, worktrees, sessions, and its own possibly customised agents. It is not a shared library, and nothing here should link into one, its own or another's. What IS shareable is skein's templates/agents/, and those are a source to copy and adapt from, not a target. Adapt matters: they are written for skein's Go codebase and its file-token pipeline. reviewer.md said "read changed Go files" and "write your review to .skein/review-<name>.md". Neither is true here; dropped in unchanged they produce confidently wrong work. So: five tracked files written for what this repo actually is — Bun, TypeScript, Effect-TS, openspec changes, `bun run typecheck`, `bun test`. coder, tester, reviewer, researcher, persona-auditor. Permissions are part of the persona, not an afterthought, and two are pinned by test because getting them wrong looks like a bad model rather than a bad config: a reviewer denied write and edit, a tester allowed bash. The auditor is the interesting one. A persona is a system prompt plus a permission set and nobody had ever checked whether either was any good, so persona-auditor judges a definition against its own stated description — permissions versus instructions, references that do not exist here, actionability, failure behaviour, overlap — and is denied write/edit so it cannot rewrite what it judges. It has been run. Five LGTM, and it verified the paths and commands it was checking rather than assuming them. One real finding: researcher had no prescribed output shape, "could lead to inconsistent answers" — it now has Answer / Evidence / Unresolved and is told not to append plans nobody asked for. Specs: repo-agent-personas plus three that build on it — persona-gate-fanout (bind personas to the queue's gates, make verify a review instead of a re-run), steer-running-subagent (/nudge into a live child, the one idea Pi has that nobody else does), role-model-chains (per-role model preference that degrades to placement instead of dead-ending, which is what made skein's chains unusable). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate names were already the role names — implement is a coder, test is a
tester, verify is a reviewer — so the mapping was sitting there unused.
Two separate things, and conflating them is why the old nudge did nothing. It
said "where tasks allow parallel work, use the task tool to delegate subtasks":
no agent named, no trigger, and nothing lost by ignoring it. It now names the
agent bound to the current gate, and it is suppressed when that agent does not
exist — an instruction the model cannot carry out is worse than none, it just
improvises.
The other thing is not a judgement call and does not belong in a brief. `verify`
is the last gate before `commit`, and until now it was a shell command: a passing
command proves the tests ran, not that the change is any good. An agent grading
its own homework there is the weakest point in an unattended run.
So gates can now be decided by a subagent's verdict, and `verify` binds to
`reviewer` by default. Same {passed, output} shape as a command gate, so strike
counting, the repair turn and quarantine are untouched — a NEEDS_WORK becomes the
failure detail on the next brief, which is the channel that already drives repair.
Everything that is not an unambiguous pass fails the gate. Errored, timed out,
empty, hedged, no recognisable verdict — all fail. A crashed reviewer must never
read as approval. Being wrong the other way costs one model turn.
The reviewer runs in a child of the session you are watching, so its parts render
inline. Its own denies go on that session, not left to agent selection alone: this
session exists for one review, and a reviewer that can edit what it judges is not
a reviewer.
Two things worth naming:
- A binding to an agent that does not exist halts the run before the first
iteration. Silently reverting to the command would mean a review gate that
stopped reviewing while the run kept advancing toward commit.
- Agent gates are excluded from the suspected-misconfigured-gate heuristic. That
heuristic un-quarantines a change when a command gate never passed once, which
is right for a bad `bun test` invocation and wrong for a reviewer that has said
NEEDS_WORK three times — that one is doing its job.
Config is `experimental.queue_personas`; a gate set to false keeps the command.
135/135 loop tests, typecheck clean. Not yet run live — 4.5 is still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the /nudge implementation turned up that the spec I wrote an hour ago specified something that cannot work, so it is corrected before anyone builds it. It said the steer would be "delivered when the turn completes", reusing the background.extend path that `task` already uses for task_id. Wrong twice. Wrong for one-shot subagents: the end of the turn IS the end of the subagent. The parent has already taken its result. A steer delivered then opens an orphaned turn nobody reads. Steering has to reach the agent while it can still change course. Wrong mechanically: the obvious shortcut — call session.prompt on the child — silently loses the message. Runner.ensureRunning returns awaitDone(existing) when the state is Running. It joins the in-flight run and discards what you submitted. No error, no delivery. That is the shape of bug that demos fine and never works. Delivery has to be between STEPS of the running turn. prompt.ts rebuilds the message list every step; that rebuild is the seam. A steer persisted as an ordinary user message before the next rebuild reaches the agent's next model call, mid-turn, with no second message channel and no interruption. Same thing Pi's comtac does, built on the message history. Drain at one known point in the loop, not at the write, so delivery is "this step" or "next step" and never sometimes-lost to a race. design.md records both wrong turns; tasks.md is re-planned around the channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion manifest (fleet-model-gallery 2.5) The Skein entry went stale when task 3.2 changed family.ts. An adoption manifest whose transformation field no longer describes the transformation is worse than none — it reads as authoritative while being wrong. Records the two defects fixed rather than reproduced (the UD ranking gap that sorted UD-Q8_0 below Q2_K, and the underscore-decimal truncation that tied internlm2_5 with internlm2_6), and the one reproduced on purpose (MoE "NxM" markers read as versions, where a narrow fix would relocate the corruption rather than remove it). Adds the golden-parity test and the three fixture files it runs against, listed individually so the manifest's own existence check covers them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ran /auto end to end in a throwaway openspec repo. The chain works — coder wrote the file, implement and test passed, the reviewer got its own child session under the one you watch, returned NEEDS_WORK, and the verdict drove a repair turn. Three real defects surfaced doing it. 1. The reviewer could not see the work. `git diff HEAD` does not show untracked files, so a change whose entire contribution is a NEW file looks empty. The reviewer saw only the tasks.md checkbox and reported the change incomplete — correct conclusion, wrong reason, and the repair turn then churned 89 messages trying to satisfy a complaint that was an artifact of the brief. The brief now carries `git status --porcelain` too and says plainly that ?? files will not appear in the diff and must be read directly. 2. `write: deny` does not stop an agent that has bash. The reviewer announced it could not write its review "due to file-writing restrictions" and then wrote it anyway with a shell redirect, leaving a file in the tree the commit gate is about to check for cleanliness. So the deny was theatre, and the test I wrote asserting it was theatre too — it asserted the session ruleset, not the capability. reviewer, persona-auditor and researcher now carry a bash allowlist: deny *, then allow the inspection commands they actually need. Tests assert both halves — git diff/status/rg/cat allowed, redirect/append/mv/rm/sed -i/git add/git commit/tee denied. 3. Copying only a persona's denies onto the review session would have broken that allowlist. Rules are last-match-wins and tools merge as [agent rules, session rules], so lifting just the `bash: * deny` out of a persona and appending it would strand the reviewer with no bash at all. The session now carries the persona's ruleset WHOLE and in order, with the derived ceiling last so the queue's no-push authority still beats anything a persona says about itself. Also: `failed to create loop` now says why and against which server. It sent me hunting for a bad queue config when the server simply was not listening on --server, and it would do that to anyone. 194 loop + agent tests, typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bash allowlist I added an hour ago does not work, and the second live run
showed exactly how: the reviewer wrote its review with
cat > .skein/review-reviewer.md << 'EOF'
`cat*` matches that. So does `git diff HEAD > f` against `git diff*`. Any
permitted prefix plus a redirect is a write, and there is no set of patterns over
shell command strings that makes a shell read-only. The allowlist was the same
theatre as `write: deny` alone, just longer.
Twice now the model has announced it could not write "due to file-writing
restrictions" and then written the file anyway. It is not being devious — it has
a tool that works and a prompt telling it not to, and the tool wins.
So the personas that DECIDE things do not get a shell. reviewer and
persona-auditor are `bash: deny` outright. They lose nothing they need: the diff
and `git status --porcelain` arrive in the brief, `read`/`grep`/`glob` open
anything else, and the test gate has already run the suite by the time verify
starts. Their prompts now say why, so the model stops trying.
researcher keeps its shell and is deliberately excluded. It is advisory — it never
decides whether work ships — so `git log -S` and `git blame` are worth more than
a guarantee nobody depends on.
The spec now says this outright rather than "denied write and edit", which was
true and insufficient.
Verified end to end before this change: a full /auto run in a throwaway openspec
repo drove implement → test → verify → commit and reported
"add-greeting: completed (gate reached: commit, iterations: 5)", nothing pushed,
work on a loop/ branch. Three reviewer sessions, one NEEDS_WORK that correctly
demanded tests and produced them on the repair turn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A live reviewer answered "**VERDICT: PASS**". readVerdict wants LGTM, so that review counts as no verdict and fails the gate. Failing is the right default — an unrecognisable answer must never advance a run toward commit — but churning a repair turn because the model chose a synonym is waste, and loosening the parser to accept PASS is worse: "all tests pass" would then read as approval. So the brief states the two tokens verbatim every time, on their own lines, and says outright that PASS / APPROVED / "looks good" are read as no verdict at all. The reviewer and auditor prompts say the same. The parser stays strict. 195 loop + agent tests, typecheck clean.
… a persona Chased down why a reviewer declaring `bash: deny` kept running shell commands. Config directories are merged with `~/.opencode` scanned LAST — after the project's `.opencode`. That machine has `~/.opencode/agent/*.md` symlinked into `~/.skein/agents/`, skein's globally seeded personas, and the global reviewer says `bash: allow, write: allow`. So the project's reviewer resolved to bash allow, write allow, edit deny — edit survived only because the global file happens not to mention it. Skein's global seed silently overrides every project's own personas, and only ever toward MORE permission. Whatever the merge order should be, a gate that decides whether work ships cannot depend on winning it. So the gate no longer asks. A gate subagent's session gets bash, write, edit, patch and apply_patch denied outright, appended last so they beat the persona's rules, the parent's, and anything a config directory contributed. The persona file is now advice about how to review; the fence is code. The test now uses a reviewer whose own definition grants it everything, and asserts the evaluated action — not merely that a deny rule is present somewhere in a list where a later allow would win. Still open, upstream: the shell tool returns early when its scanner extracts no patterns from a command (`shell.ts:300`), so such a command is never checked against any ruleset at all. Denying bash covers everything the scanner does parse. 195 loop + agent tests, typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l-gallery-ui 3.2) llama-skein's host-model-management-api sections 1-5 are merged, so the operation surface is generated rather than handwritten: createModelOperation, getModelOperation, listModelOperations, cancelModelOperation and streamModelOperationEvents, alongside the config-model CRUD and postHypotheticalFit. The diff is 20 lines in types.gen.ts and nothing in sdk.gen.ts — the operation methods were already generated from an earlier contract. What had drifted were the provenance fields llama-skein's task 5.1 added to Model: installed, source_repository, source_revision, artifact_paths, active_operation_id. Those are precisely what an Installed view needs to tell "configured" apart from "actually present on disk", and to reattach to an install still in flight — so the client was quietly missing the fields section 7 is built on, in a file nobody would think to check because it looked current. bun run typecheck clean; bun test test/local/ 189 pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e join (model-gallery-ui 5.1-5.3) The gallery data plane: which hosts exist, what each could run, and one row per (candidate, host) that the filters, ranking and classifications in 5.4-5.6 sit on top of. 5.1 hosts.ts projects opencode's existing scanLlamaSwap rather than discovering again. A second mechanism would drift from the first and show a gallery listing different hosts than the provider picker on the same screen. It adds one thing: a stable join key (normalized base URL), because 5.3 joins across independent async calls and mDNS names change while reverse-DNS names are often simply wrong. Offline hosts are kept, not filtered — 5.6 must be able to say "that host is offline", which dropping it would make indistinguishable from the host never existing. 5.2 fit.ts batches and bounds. Batching is free: the hypothetical-fit endpoint already takes a variant list, so one request carries every quantization of a candidate and the count is (candidates x hosts), not (candidates x variants x hosts) — 100 requests instead of 600 for 20 candidates over 5 hosts with 6 quants. Concurrency is capped because these are the user's actual GPUs, possibly mid-inference; the gallery is a background nicety and must never be the reason a chat stalls. 5.3 join.ts keys on hostId + candidateId and emits pairs even when a source is missing, because that absence is exactly what 5.6 classifies on. Capacity is indexed through the same normalization, so a trailing slash cannot silently lose every busy signal. The property running through all three: "we could not ask" never collapses into "the answer is no". Offline, timed out, an older build without the endpoint, a reshaped response — each yields answered:false, and an unreachable capacity probe leaves busy undefined rather than false, because a scheduler that reads "not busy" from an unreachable host dispatches into a hole. 20 tests; bun run typecheck clean; bun test test/local/ 209 pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s (model-gallery-ui 5.4-5.6) 5.4 filter.ts — "hard" means disqualifying fact, not preference. A model that cannot run on a host is not a low-scoring option, it is not an option, and it must go before ranking: ranking weights are tuned against plausible options so impossible ones distort the scores around them, and "ranked last" reads exactly like "cannot run" in a sorted list, so a user scrolling to the bottom sees a suggestion the machine cannot honour. An unknown fit deliberately does NOT disqualify — filtering there hides a good model because a host runs an older build, indistinguishable to the user from the model not existing. 5.5 rank.ts — "explained" is the requirement doing the work. One number tells the user nothing actionable: they cannot tell a model that ranked low for barely fitting from one that ranked low for being unpopular, and those call for opposite responses. So scoring emits named signed contributions whose sum IS the total, and the UI can show total, top contributor, or breakdown without any of them drifting. All seven dimensions, reusing existing vocabulary: ModelEvidence.kind gained "provenance" and "recency" (backward compatible), and quality/speed come from llmfit's tables keyed on the variant that would actually be installed. Compatibility and context outrank popularity, per the proposal. 5.6 classify.ts — one label per row because the UI shows one badge; the substance is precedence, since several are true at once and the wrong choice tells the user to fix the wrong thing. offline > unsupported > installed > unknown > stale > upgrade/fresh. "unknown" outranks stale and the family labels: claiming a fresh find on a host we could not query is an invention, while admitting we don't know is always true. Upgrade detection reuses model-catalog/family so gallery and catalog cannot disagree. 27 tests; bun run typecheck clean; bun test test/local/ 236 pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t not to You asked for a loop that runs "until all tasks are done, tested, verified, committed and deployed". I narrowed that to "never push" in the loop-spec-queue design and then wrote it into the report, the CLI help and the TUI toast as though it were a requirement. It was my call, not yours, and it made "completed" mean work that never left the machine. So the driver pushes now, by default, when a change passes the commit gate. The permission ceiling is unchanged: every session in the run still denies *git*push*, *deploy*, *ssh* and the rest. That distinction is the whole point. The model still cannot push anything it likes; the driver pushes one ref it computed itself — the current branch — after gates it evaluated itself, and reports exactly what it did. The commit gate has already established that a commit exists, that it touches this change, that the tree is clean, and that we are not on the default branch, so there is exactly one safe ref to publish. Guards: the default branch is refused even though the commit gate makes that unreachable, because it is the one failure that cannot be undone. A push failure is reported and does not change the change's outcome — the work is done and committed either way. Nothing is merged, tagged or released. `--no-push` turns it off. The report now names the branches pushed instead of claiming nothing was. Also `--guidance <text>`, which was in the server's CreateInput since the queue landed and was reachable from neither the CLI nor the TUI. It takes the rest of the line, since the TUI's single-line form has no quoting. Worth knowing: `/auto foo bar` reads foo and bar as change SLUGS, not as instructions — use --guidance for prose. The push test uses a real bare repo as origin and asserts the branch is actually on the remote afterwards, not that a report claims it. 197 loop + agent tests, typecheck clean. SDK: queuePush hand-added to the three generated spots rather than running the generator, which reformats the whole repo. A later regeneration reproduces it from the server schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code for 5.7 is already on dev, but not under its own commit: a concurrent agent's `feat(loop): auto pushes completed branches` (65612ec) ran a broad add and swept up all six in-progress gallery files alongside its own loop work. That commit is already pushed, so rewriting shared history to separate them would cost more than the tidiness is worth. Content is intact and verified — 56 gallery tests pass against it and typecheck is clean — so this records the task against the work rather than re-landing it. What landed there: a `gallery` HttpApi group (GET /gallery/hosts, POST /gallery/evaluate) registered next to `local`, so app and TUI share one typed surface instead of each growing a private path into the catalog. Ranking and classification are computed server-side and shipped as data, including the score breakdown — they depend on llama-skein fit calls a browser cannot make, and re-deriving them per frontend would guarantee the two disagree. Two shape decisions the tests pin: `busy` is optional so "unknown" stays distinct from idle (an unreachable host must never serialize as busy:false), and incompatible entries can be requested with their reasons so the UI can say why a host isn't offered rather than silently omitting it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… API 5.7 added the gallery HttpApi group server-side, but neither frontend could call it until the SDK carried it. Regenerating exposes client.gallery.hosts() and client.gallery.evaluate() with the GalleryEntry/GalleryHostInfo types, which is the plumbing section 6 (web) and section 8 (TUI) both build on — and the reason both can share one surface rather than hand-rolling fetches. Contained diff (+198 lines, generated files only). An incidental prettier reformat the build applied to v2/loop-args.ts was reverted: it is another agent's file and the change was cosmetic, so it does not belong in this commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Your two sessions were the whole argument:
Merge five specsync worktrees into main ses_026b3f8f2ffe...
Finishing specsync and merging worktrees ses_026b5aa54ffe...
Same job, two agents, each believing it was alone. Two writers on one git index.
The uncomfortable part is the data was already there. Sessions in a directory
share one store — a server on :2607 lists sessions created by servers on :2601
and :2605 in the same directory, which I found by accident while smoke-testing
/auto. fleet-instance-presence already landed the record type, the status
derivation and GET /agents. Nothing needed discovering or transporting. What was
missing was smaller and duller: no agent could ask.
So: a `peers` tool, and a paragraph in the queue brief when the repo is not quiet.
Two exclusions carry the design. Your own session, obviously. And your own
subagents at any depth — otherwise a run that fans out to a reviewer sees its own
children as competitors, and the collision signal is loudest exactly when the run
is behaving correctly. Idle sessions are excluded too: a directory accumulates
abandoned sessions, and a warning that fires on all of them is one nobody reads.
Awareness, not enforcement. `activePeers` feeds the brief and nothing else —
there is no branch anywhere that acts on the roster. Separating concurrent runs
is agent-worktree-isolation's job and should stay there.
Titles are included even though the presence record is deliberately metadata-only.
Those two sessions are distinguishable by nothing else, and "is this the same
work" is the entire question. No message text, prompt, tool call or tool output
is reachable.
Two things this uncovered:
An import cycle. peers needs the status derivation from agent/presence.ts, which
imports Loop for its schema, and the loop imports peers. That closes at
module-init and surfaces as "Cannot access 'defaultLayer' before initialization"
with every loop test erroring before it runs — not as a compile error. The
derivation moved to agent/presence-status.ts, a leaf, taking the loop
structurally ({ status }) because depending on Loop.Info would re-close it.
The same cycle blocks the tool from reading loop state, so the tool sees session
status alone. Accurate in practice — a loop-driven session is busy while it works
— and the brief, which runs inside the loop, passes the real thing.
Also fixed in passing: the task tool's snapshot never contained this fork's
`provider` param, so test/tool has been failing on that.
851 tests pass. The 9 remaining failures in test/session are pre-existing —
verified by running the same suite in a clean worktree at HEAD.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You asked me to find what the stashed work connected to and finish it. It connects to `retire-auto-reply`, and finishing it means deleting the file the stash was fixing. The stash held an unfinished `bigramSimilarity` for pattern-detection.ts. The motivation was sound — that file's `similarity()` counts, for each character of the shorter string, whether it appears ANYWHERE in the longer one, so any two prose strings score near 1.0. A false-positive machine. But: - `loop/similarity.ts` already exports exactly that Sørensen–Dice bigram function, shared by loop.ts and prompt.ts and deliberately import-free. The stash was reimplementing a module that exists. - It was fixing a file with no live caller. So I ran the audit retire-auto-reply asks for before deleting anything, rather than taking the proposal's word: - auto-reply: hits in exactly the six predicted files, nothing in session/, tui/ or sdk/. - automation-features: imported by two test files and nothing else. - pattern-detection: imported by automation-features and its own CLI command. That command does `Effect.provide(PatternDetection.layer)` in the handler, so it builds a FRESH service per invocation, mutates a Ref, prints "pattern detection enabled", and exits. The Ref dies with the process. It configured nothing that outlived it — worse than dead code, it reported success for a no-op. Same shape as `auto-reply on`. - None appear in the server.ts node list, so nothing long-lived held one. - The live sub-agent loop detection is loopStreak/LoopMaxStreak in prompt.ts and does not import PatternDetection. Deleting this regresses nothing. scheduler.ts is inert on the same evidence and is NOT deleted here — task 1.3 says to record it for its own change. The docs were the bigger lie. AUTOMATION_FEATURES.md was 333 lines describing auto-reply, pattern detection, a webhook hook system, and cron `/loop` scheduling: `/loop every 5 minutes`, `/loop-pause <task-id>`, `--threshold`, `--repetitions`. None of that syntax exists — not even for the parts that DO exist. Removing four sections would have left a document still lying about the feature it redirected to, so it is now a short accurate one: the real /loop and /auto surfaces, where "done" comes from, the authority boundary, gate personas, and a notice saying plainly what was never real and why. CHANGELOG's two "Added" claims are struck through with a Removed entry; the two skein.json capability entries are gone (edited as text — re-serializing reformatted the whole file). Typecheck clean, 493 tests pass, no dangling references. test/tool/shell is flaky under a loaded parallel run — a different shell test failed on each of two runs, a third was fully green, and it passes alone and on clean HEAD. Also dropped from the stash: a pin of @opentui/solid from `catalog:` to `0.3.4`. The root catalog already resolves it to 0.3.4, so the pin changes nothing today and would silently exclude the package from a future catalog bump. BREAKING CHANGE: `opencode auto-reply` and `opencode pattern-detection` are removed. Neither ever did anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
retire-auto-reply found three dead modules by accident, from a stray stash. The
obvious question is what else is like that, so this is the sweep instead of the
next accident.
Every module under packages/*/src checked for inbound references, then split by
`git cat-file -e upstream/dev:<path>`. 19 candidates. 5 were false positives, 11
are upstream, 3 were ours. One live command was found lying.
Deleted, fork-owned and inert:
scheduler/scheduler.ts service, no importer, absent from the node list.
Third member of the auto-reply group;
retire-auto-reply 1.3 deferred it here.
session/event-error.ts error factories, no importer. Distinct from
ProviderShared.eventError in packages/llm, live.
tui/util/auto-mode.ts the manual/skip-ask/continue/auto ladder,
orphaned when that UI was removed.
Deleted, and this is the category that matters:
cli/cmd/hook.ts its entire behaviour was printing "hooks are
configured in your opencode config file under the
'hooks' key". There is NO `hooks` key in the
config schema. Plugin hooks exist and live under
`plugin`. So a command described as "manage
opencode hooks" managed nothing and sent you to a
key that does not exist.
Same shape as `pattern-detection --enable`: registered, reachable, reports
success, does nothing. Dead code is inert; a lying command gets acted on. I
considered just fixing the message — rejected, because the command would still
manage nothing and --help would still offer it as though it did.
NOT deleted: 11 unreferenced files that exist on upstream/dev. Deleting an
upstream file costs a conflict on every sync and buys nothing; whether they are
dead is upstream's call. Listed in the proposal so the next sweep does not
re-litigate them.
One false-positive class worth knowing about, now in the spec: sqlite.bun.ts,
sqlite.node.ts, pty.bun.ts, pty.node.ts and fff.node.ts are all LIVE, resolved
through core/package.json's `imports` conditions (#sqlite, #pty, #fff). No import
specifier names them anywhere, so a naive "find unimported files" scan condemns
all five. Acting on that report deletes the database layer.
Typecheck clean, 494 tests pass, `hook` gone from --help, and a re-run of the
sweep reports zero fork-owned unreferenced modules.
BREAKING CHANGE: `opencode hook` is removed. It never managed anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d up Caught by noticing `beads` was missing from --help right after removing `hook`. cli/cmd/beads.ts exports BeadsCommand — 194 lines over the live BeadsSync service, with status/sync/unlink/list — and nothing ever imported it. Not in ForkCommands, not in index.ts. Completely unreachable. Registered, not deleted, and the distinction is the point of this whole sweep: unreferenced and useless are different findings with opposite actions. `hook` was reachable and did nothing, so it went. This does something real and was not reachable, so it gets connected. Deleting working code to satisfy a tidiness rule would have been the wrong reading of my own scan. My scan missed it, which is worth recording: the file-reference check saw the live `beads/` service directory and counted the name as referenced. A false negative to sit alongside the #sqlite/#pty/#fff false positives already in the spec. Verified live: `beads status` reports the bd CLI state and beads dir, `beads list` returns "No linked beads tasks", `beads --help` lists all four subcommands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now the only input that reached a running loop was a plain message, and a
plain message CANCELS it. Nothing between watching it do the wrong thing and
stopping everything.
You asked whether this and /btw should be the same verb. They shouldn't, and
there is one test that separates them: does what you typed leave a trace?
/btw asks YOU a question, answered from context, no tools, never joins the
conversation. Its value IS that it leaves nothing.
/nudge tells THE WORK something. It has to persist, or the next iteration
forgets it.
Merged, that property would flip invisibly depending on what happens to be
running — the same hidden mode-switch that made auto mode unlearnable. And the
failure is concrete: under one verb, "what was that file called?" typed during a
run would be injected into the run's standing instructions and acted on.
What they do share is already modelled: both are run control, input that must
never cancel a loop. /nudge joins isRunControlInput next to /loop, /auto and
/btw. That is the whole overlap. Two verbs, one distinction: btw asks, nudge
tells.
Scope also changed from the original spec. That aimed at steering a subagent
mid-turn, which is the hard and rare case — a subagent is transient and delivery
has to land between steps. The LOOP is the target actually worth hitting: it is
long-lived, it is what's going the wrong way, and it re-reads its instructions
every iteration. So delivery is an append to the loop record. Both modes rebuild
their prompt from that record every iteration, which means no mid-turn injection,
no race, nothing to poll. Subagent targeting is deferred, on the roster `peers`
already computes.
Corrections render AFTER the standing --guidance and say so explicitly: guidance
is what the run started with, a correction arrived because it was going wrong, so
where they conflict the correction wins. Ordering is the semantics; a test
asserts it. In prompt mode they are appended rather than woven in — your own
prompt stays recognisable as yours, and a correction that silently rewrote it
could not be audited.
The in-flight iteration is not interrupted, and the toast says "applies from the
next iteration" rather than claiming it already took effect.
Nothing to steer → says so and delivers nothing. It never falls back to sending
the text as a normal message, which would cancel the very run it was preserving.
The test proves persistence, not just delivery: a correction issued once must
appear on a LATER iteration too. Getting that observable took prompt mode
(a queue run quarantines after three gate failures), noProgressLimit: 0 and
varied model output — otherwise the run ended before the second delivery.
217 tests pass, typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…udge live Two things, both found by actually running it rather than trusting the tests. The live check: a real loop against a real model in a throwaway repo. Turns 1-3 answered in English. POSTed "From now on, answer only in Swedish" to the running loop — returned true. Turns 4 and 5: Det är soligt och varmt idag. Det regnar lite grått och molnigt idag. ...with the corrections block present in BOTH later prompts. Delivery and persistence, proven end to end. The tests asserted it; this is it happening. Getting there took three attempts, which is its own finding: the first two loops finished before I could steer them (the model emitted the completion token at iteration 2-3), so the check needed a completion token the model would never say. A steer only has something to steer while the run is still running. The flag bug: `opencode loop "x" --no-progress-limit 0` printed the help text instead of running. yargs' boolean-negation reads `--no-X` as `X=false`, so the `0` became a stray positional and yargs answered with usage. Only the `=` form ever worked, which is not how the flag reads in --help. Pre-existing, and exactly the shape of thing this week has been about: documented, reachable, and wrong. Renamed to `--stall-limit` with `no-progress-limit` kept as an alias so the `=` form anyone already uses keeps working. parseLoopArgs accepts both — the CLI and TUI must not drift on flag names. Verified both spellings start a run. Not fixable by disabling boolean-negation, incidentally: `--no-push` depends on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue for this PR
Closes #6231
Closes #27553
Type of change
What does this PR do?
Adds
Local (LAN)discovery in/connectfor local OpenAI-compatible servers.Auto-discovers models for OpenAI-compatible providers from
/models, including limits like context window and max output tokens when available.Compatible with and preserves manually configured/edited providers and models and respects
discoverModels: true|false.Adds context
current / context_lengthwhen available (see llama-swap PR below) to Context in sidebar and footer.Fixes unrelated bugs introduced by rebase, --agent parameter would crash opencode.
Related
Server-side companion: mostlygeek/llama-swap#755 adds
context_lengthandmax_output_tokensto the/v1/modelsresponse. When those fields are present, this PR reads them to configure each model's context window and output token limits automatically — no manual config needed.How did you verify your code works?
bun run typecheckbun test test/provider/provider.test.ts --timeout 30000bun turbo typecheck/connect -> Local (LAN)against multiple local OpenAI-compatible servers on localhost and LAN.Screenshots / recordings
Provider config example
Checklist