Problem
plugins/opencode/scripts/lib/state.mjs::stateRoot:
export function stateRoot(workspacePath) {
const base =
process.env.CLAUDE_PLUGIN_DATA
? path.join(process.env.CLAUDE_PLUGIN_DATA, "state")
: path.join("/tmp", "opencode-companion");
const hash = crypto.createHash("sha256").update(workspacePath).digest("hex").slice(0, 16);
return path.join(base, hash);
}
Claude Code does not always set CLAUDE_PLUGIN_DATA in every invocation context. The inconsistency shows up in practice:
/opencode:rescue invokes the companion through an agent's Bash tool where CLAUDE_PLUGIN_DATA is set -> state lands in the persistent plugin data dir.
- The stop-review-gate hook and some !-pattern commands run without
CLAUDE_PLUGIN_DATA -> state lands in /tmp/opencode-companion/.
The two paths don't see each other. A job written via one code path is invisible to /opencode:status when that query runs via the other, and vice versa. Over time /tmp cleanup wipes the tmpdir state, silently dropping job history.
Also: /tmp is Unix-specific. On Windows the fallback "/tmp" literal is broken — should be os.tmpdir().
Fix (adapted from upstream)
1. Fix the fallback to use os.tmpdir():
import os from "node:os";
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "opencode-companion");
export function stateRoot(workspacePath) {
const hash = crypto.createHash("sha256").update(workspacePath).digest("hex").slice(0, 16);
const dirName = `${hash}`; // or include a slug — see below
const pluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
if (pluginDataDir) {
const primaryDir = path.join(pluginDataDir, "state", dirName);
const fallbackDir = path.join(FALLBACK_STATE_ROOT_DIR, dirName);
// If state exists only in the tmpdir fallback (written by a Bash command
// without CLAUDE_PLUGIN_DATA), migrate it to the persistent location so
// future reads/writes use the plugin data dir and state survives tmp cleanup.
if (
!fs.existsSync(path.join(primaryDir, "state.json")) &&
fs.existsSync(path.join(fallbackDir, "state.json"))
) {
fs.cpSync(fallbackDir, primaryDir, { recursive: true });
// Rewrite absolute paths in the migrated JSON so logFile / jobs/*.json
// references point at the new location.
const rewritePaths = (filePath) => {
try {
let txt = fs.readFileSync(filePath, "utf8");
const orig = txt;
txt = txt.replaceAll(fallbackDir, primaryDir);
const escapedFallback = fallbackDir.replaceAll("\\", "\\\\");
const escapedPrimary = primaryDir.replaceAll("\\", "\\\\");
if (escapedFallback !== fallbackDir) {
txt = txt.replaceAll(escapedFallback, escapedPrimary);
}
if (txt !== orig) fs.writeFileSync(filePath, txt, "utf8");
} catch { /* non-fatal */ }
};
rewritePaths(path.join(primaryDir, "state.json"));
const jobsDir = path.join(primaryDir, "jobs");
if (fs.existsSync(jobsDir)) {
for (const entry of fs.readdirSync(jobsDir)) {
if (entry.endsWith(".json")) rewritePaths(path.join(jobsDir, entry));
}
}
}
return primaryDir;
}
return path.join(FALLBACK_STATE_ROOT_DIR, dirName);
}
Key behaviors
- Migration is one-way and idempotent: once state lives in the plugin data dir, future reads prefer it. The tmpdir copy is left behind (don't delete — if the next invocation again has no
CLAUDE_PLUGIN_DATA, it needs its own state there).
- Path rewriting is best-effort: any JSON field that embeds the old absolute path (log files, job data paths) is rewritten. Fields that don't need rewriting pass through unchanged.
- Windows escaping: the migration also handles
\\-escaped paths for Windows JSON. Tests should cover this on Windows CI if we add Windows CI.
Test plan (tests/state.test.mjs additions)
- No
CLAUDE_PLUGIN_DATA: state lands in os.tmpdir()/opencode-companion/<hash>. Verify path starts with os.tmpdir().
- With
CLAUDE_PLUGIN_DATA set and no pre-existing state: state lands in $CLAUDE_PLUGIN_DATA/state/<hash>.
- Seed state in the tmpdir, then call
stateRoot with CLAUDE_PLUGIN_DATA set. Assert: migrated dir exists, state.json present, jobs/*.json log paths rewritten to primary dir.
- Subsequent calls return primary dir and do not re-migrate.
- Migration is robust to missing
jobs/ subdir and to malformed JSON (logged and skipped).
Upstream reference
openai/codex-plugin-cc#125 (open).
Port of openai/codex-plugin-cc#125 (open)
Problem
plugins/opencode/scripts/lib/state.mjs::stateRoot:Claude Code does not always set
CLAUDE_PLUGIN_DATAin every invocation context. The inconsistency shows up in practice:/opencode:rescueinvokes the companion through an agent's Bash tool whereCLAUDE_PLUGIN_DATAis set -> state lands in the persistent plugin data dir.CLAUDE_PLUGIN_DATA-> state lands in/tmp/opencode-companion/.The two paths don't see each other. A job written via one code path is invisible to
/opencode:statuswhen that query runs via the other, and vice versa. Over time/tmpcleanup wipes the tmpdir state, silently dropping job history.Also:
/tmpis Unix-specific. On Windows the fallback"/tmp"literal is broken — should beos.tmpdir().Fix (adapted from upstream)
1. Fix the fallback to use
os.tmpdir():Key behaviors
CLAUDE_PLUGIN_DATA, it needs its own state there).\\-escaped paths for Windows JSON. Tests should cover this on Windows CI if we add Windows CI.Test plan (
tests/state.test.mjsadditions)CLAUDE_PLUGIN_DATA: state lands inos.tmpdir()/opencode-companion/<hash>. Verify path starts withos.tmpdir().CLAUDE_PLUGIN_DATAset and no pre-existing state: state lands in$CLAUDE_PLUGIN_DATA/state/<hash>.stateRootwithCLAUDE_PLUGIN_DATAset. Assert: migrated dir exists,state.jsonpresent, jobs/*.json log paths rewritten to primary dir.jobs/subdir and to malformed JSON (logged and skipped).Upstream reference
openai/codex-plugin-cc#125 (open).