From dc3859975d25cf4a5ebca0fca5d3d20f168af321 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 14:56:18 +0000 Subject: [PATCH] coder(providers): capture + persist opencode's live ACP commands (no dispatch needed) The cold ACP probe captured available_commands but read probedCommands synchronously right after newSession, racing opencode's async available_commands_update notification -> captured nothing, only the static manifest showed. The probe now waits (poll <=3s + 300ms settle) for the notification. Captured commands persist to a new available_agents.commands column and are served (merged with the manifest) on the tier-2-skip path, so the agent's discovered commands survive once models are warm and show without a dispatch. Boot warms via the force:true startup snapshot. Caveat: relies on opencode emitting available_commands_update on session creation, not only post-prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ apps/coder/src/schema.sql | 4 ++ apps/coder/src/services/acp-probe.ts | 11 +++++ apps/coder/src/services/provider-snapshot.ts | 48 ++++++++++++++------ 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 024e175..60876a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.5.10-opencode-live-commands — 2026-05-29 + +Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred. + ## v2.5.9-agent-slash-commands — 2026-05-29 Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean. diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 2e1e4dd..28a1d5e 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -66,6 +66,10 @@ CREATE OR REPLACE VIEW human_inbox AS ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; +-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so +-- an agent's live command set survives the tier-2 probe skip and shows without a +-- dispatch. +ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb; -- v2.2.0: Paseo-style session config on tasks. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; diff --git a/apps/coder/src/services/acp-probe.ts b/apps/coder/src/services/acp-probe.ts index fff9c64..a566312 100644 --- a/apps/coder/src/services/acp-probe.ts +++ b/apps/coder/src/services/acp-probe.ts @@ -130,6 +130,17 @@ export async function probeAcpProvider( }); const session = await connection.newSession({ cwd, mcpServers: [] }); + // available_commands_update is an async session notification opencode sends + // shortly AFTER newSession resolves — reading probedCommands synchronously + // here races it and captures nothing. Wait briefly for the first batch, then + // a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS). + const deadline = Date.now() + 3_000; + while (probedCommands.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 150)); + } + if (probedCommands.length > 0) { + await new Promise((r) => setTimeout(r, 300)); + } const result = parseSessionResponse(session, agent); result.commands = probedCommands; await connection.closeSession({ sessionId: session.sessionId }).catch(() => {}); diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts index 53b52fb..459cff1 100644 --- a/apps/coder/src/services/provider-snapshot.ts +++ b/apps/coder/src/services/provider-snapshot.ts @@ -11,7 +11,7 @@ import { PROVIDER_MANIFEST, } from './provider-manifest.js'; import { probeAcpProvider } from './acp-probe.js'; -import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; +import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { readQwenSettingsModels } from './qwen-settings.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; @@ -22,6 +22,7 @@ interface AgentRow { install_path: string | null; supports_acp: boolean; models: ProviderModel[] | null; + commands: AgentCommand[] | null; label: string | null; transport: string | null; last_probed_at: string | Date | null; @@ -82,6 +83,9 @@ async function buildProviderEntry( const fallbackModes = getManifestModes(name); const defaultModeId = getManifestDefaultModeId(name); const manifestCommands = getManifestCommands(name); + // Manifest + persisted live ACP commands (captured on a prior cold probe), so + // the agent's discovered commands show even when the tier-2 probe is skipped. + const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []); const label = agentRow?.label ?? resolved.configLabel ?? resolved.label; const descr = resolved.configDescription ? { description: resolved.configDescription } : {}; @@ -175,7 +179,7 @@ async function buildProviderEntry( } return { name, label, transport, status: 'ready', enabled: true, installed: true, - models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands, + models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands, }; } @@ -213,7 +217,7 @@ async function buildProviderEntry( } return { name, label, transport, status: 'ready', enabled: true, installed: true, - models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands, + models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands, }; } @@ -242,7 +246,7 @@ export async function getProviderSnapshot( const build = async (): Promise => { const llamaModels = await fetchLlamaSwapModels(config); const agents = await sql` - SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents + SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents `; const agentMap = new Map(agents.map((a) => [a.name, a])); const ttlMs = config.PROVIDER_PROBE_TTL_MS; @@ -284,16 +288,34 @@ export async function persistProbedModels( ): Promise { let count = 0; for (const entry of entries) { - if (entry.name === 'boocode' || entry.models.length === 0) continue; - const flatModels = entry.models.map(({ id, label }) => ({ id, label })); - await sql` - UPDATE available_agents - SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp() - WHERE name = ${entry.name} - `; - count++; + if (entry.name === 'boocode') continue; + let persisted = false; + if (entry.models.length > 0) { + const flatModels = entry.models.map(({ id, label }) => ({ id, label })); + await sql` + UPDATE available_agents + SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp() + WHERE name = ${entry.name} + `; + persisted = true; + } + // Persist captured ACP commands so they survive the tier-2 probe skip and + // show without a dispatch. Only when non-empty — never clobber a prior set. + if (entry.commands.length > 0) { + const flatCommands = entry.commands.map((c) => ({ + name: c.name, + ...(c.description ? { description: c.description } : {}), + })); + await sql` + UPDATE available_agents + SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp() + WHERE name = ${entry.name} + `; + persisted = true; + } + if (persisted) count++; } if (count > 0) { - log.info({ count }, 'provider-snapshot: persisted models to available_agents'); + log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents'); } }