From c226e8a46d9a29f5bd864be13c5953d435620ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 13:24:10 +0000 Subject: [PATCH] =?UTF-8?q?copy=20the=20skills,=20do=20not=20link=20them?= =?UTF-8?q?=20=E2=80=94=20a=20link=20is=20what=20made=20it=20hang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the agent called `skill calendar-and-contacts` and sat there. not an error, not a refusal: a spinner, on three different models. opencode grants `external_directory` for the paths it discovered — literally `~/.config/opencode/skills//*` — and a symlink RESOLVES to the plugin directory, which is not in that list. so reading the skill body fell through to `external_directory: "*": "ask"`, and a server session has nobody to ask. the request waits forever. the permission list the serve reports says it plainly: every skill path is allowed at its LINK location and nothing allows the target. copying makes the real path the allowed path. the cost is drift — a skill edited in its plugin is not live until this sidecar restarts — which is why the copy is rewritten from scratch on every boot, and it is a good trade against a hang with no message. copies are marked so a skill somebody wrote there by hand is never clobbered and ours are replaced rather than merged, so a file deleted upstream cannot survive in the copy. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/sidecar/opencode/serve-env.ts | 54 ++++++++++++++--------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/servers/sidecar/opencode/serve-env.ts b/src/servers/sidecar/opencode/serve-env.ts index 6bc3e46f..80554518 100644 --- a/src/servers/sidecar/opencode/serve-env.ts +++ b/src/servers/sidecar/opencode/serve-env.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { getOwnerUser } from 'officerdb'; @@ -30,9 +30,18 @@ import { capabilityRoots } from '../../capability-sources'; * 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. + * So each skill directory is COPIED into `~/.config/opencode/skills/`, which is a documented discovery + * path. + * + * COPIED, NOT LINKED, and that is the whole point of this paragraph. Links were tried first and hung the + * agent: OpenCode grants `external_directory` for the paths it discovered — `~/.config/opencode/skills/ + * /*` — and a symlink RESOLVES to the plugin directory, which is not in that list. Reading the + * body then fell through to `external_directory: "*": "ask"`, and a server session has nobody to ask, so + * the turn sat on `skill calendar-and-contacts` forever. Not an error, not a refusal: a spinner. + * + * A copy makes the real path the allowed path. The cost is drift — a skill edited in its plugin is not + * live until this sidecar restarts — which is why the copy is rewritten from scratch on every boot, and + * why anything else is a bad trade against a hang with no message. * * Two more things the documentation settles, both of which cost an afternoon: * @@ -97,17 +106,19 @@ 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'); +/** Marks a copy as ours, so a hand-made skill in the same directory is never clobbered. */ +const MANAGED = '.officer-managed'; + /** 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. + * Copy every skill into OpenCode's own discovery directory. Returns how many. * - * 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. + * Ours are marked, so a skill someone put there by hand is never touched and ours are always replaced + * rather than merged — a file deleted upstream must not survive in the copy. */ -export function linkSkills(roots: string[]): number { +export function copySkills(roots: string[]): number { const dir = skillLinkDir(); mkdirSync(dir, { recursive: true }); @@ -122,20 +133,23 @@ export function linkSkills(roots: string[]): number { } } + // Ours from a previous boot go first, whether or not they are still wanted. 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 }); + const here = join(dir, entry.name); + if (entry.isSymbolicLink() || existsSync(join(here, MANAGED))) rmSync(here, { recursive: true, force: true }); } - for (const [name, target] of wanted) { - const link = join(dir, name); - if (existsSync(link)) continue; + for (const [name, source] of wanted) { + const target = join(dir, name); + if (existsSync(target)) { + console.error(`[opencode] not overwriting ${target} — it is not ours`); + continue; + } try { - symlinkSync(target, link, 'dir'); + cpSync(source, target, { recursive: true, dereference: true }); + writeFileSync(join(target, MANAGED), 'Written by officer-opencode on boot. Edit the original.\n'); } catch (err) { - console.error(`[opencode] could not link skill ${name}`, err); + console.error(`[opencode] could not copy skill ${name}`, err); } } return wanted.size; @@ -260,8 +274,8 @@ export async function buildServeEnv(serveCwd: string): Promise