Skip to content

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

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

feat(opencode): local LAN provider discovery + auto-discover models#27554
androidand wants to merge 297 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.

@androidand
androidand force-pushed the dev branch 2 times, most recently from c368353 to a56a8fe Compare May 14, 2026 14:53
@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. 👍

@androidand
androidand requested a review from adamdotdevin as a code owner May 14, 2026 15:34
@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

Copy link
Copy Markdown
Author

Please have a look, I think people will appreciate this, especially in combination with the updated llama-swap which adds feature parity (and more) with ollama.

@vlameiras

Copy link
Copy Markdown

It would be very much appreciated. Thanks!

androidand and others added 6 commits June 2, 2026 00:55
A machine with multiple LAN interfaces was being added as a new provider
for each non-primary interface (probeLAN only skips one IP per subnet).
Skip any discovered host that matches a local network interface address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
scanMDNSOnly() exposes just the Bonjour browse phase without the
localhost/LAN fallback — useful for verifying mDNS advertisement.

The integration test (skipped in CI) runs both mDNS-only and full
discovery scans, prints all found providers with models and online
status, and asserts at least one provider is reachable. Run with:

  bun test test/local/discovery.integration.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
m3 was appearing twice — once via mDNS (192.168.1.237:11435) and
once via localhost probe (127.0.0.1:11435). Both paths set name to
the hostname, so dedup by name catches the duplicate. mDNS results
are preferred; localhost/LAN hits with the same name are skipped.
androidand and others added 7 commits June 5, 2026 22:38
Replace /Users/andreas/dev/ paths with ~/dev/ throughout. Add ecosystem
position diagram and multi-repo rules section covering: llama-skein client
codegen protocol, --agent flag preservation requirement, mDNS discovery
protection, and upstream sync policy. Collect code generation commands and
branch conventions into dedicated sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix dialog-model-ctx: use sdk.client.local.model.setCtxSize (correct
  path per generated SDK), replace broken getSuggestions with PRESETS,
  remove prompt()-based custom input (invalid in terminal TUI), fix all
  TypeScript errors from prior incomplete implementation
- Sidebar context: make ctx window clickable for local providers, accent
  color on ctx value, hide cost display for local (cost not applicable),
  add isLocal detection via provider baseURL option
- Rename llama-swap refs to llama-skein in AGENTS.md, mdns.ts
- theme.tsx: theme registration additions
- provider.ts: local provider fetch improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…alog

- Poll llama-skein /api/hardware every 15s when dialog is open; detect
  GPU (discrete VRAM) vs unified/Apple-Silicon memory automatically
- Show free/total memory in dialog title: "Context — modelName · 4.2/18.6 GB Unified free"
- Add per-preset descriptions:
  - < 65k: warns "too small — MCP tools fill this before meaningful work"
  - 131k: marks as recommended (sweet spot for MCP workflows)
  - > current: shows +delta and OOM warning when free memory is low
- Normalize base URL for control API (strip /v1 suffix, matches server handler)
- Import from correct llama-skein gen/client path (createClient/createConfig)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Poll /api/hardware every 30s when a local llama-skein provider is active.
Render a 20-char two-segment bar: used (amber █) vs free (muted ░).
Show GB label: "10.2/18.6 GB Unified · 8.4 free" below the bar.
Auto-detects GPU VRAM vs Apple Silicon unified memory from the snapshot.
Bar only appears for local providers; cloud sessions are unaffected.

Three-segment (model/KV/free) requires API extension to ResourceSnapshot
— tracked separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ware

- Sidebar bar: model weights (amber █) / KV cache (accent ▓) / free (░)
  Falls back to two-segment used/free when loaded_model is absent
- Label row: "1.7 model · 0.8 KV · 6.2 free" when breakdown available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restructure the sidebar Context panel into two distinct sections with
bold headers, moving speed to the bottom:

  Tokens               ← clickable header (opens ctx dialog)
  15,320 / 128k  12%
  ████████░░░░░░░░░░░░ ← accent fill = used, muted = free, clickable

  VRAM / Unified / RAM ← label from hardware data
  ████████████░░░░░░░░ ← amber=model, accent=KV, muted=free
  15.7 · 5.1 · 3.6 GB ← with GB unit

  1.1 t/s              ← speed below both bars

The memory section degrades gracefully: shows used/free bar when
/api/hardware is available but no loaded_model data yet (e.g. model
not yet loaded), and disappears entirely for non-local providers.
The GB unit was previously missing from the memory text line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
androidand and others added 12 commits August 14, 2026 23:07
Merges 20 upstream commits — model catalogue updates (GLM 5.3, Gemini 3.7,
Grok endpoint), a unicode fix in grep previews, v1 database compatibility,
and generated/nix churn. Merged without conflicts.

Only one of the 80 files upstream touched is fork-registered
(tool/registry.ts), which is why this was a clean merge rather than the
seven-file reconciliation the previous sync needed.

fork:verify flagged 46 unregistered divergences before the baseline bump —
all of them the stale ref, not drift: owned 150/150 present and patched 95/95
intact throughout, so no fork feature was lost. Bumping upstreamRef to the
newly merged commit clears them, exactly as the manifest's own comment
prescribes.

Verified after: fork:verify clean (150 owned, 95 patched, 0 unregistered),
typecheck 0 errors in both packages, loop suite 149/0.
fork:verify became enforcing this week and immediately failed its first sync
with 46 unregistered divergences — while owned files sat at 150/150 and patch
markers at 95/95. Nothing was lost; the baseline ref was stale. Reading that
correctly is the difference between bumping a field and hunting a phantom
regression, and nothing wrote it down.

The skill records that distinction, the sync procedure that produced a
conflict-free merge of 20 upstream commits, and three traps:

- measure the upstream delta from the merge-base. `git diff HEAD..upstream/dev`
  includes the fork's own ~270 commits and reported 245 fork-registered files
  for a sync whose real answer was 1.
- merge, do not rebase. A rebase replays one decision once per local commit.
- grep upstream/dev before calling a symbol fork-specific. McpTool looked like
  ours by name and by its consumers; it is upstream's, and mistaking it cost a
  wrong diagnosis of 35 type errors.

Also carries the maintainer's standing rule for conflicting hunks — upstream
wins ties, the fork wins where it adds something, ambiguous cases go on a list
rather than being guessed — and the worktree recipe for landing a commit
without touching another agent's working tree.
Verifying the loop-stall fix required actually running test/loop/*,
which uncovered that the fork's LayerNode/defaultLayer plumbing had
been broken for a while and this test suite had likely never passed:

- LayerNode.make(layer, [...]) positional-call sites (should be the
  object form { service, layer, deps })
- 9 services missing an exported `layer` binding their own tests
  reference directly (SessionStatus, SessionRunState, Question, Todo,
  ToolRegistry, Truncate, SessionProcessor, SessionCompaction)
- 77 services missing a `defaultLayer` export entirely; added one that
  mirrors each service's own `deps` list via
  `layer.pipe(Layer.provide(dep.defaultLayer), ...)`, referencing each
  dependency's own canonical defaultLayer so shared singletons
  (Database in particular) stay memoized as one instance instead of
  getting rebuilt per subtree
- 4 of those had a `node.layer` field that wasn't the bare `layer`
  identifier (database.ts, runtime-flags.ts, llm.ts, core session.ts)
  the generated defaultLayer wired the wrong construction
- 4 files transitively depend on unbound (tag-hoisted) nodes and can't
  have a standalone defaultLayer at all (acp/usage.ts, acp/directory.ts,
  project/instance-store.ts, worktree/index.ts) - left unexported
- tool/registry.ts's defaultLayer chained 21 separate Layer.provide()
  calls in one .pipe(), over TS's pipe-overload arity limit - switched
  to the array form Layer.provide([...])
- loop.test.ts and queue-mode.test.ts were both missing SessionProjector
  (the event-driven service that persists sessions to SQLite) from
  their hand-built layer graphs, so sessions were "created" but never
  written to disk before the first prompt looked them up
- session/prompt.ts used `new Prompt(...)` where Prompt is a
  Schema.Struct, not a class; crashed any prompt once the event-sourced
  session path actually ran. Fixed to Prompt.make(...), matching the
  convention used elsewhere (core/src/session.ts)

test/loop/ now passes clean (143 tests, 8 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The no-progress guard flagged genuinely-progressing iterations as
stalled whenever their output was long and structurally repetitive
(file dumps, boilerplate) - character-bigram similarity scores high on
that kind of text regardless of the actual edit, and the old 0.92
threshold with a default noProgressLimit of 10 gave real progress too
little room before the guard fired.

- Raise the near-identical similarity threshold 0.92 -> 0.96
- Raise the default noProgressLimit 10 -> 15
- Add a length-change veto: an output whose length differs from the
  previous iteration's by more than 10% is treated as progress even
  when bigram similarity alone would call it near-identical, since a
  large length swing means real content was added or removed
- Bump the SDK's noProgressLimit default in loop-args.ts to match (3 -> 10)

fix-loop-stall tasks 1-5 done; task 6 (verify with real /loop usage)
left open pending a live TUI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ran two real /loop sessions through the CLI against a running server
and live llama-skein backends (rocky/qwen3-35b-a3b): one file-writing
task (8 files, 17 tool calls, completed correctly) and one text-only
task explicitly designed to produce long, near-identical output across
iterations. Both completed cleanly via the promise token, no false
stalls, no regressions from the LayerNode/defaultLayer fixes.

Neither run naturally produced multiple no-tool-call iterations - a
capable model just finishes an unconstrained task in one turn - so the
precise similarity/length-veto boundary condition stays covered by the
existing deterministic unit tests rather than a live run, which is the
more reliable way to pin an exact numeric threshold anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sync.tsx and local.tsx both call usePermission() from their own init(),
but nothing in app.tsx's provider tree ever wrapped it in
<PermissionProvider> — permission.tsx (and its two consumers) landed via
the Aug 7 upstream sync, and the wrapper never got added during that
merge.

Silent until now: existing long-running sessions had already booted
past this code path before today, so the gap only surfaces on a fresh
boot into an existing session (`opencode -s <id>`) - crashing with
"Permission context must be used within a context provider" before any
prompt is even reachable. Reproduced and confirmed fixed against a full
isolated copy of a real, affected session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same issue as core/src/session.ts: LocationServiceMap.node is
LayerNode.unbound (tag-hoisted, replaced only by app-node-builder's real
graph), so it has no defaultLayer and never can. The earlier defaultLayer
sweep referenced LocationServiceMap.defaultLayer here anyway - missed in
that pass, caught now by the pre-push typecheck gate actually running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gration, provider capabilities, and TUI

Fixes the ~15 pre-existing typecheck error clusters the newly-enforced
pre-push fork:verify+typecheck gate exposed (never run before today, so this
debt accumulated silently). All 30 turbo packages now pass `bun typecheck`.

- mcp/index.ts: reshape MCP.tools() to return {def, client, timeout} per-tool
  (matching what session/tools.ts, code-mode.ts and tests already expected);
  implement instructions() via the MCP SDK's Client.getInstructions();
  fix getAuthStatus to compute expiry inline instead of calling a
  nonexistent isTokenExpired
- provider.ts: widen ProviderInterleaved.field to a plain string and add
  normalizeInterleaved() so config/catalog-supplied interleaved values
  (bare string or {field}) both normalize correctly; cast at the two
  plugin/SDK boundaries where our internal Model type is wider than the
  generated SDK's still-narrow capabilities.interleaved
- share-next.ts: cast outbound share data to the generated SDK's Model[]
  at the same interleaved-widening boundary
- beads/sync.ts, effect/app-runtime.ts: wire BeadsSync into AppLayer via a
  LayerNode `node` export - it was never registered, so `opencode beads
  sync` would defect at runtime on a missing service
- tool/code-mode.ts: migrate off the old @modelcontextprotocol/sdk types
  (CallToolResultSchema arg, 3-arg callTool) to the new split SDK client
  API already used by mcp/catalog.ts
- tool/shell.ts, tool/shell/prompt.ts: add the optional `description`
  field to the shell tool's parameter schema - referenced at every
  ctx.ask/metadata call site since the tool's introduction but never
  declared, so it was always undefined; fall back to the command itself
  for the result title
- session/prompt.ts: drop a stray `messages` argument to
  compaction.isOverflow that the interface never accepted
- cli/cmd/attach.ts: --mini pointed at a runMini export that never
  existed; fail explicitly instead of a silent dead import
- cli/cmd/mcp.ts: authenticate() now opens the browser itself; drop the
  obsolete callback-based call
- tui/app.tsx: wrap the app in LocationProvider (paired with the
  already-committed PermissionProvider fix) - both providers were added
  by the Aug 7 upstream sync but never wired into the component tree,
  crashing on session resume
- tui/component/dialog-move-session.tsx: break a circular generic
  inference in createResource (T needed from both the explicit return
  annotation and the untyped info param) that collapsed the resolved
  type to {} and cascaded into ~11 errors
- test/mcp/oauth-provider.test.ts, test/session/prompt.test.ts: remove
  two tests that reached for SDK/service surfaces removed by the v1->v2
  MCP SDK migration and an architecturally-unbindable SessionV2.defaultLayer
  respectively; both were already skip'd or testing internals no longer
  exported
- test/mcp/catalog.test.ts, test/tool/code-mode*.test.ts,
  test/tool/registry.test.ts: migrate remaining old-SDK imports
  (Server/Client/InMemoryTransport/schemas) to the new split packages

Verified: bun run typecheck clean across all 30 packages, bun run
fork:verify clean, and a stash-based control test (same 44-file suite
with/without this commit) shows 972/1047 pass vs 953/1040 on the
pre-fix baseline - net improvement, no regressions introduced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…typecheck fixes

Re-baselines fork/manifest.json's accepted-divergence list after rebasing
onto origin/dev's fork-sync (4643e65, fork/2026-08-14.1). fork:verify
was reporting 66 unregistered divergences purely from files already in
this branch's history, not anything newly lost in the rebase (verified:
origin/dev alone reports 0 unregistered).

Sampled across the flagged files to classify before accepting, per the
fork-sync skill's rule against blind --accept-divergence:

- ~55 files (core services + opencode services): the mechanical
  defaultLayer-export sweep from an earlier commit in this branch —
  `const layer` -> `export const layer` plus a `defaultLayer` composing
  each service's own deps' defaultLayers. Purely additive, one consistent
  pattern, verified via direct diff sampling (process.ts, session/store.ts,
  question/index.ts, session/compaction.ts, tool/truncate.ts,
  tool/registry.ts) — no upstream behavior altered or removed.
- The rest: today's typecheck-fix work and the loop-stall fix, both
  already verified via typecheck, fork:verify's owned/patched counts,
  and (for the typecheck fixes) a stash-based control test against the
  pre-fix baseline.

owned 150/150, patched 95/95 unaffected throughout — nothing was lost,
only unclassified. baseline.upstreamRef/syncedAt/forkTag unchanged
(still accurate). Individual markers for the defaultLayer sweep are left
as a followup, not blocking here — it is one coherent, understood
pattern repeated across files, not 55 separate decisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… defaultLayers

Rebasing onto origin/dev surfaced real breakage that merged silently (no
conflict markers, since both sides touched different lines of the same
files):

- beads/sync.ts, effect/app-runtime.ts: duplicate BeadsSync import/node
  export — upstream/dev independently wired BeadsSync into AppLayer too;
  kept the one with the explanatory fork comment, dropped my duplicate.
- mcp/index.ts: duplicate McpTool declaration — mine (from today's
  typecheck fixes) vs upstream's canonical, better-documented readonly
  interface. Dropped mine.
- test/loop/loop.test.ts, queue-mode.test.ts: duplicate SessionProjector
  import and duplicate `instructions` mock property, both from git
  splicing in two independent additions of the same line.
- test/session/snapshot-tool-race.test.ts: MCP mock was missing
  `resourceTemplates`, now a required Interface member.

Also: provider.ts, mcp/index.ts, session/prompt.ts, auto-mode/service.ts
were missing `defaultLayer` even on origin/dev itself (confirmed: these
errors exist on origin/dev alone once files elsewhere reference
Provider.defaultLayer/MCP.defaultLayer/SessionPrompt.defaultLayer that
never existed) — added, following the established pattern, only after
confirming every dependency in each node's `deps` list already has its
own defaultLayer.

bun typecheck: 0 errors across all 30 packages. fork:verify: 150/150
owned, 95/95 patched, 0 unregistered. test/loop/: 149/149, matching the
pre-rebase dev baseline exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eter

The shell tool's description parameter (added to fix a pre-existing
typecheck gap where the field was referenced at every ctx.ask/metadata
call site but never declared in the schema) changes its JSON Schema wire
shape. Snapshot updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
androidand and others added 16 commits August 15, 2026 01:38
llama-skein's max_fit_ctx has vramSafetyFrac/promptMarginFrac headroom
built in, so it's conservative by design — but this dialog was also
using it as the hard block in apply(), which meant the recommended
value and the refusal threshold could disagree: pick the recommended
ctx, get told it doesn't fit.

max_physical_ctx (new llama-skein field, no discretionary margin) is
now the block in apply() and the ceiling used for aboveCeiling(). The
dialog surfaces both: the physical max as a selectable option and in
the title (`max X`), and max_fit_ctx alongside it (`safe ~Y`) so a
user who wants headroom still sees it.

- packages/*/gen/types.gen.ts: regenerated (opencode) / hand-added
  (tui, which has no dedicated regen script for this client copy)
- packages/tui/src/local/model-fit.ts: aboveCeiling doc updated for
  the semantic shift, logic unchanged
- packages/tui/src/component/dialog-model-ctx.tsx: maxPhysical memo,
  new selectable option, apply() gate, titleSuffix shows both numbers
The foreign-turn guard skips prompting when the target session already
has a turn in flight that this loop didn't start (the user's own
message, or a slow previous turn not yet settled) — otherwise
ensureRunning would join that turn and attribute its output to this
loop. But the skip result still advanced info.iteration and counted
against maxIterations. With the 2s default poll interval, starting a
loop against a busy session burned the whole iteration budget in
seconds without ever sending the loop's own first prompt, landing on
"max_reached" while the model was still generating unrelated output.

Both call sites (prompt mode's run(), queue mode's queueTurn()) now
retry a skip without touching the counter or the iterations history.
queueTurn retries internally so its three call sites keep treating a
returned result as a real gate attempt — a skipped result surfacing as
"0 tool calls, empty output" would have read as a failing turn and
cost the change a strike it didn't take.

Added a regression test that reproduces the bug (a foreign turn held
open on the loop's session) and fails without the fix.
ae9cc41 deleted this as part of tearing out a fork-authored 4-state
autonomy ladder (manual/skip-ask/continue/auto). Two of those states
really were dead duplicates of /loop and /auto, correctly removed —
but the permission toggle wasn't a duplicate of anything, it was the
one real setting in that ladder, and it got swept out with the rest
because by then it only existed as one branch of a shared dispatcher
rather than its own command.

This is upstream's own permission.mode command (context/permission.tsx's
toggle()/set() were never touched by that deletion, only the palette
entry and keybind calling them were), restored verbatim: same command
name, same title strings, same behavior. <leader>p is the same binding
it had before the deletion.

fixes the "briefly saw an auto indicator, can't find how to toggle it
again" gap — the indicator in prompt/index.tsx was always live, this
was the only thing missing.
/auto collided with upstream's own "Auto mode" (permission auto-approve,
opencode.ai/docs/permissions, just restored in the previous commit) —
not an internal naming preference this time, a real feature upstream
documents and ships today. One word, two unrelated meanings.

/backlog names what the command works on rather than how it's built,
which also avoids reintroducing the "queue is an implementation
detail" complaint that the original /queue -> /auto rename existed to
fix. /queue remains as an alias, unchanged.

Renamed the command, its slash name, status-pill label, toasts, and
every doc/comment reference. Left mode: "queue", QueueAuthority,
experimental.queue_gate/queue_personas, and other internal identifiers
alone — the collision was in the discoverable verb, not the
implementation.

Also fixed the auto_mode config field's doc comment, which still
described a "Toggle auto mode" TUI command that no longer exists.
loop.create was called with no sessionID from the command palette
entry, so it always fell back to minting a new hidden session. The
run really did work — it just had nowhere visible to show up: no
status pill, no way to find it from the session you were looking at,
and a second attempt from the same directory hit the
one-queue-run-per-directory guard with no visible reason why.

Now scopes to the session you're viewing, matching what /backlog's
prompt intercept already did. Navigates to the new session when
started from the home screen (no session open to attach to).
180s covered a cold model load, which is what it was calibrated
against. It didn't cover a multi-day /loop or /backlog session whose
context only grows — prefill on that much context can genuinely take
longer than 180s on real local hardware with the backend perfectly
healthy. Observed killing an otherwise-fine generation mid-run.

600s keeps the same purpose (turn a truly-dead connection into a clear
timeout instead of an indefinite hang) with realistic margin for
long-running unattended work. Per-provider options.headerTimeout still
overrides this for anyone who wants a different value.
<leader>p was defined in keybind.ts and the command existed, but
appBindingCommands — the list app.tsx actually uses to turn keybind
definitions into live bindings — never had "permission.mode" added to
it. The keybind silently did nothing; only selecting the command by
hand from the palette worked at all.
Weak local orchestrator models reliably ignore a "prefer background=true"
tool description and just call task() with the (blocking) default,
delegating large work to one foreground subagent while the rest of the
fleet sits idle. Flip runInBackground to opt-out (params.background !==
false) once OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS is on, keeping the
flag-off fallback to foreground intact.

Fixes #76

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…inspector

The "always allow" TUI prompt only ever approved a pattern for the running
instance's lifetime; there was no path from an interactive approval to a
persisted opencode.json rule. Add a "Remember (save to config)" choice
alongside the existing until-restart approval, and give the until-restart
option an accurate label ("Until restart") instead of the outgoing text's
"until OpenCode is restarted" claim getting quietly narrowed to "this
session" — the approval is still instance-wide (InstanceState is keyed by
project directory, not session), so the label needed to stay honest about
scope.

Add `opencode permission status`/`scan` as the batch-oriented counterpart:
`status` reports the effective ruleset (global + optional per-agent) plus
the always-on loop/backlog deny ceiling; `scan` mines session history for
repeatedly-asked bash prefixes and drafts additive allow rules, mirroring
the fewer-permission-prompts skill's allowlist logic against opencode's own
permission schema.

Also give the permission-auto-approve prompt badge its own accent
(theme.warning) so it reads as distinct from the loop/backlog badge
(theme.success) when both are active at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Raise the stream-inactivity watchdog default from 600s to 1800s
  (llm.ts). 600s was tied exactly to SUBAGENT_TASK_TIMEOUT_MS, so a
  long subagent or a compaction pass (one giant single-shot summary
  call with no tool ticks, firing exactly when the prompt is largest)
  raced the same deadline and lost. Also fixes the config schema doc,
  which still claimed the old 300s default.
- loop.ts: a stalled stream resolves as a completed assistant message
  carrying `.info.error` rather than an Effect failure, so it was
  scored as "no progress" and silently re-prompted every iteration up
  to maxIterations instead of failing the loop immediately.
- local.ts setModelCtxSize: stop collapsing "backend rejected the
  patch" into the same `false` used for "no baseURL" / "above
  ceiling" — the TUI toast was blaming the wrong cause. A genuine
  backend failure now surfaces as a server error with a ref instead
  of a misleading message.
- dialog-model.tsx: an exact/prefix hit on a provider's own id or name
  (e.g. a local host like "m3") now shows all of that provider's
  models directly, instead of fuzzy-matching the same short needle
  against every model title too and burying the provider's models in
  unrelated OpenRouter-catalog noise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It was meant to catch a truly wedged provider connection, but every
real case it fired on was a slow-but-alive stream: host-paced hybrid
models faulting weights in, foreground subagents, compaction's single
big call, cold-load chunks. Each got a special-cased exemption; this
finishes that trend — no genuinely-wedged-socket case ever showed up
to justify keeping it, and the false positives were abandoning working
turns fleet-wide ("Provider stream stalled: no events for 1800s").

Removes stream-stalled.ts, tool-activity.ts, and every seam that fed
or consumed them (llm.ts stream wrapping, message-v2.ts error mapping,
tools.ts liveness ticks, loop.ts no-progress heuristic comment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FK9MDUfpqc2D2jLF1ya8yR
…work

Plans the full scope from the loop/backlog reliability and cross-session
messaging discussion as linked, dependency-ordered openspec changes:
loop-eternal-by-default, peer-messaging, subagent-notification-reliability,
free-model-subagent-pool, claude-peer-protocol-spike, and a gated
claude-peer-protocol stub that does not get real tasks until the spike
reports findings. Also extends the existing fleet-instance-presence change
(Phase 6) tying its unfinished wedge/heartbeat work directly to the reported
"backlog suggests wrong/unreachable agents" symptom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNLPKoYAazB85eSHgToec1
Plain `/loop` used to stop the moment its own prompt completed, even with
unchecked openspec work still sitting in the backlog — the top complaint
driving this change. On completion, a prompt-mode loop now checks the
disk-derived eligible-change queue (`resolveQueue`) before finalizing; if
work remains, it transitions into queue-style continuation under the same
QueueDenyRules authority ceiling a `/backlog` run gets (never more push/
deploy authority than before), instead of just stopping. `--no-eternal`
(`eternal: false`) restores the old stop-on-completion behavior exactly.

Also adds one bounded stall reprieve — a single harder-worded retry before
finalizing as `stalled`, never unbounded — using the same eternal opt-in.

Two other documented `loop-spec-queue` follow-ups turned out to already be
fixed in the current tree (priority-aware queue ordering in queue.ts,
whole-turn tool-call counting in loop.ts) — verified and the tracking
corrected rather than reimplementing them.

Wired through the CLI (--eternal/--no-eternal), TUI (/loop --no-eternal),
and SDK loop-args parser; SDK client types regenerated via the scoped
packages/sdk/js/script/build.ts (avoids script/generate.ts's whole-repo
reformat side effect).

153/153 loop tests pass (3 new), typecheck clean across opencode/sdk/tui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNLPKoYAazB85eSHgToec1
…ng (peer-messaging)

The existing `peers` tool only ever shipped discovery (session-peer-awareness
deliberately scoped out messaging). Nothing filled that gap except an
untracked, unregistered, broken sketch (plugin/skein-peers.ts: hardcoded
localhost:4096, an invalid fetch() call for a local file, a parallel
list_agents tool duplicating `peers` under a new name) — now deleted and
superseded.

send_peer_message extends the same roster `peers` already computes rather
than inventing a second one: resolve by exact session id or an unambiguous
title prefix (ambiguous match refused, never guessed), deliver via the same
ctx.extra.promptOps primitive task.ts uses for background-subagent result
injection, with a structured provenance envelope so a peer message can't be
mistaken for a user prompt or a permission grant.

Delivery is fire-and-forget (forked), matching task.ts's own pattern, so the
tool honestly reports "accepted for delivery" rather than a stronger claim
it can't back up without blocking the sender on the peer's whole reply. A
literally mid-turn (busy) target is refused outright rather than risked —
the same foreign-turn hazard loop.ts already guards against.

Cross-instance delivery stays out of scope here, gated on
fleet-instance-presence Phase 4 per openspec/changes/peer-messaging.

Also fixes a pre-existing stale snapshot in test/tool/__snapshots__ left
over from subagent-background-default's already-shipped description
reword (confirmed present on a clean tree before this change; unrelated
one-line --update-snapshots fix, bundled here since it was found while
running this change's test suite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNLPKoYAazB85eSHgToec1
fork:verify was failing on push with 10 regressions, blocking everything
in this session. Investigated each one against actual diffs before
touching anything:

- 3 "DROPPED owned files" + 3 "LOST patch markers" all trace to a single
  commit, a90c498 ("fix(session): remove the stream-inactivity
  watchdog"), already on dev before this session started. Its own commit
  message gives a full, reasoned account of a deliberate removal (the
  watchdog only ever fired on false positives, never once caught a
  genuinely wedged socket) — confirmed against the actual diffs for
  llm.ts, message-v2.ts, and tools.ts, all fully self-contained with no
  partial/accidental collateral. The manifest was simply never updated
  at deletion time. Recorded under _ownedRemoved_2026-09-05, matching
  this file's own established convention for exactly this situation.
  llm.ts and tools.ts still diverge from upstream for unrelated, live
  fork reasons (defaultLayer export; the loop-spec-queue authority
  ceiling) and were re-registered in `patched` with fresh markers rather
  than dropped — message-v2.ts no longer diverges at all and was
  removed outright.
- 4 "UNREGISTERED divergence": packages/opencode/src/cli/cmd/permission.ts
  and packages/tui/test/component/dialog-model.test.ts predate this
  session (already on dev); packages/opencode/src/tool/send-peer-message.ts
  and .txt are this session's own new tool. Verified none exist in
  upstream/dev, then registered all four as owned.

`bun run fork:verify` now reports 151/151 owned, 94/94 patched, 0
unregistered divergence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNLPKoYAazB85eSHgToec1
Direct answer to "does peer messaging actually work": no, not until this
commit. Writing a real end-to-end test (not just the pure-function unit
tests from the previous commit) instead of trusting typecheck + mocked
tests surfaced that the model itself said "Model tried to call unavailable
tool 'send_peer_message'."

Root cause: packages/opencode/src/tool/registry.ts builds each tool via an
Effect.all({...}) object (session-peer-awareness added `peers` there
2026-08-07, this session added `send_peer_message` the same way), but the
tools a model can actually call come from a SEPARATE, hand-maintained
`builtin` array a few lines below. `peers` was never added to it — meaning
the already-shipped, fully unit-tested `peers` tool has never once been
callable by a real model since it landed. `send_peer_message` inherited
the identical gap. Both added to `builtin` now.

Also fixed while writing the real test: target resolution was reusing
`resolvePeers`'s "actively working" filter, which correctly excludes idle
sessions from the awareness roster (alert-fatigue) but wrongly excluded
them as message targets — messaging an idle session ("update your client
when you get back to this") is the normal case for an explicit send, not
an edge case. Added `resolveMessageTargets` in session/peers.ts as a
sibling projection (same caller/descendant/directory exclusions, no
idle filter) rather than changing resolvePeers's behavior for `peers`.

New tests in test/session/prompt.test.ts drive the real ToolRegistry /
SessionTools.resolve path with a real (mocked-provider) model turn, not a
direct call to the tool's execute() function: "peers tool is reachable by
the model and reports another active session" and "send_peer_message
delivers into a second, idle session in the same directory" (the latter
polls the actual receiving session's message history for the delivered,
provenance-tagged text — proving the forked delivery path lands, not just
that the tool call returns "accepted").

bun test test/loop/ test/session/ test/tool/: 926 pass, 0 fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNLPKoYAazB85eSHgToec1
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