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 * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger'; import { logger } from '../logger';
import { setOpenCodeSession } from './state'; 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 // 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 // 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; const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return; if (typeof port !== 'number') return;
serverPort = port; 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() }); 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 { 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: // 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. // no error, no warning, just an agent that never sees a skill. So the keys are asserted literally.
describe('serveConfigContent', () => { describe('serveConfigContent', () => {
test('names skills.paths and instructions exactly', () => { test('allows the skill tool, because nothing here can answer a prompt', () => {
const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'], 'PROMPT')); const parsed = JSON.parse(serveConfigContent([], '/tmp/p.md'));
expect(parsed.skills).toEqual({ paths: ['/a/skills', '/b/skills'] }); 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']); expect(parsed.instructions).toEqual(['/tmp/aff.md']);
// The agent is what a session is created with; a config without it silently falls back to // 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. // OpenCode's coding-agent framing, which is the bug this whole file exists to stop.
expect(parsed.default_agent).toBe('officer'); expect(parsed.default_agent).toBe('officer');
expect(parsed.agent.officer.prompt).toBe('PROMPT'); // `{file:}` — the documented form. Inline prompt text is accepted and never applied.
expect(parsed.agent.officer.mode).toBe('primary'); 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, // An empty array is not the same as an absent key: merged over a config that sets some, it erases them.
// and merged over a config that sets some it would erase them. test('omits instructions rather than sending it empty', () => {
test('omits a key rather than sending it empty', () => { expect(JSON.parse(serveConfigContent([], '/tmp/p.md')).instructions).toBeUndefined();
expect(JSON.parse(serveConfigContent([], [], 'P')).skills).toBeUndefined(); expect(JSON.parse(serveConfigContent(['/tmp/a.md'], '/tmp/p.md')).instructions).toEqual(['/tmp/a.md']);
expect(JSON.parse(serveConfigContent(['/a'], [], 'P')).skills).toEqual({ paths: ['/a'] });
}); });
test('is valid JSON for an env var — no newlines to be mangled by a shell', () => { 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', () => { 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'); 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, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { getOwnerUser } from 'officerdb'; import { getOwnerUser } from 'officerdb';
import { mintSessionCredential } from '../agent-credential'; 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 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`. * 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 * It has a `skill` tool and the same `<name>/SKILL.md` layout the platform already stores. What it does
* directories of `<name>/SKILL.md`. That is the same progressive disclosure Claude does and the same * NOT have is a way to be told where else to look: `skills.paths` appears in the binary's own config
* layout the platform already stores, so this points OpenCode at the SAME FILES rather than compiling a * example and is not in the documentation, and the difference showed — skills configured that way were
* second index in a second format. `skills/load.ts` says a skill is a path because every harness can read * LISTED to the agent and then failed to load, with `Unable to load skill calendar-and-contacts`. Being
* a file; this is the better version of that bet — the harness had the feature all along. * 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 ── * ── 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 * 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. * 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 ── * ── 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: * 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. */ /** 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'); 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 * 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. * 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; 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({ 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 } : {}), ...(instructions.length ? { instructions } : {}),
agent: { agent: {
[OFFICER_AGENT]: { [OFFICER_AGENT]: {
description: "Officer's own assistant: this machine, not just its code.", description: "Officer's own assistant: this machine, not just its code.",
mode: 'primary', mode: 'primary',
prompt: agentPrompt, // `{file:}`, per the documentation. Inline prompt text is parsed and never applied.
prompt: `{file:${promptFile}}`,
}, },
}, },
default_agent: OFFICER_AGENT, 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); 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. // The same index a Claude session is given, compiled by the same function.
skills = await skillsBlock(); skills = await skillsBlock();
const linked = linkSkills(skillRoots);
console.log(`[opencode] linked ${linked} skill(s) into ${skillLinkDir()}`);
} catch (err) { } catch (err) {
console.error('[opencode] could not resolve the skill roots', 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 { try {
const affordances = await affordanceBlock(); const affordances = await affordanceBlock();
if (affordances) { 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); 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( console.log(
`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` + `[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` +
`${skills ? 'index compiled into the agent prompt' : 'NO skills index'}`, `${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 = { export type Skill = {
/** Directory name — `email-db`. Unique within the store. */ /** Directory name — `email-db`. Unique within the store. */
id: string; 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; 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. */ /** WHEN to reach for this. The only thing the agent sees before deciding to open the body. */
description: string; description: string;
/** Absolute path to SKILL.md. The agent cannot guess it, so the platform supplies it. */ /** 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 fm = frontmatter(readFileSync(path, 'utf8'));
const description = fm.description == null ? '' : String(fm.description).trim(); const description = fm.description == null ? '' : String(fm.description).trim();
if (!description) return null; 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 { } catch {
return null; 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', '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.', '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'); ].join('\n');
} }