Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/pull-request-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions plugins/opencode/scripts/lib/claude-session-transfer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
26 changes: 20 additions & 6 deletions plugins/opencode/scripts/lib/opencode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -1386,7 +1394,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
Expand Down Expand Up @@ -1471,7 +1479,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 <id>`.
taskSessionTitle: false,
threadName: options.threadName ?? "OpenCode Review"
});
return {
Expand All @@ -1487,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;
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

Expand Down
49 changes: 25 additions & 24 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}) => {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions tests/transfer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"], {
Expand Down