From 26f313799667850ce829c13e6c33e5621a28d372 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 21:54:39 +0200 Subject: [PATCH 1/2] Fix install docs, widen CI matrix, neutral transfer metadata (#33) The README's marketplace command used an invalid source format, so a fresh user failed at the first documented step; CI ran only Node 22 on ubuntu-latest despite declaring Node >=18.18 support and containing Windows-specific process logic; the transfer converter stamped openai/gpt-5.4-mini into every imported session regardless of the user's provider; and the misleading persistThread option suggested review sessions get deleted when it only ever controlled the session title. README now documents `/plugin marketplace add TheRealDinghyDog/opencode-plugin-cc`; CI runs Node 18.18/20/22 on ubuntu-latest plus Node 22 on windows-latest with fail-fast off; imported sessions carry neutral claude-code/imported-transcript metadata (verified against a real OpenCode 1.17.15 import + export); and persistThread is renamed to taskSessionTitle with a comment stating that review sessions intentionally remain reopenable in OpenCode's store. Drafted by OpenCode via the plugin's own rescue path (issue #33 task); review found no defects to correct. Live-verified the neutral metadata against the real importer. Co-Authored-By: Claude Fable 5 --- .github/workflows/pull-request-ci.yml | 14 +++++++++++--- README.md | 2 +- .../scripts/lib/claude-session-transfer.mjs | 4 ++-- plugins/opencode/scripts/lib/opencode.mjs | 6 ++++-- plugins/opencode/scripts/opencode-companion.mjs | 2 +- tests/transfer.test.mjs | 4 ++-- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index eedb466..8b2e1f4 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -8,9 +8,17 @@ permissions: jobs: ci: - name: CI - runs-on: ubuntu-latest + name: CI (Node ${{ matrix.node-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node-version: [18.18, 20, 22] + include: + - os: windows-latest + node-version: 22 steps: - name: Check out repository @@ -19,7 +27,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies diff --git a/README.md b/README.md index c5be33e..492c7df 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The plugin starts its own local `opencode serve` on demand, bound to `127.0.0.1` Add the marketplace in Claude Code: ```bash -/plugin marketplace add opencode +/plugin marketplace add TheRealDinghyDog/opencode-plugin-cc ``` Install the plugin: diff --git a/plugins/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs index 77f3edb..46b94e9 100644 --- a/plugins/opencode/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -6,8 +6,8 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "OPENCODE_COMPANION_TRANSCRIPT_PATH"; -export const OPENCODE_IMPORT_MODEL_ID = "gpt-5.4-mini"; -export const OPENCODE_IMPORT_PROVIDER_ID = "openai"; +export const OPENCODE_IMPORT_MODEL_ID = "imported-transcript"; +export const OPENCODE_IMPORT_PROVIDER_ID = "claude-code"; export const OPENCODE_IMPORT_AGENT = "build"; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index d6b898f..5689951 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1386,7 +1386,7 @@ export async function runServerTurn(cwd, options = {}) { }); const session = await client.createSession( buildCreateSessionParams(cwd, { - title: options.threadName ?? options.title ?? (options.persistThread ? buildTaskSessionName(prompt) : null), + title: options.threadName ?? options.title ?? (options.taskSessionTitle ? buildTaskSessionName(prompt) : null), model: options.model, write, agent @@ -1471,7 +1471,9 @@ export async function runServerReview(cwd, options = {}) { ...options, agent: READ_ONLY_AGENT, sandbox: "read-only", - persistThread: false, + // Review sessions intentionally remain in OpenCode's session store so + // users can reopen them with `opencode --session `. + taskSessionTitle: false, threadName: options.threadName ?? "OpenCode Review" }); return { diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index fb4be68..6d3563d 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -424,7 +424,7 @@ async function executeTaskRun(request) { write: request.write, sandbox: request.write ? "workspace-write" : "read-only", onProgress: request.onProgress, - persistThread: true, + taskSessionTitle: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) }); diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 4bf1def..0779c17 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -68,8 +68,8 @@ test("Claude JSONL converts to a well-formed OpenCode import document", () => { assert.equal(doc.info.slug, "investigate-the-failure"); assert.equal(doc.info.agent, "build"); assert.deepEqual(doc.info.model, { - id: "gpt-5.4-mini", - providerID: "openai", + id: "imported-transcript", + providerID: "claude-code", variant: "default" }); assert.equal(doc.info.version, "1.17.10-test"); From a4d6f8349dc274a8855cee4c951bb6eef57d62dc Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Sat, 11 Jul 2026 02:37:23 +0200 Subject: [PATCH 2/2] Fix the Windows failures the new CI matrix surfaced (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-latest leg added in this PR ran the suite on Windows for the first time and found six failures, all environmental: - canonicalWorkspaceDirectory and the tests' expected paths disagreed on Windows 8.3 short names (RUNNER~1 vs runneradmin): use fs.realpathSync.native, which expands short names and matches a child process's cwd, consistent with resolveStateDir. - findLatestTaskThread compared stored canonical directories against the raw cwd, which on Windows is git's forward-slash toplevel — resume lookups could never match; compare canonical-to-canonical. - Two tests hand-rolled a shebang opencode stub that Windows cannot execute via PATH; they now install the same .cmd shim the main fixture uses. - node --import was passed a bare absolute path, which Windows parses as an unsupported "d:" URL scheme; pass a file:// URL. - The transfer test faked only HOME; os.homedir() reads USERPROFILE on Windows. Co-Authored-By: Claude Fable 5 --- plugins/opencode/scripts/lib/opencode.mjs | 20 +++++++-- tests/runtime.test.mjs | 49 ++++++++++++----------- tests/transfer.test.mjs | 2 + 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 5689951..a5e19ea 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1074,12 +1074,20 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } // The canonical (symlink-resolved) workspace path is what OpenCode records as -// a session's directory, so use it for the client's directory scope. +// a session's directory, so use it for the client's directory scope. Use the +// native resolver: unlike the JS implementation it also expands Windows 8.3 +// short names (RUNNER~1 -> runneradmin) and normalizes separators, so the +// value matches what a child process sees as its cwd. Same choice as +// resolveStateDir in state.mjs. function canonicalWorkspaceDirectory(cwd) { try { - return fs.realpathSync(cwd); + return fs.realpathSync.native(cwd); } catch { - return cwd; + try { + return fs.realpathSync(cwd); + } catch { + return cwd; + } } } @@ -1489,12 +1497,16 @@ export async function findLatestTaskThread(cwd) { throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode:setup`."); } + // Compare canonical-to-canonical: stored session directories originate from + // the canonical directory the client sends, while `cwd` here can be git's + // forward-slash toplevel (Windows) or a symlinked path (macOS /var). + const canonicalCwd = canonicalWorkspaceDirectory(cwd); return withServer(cwd, async (client) => { const sessions = getSessionsArray(await client.listSessions()) .filter((session) => sessionTitle(session).startsWith(TASK_SESSION_PREFIX)) .filter((session) => { const directory = sessionDirectory(session); - return !directory || directory === cwd; + return !directory || directory === canonicalCwd; }) .sort((left, right) => sessionUpdatedAt(right) - sessionUpdatedAt(left)); return sessions[0] ?? null; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 770d4b3..68be56e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { interruptServerTurn, runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; import { loadServerSession, saveServerSession, SERVER_URL_ENV } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; @@ -154,9 +154,10 @@ function startExternalFixtureServer(binDir) { return { child, url }; } -test("interruptServerTurn marks env-provided server urls as external", async () => { - const workspace = makeTempDir(); - const binDir = makeTempDir(); +// Hand-rolled availability-only opencode stub (no HTTP server). Mirrors +// installFakeOpencode's Windows shim: a bare shebang script is not executable +// via PATH on win32, so binaryAvailable() would report opencode missing there. +function installStubOpencodeBinary(binDir) { writeExecutable( path.join(binDir, "opencode"), `#!/usr/bin/env node @@ -171,6 +172,17 @@ if (process.argv[2] === "serve" && process.argv.includes("--help")) { process.exit(1); ` ); + if (process.platform === "win32") { + fs.writeFileSync(path.join(binDir, "opencode.cmd"), `@echo off\r\nnode "%~dp0opencode" %*\r\n`, { + encoding: "utf8" + }); + } +} + +test("interruptServerTurn marks env-provided server urls as external", async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installStubOpencodeBinary(binDir); const previousFetch = globalThis.fetch; const calls = []; globalThis.fetch = async (requestUrl, options = {}) => { @@ -492,12 +504,12 @@ test("external server requests are bound to each invoking workspace (issue #29)" // to the invoking repo, not the server process's launch directory. const fakeState = readFakeState(binDir); const directories = fakeState.sessions.map((session) => session.directory).sort(); - assert.deepEqual(directories, [fs.realpathSync(repoA), fs.realpathSync(repoB)].sort()); + assert.deepEqual(directories, [fs.realpathSync.native(repoA), fs.realpathSync.native(repoB)].sort()); // The event subscriptions carried the workspace scope too. const eventDirectories = (fakeState.eventDirectories || []).filter(Boolean); - assert.ok(eventDirectories.includes(fs.realpathSync(repoA)), "event stream scoped to workspace A"); - assert.ok(eventDirectories.includes(fs.realpathSync(repoB)), "event stream scoped to workspace B"); + assert.ok(eventDirectories.includes(fs.realpathSync.native(repoA)), "event stream scoped to workspace A"); + assert.ok(eventDirectories.includes(fs.realpathSync.native(repoB)), "event stream scoped to workspace B"); } finally { child.kill(); } @@ -609,20 +621,7 @@ globalThis.fetch = async (requestUrl, options = {}) => { `, "utf8" ); - writeExecutable( - path.join(binDir, "opencode"), - `#!/usr/bin/env node -if (process.argv[2] === "--version") { - console.log("opencode test"); - process.exit(0); -} -if (process.argv[2] === "serve" && process.argv.includes("--help")) { - console.log("serve help"); - process.exit(0); -} -process.exit(1); -` - ); + installStubOpencodeBinary(binDir); await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { const jobId = "job-cancel-external-server"; @@ -649,7 +648,9 @@ process.exit(1); }); writeJobFile(workspace, jobId, runningJob); - const result = run(process.execPath, ["--import", fetchPreload, SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { + // --import requires a file:// URL on Windows (a bare D:\... path parses as + // an unsupported "d:" URL scheme). + const result = run(process.execPath, ["--import", pathToFileURL(fetchPreload).href, SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { cwd: workspace, env: { ...process.env, @@ -1108,7 +1109,7 @@ test("task fails closed when a resumed-session snapshot fails and only stale mes sessions: [ { id: "ses_existing", - directory: fs.realpathSync(repo), + directory: fs.realpathSync.native(repo), title: "OpenCode Companion Task: prior fixture task", agent: "plan", model: null, @@ -1268,7 +1269,7 @@ test("task fails when completion has no recoverable current-turn message (issue id: "ses_existing", // realpath: makeTempDir returns a symlinked /var path on macOS, but // findLatestTaskThread matches against the realpath'd workspace root. - directory: fs.realpathSync(repo), + directory: fs.realpathSync.native(repo), title: "OpenCode Companion Task: prior fixture task", agent: "plan", model: null, diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 0779c17..3bfb7ca 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -179,7 +179,9 @@ test("transfer imports a Claude transcript and prints an OpenCode resume command fs.writeFileSync(transcriptPath, sampleClaudeJsonl(), "utf8"); const env = buildEnv(binDir, { + // os.homedir() reads USERPROFILE on Windows and HOME elsewhere. HOME: home, + USERPROFILE: home, OPENCODE_COMPANION_TRANSCRIPT_PATH: transcriptPath }); const result = run("node", [SCRIPT, "transfer"], {