read the documentation: four things wrong, none of them the model

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 13:11:19 +00:00
co-authored by Claude Opus 5
parent a873690e27
commit f0801808c4
4 changed files with 183 additions and 26 deletions
@@ -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() });
});
+24 -12
View File
@@ -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');
});
});
+130 -12
View File
@@ -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 `<name>/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 `<name>/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<string, string>();
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<Record<string, st
for (const root of await capabilityRoots('skills')) if (existsSync(root)) skillRoots.push(root);
// The same index a Claude session is given, compiled by the same function.
skills = await skillsBlock();
const linked = linkSkills(skillRoots);
console.log(`[opencode] linked ${linked} skill(s) into ${skillLinkDir()}`);
} catch (err) {
console.error('[opencode] could not resolve the skill roots', err);
}
try {
const file = agentFilePath();
mkdirSync(join(file, '..'), { recursive: true });
writeFileSync(file, agentFile(officerAgentPrompt(skills)));
console.log(`[opencode] wrote the officer agent to ${file}`);
} catch (err) {
console.error('[opencode] could not write the agent file — turns will use the built-in agent', err);
}
try {
mkdirSync(serveCwd, { recursive: true });
writeFileSync(agentPromptPath(serveCwd), officerAgentPrompt(skills));
} catch (err) {
console.error('[opencode] could not write the agent prompt — turns will use the built-in agent', err);
}
try {
const affordances = await affordanceBlock();
if (affordances) {
@@ -175,7 +293,7 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
console.error('[opencode] could not write the affordances file', err);
}
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(skillRoots, instructions, officerAgentPrompt(skills));
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(instructions, agentPromptPath(serveCwd));
console.log(
`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` +
`${skills ? 'index compiled into the agent prompt' : 'NO skills index'}`,
+15 -2
View File
@@ -32,7 +32,18 @@ export const SKILLS_DIR = join(OFFICER_ITEMS_DIR, 'skills');
export type Skill = {
/** Directory name — `email-db`. Unique within the store. */
id: string;
/**
* The identifier, and it must equal the directory name.
*
* Not decoration and not a label. A harness with native skill support passes this string back to load
* the skill (`skill({ name: "email-db" })`), and both OpenCode and Claude Code require it to match
* `^[a-z0-9]+(-[a-z0-9]+)*$` and the directory it sits in. Every skill here carried a human title in
* this field until 2026-08-27 — "Calendar and contacts" — so every one was LISTED and none could be
* LOADED. An agent saw the skill, could not open it, and answered as though it did not exist.
*/
name: string;
/** What a person reads. Free text, and purely cosmetic — `name` is the part that is not. */
title: string;
/** WHEN to reach for this. The only thing the agent sees before deciding to open the body. */
description: string;
/** Absolute path to SKILL.md. The agent cannot guess it, so the platform supplies it. */
@@ -75,7 +86,9 @@ export function loadSkills(root: string = SKILLS_DIR): Skill[] {
const fm = frontmatter(readFileSync(path, 'utf8'));
const description = fm.description == null ? '' : String(fm.description).trim();
if (!description) return null;
return { id: dir, name: fm.name == null ? dir : String(fm.name), description, path };
// `name` falls back to the directory, which is what it must equal anyway.
const name = fm.name == null ? dir : String(fm.name);
return { id: dir, name, title: fm.title == null ? name : String(fm.title), description, path };
} catch {
return null;
}
@@ -100,7 +113,7 @@ export function compileSkills(skills: Skill[]): string | null {
'covers what you are about to do, READ THE FILE first — it will be more current than anything you',
'remember, and it exists so you do not have to rediscover what is already written down.',
'',
...skills.map((s) => `- **${s.name}** — ${s.description}\n Read: \`${s.path}\``),
...skills.map((s) => `- **${s.title}** (\`${s.name}\`) — ${s.description}\n Read: \`${s.path}\``),
].join('\n');
}