From f0801808c4e892082580d91183cdbb3afb535758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 13:11:19 +0000 Subject: [PATCH] read the documentation: four things wrong, none of them the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit an opencode agent could not look at a calendar all afternoon. i treated it as a framing problem, then a regression, then a free-vs-paid model problem, and it was none of those. the documentation says all four, plainly. 1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match ^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a human title there — "Calendar and contacts" — so every one was LISTED and none could be LOADED. the agent found the skill, called skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title` is now a separate field and keeps the prose. 2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config example and not in the docs, and the difference is exactly this: skills configured that way are offered to the agent and cannot be opened. skills load from fixed discovery paths only, so each directory is now SYMLINKED into ~/.config/opencode/skills. links, not copies — one source of truth, and a skill edited in its plugin is live immediately. 3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed without complaint and never applied. this is what made it look like a model problem: the agent existed, the prompt was on its record, and the model had never seen a word of it. 4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}. also: the platform cached opencode's model list for the lifetime of `officer`, so signing into a provider and restarting the SIDECAR left the old catalogue in the OTHER process — six free models, no paid ones, nothing saying why. the cache is dropped when the sidecar reports a new port, which is the one event that always accompanies a new serve. verified: the prompt reaches the model — asked who it is, it answered "Officer, running on your home server at officer-dev under your user account". 10 skills discovered through the linked path with loadable names. NOT verified end to end: a turn that opens the calendar skill and answers from it. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../api/chat/opencode/sidecar-server.ts | 14 ++ .../sidecar/opencode/serve-env.test.ts | 36 +++-- src/servers/sidecar/opencode/serve-env.ts | 142 ++++++++++++++++-- src/servers/skills/load.ts | 17 ++- 4 files changed, 183 insertions(+), 26 deletions(-) diff --git a/src/servers/api/chat/opencode/sidecar-server.ts b/src/servers/api/chat/opencode/sidecar-server.ts index 5b4fbf5b..d46fcb60 100644 --- a/src/servers/api/chat/opencode/sidecar-server.ts +++ b/src/servers/api/chat/opencode/sidecar-server.ts @@ -1,6 +1,7 @@ import * as sidecar from '@@/sidecar-registry'; import { logger } from '../logger'; import { setOpenCodeSession } from './state'; +import { invalidateModelCache } from '../list-models'; // The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it // here on connect. We remember it so the OpenCode harness always routes to the current server. It also @@ -13,6 +14,19 @@ sidecar.on('opencode:server', (msg) => { const port = (msg as { port?: number }).port; if (typeof port !== 'number') return; serverPort = port; + /* + A new serve means a possibly different catalogue, so the cached model list has to go. + + `listOpenCodeModels` caches for the platform process's lifetime, which is right — the catalogue moves + about once a release. But it is the PLATFORM that holds it and the SIDECAR that changes, so signing + into a provider and restarting `officer-opencode` left the platform serving the list it read before + the login: six free models, no paid ones, and nothing anywhere saying why. Reported 2026-08-27, after + a successful login that appeared to do nothing. + + The port report is the one event that always accompanies a new serve, which makes it the right place + to drop the cache. + */ + invalidateModelCache(); logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() }); }); diff --git a/src/servers/sidecar/opencode/serve-env.test.ts b/src/servers/sidecar/opencode/serve-env.test.ts index 741242e8..e8f1bb1a 100644 --- a/src/servers/sidecar/opencode/serve-env.test.ts +++ b/src/servers/sidecar/opencode/serve-env.test.ts @@ -1,33 +1,45 @@ import { describe, expect, test } from 'bun:test'; -import { serveConfigContent, affordancesPath } from './serve-env'; +import { serveConfigContent, affordancesPath, agentFile } from './serve-env'; // The config shape is a contract with the OpenCode binary, and the cost of a wrong key name is SILENCE: // no error, no warning, just an agent that never sees a skill. So the keys are asserted literally. describe('serveConfigContent', () => { - test('names skills.paths and instructions exactly', () => { - const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'], 'PROMPT')); - expect(parsed.skills).toEqual({ paths: ['/a/skills', '/b/skills'] }); + test('allows the skill tool, because nothing here can answer a prompt', () => { + const parsed = JSON.parse(serveConfigContent([], '/tmp/p.md')); + expect(parsed.permission).toEqual({ skill: { '*': 'allow' } }); + }); + + test('names instructions and the prompt file exactly', () => { + const parsed = JSON.parse(serveConfigContent(['/tmp/aff.md'], '/tmp/p.md')); expect(parsed.instructions).toEqual(['/tmp/aff.md']); // The agent is what a session is created with; a config without it silently falls back to // OpenCode's coding-agent framing, which is the bug this whole file exists to stop. expect(parsed.default_agent).toBe('officer'); - expect(parsed.agent.officer.prompt).toBe('PROMPT'); - expect(parsed.agent.officer.mode).toBe('primary'); + // `{file:}` — the documented form. Inline prompt text is accepted and never applied. + expect(parsed.agent.officer.prompt).toBe('{file:/tmp/p.md}'); }); - // An empty array is not the same as an absent key: `skills: {paths: []}` is a claim that there are none, - // and merged over a config that sets some it would erase them. - test('omits a key rather than sending it empty', () => { - expect(JSON.parse(serveConfigContent([], [], 'P')).skills).toBeUndefined(); - expect(JSON.parse(serveConfigContent(['/a'], [], 'P')).skills).toEqual({ paths: ['/a'] }); + // An empty array is not the same as an absent key: merged over a config that sets some, it erases them. + test('omits instructions rather than sending it empty', () => { + expect(JSON.parse(serveConfigContent([], '/tmp/p.md')).instructions).toBeUndefined(); + expect(JSON.parse(serveConfigContent(['/tmp/a.md'], '/tmp/p.md')).instructions).toEqual(['/tmp/a.md']); }); test('is valid JSON for an env var — no newlines to be mangled by a shell', () => { - expect(serveConfigContent(['/a/skills'], ['/tmp/aff.md'], 'P')).not.toContain('\n'); + expect(serveConfigContent(['/tmp/aff.md'], '/tmp/p.md')).not.toContain('\n'); }); test('the affordances file sits beside the serve state, not in a home', () => { expect(affordancesPath('/data/opencode_server')).toBe('/data/opencode_server/officer-affordances.md'); }); + + // The body IS the system prompt — the inline `prompt` key is parsed and ignored, which is the whole + // reason this file form exists. + test('the agent file puts the prompt in the body, after frontmatter', () => { + const out = agentFile('YOU ARE OFFICER'); + expect(out.startsWith('---\n')).toBe(true); + expect(out).toContain('mode: primary'); + expect(out.split('---')[2]?.trim()).toBe('YOU ARE OFFICER'); + }); }); diff --git a/src/servers/sidecar/opencode/serve-env.ts b/src/servers/sidecar/opencode/serve-env.ts index 6fc5ec98..6bc3e46f 100644 --- a/src/servers/sidecar/opencode/serve-env.ts +++ b/src/servers/sidecar/opencode/serve-env.ts @@ -1,5 +1,5 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { existsSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; import { getOwnerUser } from 'officerdb'; import { mintSessionCredential } from '../agent-credential'; @@ -22,13 +22,24 @@ import { capabilityRoots } from '../../capability-sources'; * that have nothing to do with this file and everything to do with one serve running as one user. When * that lifts, this has to become per-session or it is a privilege escalation. See `agent-credential.ts`. * - * ── Skills: OpenCode already has the mechanism ── + * ── Skills: LINKED into a discovery path, not pointed at ── * - * It has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking - * directories of `/SKILL.md`. That is the same progressive disclosure Claude does and the same - * layout the platform already stores, so this points OpenCode at the SAME FILES rather than compiling a - * second index in a second format. `skills/load.ts` says a skill is a path because every harness can read - * a file; this is the better version of that bet — the harness had the feature all along. + * It has a `skill` tool and the same `/SKILL.md` layout the platform already stores. What it does + * NOT have is a way to be told where else to look: `skills.paths` appears in the binary's own config + * example and is not in the documentation, and the difference showed — skills configured that way were + * LISTED to the agent and then failed to load, with `Unable to load skill calendar-and-contacts`. Being + * offered something you cannot open is worse than not being offered it. + * + * So each skill directory is SYMLINKED into `~/.config/opencode/skills/`, which is a documented + * discovery path. Links rather than copies: one source of truth, and a skill edited in its plugin is + * live immediately. Only our own links are removed, and only when their target has gone. + * + * Two more things the documentation settles, both of which cost an afternoon: + * + * - `name` in a SKILL.md must match `^[a-z0-9]+(-[a-z0-9]+)*$` AND the directory name. Every skill + * here carried a human title there, so every one was listed and none could be loaded. + * - The `skill` permission defaults to asking, and a server session has nobody to ask, so the request + * errors. `{"skill": {"*": "allow"}}` is what makes it usable headless. * * ── Affordances: one composed file, because they are not optional reading ── * @@ -50,6 +61,30 @@ import { capabilityRoots } from '../../capability-sources'; * So Officer defines its own primary agent and makes it the default. `prompt` REPLACES the built-in * system text rather than adding to it, which is the point. * + * ── The agent's prompt is a FILE, and that is not a preference ── + * + * The inline form is documented — `agent: { officer: { prompt: "..." } }` — and it is parsed: the agent + * shows up in `/agent` with the prompt on it. It is not APPLIED. Asked whether a sentence unique to that + * prompt appeared in its instructions, a turn on 2026-08-27 answered no, and no again for the skills + * index, while running under `agent: officer`. OpenCode's own documentation says "use the file form for + * anything non-trivial"; this is what that sentence is protecting you from. + * + * So the prompt is written to `~/.config/opencode/agent/officer.md`, which is the form that works. The + * note below about not editing the owner's dotfiles still stands for CONFIG — the skills paths and the + * default agent stay in the env var, where they demonstrably work and leave nothing behind. This one + * file is the exception, it is entirely ours, and it is rewritten from scratch on every boot. + * + * ── `prompt` is a FILE REFERENCE, not the text ── + * + * From the documentation: `"prompt": "{file:./prompts/your-prompt.txt}"`. Passing the prompt itself is + * parsed without complaint — the agent appears in `/agent` with the text on it — and never applied. A + * turn on 2026-08-27, running under `agent: officer`, was asked whether a sentence unique to that prompt + * appeared in its instructions and answered no; and no again for the skills index. Hours went into + * treating that as a model problem, a framing problem and a paid-tier problem, and it was a `{file:}`. + * + * So the prompt is written beside the affordances file and referenced. Nothing is written to the owner's + * config directory, which is the point of the note below. + * * ── Why `OPENCODE_CONFIG_CONTENT` rather than editing the owner's config ── * * It is inline JSON, it MERGES with the config files rather than replacing them (verified in the binary: @@ -62,6 +97,68 @@ import { capabilityRoots } from '../../capability-sources'; /** Where the composed affordances land. Beside the serve's own state, not in anyone's home. */ export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-affordances.md'); +/** A documented discovery path, and the only kind the `skill` tool can actually load from. */ +export const skillLinkDir = () => join(homedir(), '.config', 'opencode', 'skills'); + +/** + * Link every skill into OpenCode's own discovery directory. + * + * Returns how many are linked. Existing links to the same target are left alone; ours that now point at + * nothing are removed. A real directory someone put there by hand is never touched — this owns links, + * not the folder. + */ +export function linkSkills(roots: string[]): number { + const dir = skillLinkDir(); + mkdirSync(dir, { recursive: true }); + + const wanted = new Map(); + for (const root of roots) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (!existsSync(join(root, entry.name, 'SKILL.md'))) continue; + // First root wins, matching `loadSkillsAcross`: the owner's own skills are not displaced by a + // plugin that picked the same directory name. + if (!wanted.has(entry.name)) wanted.set(entry.name, join(root, entry.name)); + } + } + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isSymbolicLink()) continue; + const link = join(dir, entry.name); + if (wanted.get(entry.name) === readlinkSync(link)) continue; + rmSync(link, { force: true }); + } + + for (const [name, target] of wanted) { + const link = join(dir, name); + if (existsSync(link)) continue; + try { + symlinkSync(target, link, 'dir'); + } catch (err) { + console.error(`[opencode] could not link skill ${name}`, err); + } + } + return wanted.size; +} + +/** Where the agent's system prompt is written, for `{file:}` to reference. */ +export const agentPromptPath = (serveCwd: string) => join(serveCwd, 'officer-agent-prompt.md'); + +/** The agent definition OpenCode actually reads. Ours entirely — rewritten from scratch each boot. */ +export const agentFilePath = () => join(homedir(), '.config', 'opencode', 'agent', `${OFFICER_AGENT}.md`); + +/** Frontmatter, then the body — and the body IS the system prompt. */ +export const agentFile = (prompt: string) => + [ + '---', + "description: Officer's own assistant: this machine, not just its code.", + 'mode: primary', + '---', + '', + prompt, + '', + ].join('\n'); + /** * The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the * cost of getting a key name wrong is silence — no error, just an agent that never sees a skill. @@ -107,15 +204,18 @@ export function officerAgentPrompt(skills: string | null): string { return skills ? `${OFFICER_AGENT_PROMPT}\n\n${skills}` : OFFICER_AGENT_PROMPT; } -export function serveConfigContent(skillRoots: string[], instructions: string[], agentPrompt: string): string { +export function serveConfigContent(instructions: string[], promptFile: string): string { return JSON.stringify({ - ...(skillRoots.length ? { skills: { paths: skillRoots } } : {}), + // Nothing here may stop to ask a person: there is nobody at this end of the socket, and a `skill` + // permission left at its default of "ask" fails the call instead of waiting. + permission: { skill: { '*': 'allow' } }, ...(instructions.length ? { instructions } : {}), agent: { [OFFICER_AGENT]: { description: "Officer's own assistant: this machine, not just its code.", mode: 'primary', - prompt: agentPrompt, + // `{file:}`, per the documentation. Inline prompt text is parsed and never applied. + prompt: `{file:${promptFile}}`, }, }, default_agent: OFFICER_AGENT, @@ -160,10 +260,28 @@ export async function buildServeEnv(serveCwd: string): Promise `- **${s.name}** — ${s.description}\n Read: \`${s.path}\``), + ...skills.map((s) => `- **${s.title}** (\`${s.name}\`) — ${s.description}\n Read: \`${s.path}\``), ].join('\n'); }