From 08024d9f03e6c2d22081b5255ea504f901a5e203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Manuel=20B=C3=A9cares?= Date: Thu, 15 Oct 2026 19:27:47 +0200 Subject: [PATCH 01/11] feat(usage): group usage by session, day, hour, weekday, model, provider or agent Real cost as opencode recorded it next to an estimate at the models' list prices, so promotions and free tiers show what they save. New endpoint GET /api/v1/usage returns one row per assistant reply plus a price table. --- app/pages/p/[dir]/stats.vue | 659 ++++++++++++++++++++++++++++++++---- server/api/v1/usage.get.ts | 61 ++++ server/utils/usage.ts | 176 ++++++++++ tests/unit/usage.test.ts | 100 ++++++ 4 files changed, 924 insertions(+), 72 deletions(-) create mode 100644 server/api/v1/usage.get.ts create mode 100644 server/utils/usage.ts create mode 100644 tests/unit/usage.test.ts diff --git a/app/pages/p/[dir]/stats.vue b/app/pages/p/[dir]/stats.vue index 2f019b8..b4ba3b2 100644 --- a/app/pages/p/[dir]/stats.vue +++ b/app/pages/p/[dir]/stats.vue @@ -1,39 +1,295 @@ diff --git a/server/api/v1/usage.get.ts b/server/api/v1/usage.get.ts new file mode 100644 index 0000000..62da033 --- /dev/null +++ b/server/api/v1/usage.get.ts @@ -0,0 +1,61 @@ +// Usage & cost over a time window: one compact row per assistant reply (real +// cost as opencode recorded it + tokens) and the models' list prices, so the +// page can group any way it likes and estimate the no-promotion cost. +// Messages are cached per session until its `time.updated` changes. +import type { UsageRow, UsageSession } from '../../utils/usage' +import { UsageRowCache, mapLimit, priceTable, usageRowsFromMessages } from '../../utils/usage' + +interface SessionLite extends UsageSession { + time?: { created?: number; updated?: number } +} + +const cache = new UsageRowCache(2000) + +export default defineEventHandler(async (event) => { + requireApiToken(event) + const query = getQuery(event) as { directory?: string; days?: string } + const directory = query.directory + if (!directory) throw createError({ statusCode: 400, statusMessage: 'directory is required' }) + const days = Math.min(365, Math.max(1, Math.round(Number(query.days) || 30))) + const since = Date.now() - days * 86400_000 + + const [sessions, providers] = await Promise.all([ + opencodeFetch('/session', { query: { directory } }), + opencodeFetch('/config/providers', { query: { directory } }).catch(() => null) + ]) + const all = (Array.isArray(sessions) ? sessions : []).filter((s) => s?.id) + const inWindow = all.filter((s) => (s.time?.updated ?? s.time?.created ?? 0) >= since) + const titles = new Map(all.map((s) => [s.id, s.title])) + + const perSession = await mapLimit(inWindow, 6, async (s) => { + const updated = s.time?.updated ?? 0 + const key = `${directory}\n${s.id}` + const hit = cache.get(key, updated) + if (hit) return hit + try { + const messages = await opencodeFetch(`/session/${encodeURIComponent(s.id)}/message`, { + query: { directory }, + timeoutMs: 20000 + }) + const rows = usageRowsFromMessages(s, messages) + cache.set(key, updated, rows) + return rows + } catch { + return [] as UsageRow[] // one unreadable session shouldn't sink the page; not cached + } + }) + + const rows: UsageRow[] = [] + for (const list of perSession) { + for (const r of list) { + if (!r.time || r.time < since) continue + // titles change (auto-titling), so take them from the fresh session list + const row = { ...r, title: titles.get(r.sessionID) ?? r.title } + if (r.parentID) row.parentTitle = titles.get(r.parentID) + rows.push(row) + } + } + rows.sort((a, b) => a.time - b.time) + + return { generatedAt: Date.now(), days, rows, prices: providers ? priceTable(providers) : {} } +}) diff --git a/server/utils/usage.ts b/server/utils/usage.ts new file mode 100644 index 0000000..86d2170 --- /dev/null +++ b/server/utils/usage.ts @@ -0,0 +1,176 @@ +// Usage & cost: turn opencode messages into compact per-reply rows and the +// providers list into a list-price table. Pure helpers (no Nitro globals) so +// they unit-test directly; the endpoint is server/api/v1/usage.get.ts. + +export interface UsageTokens { + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number +} + +/** One assistant reply: who/when/which model, what opencode charged, the tokens. */ +export interface UsageRow { + sessionID: string + /** set when the reply belongs to a subagent session */ + parentID?: string + title?: string + parentTitle?: string + time: number + completed?: number + providerID: string + modelID: string + agent?: string + cost: number + tokens: UsageTokens +} + +/** List price in USD per million tokens. */ +export interface ModelPrice { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export interface UsageSession { + id: string + title?: string + parentID?: string +} + +type Loose = Record + +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + +function tokensOf(t: Loose | undefined): UsageTokens { + return { + input: num(t?.input), + output: num(t?.output), + reasoning: num(t?.reasoning), + cacheRead: num(t?.cache?.read), + cacheWrite: num(t?.cache?.write) + } +} + +/** + * Rows for every assistant message of one session. Older servers keep the + * tokens (and sometimes the cost) only on `step-finish` parts: those are + * summed when the message itself has none. + */ +export function usageRowsFromMessages(session: UsageSession, messages: unknown): UsageRow[] { + if (!Array.isArray(messages)) return [] + const rows: UsageRow[] = [] + for (const m of messages as Loose[]) { + const info = m?.info as Loose | undefined + if (!info || info.role !== 'assistant') continue + let tokens = info.tokens ? tokensOf(info.tokens) : undefined + let cost = num(info.cost) + if (!tokens || cost === 0) { + const steps = (Array.isArray(m.parts) ? m.parts : []).filter((p: Loose) => p?.type === 'step-finish') + if (!tokens) { + tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 } + for (const p of steps) { + const t = tokensOf(p.tokens) + tokens.input += t.input + tokens.output += t.output + tokens.reasoning += t.reasoning + tokens.cacheRead += t.cacheRead + tokens.cacheWrite += t.cacheWrite + } + } + if (cost === 0) cost = steps.reduce((n: number, p: Loose) => n + num(p.cost), 0) + } + const row: UsageRow = { + sessionID: session.id, + title: session.title, + time: num(info.time?.created) || num(info.time?.completed), + providerID: String(info.providerID || 'unknown'), + modelID: String(info.modelID || 'unknown'), + cost, + tokens + } + if (session.parentID) row.parentID = session.parentID + if (info.time?.completed) row.completed = num(info.time.completed) + const agent = info.agent || info.mode + if (agent) row.agent = String(agent) + rows.push(row) + } + return rows +} + +/** + * `{ "provider/model": price }` from /config/providers. Prices come from + * models.dev as `cost: { input, output, cache: { read, write } }` (newer) or + * `cost: { input, output, cache_read, cache_write }` (older). Models without a + * `cost` object are left out (unknown price); all-zero prices stay (free). + */ +export function priceTable(providers: unknown): Record { + const list = Array.isArray(providers) + ? providers + : (providers as Loose)?.providers ?? (providers as Loose)?.all ?? [] + const out: Record = {} + for (const p of Array.isArray(list) ? list as Loose[] : []) { + if (!p?.id || !p.models || typeof p.models !== 'object') continue + for (const [key, model] of Object.entries(p.models as Record)) { + const c = model?.cost + if (!c || typeof c !== 'object') continue + out[`${p.id}/${model.id || key}`] = { + input: num(c.input), + output: num(c.output), + cacheRead: num(c.cache?.read ?? c.cache_read), + cacheWrite: num(c.cache?.write ?? c.cache_write) + } + } + } + return out +} + +/** The same tokens at list price (reasoning billed as output, like opencode does). */ +export function estimateCost(tokens: UsageTokens, price: ModelPrice) { + return ( + tokens.input * price.input + + (tokens.output + tokens.reasoning) * price.output + + tokens.cacheRead * price.cacheRead + + tokens.cacheWrite * price.cacheWrite + ) / 1_000_000 +} + +/** `fn` over `items`, at most `limit` running at once; results keep the input order. */ +export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length) + let next = 0 + const worker = async () => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return out +} + +/** Small LRU of per-session rows, valid while the session's `time.updated` is unchanged. */ +export class UsageRowCache { + private map = new Map() + constructor(private max = 2000) {} + + get(key: string, updated: number) { + const hit = this.map.get(key) + if (!hit || hit.updated !== updated) return undefined + this.map.delete(key) // refresh recency + this.map.set(key, hit) + return hit.rows + } + + set(key: string, updated: number, rows: UsageRow[]) { + this.map.delete(key) + this.map.set(key, { updated, rows }) + while (this.map.size > this.max) this.map.delete(this.map.keys().next().value!) + } + + get size() { + return this.map.size + } +} diff --git a/tests/unit/usage.test.ts b/tests/unit/usage.test.ts new file mode 100644 index 0000000..39427e1 --- /dev/null +++ b/tests/unit/usage.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { UsageRowCache, estimateCost, mapLimit, priceTable, usageRowsFromMessages } from '../../server/utils/usage' + +describe('usageRowsFromMessages', () => { + it('keeps assistant replies with their model, agent, cost and tokens', () => { + const rows = usageRowsFromMessages({ id: 'ses_child', title: 'Explore', parentID: 'ses_1' }, [ + { info: { id: 'm1', role: 'user', time: { created: 1 } }, parts: [] }, + { + info: { + id: 'm2', role: 'assistant', time: { created: 2, completed: 5 }, providerID: 'anthropic', modelID: 'claude-x', + mode: 'build', cost: 0.25, tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 1000, write: 50 } } + }, + parts: [{ type: 'step-finish', cost: 9, tokens: { input: 9 } }] + } + ]) + expect(rows).toEqual([{ + sessionID: 'ses_child', parentID: 'ses_1', title: 'Explore', time: 2, completed: 5, + providerID: 'anthropic', modelID: 'claude-x', agent: 'build', cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cacheRead: 1000, cacheWrite: 50 } + }]) + }) + + it('sums step-finish parts when the message has no tokens or cost (older servers)', () => { + const [row] = usageRowsFromMessages({ id: 's' }, [{ + info: { id: 'm', role: 'assistant', time: { created: 3 }, providerID: 'p', modelID: 'm', agent: 'plan' }, + parts: [ + { type: 'step-start' }, + { type: 'step-finish', cost: 0.1, tokens: { input: 10, output: 2, cache: { read: 4 } } }, + { type: 'step-finish', cost: 0.2, tokens: { input: 5, output: 1, reasoning: 3 } } + ] + }]) + expect(row!.cost).toBeCloseTo(0.3) + expect(row!.tokens).toEqual({ input: 15, output: 3, reasoning: 3, cacheRead: 4, cacheWrite: 0 }) + expect(row!.agent).toBe('plan') + expect(row!.parentID).toBeUndefined() + }) + + it('tolerates junk', () => { + expect(usageRowsFromMessages({ id: 's' }, null)).toEqual([]) + expect(usageRowsFromMessages({ id: 's' }, [null, {}, { info: { role: 'assistant' } }])).toHaveLength(1) + }) +}) + +describe('priceTable', () => { + it('reads both cost shapes and skips models without a price', () => { + const table = priceTable({ + providers: [ + { id: 'anthropic', models: { a: { id: 'a', cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } } } } }, + { id: 'old', models: { b: { cost: { input: 1, output: 2, cache_read: 0.1, cache_write: 1.25 } }, c: { name: 'no price' } } }, + { id: 'free', models: { z: { id: 'z', cost: { input: 0, output: 0 } } } } + ] + }) + expect(table).toEqual({ + 'anthropic/a': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'old/b': { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1.25 }, + 'free/z': { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + }) + expect(priceTable(null)).toEqual({}) + }) +}) + +describe('estimateCost', () => { + it('prices per million tokens, reasoning at the output rate', () => { + const cost = estimateCost( + { input: 1_000_000, output: 100_000, reasoning: 100_000, cacheRead: 2_000_000, cacheWrite: 0 }, + { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } + ) + expect(cost).toBeCloseTo(3 + 3 + 0.6) + }) +}) + +describe('mapLimit', () => { + it('keeps order and never runs more than the limit at once', async () => { + let running = 0 + let peak = 0 + const out = await mapLimit([1, 2, 3, 4, 5, 6, 7], 3, async (n) => { + running++ + peak = Math.max(peak, running) + await new Promise((r) => setTimeout(r, 5 * (8 - n))) + running-- + return n * 2 + }) + expect(out).toEqual([2, 4, 6, 8, 10, 12, 14]) + expect(peak).toBe(3) + }) +}) + +describe('UsageRowCache', () => { + it('hits only while updated matches and evicts the least recently used', () => { + const cache = new UsageRowCache(2) + cache.set('a', 1, []) + cache.set('b', 1, []) + expect(cache.get('a', 2)).toBeUndefined() + expect(cache.get('a', 1)).toEqual([]) + cache.set('c', 1, []) + expect(cache.size).toBe(2) + expect(cache.get('b', 1)).toBeUndefined() + expect(cache.get('a', 1)).toEqual([]) + }) +}) From d07d478aba08b3007b74b48f2d0b94dfe9d6a31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Manuel=20B=C3=A9cares?= Date: Thu, 15 Oct 2026 19:27:47 +0200 Subject: [PATCH 02/11] feat(agents): edit agents and choose which MCP servers each one may use New Agents page: description, mode, model, prompt, temperature, built-in tools and per-server MCP access (inherit / allow / block), saved to the project or global config. Descriptions and prompts can be drafted by a model through the new POST /api/v1/ai-text. Long model lists open a roomy searchable picker. --- app/components/ModelSelect.vue | 111 +++ app/components/PickerModal.vue | 152 ++++ app/composables/useModelCatalog.ts | 77 ++ app/pages/p/[dir]/agents.vue | 1111 ++++++++++++++++++++++++++++ server/api/v1/ai-text.post.ts | 21 + server/utils/ai.ts | 57 ++ 6 files changed, 1529 insertions(+) create mode 100644 app/components/ModelSelect.vue create mode 100644 app/components/PickerModal.vue create mode 100644 app/composables/useModelCatalog.ts create mode 100644 app/pages/p/[dir]/agents.vue create mode 100644 server/api/v1/ai-text.post.ts create mode 100644 server/utils/ai.ts diff --git a/app/components/ModelSelect.vue b/app/components/ModelSelect.vue new file mode 100644 index 0000000..de815fd --- /dev/null +++ b/app/components/ModelSelect.vue @@ -0,0 +1,111 @@ + + + diff --git a/app/components/PickerModal.vue b/app/components/PickerModal.vue new file mode 100644 index 0000000..7af88c5 --- /dev/null +++ b/app/components/PickerModal.vue @@ -0,0 +1,152 @@ + + + diff --git a/app/composables/useModelCatalog.ts b/app/composables/useModelCatalog.ts new file mode 100644 index 0000000..2b8ed0b --- /dev/null +++ b/app/composables/useModelCatalog.ts @@ -0,0 +1,77 @@ +import type { ProvidersResponse } from '#shared/types/opencode' + +export interface ModelItem { + label: string + /** "providerID/modelID" */ + value: string + provider: string + providerID: string + reasoning: boolean + /** context window in thousands of tokens */ + ctx: number + /** "$in/$out" per million tokens, "free" or "" */ + cost: string + attachment: boolean +} + +/** Flat, sorted model list from opencode's /config/providers answer. */ +export function modelItemsOf(providers: ProvidersResponse | null | undefined): ModelItem[] { + if (!providers) return [] + const items: ModelItem[] = [] + for (const provider of providers.providers || []) { + const providerName = String(provider.name || provider.id) + for (const [id, m] of Object.entries(provider.models || {})) { + const cin = m.cost?.input + const cout = m.cost?.output + items.push({ + label: `${m.name || id}`, + value: `${provider.id}/${id}`, + provider: providerName, + providerID: provider.id, + reasoning: Boolean(m.reasoning || m.capabilities?.reasoning), + ctx: Math.round((m.limit?.context || 0) / 1000), + cost: cin != null && cout != null && (cin > 0 || cout > 0) + ? `$${cin}/${cout}` + : (cin === 0 && cout === 0 ? 'free' : ''), + attachment: Boolean(m.attachment) + }) + } + } + return items.sort((a, b) => a.provider.localeCompare(b.provider) || a.label.localeCompare(b.label)) +} + +/** + * Providers and models of a project, painted from the local cache first + * (same cache as the chat) and refreshed from the server. + */ +export function useModelCatalog(directory: MaybeRefOrGetter) { + const api = useOpencodeApi(directory) + const providers = ref(null) + const defaultModel = ref('') + const loading = ref(false) + const cacheKey = () => `opencode-web.providers.${toValue(directory) || ''}` + + async function load() { + loading.value = true + try { + const cached = JSON.parse(localStorage.getItem(cacheKey()) || 'null') + if (cached && !providers.value) providers.value = cached + } catch { /* ignore */ } + try { + const [prov, cfg] = await Promise.all([ + api.providers().catch(() => null), + api.config().catch(() => null) + ]) + if (prov) { + providers.value = prov + try { localStorage.setItem(cacheKey(), JSON.stringify(prov)) } catch { /* full */ } + } + if (typeof cfg?.model === 'string') defaultModel.value = cfg.model + } finally { + loading.value = false + } + } + + const items = computed(() => modelItemsOf(providers.value)) + return { providers, items, defaultModel, loading, load } +} diff --git a/app/pages/p/[dir]/agents.vue b/app/pages/p/[dir]/agents.vue new file mode 100644 index 0000000..d035f0b --- /dev/null +++ b/app/pages/p/[dir]/agents.vue @@ -0,0 +1,1111 @@ + + + diff --git a/server/api/v1/ai-text.post.ts b/server/api/v1/ai-text.post.ts new file mode 100644 index 0000000..4aeb7f6 --- /dev/null +++ b/server/api/v1/ai-text.post.ts @@ -0,0 +1,21 @@ +// Generate a short text with one of opencode's models (see utils/ai.ts). +import type { AiTextRequest } from '../../utils/ai' +export default defineEventHandler(async (event) => { + requireApiToken(event) + assertBodySize(event, 256 * 1024) + const body = await readBody>(event) + if (!body?.prompt || typeof body.prompt !== 'string') { + throw createError({ statusCode: 400, message: 'prompt is required' }) + } + try { + const text = await aiText({ + prompt: body.prompt, + system: typeof body.system === 'string' ? body.system : undefined, + model: typeof body.model === 'string' ? body.model : undefined, + directory: typeof body.directory === 'string' ? body.directory : undefined + }) + return { text } + } catch (error) { + throw createError({ statusCode: 502, message: error instanceof Error ? error.message : String(error) }) + } +}) diff --git a/server/utils/ai.ts b/server/utils/ai.ts new file mode 100644 index 0000000..8821a66 --- /dev/null +++ b/server/utils/ai.ts @@ -0,0 +1,57 @@ +// One-shot text generation with a model the opencode server already knows: +// a throwaway session gets the prompt (no tools), the reply text comes back +// and the session is deleted. Used to write MCP summaries and agent +// descriptions with "Generate with AI". + +export interface AiTextRequest { + /** "provider/model"; omit for opencode's default model */ + model?: string + /** project the throwaway session runs in; default: opencode's own */ + directory?: string + /** instructions placed before the task */ + system?: string + prompt: string +} + +function parseModel(model?: string) { + if (!model || !model.includes('/')) return undefined + const [providerID, ...rest] = model.split('/') + return providerID && rest.length ? { providerID, modelID: rest.join('/') } : undefined +} + +export async function aiText(req: AiTextRequest): Promise { + const query = { directory: req.directory || undefined } + const session = await opencodeFetch<{ id: string }>('/session', { + method: 'POST', + body: { title: 'opencode-web helper (deleted when done)' }, + query, + timeoutMs: 20_000 + }) + try { + const reply = await opencodeFetch<{ info?: { error?: { data?: { message?: string }; name?: string } }; parts?: Array<{ type: string; text?: string; synthetic?: boolean }> }>( + `/session/${encodeURIComponent(session.id)}/message`, + { + method: 'POST', + query, + timeoutMs: 5 * 60_000, + body: { + model: parseModel(req.model), + // a plain completion: no file edits, no MCP calls + tools: { '*': false }, + parts: [{ type: 'text', text: [req.system?.trim(), req.prompt.trim()].filter(Boolean).join('\n\n') }] + } + } + ) + const error = reply?.info?.error + if (error) throw new Error(error.data?.message || error.name || 'the model returned an error') + const text = (reply?.parts || []) + .filter((p) => p.type === 'text' && p.text && !p.synthetic) + .map((p) => p.text!.trim()) + .join('\n\n') + .trim() + if (!text) throw new Error('the model returned no text') + return text + } finally { + opencodeFetch(`/session/${encodeURIComponent(session.id)}`, { method: 'DELETE', query }).catch(() => {}) + } +} From fdea1e892139ab0f2efac4eeb7cd23980b0bc55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Manuel=20B=C3=A9cares?= Date: Thu, 15 Oct 2026 19:27:48 +0200 Subject: [PATCH 03/11] feat(control): agent control on by default, live MCP switching, reload & continue - the control MCP server is registered once by default (NUXT_AGENT_CONTROL=auto|off), so every chat lists it; switching it off is remembered - switching an MCP server on or off connects/disconnects it in the running opencode at once (POST /api/v1/mcp-apply); the MCP page flags servers whose config and running state disagree, with Connect now / Reload buttons - reloads can resume chats afterwards: reload_opencode continues the busy chats (the asking one included), the UI offers "Reload now & continue" - new tools run_subagent (any agent in a sub-chat) and current_session --- app/components/AgentControlCard.vue | 9 +- app/components/McpServerList.vue | 3 + app/composables/useReload.ts | 28 +++++- app/pages/p/[dir]/mcp.vue | 113 ++++++++++++++++++++++- nuxt.config.ts | 3 + playwright.compat.config.ts | 1 + playwright.config.ts | 4 +- server/api/v1/capabilities.get.ts | 2 + server/api/v1/control-mcp.get.ts | 1 + server/api/v1/control-mcp.post.ts | 15 +--- server/api/v1/mcp-apply.post.ts | 10 +++ server/api/v1/reload.post.ts | 21 ++++- server/plugins/control-mcp.ts | 6 ++ server/utils/control.ts | 135 +++++++++++++++++++++++++++- server/utils/mcp-admin.ts | 27 ++++++ 15 files changed, 346 insertions(+), 32 deletions(-) create mode 100644 server/api/v1/mcp-apply.post.ts create mode 100644 server/plugins/control-mcp.ts diff --git a/app/components/AgentControlCard.vue b/app/components/AgentControlCard.vue index f171bc8..47132ad 100644 --- a/app/components/AgentControlCard.vue +++ b/app/components/AgentControlCard.vue @@ -53,9 +53,10 @@ async function install(enabled = true) { } const TOOLS = [ - ['MCP', 'list, test, add, reconnect, set modes'], - ['Reload', 'one project or all of opencode, after replies finish'], + ['MCP', 'list, test, add, switch on/off at once, set modes'], + ['Reload', 'one project or all of opencode; the chat continues by itself afterwards'], ['Chats', 'list, read and prompt other sessions, abort'], + ['Sub-chats', 'run any agent on a task in a sub-chat (run_subagent)'], ['Tasks', 'todo lists of busy and recent sessions'], ['Schedules', 'create, run and manage scheduled prompts'], ['Info', 'version, models, agents, commands, skills, config'] @@ -81,7 +82,7 @@ const TOOLS = [
-
+
{{ title }}
{{ text }}
@@ -123,7 +124,7 @@ const TOOLS = [ label="Tools" @click="emit('details', state.name)" /> - Saved in the global config; new chats get the tools. + On by default, saved in the global config: every chat lists it among its MCP servers. Switching it off here is remembered.
diff --git a/app/components/McpServerList.vue b/app/components/McpServerList.vue index 5dd9ade..7c0dcd4 100644 --- a/app/components/McpServerList.vue +++ b/app/components/McpServerList.vue @@ -319,6 +319,9 @@ function toolsHint(server: McpListServer) { />
+ + +
false) + // changes saved to the config that opencode only picks up after a reload, + // by project directory ('*' = global): pages show a "reload to apply" notice + const pendingReload = useState>('oc-pending-reload', () => ({})) - async function reload(opts: { scope: 'project' | 'all'; directory?: string; when?: 'now' | 'idle' }) { + function markPending(directory: string | '*', what: string) { + const list = pendingReload.value[directory] || [] + if (!list.includes(what)) pendingReload.value = { ...pendingReload.value, [directory]: [...list, what] } + } + + function pendingFor(directory: string) { + return [...new Set([...(pendingReload.value['*'] || []), ...(pendingReload.value[directory] || [])])] + } + + /** + * `resume`: chats (of `directory`) to continue once the reload is done; + * `resumeBusy`: continue every chat that is busy when the reload starts. + */ + async function reload(opts: { scope: 'project' | 'all'; directory?: string; when?: 'now' | 'idle'; resume?: string[]; resumeBusy?: boolean }) { if (!caps.value.config) { toast.add({ title: 'Not available on this server', description: 'Restart the opencode server process to reload its config.', color: 'warning' }) return @@ -34,7 +51,9 @@ export function useReload() { state = await $fetch('/api/v1/reload', { timeout: 10000 }).catch(() => state) } if (state?.state === 'done') { - toast.add({ title: `Reloaded ${label}`, description: 'opencode re-read its config. New sessions use it.', color: 'success' }) + pendingReload.value = {} + const resumed = state.resumed?.length ? ` ${state.resumed.length} chat(s) continue where they stopped.` : '' + toast.add({ title: `Reloaded ${label}`, description: `opencode re-read its config. New messages use it.${resumed}`, color: 'success' }) } else { toast.add({ title: `Reload of ${label}: ${state?.state || 'unknown'}`, description: state?.message, color: 'warning' }) } @@ -52,9 +71,10 @@ export function useReload() { { label: 'Reload this project', description: 'when its sessions are idle', icon: 'i-lucide-folder-sync', onSelect: () => reload({ scope: 'project', directory }) }, { label: 'Reload all projects', description: 'like restarting opencode serve', icon: 'i-lucide-server-cog', onSelect: () => reload({ scope: 'all' }) } ], [ - { label: 'Reload this project now', description: 'interrupts running replies', icon: 'i-lucide-zap', color: 'warning' as const, onSelect: () => reload({ scope: 'project', directory, when: 'now' }) } + { label: 'Reload now & continue', description: 'interrupts running replies, then resumes them', icon: 'i-lucide-zap', color: 'warning' as const, onSelect: () => reload({ scope: 'project', directory, when: 'now', resumeBusy: true }) }, + { label: 'Reload this project now', description: 'interrupts running replies', icon: 'i-lucide-zap-off', color: 'warning' as const, onSelect: () => reload({ scope: 'project', directory, when: 'now' }) } ]] } - return { reload, reloading, menuItems } + return { reload, reloading, menuItems, pendingReload, markPending, pendingFor } } diff --git a/app/pages/p/[dir]/mcp.vue b/app/pages/p/[dir]/mcp.vue index c5eef6b..160fa05 100644 --- a/app/pages/p/[dir]/mcp.vue +++ b/app/pages/p/[dir]/mcp.vue @@ -73,9 +73,63 @@ function reportBroken(name: string, action: string) { // the subagent-per-MCP card re-plans after servers change const nestCard = ref<{ load: () => void } | null>(null) -const { reloading, menuItems } = useReload() +const { reload, reloading, menuItems } = useReload() const reloadMenu = computed(() => menuItems(directory.value)) +// ---- config vs running opencode ---- +// opencode reads `enabled` when an instance starts: a server switched on or +// off through the config can disagree with what is running. Such rows get a +// notice with a one-click fix (connect / disconnect now, or reload). +const LIVE = ['connected', 'running', 'ok', 'success'] +interface Drift { kind: 'load' | 'unload'; text: string } +function driftOf(name: string): Drift | undefined { + const entry = entries.value.find((e) => e.name === name) + if (!entry?.config) return undefined + const configOn = entry.config.enabled !== false + if (configOn && ['disabled', 'unknown'].includes(entry.status)) { + return { kind: 'load', text: 'Switched on in the config, but the running opencode has not loaded it yet.' } + } + if (!configOn && LIVE.includes(entry.status)) { + return { kind: 'unload', text: 'Switched off in the config, but still connected in the running opencode.' } + } + return undefined +} +const driftCount = computed(() => entries.value.filter((e) => driftOf(e.name)).length) +const applying = ref(null) + +/** Connect or disconnect a server in the running opencode to match its config. */ +async function applyLive(name: string, enabled?: boolean, quiet = false) { + applying.value = name + try { + const result = await $fetch<{ status?: { status: string; error?: string } }>('/api/v1/mcp-apply', { + method: 'POST', + body: { name, directory: directory.value, ...(typeof enabled === 'boolean' ? { enabled } : {}) }, + timeout: 90_000 + }) + if (!quiet) { + const status = result.status?.status || 'unknown' + toast.add({ title: `${name}: ${status}`, color: LIVE.includes(status) || status === 'disabled' ? 'success' : 'warning' }) + } + return result.status + } catch (e) { + if (!quiet) toast.add({ title: `Could not apply ${name}`, description: errorMessage(e), color: 'error' }) + } finally { + applying.value = null + } +} + +async function applyAndRefresh(name: string) { + const drift = driftOf(name) + await applyLive(name, drift ? drift.kind === 'load' : undefined) + await refresh(true) + reportBroken(name, 'connected') +} + +async function reloadAndContinue() { + await reload({ scope: 'project', directory: directory.value, resumeBusy: true }) + await refresh(true) +} + function errorMessage(e: unknown) { const data = (e as { data?: { message?: string; data?: { message?: string } } })?.data return data?.data?.message || data?.message || (e instanceof Error ? e.message : String(e)) @@ -313,12 +367,17 @@ async function setServerMode(entry: McpEntry, mode: ServerMode) { }) configPermission.value = { ...configPermission.value, [`${entry.name}_*`]: mode } } + // make the running opencode follow at once: no reload needed + const live = await applyLive(entry.name, mode !== 'off', true) await refresh() if (mode === 'off' || !reportBroken(entry.name, 'enabled')) { + const drift = driftOf(entry.name) toast.add({ title: `${entry.name}: ${mode === 'off' ? 'disabled' : mode === 'ask' ? 'enabled (asks first)' : 'enabled (no ask)'}`, - description: 'Applies to new sessions.', - color: 'success' + description: drift + ? 'Saved. The running opencode still needs a reload: see the notice on the server.' + : `Saved and applied${live?.status ? ` (${live.status})` : ''}. Chats use it from their next message.`, + color: drift ? 'warning' : 'success' }) } } catch (e) { @@ -703,7 +762,7 @@ useHead(() => ({ title: `MCP · ${dirName(directory.value)} · opencode web` }))

- +
@@ -743,6 +802,23 @@ useHead(() => ({ title: `MCP · ${dirName(directory.value)} · opencode web` })) + 'directory is missing. Open a project path that exists on the opencode host.'" /> + + +
+ + + +
+
+
@@ -773,6 +849,35 @@ useHead(() => ({ title: `MCP · ${dirName(directory.value)} · opencode web` })) @authenticate="startAuth" @details="openDetails" > +