Skip to content

Port: migrate tmpdir state to CLAUDE_PLUGIN_DATA when available #47

Closed

Description

@JohnnyVicious

Port of openai/codex-plugin-cc#125 (open)

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)

  1. No CLAUDE_PLUGIN_DATA: state lands in os.tmpdir()/opencode-companion/<hash>. Verify path starts with os.tmpdir().
  2. With CLAUDE_PLUGIN_DATA set and no pre-existing state: state lands in $CLAUDE_PLUGIN_DATA/state/<hash>.
  3. 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.
  4. Subsequent calls return primary dir and do not re-migrate.
  5. Migration is robust to missing jobs/ subdir and to malformed JSON (logged and skipped).

Upstream reference

openai/codex-plugin-cc#125 (open).

Activity

  1. JohnnyVicious commented on Apr 12, 2026

    @JohnnyVicious
    OwnerAuthor

    Consolidated into #30. Tmpdir-to-CLAUDE_PLUGIN_DATA migration is part of the same state-root consistency fix.

  2. added 2 commits that reference this issue on Apr 12, 2026
    3535f56
    e6a18bc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions