Skip to content

feat(opencode): local LAN provider discovery + auto-discover models - #27554

Open
androidand wants to merge 204 commits into
anomalyco:devfrom
androidand:dev
Open

feat(opencode): local LAN provider discovery + auto-discover models#27554
androidand wants to merge 204 commits into
anomalyco:devfrom
androidand:dev

Conversation

@androidand

@androidand androidand commented May 14, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #6231
Closes #27553

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

  • Adds Local (LAN) discovery in /connect for local OpenAI-compatible servers.

    • Combines mDNS, localhost probing, and bounded LAN probing to find reachable local instances.
    • Lets users add discovered providers in bulk and marks already configured entries.
    • Shows if local providers are online or offline
  • 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_length when 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_length and max_output_tokens to the /v1/models response. 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 typecheck
  • bun test test/provider/provider.test.ts --timeout 30000
  • bun turbo typecheck
  • Manually tested /connect -> Local (LAN) against multiple local OpenAI-compatible servers on localhost and LAN.

Screenshots / recordings

image image image

Provider config example

    "proxmox": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Proxmox",
      "options": {
        "baseURL": "http://192.168.1.123:8080/v1",
        "apiKey": "ollama"
      },
      "discoverModels": true
    },

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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 /v1/models for LM Studio specifically. You may want to review if this current PR consolidates or extends that approach to work with all OpenAI-compatible providers.

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>
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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>
@androidand
androidand requested a review from adamdotdevin as a code owner May 14, 2026 15:34
androidand and others added 8 commits May 14, 2026 18:01
…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.
@androidand androidand changed the title feat(opencode): auto-discover models from openai-compatible providers feat(opencode): local LAN provider discovery + auto-discover models May 14, 2026
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels May 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

androidand and others added 27 commits August 6, 2026 02:23
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>
androidand and others added 2 commits August 7, 2026 19:16
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Auto-discover models from OpenAI-compatible providers Auto-discover models from OpenAI-compatible provider endpoints

3 participants