Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
214852a
fix: quote $ARGUMENTS in cancel/result/status commands
JohnnyVicious Apr 12, 2026
6665b2d
fix: declare model: sonnet in opencode-rescue agent frontmatter
JohnnyVicious Apr 12, 2026
260d84b
fix: scope /opencode:cancel default to current Claude session
JohnnyVicious Apr 12, 2026
cad21e8
fix: enforce hard wall-clock timeout on runTrackedJob
JohnnyVicious Apr 12, 2026
a4e0097
fix: reconcile dead-PID jobs on every status read
JohnnyVicious Apr 12, 2026
e196f88
fix: avoid embedding large diffs in review prompts
JohnnyVicious Apr 12, 2026
f8d3fcc
fix: respect \$SHELL on Windows when spawning child processes
JohnnyVicious Apr 12, 2026
3535f56
fix: migrate tmpdir state to CLAUDE_PLUGIN_DATA + fix /tmp literal
JohnnyVicious Apr 12, 2026
a1019be
feat: pass last review findings to rescue automatically
JohnnyVicious Apr 12, 2026
1c61980
feat: throttle controls for stop-time review gate
JohnnyVicious Apr 12, 2026
23f7fef
feat: --worktree flag for isolated write-capable rescue tasks
JohnnyVicious Apr 12, 2026
c9c4e74
fix: address pr51 review findings
JohnnyVicious Apr 12, 2026
83e22a9
Merge remote-tracking branch 'origin/main' into feat/codex-port-batch
JohnnyVicious Apr 12, 2026
88b3532
fix: keep tracked job timeout referenced
JohnnyVicious Apr 12, 2026
d299b96
fix: address pr51 review conversations
JohnnyVicious Apr 12, 2026
328576e
fix: add exclusive file lock to updateState for concurrency safety
JohnnyVicious Apr 12, 2026
485d25c
fix: address brownfield discovery bugs
JohnnyVicious Apr 12, 2026
05e70f3
fix: polish pr51 follow-up fixes
JohnnyVicious Apr 12, 2026
892493c
fix: address Copilot PR#51 review comments
JohnnyVicious Apr 12, 2026
b49da73
fix: keep reading fallback state while migrate lock is held
JohnnyVicious Apr 12, 2026
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
2 changes: 2 additions & 0 deletions plugins/opencode/agents/opencode-rescue.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
name: opencode-rescue
description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to OpenCode through the shared runtime
model: sonnet
tools: Bash
skills:
- opencode-runtime
Expand Down Expand Up @@ -28,6 +29,7 @@ Forwarding rules:
- Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan).
- Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model.
- Treat `--agent <value>` and `--model <value>` as runtime controls and do not include them in the task text you pass through.
- If the request includes `--worktree`, pass `--worktree` through to `task`. This runs OpenCode in an isolated git worktree instead of editing the working directory in-place.
- Default to a write-capable OpenCode run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through.
- `--resume` means add `--resume-last`.
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/commands/cancel.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ allowed-tools: Bash(node:*)
Run the cancel command and return output verbatim.

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" cancel $ARGUMENTS
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" cancel "$ARGUMENTS"
```

- Return the command stdout verbatim, exactly as-is.
Expand Down
24 changes: 22 additions & 2 deletions plugins/opencode/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the OpenCode rescue subagent
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <provider/model>] [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
argument-hint: "[--background|--wait] [--worktree] [--resume|--fresh] [--model <provider/model>] [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*)
---
Expand All @@ -18,6 +18,7 @@ Execution mode:
- If neither flag is present, default to foreground.
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
- `--model` and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
- `--worktree` is an isolation flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. When present, OpenCode runs in an isolated git worktree instead of editing the working directory in-place.
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
- Otherwise, before starting OpenCode, check for a resumable rescue session from this Claude session by running:
Expand Down Expand Up @@ -46,4 +47,23 @@ Operating rules:
- Leave the model unset unless the user explicitly asks for one.
- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command.
- If the helper reports that OpenCode is missing or unauthenticated, stop and tell the user to run `/opencode:setup`.
- If the user did not supply a request, ask what OpenCode should investigate or fix.
- If the user did not supply a request, check for a saved review from `/opencode:review` or `/opencode:adversarial-review`:

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" last-review
```

- If stdout is `LAST_REVIEW_AVAILABLE`, use `AskUserQuestion` exactly once with two options:
- `Fix issues from last review (Recommended)` — prepend the saved review content as context for the rescue task
- `Describe a new task` — ask what OpenCode should investigate or fix
- If the user chooses to fix from last review, read the saved review via:

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" last-review --content
```

and include its stdout verbatim in the forwarded task text, prefixed with:

`The following issues were found in a prior OpenCode review. Please fix them:\n\n`

- If stdout is `NO_LAST_REVIEW`, ask what OpenCode should investigate or fix.
2 changes: 1 addition & 1 deletion plugins/opencode/commands/result.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ allowed-tools: Bash(node:*)
Run the result command and return output verbatim.

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" result $ARGUMENTS
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" result "$ARGUMENTS"
```

- Return the command stdout verbatim, exactly as-is.
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/commands/setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Check whether the local OpenCode CLI is ready and optionally toggle the stop-time review gate
argument-hint: '[--enable-review-gate|--disable-review-gate]'
argument-hint: '[--enable-review-gate|--disable-review-gate] [--review-gate-max <n|off>] [--review-gate-cooldown <minutes|off>]'
allowed-tools: Bash(node:*), Bash(npm:*), Bash(brew:*), Bash(curl:*), AskUserQuestion
---

Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode/commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ allowed-tools: Bash(node:*)
Run the status command and return output verbatim.

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" status $ARGUMENTS
node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" status "$ARGUMENTS"
```

- Return the command stdout verbatim, exactly as-is.
Expand Down
4 changes: 4 additions & 0 deletions plugins/opencode/prompts/adversarial-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Before finalizing, check that each finding is:
- actionable for an engineer fixing the issue
</final_check>

<review_collection_guidance>
{{REVIEW_COLLECTION_GUIDANCE}}
</review_collection_guidance>

<repository_context>
{{REVIEW_INPUT}}
</repository_context>
153 changes: 153 additions & 0 deletions plugins/opencode/scripts/lib/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// `gh` instead of only local working-tree state. (Apache License 2.0 §4(b)
// modification notice.)

import fs from "node:fs";
import path from "node:path";
import { runCommand } from "./process.mjs";

/**
Expand Down Expand Up @@ -69,6 +71,22 @@ export async function getDiffStat(cwd, opts = {}) {
return stdout.trim();
}

/**
* Measure the byte size of a git diff without streaming the full contents
* back to the caller. Useful for "is this diff too big to inline?" checks.
*
* @param {string} cwd
* @param {{ base?: string, cached?: boolean }} [opts]
* @returns {Promise<number>}
*/
export async function getDiffByteSize(cwd, opts = {}) {
const args = ["diff"];
if (opts.base) args.push(`${opts.base}...HEAD`);
else if (opts.cached) args.push("--cached");
const { stdout } = await runCommand("git", args, { cwd });
return Buffer.byteLength(stdout, "utf8");
}
Comment thread
JohnnyVicious marked this conversation as resolved.
Outdated

/**
* Get git status (short format).
* @param {string} cwd
Expand Down Expand Up @@ -173,3 +191,138 @@ export async function getPrDiff(cwd, prNumber) {
}
return stdout;
}

// ------------------------------------------------------------------
// Worktree helpers (for --worktree isolated rescue runs)
// ------------------------------------------------------------------

/**
* Create a disposable git worktree under `<repoRoot>/.worktrees/opencode-<ts>`
* on a new `opencode/<ts>` branch. Also adds `.worktrees/` to the repo's
* `.git/info/exclude` so the directory never shows up in `git status`.
*
* @param {string} repoRoot
* @returns {Promise<{ worktreePath: string, branch: string, repoRoot: string, baseCommit: string, timestamp: number }>}
*/
export async function createWorktree(repoRoot) {
Comment thread
JohnnyVicious marked this conversation as resolved.
const ts = Date.now();
const worktreesDir = path.join(repoRoot, ".worktrees");
fs.mkdirSync(worktreesDir, { recursive: true });

// Resolve the real git dir (handles linked worktrees where .git is a file).
const gitDirResult = await runCommand("git", ["rev-parse", "--git-dir"], { cwd: repoRoot });
const rawGitDir = gitDirResult.stdout.trim();
const gitDir = path.resolve(repoRoot, rawGitDir);
const excludePath = path.join(gitDir, "info", "exclude");
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (!existing.includes(".worktrees")) {
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
fs.appendFileSync(excludePath, `${sep}.worktrees/\n`);
}

const worktreePath = path.join(worktreesDir, `opencode-${ts}`);
const branch = `opencode/${ts}`;
const baseCommitResult = await runCommand("git", ["rev-parse", "HEAD"], { cwd: repoRoot });
if (baseCommitResult.exitCode !== 0) {
throw new Error(`git rev-parse HEAD failed: ${baseCommitResult.stderr.trim()}`);
}
const baseCommit = baseCommitResult.stdout.trim();

const addResult = await runCommand(
"git",
["worktree", "add", worktreePath, "-b", branch],
{ cwd: repoRoot }
);
if (addResult.exitCode !== 0) {
throw new Error(`git worktree add failed: ${addResult.stderr.trim()}`);
}

return { worktreePath, branch, repoRoot, baseCommit, timestamp: ts };
}

/**
* Remove a worktree (force). Swallows "not a working tree" so callers can
* safely retry cleanup.
* @param {string} repoRoot
* @param {string} worktreePath
*/
export async function removeWorktree(repoRoot, worktreePath) {
const { exitCode, stderr } = await runCommand(
"git",
["worktree", "remove", "--force", worktreePath],
{ cwd: repoRoot }
);
if (exitCode !== 0 && !stderr.includes("is not a working tree")) {
throw new Error(`git worktree remove failed: ${stderr.trim()}`);
}
}

/**
* Delete a branch (force). Failures are swallowed — this is best-effort
* cleanup after the worktree has already been removed.
*/
export async function deleteWorktreeBranch(repoRoot, branch) {
await runCommand("git", ["branch", "-D", branch], { cwd: repoRoot });
}

/**
* Compute the diff the worktree made on top of the base commit. Stages
* everything first so uncommitted edits (which is what OpenCode actually
* produces) show up in the diff.
* @returns {Promise<{ stat: string, patch: string }>}
*/
export async function getWorktreeDiff(worktreePath, baseCommit) {
await runCommand("git", ["add", "-A"], { cwd: worktreePath });
const statR = await runCommand(
"git",
["diff", "--cached", baseCommit, "--stat"],
{ cwd: worktreePath }
);
if (statR.exitCode !== 0 || !statR.stdout.trim()) {
return { stat: "", patch: "" };
}
const patchR = await runCommand(
"git",
["diff", "--cached", baseCommit],
{ cwd: worktreePath }
);
return { stat: statR.stdout.trim(), patch: patchR.stdout };
}

/**
* Apply the worktree diff back to `repoRoot` as a staged patch. Returns
* `{ applied, detail }` — detail includes any git error when apply fails.
*/
export async function applyWorktreePatch(repoRoot, worktreePath, baseCommit) {
await runCommand("git", ["add", "-A"], { cwd: worktreePath });
const patchR = await runCommand(
"git",
["diff", "--cached", baseCommit],
{ cwd: worktreePath }
);
if (patchR.exitCode !== 0 || !patchR.stdout.trim()) {
return { applied: false, detail: "No changes to apply." };
}
const patchPath = path.join(
repoRoot,
`.opencode-worktree-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`
);
try {
fs.writeFileSync(patchPath, patchR.stdout, "utf8");
const applyR = await runCommand(
"git",
["apply", "--index", patchPath],
{ cwd: repoRoot }
);
if (applyR.exitCode !== 0) {
return {
applied: false,
detail: applyR.stderr.trim() || "Patch apply failed (conflicts?).",
Comment thread
JohnnyVicious marked this conversation as resolved.
Outdated
};
}
return { applied: true, detail: "Changes applied and staged." };
} finally {
fs.rmSync(patchPath, { force: true });
Comment thread
JohnnyVicious marked this conversation as resolved.
Outdated
}
}
Loading
Loading