copy the skills, do not link them — a link is what made it hang
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/<name>/*` — 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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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 { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getOwnerUser } from 'officerdb';
|
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
|
* 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.
|
* 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
|
* So each skill directory is COPIED into `~/.config/opencode/skills/`, which is a documented discovery
|
||||||
* discovery path. Links rather than copies: one source of truth, and a skill edited in its plugin is
|
* path.
|
||||||
* live immediately. Only our own links are removed, and only when their target has gone.
|
*
|
||||||
|
* 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/
|
||||||
|
* <name>/*` — 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:
|
* 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. */
|
/** 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');
|
||||||
|
|
||||||
|
/** 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. */
|
/** A documented discovery path, and the only kind the `skill` tool can actually load from. */
|
||||||
export const skillLinkDir = () => join(homedir(), '.config', 'opencode', 'skills');
|
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
|
* Ours are marked, so a skill someone put there by hand is never touched and ours are always replaced
|
||||||
* nothing are removed. A real directory someone put there by hand is never touched — this owns links,
|
* rather than merged — a file deleted upstream must not survive in the copy.
|
||||||
* not the folder.
|
|
||||||
*/
|
*/
|
||||||
export function linkSkills(roots: string[]): number {
|
export function copySkills(roots: string[]): number {
|
||||||
const dir = skillLinkDir();
|
const dir = skillLinkDir();
|
||||||
mkdirSync(dir, { recursive: true });
|
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 })) {
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (!entry.isSymbolicLink()) continue;
|
const here = join(dir, entry.name);
|
||||||
const link = join(dir, entry.name);
|
if (entry.isSymbolicLink() || existsSync(join(here, MANAGED))) rmSync(here, { recursive: true, force: true });
|
||||||
if (wanted.get(entry.name) === readlinkSync(link)) continue;
|
|
||||||
rmSync(link, { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, target] of wanted) {
|
for (const [name, source] of wanted) {
|
||||||
const link = join(dir, name);
|
const target = join(dir, name);
|
||||||
if (existsSync(link)) continue;
|
if (existsSync(target)) {
|
||||||
|
console.error(`[opencode] not overwriting ${target} — it is not ours`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error(`[opencode] could not link skill ${name}`, err);
|
console.error(`[opencode] could not copy skill ${name}`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return wanted.size;
|
return wanted.size;
|
||||||
@@ -260,8 +274,8 @@ 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);
|
const copied = copySkills(skillRoots);
|
||||||
console.log(`[opencode] linked ${linked} skill(s) into ${skillLinkDir()}`);
|
console.log(`[opencode] copied ${copied} 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user