Skip to content

OpenCode

ff hook opencode installs a snapshot plugin and the shipped skill for OpenCode.

Install

ff hook opencode

Activate

Restart OpenCode to load the plugin. The plugin bakes in the path of the ff that installed it, so OpenCode's PATH does not matter.

Verify

ff hook -l
ff doctor --no-fetch

These inspect installed files only. Confirm real capture with the capture-and-recovery check.

Files changed

The installer writes plugins/fufu.js and skills/fufu/ under $XDG_CONFIG_HOME/opencode, or ~/.config/opencode when that variable is unset. The plugin file and skill directory are managed by fufu. opencode.json and unrelated files are left alone.

$ ff hook opencode
opencode plugin written to ~/.config/opencode/plugins/fufu.js
  skill written to ~/.config/opencode/skills/fufu
  the briefing is included in the system prompt on every model call
  restart OpenCode to load it

$ find ~/.config/opencode -type f | sort
~/.config/opencode/plugins/fufu.js
~/.config/opencode/skills/fufu/SKILL.md

$ cat ~/.config/opencode/plugins/fufu.js
// Written by `ff hook opencode`. Rewritten by `ff hook -u`, removed by `ff unhook opencode`.
// The whole file is fufu's: a byte changed reads as stale, and `ff hook -u` rewrites it.
const FF = "/usr/local/bin/ff";

export const FufuPlugin = async ({ $, directory }) => ({
  // The one variable is both the session tag and the client marker.
  "shell.env": async (input, output) => {
    if (input.sessionID) output.env.OPENCODE_SESSION_ID = input.sessionID;
  },
  // The briefing, standing: in the system prompt of every model call, so it
  // survives compaction. The trigger captures too, and an unchanged tree
  // captures nothing. A trigger that fails or says nothing puts nothing in
  // the prompt.
  "experimental.chat.system.transform": async (input, output) => {
    const payload = JSON.stringify({ hook_event_name: "SessionStart", session_id: input.sessionID ?? "", cwd: directory });
    const text = await $`echo ${payload} | ${FF} trigger opencode`.cwd(directory).nothrow().text();
    if (text.trim()) output.system.push(text.trim());
  },
  // The floor: a snapshot before every tool call, nothing printed. The
  // bash tool's args carry `command`, which is what the label and the
  // gitPolicy tally read.
  "tool.execute.before": async (input, output) => {
    const payload = JSON.stringify({ hook_event_name: "PreToolUse", session_id: input.sessionID, cwd: directory, tool_name: input.tool, tool_input: output.args ?? {} });
    await $`echo ${payload} | ${FF} trigger opencode`.cwd(directory).nothrow().quiet();
  },
});

The plugin registers three hooks:

  • tool.execute.before attempts capture before every tool call. The tool's name and arguments supply snapshot labels and the Git-policy tally. OpenCode discards the hook's output, so no coaching or denial reaches the model, including under strict policy.
  • experimental.chat.system.transform includes the briefing in the system prompt on every model call, including after compaction. It also attempts capture; an unchanged tree adds no snapshot.
  • shell.env supplies OPENCODE_SESSION_ID to shell commands so their fufu operations use the same session tag as hook captures.

ff hook -l compares the plugin with the version this binary would write. A file with fufu's ownership header but different content is reported as stale; ff hook -u replaces it. A file without that header is reported as hand-written and is never rewritten or removed.

Remove

ff unhook opencode removes the plugin file when it is fufu's and the skill directory. Restart OpenCode afterward.

$ ff unhook opencode
opencode removed ~/.config/opencode/plugins/fufu.js
  removed ~/.config/opencode/skills/fufu

$ find ~/.config/opencode -type f | sort

Troubleshooting and migration

If the listing says hand-written, a plugins/fufu.js that fufu did not write is in the way: move it aside and run ff hook opencode again. ff doctor --fix and ff hook -u rewrite a stale plugin; neither touches a hand-written one.