Skip to content

Commit 898600c

Browse files
Fix PR #51 verification follow-ups (#56)
* fix: address pr51 verification follow-ups * fix: track plugin-spawned server PID and warn on deny rules - opencode-server.mjs: save lastServerPid to server.json on successful spawn. add reapServerIfOurs() that sends SIGTERM after 5min idle and re-checks port ownership before declaring success. - session-lifecycle-hook.mjs: call reapServerIfOurs on SessionEnd. - opencode-companion.mjs: warn to stderr when workspace has .claude/settings.json deny rules and task is write-capable (issue #33). - fs.mjs: add readDenyRules() and denyRulesApplyToPath() helpers. - README.md: document permission boundary between Claude Code and OpenCode. Closes #19, #33 * fix: address Copilot PR#56 review comments - Guard serverStatePath against undefined workspacePath to prevent crypto.update() throwing when ensureServer is called without opts.cwd. - Stop clobbering plugin-owned state in the ensureServer early-return path — only clear tracked pid/startedAt when the tracked pid is dead, so reapServerIfOurs can still identify a server spawned by a prior plugin session. - Persist lastServerPid/lastServerStartedAt once the spawned server becomes healthy, and return alreadyRunning: false (with pid) so reapServerIfOurs has something to match against. - Add a 250ms delay to the readiness poll loop so it no longer spins hot hammering fetch() for up to 30 seconds. - Remove unused denyRulesApplyToPath import from opencode-companion. - README: "writeable" → "writable", "an disposable" → "a disposable".
1 parent 6450bfa commit 898600c

9 files changed

Lines changed: 351 additions & 24 deletions

File tree

README.md

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ they already have.
2222

2323
- `/opencode:review` for a normal read-only OpenCode review
2424
- `/opencode:adversarial-review` for a steerable challenge review
25-
- `/opencode:rescue`, `/opencode:status`, `/opencode:result`, and `/opencode:cancel` to delegate work and manage background jobs
25+
- `/opencode:rescue`, `/opencode:status`, `/opencode:result`, and `/opencode:cancel` to delegate work and manage active jobs
26+
- Optional rescue worktrees so OpenCode can make writable changes in an isolated git worktree before you keep or discard them
2627

2728
## Requirements
2829

@@ -96,17 +97,51 @@ To check your configured providers:
9697

9798
## Slash Commands
9899

99-
- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--wait`, `--background`.
100-
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Accepts custom focus text.
101-
- `/opencode:rescue` -- Delegates a task to OpenCode via the `opencode:opencode-rescue` subagent. Supports `--model`, `--agent`, `--resume`, `--fresh`, `--background`.
100+
- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, and `--background`.
101+
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, `--background`, and custom focus text.
102+
- `/opencode:rescue` -- Delegates a task to OpenCode via the `opencode:opencode-rescue` subagent. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`.
102103
- `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo.
103104
- `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming.
104-
- `/opencode:cancel` -- Cancels an active background OpenCode job.
105-
- `/opencode:setup` -- Checks OpenCode install/auth, can enable/disable the review gate hook.
105+
- `/opencode:cancel` -- Cancels an active OpenCode job.
106+
- `/opencode:setup` -- Checks OpenCode install/auth, can enable/disable the review gate hook, and can configure review-gate throttles.
106107

107108
## Review Gate
108109

109-
When enabled via `/opencode:setup --enable-review-gate`, a Stop hook runs a targeted OpenCode review on Claude's response. If issues are found, the stop is blocked so Claude can address them first. Warning: can create long-running loops and drain usage limits.
110+
When enabled via `/opencode:setup --enable-review-gate`, a Stop hook runs a targeted OpenCode review on Claude's response. If issues are found, the stop is blocked so Claude can address them first. Warning: without limits this can create long-running loops and drain usage.
111+
112+
Throttle controls:
113+
114+
```
115+
/opencode:setup --review-gate-max 5
116+
/opencode:setup --review-gate-cooldown 10
117+
/opencode:setup --review-gate-max off
118+
/opencode:setup --review-gate-cooldown off
119+
```
120+
121+
- `--review-gate-max <n|off>` limits how many stop-time reviews can run in one Claude session.
122+
- `--review-gate-cooldown <minutes|off>` enforces a minimum delay between stop-time reviews in the same Claude session.
123+
124+
## Rescue Worktrees
125+
126+
Use `/opencode:rescue --worktree ...` for write-capable tasks you want isolated from the current working tree. OpenCode runs in a disposable `.worktrees/opencode-*` git worktree on an `opencode/*` branch. When the task completes, the output includes keep/discard commands:
127+
128+
- `keep` applies the worktree diff back to the main working tree as staged changes, then removes the temporary worktree and branch.
129+
- `discard` removes the temporary worktree and branch without applying changes.
130+
131+
If applying the patch fails, the worktree is preserved for manual recovery.
132+
133+
## Review-to-Rescue
134+
135+
After a successful `/opencode:review` or `/opencode:adversarial-review`, the rendered review is saved for the current repository. If you run `/opencode:rescue` with no task text, Claude can offer to pass the last review findings back to OpenCode as the rescue task.
136+
137+
## Permission Boundary
138+
139+
OpenCode runs as an independent process with its own permission system, separate from Claude Code's `.claude/settings.json` deny rules. This means:
140+
141+
- A file that Claude Code cannot read or edit (due to a deny rule) **may still be accessible to OpenCode**.
142+
- `/opencode:rescue` is write-capable by default and has full read/write access to the workspace.
143+
144+
If your workspace uses deny rules, the companion will emit a warning when you start a write-capable task so you can make an informed choice. For fully isolated write operations, use `/opencode:rescue --worktree` which runs in a disposable git worktree.
110145

111146
## Troubleshooting
112147

@@ -164,20 +199,24 @@ opencode-plugin-cc/
164199
│ ├── schemas/ # Output schemas
165200
│ ├── scripts/ # Node.js runtime
166201
│ │ ├── opencode-companion.mjs # CLI entry point
202+
│ │ ├── safe-command.mjs # Safe slash-command argument bridge
167203
│ │ ├── session-lifecycle-hook.mjs
168204
│ │ ├── stop-review-gate-hook.mjs
169205
│ │ └── lib/ # Core modules
170206
│ │ ├── opencode-server.mjs # HTTP API client
171207
│ │ ├── state.mjs # Persistent state
172208
│ │ ├── job-control.mjs # Job management
173209
│ │ ├── tracked-jobs.mjs # Job lifecycle tracking
174-
│ │ ├── render.mjs # Output rendering
175-
│ │ ├── prompts.mjs # Prompt construction
176-
│ │ ├── git.mjs # Git utilities
177-
│ │ ├── process.mjs # Process utilities
178-
│ │ ├── args.mjs # Argument parsing
179-
│ │ ├── fs.mjs # Filesystem utilities
180-
│ │ └── workspace.mjs # Workspace detection
210+
│ │ ├── worktree.mjs # Disposable worktree sessions
211+
│ │ ├── render.mjs # Output rendering
212+
│ │ ├── prompts.mjs # Prompt construction
213+
│ │ ├── git.mjs # Git utilities
214+
│ │ ├── process.mjs # Process utilities
215+
│ │ ├── model.mjs # Model selection helpers
216+
│ │ ├── review-agent.mjs # Review agent resolution
217+
│ │ ├── args.mjs # Argument parsing
218+
│ │ ├── fs.mjs # Filesystem utilities
219+
│ │ └── workspace.mjs # Workspace detection
181220
│ └── skills/ # Internal skills
182221
├── tests/ # Test suite
183222
├── LICENSE # Apache License 2.0

plugins/opencode/scripts/lib/fs.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,42 @@ export function tailLines(filePath, n = 10) {
6262
return [];
6363
}
6464
}
65+
66+
/**
67+
* Read Claude Code project-level deny rules from .claude/settings.json.
68+
* Returns an array of {path, read, edit} deny entries found, if any.
69+
* @param {string} cwd
70+
* @returns {{ path: string, read?: boolean, edit?: boolean }[]}
71+
*/
72+
export function readDenyRules(cwd) {
73+
try {
74+
const settingsPath = path.join(cwd, ".claude", "settings.json");
75+
const data = readJson(settingsPath);
76+
if (!data || typeof data !== "object") return [];
77+
const deny = data.deny ?? data.permission?.deny;
78+
if (!Array.isArray(deny)) return [];
79+
return deny.filter(
80+
(rule) =>
81+
rule &&
82+
typeof rule === "object" &&
83+
typeof rule.path === "string"
84+
);
85+
} catch {
86+
return [];
87+
}
88+
}
89+
90+
/**
91+
* Check if any deny rules apply to a given path.
92+
* @param {{ path: string, read?: boolean, edit?: boolean }[]} rules
93+
* @param {string} targetPath
94+
* @returns {boolean}
95+
*/
96+
export function denyRulesApplyToPath(rules, targetPath) {
97+
if (!rules || rules.length === 0) return false;
98+
for (const rule of rules) {
99+
if (rule.path === targetPath) return true;
100+
if (targetPath.startsWith(rule.path + path.sep)) return true;
101+
}
102+
return false;
103+
}

plugins/opencode/scripts/lib/opencode-server.mjs

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
// OpenCode to return plans instead of reviews.
2020
// (Apache License 2.0 §4(b) modification notice — see NOTICE.)
2121

22+
import crypto from "node:crypto";
23+
import os from "node:os";
2224
import { spawn } from "node:child_process";
2325
import fs from "node:fs";
2426
import path from "node:path";
@@ -27,6 +29,42 @@ import { platformShellOption } from "./process.mjs";
2729
const DEFAULT_PORT = 4096;
2830
const DEFAULT_HOST = "127.0.0.1";
2931
const SERVER_START_TIMEOUT = 30_000;
32+
const SERVER_REAP_IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
33+
34+
function serverStatePath(workspacePath) {
35+
const key = typeof workspacePath === "string" && workspacePath.length > 0 ? workspacePath : process.cwd();
36+
const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
37+
const pluginDataDir = process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), "opencode-companion");
38+
return path.join(pluginDataDir, "state", hash, "server.json");
39+
}
40+
41+
function loadServerState(workspacePath) {
42+
try {
43+
const p = serverStatePath(workspacePath);
44+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
45+
} catch {
46+
return {};
47+
}
48+
}
49+
50+
function saveServerState(workspacePath, data) {
51+
try {
52+
const p = serverStatePath(workspacePath);
53+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
54+
fs.writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
55+
} catch {
56+
// best-effort
57+
}
58+
}
59+
60+
function isProcessAlive(pid) {
61+
try {
62+
process.kill(pid, 0);
63+
return true;
64+
} catch {
65+
return false;
66+
}
67+
}
3068

3169
/**
3270
* Resolve the bundled opencode config directory shipped inside the plugin.
@@ -75,6 +113,16 @@ export async function ensureServer(opts = {}) {
75113
const url = `http://${host}:${port}`;
76114

77115
if (await isServerRunning(host, port)) {
116+
// A server is already on the port. Only clear tracked state if the
117+
// tracked pid is dead (stale from a prior run) — otherwise it may be
118+
// a plugin-owned server from a previous session that reapServerIfOurs
119+
// should still be able to identify on SessionEnd.
120+
const state = loadServerState(opts.cwd);
121+
if (state.lastServerPid && !isProcessAlive(state.lastServerPid)) {
122+
delete state.lastServerPid;
123+
delete state.lastServerStartedAt;
124+
saveServerState(opts.cwd, state);
125+
}
78126
return { url, alreadyRunning: true };
79127
}
80128

@@ -104,15 +152,38 @@ export async function ensureServer(opts = {}) {
104152
shell: platformShellOption(),
105153
windowsHide: true,
106154
});
155+
let spawnError = null;
156+
let earlyExit = null;
157+
proc.once("error", (err) => {
158+
spawnError = err;
159+
});
160+
proc.once("exit", (code, signal) => {
161+
earlyExit = { code, signal };
162+
});
107163
proc.unref();
108164

109-
// Wait for the server to become ready
165+
// Wait for the server to become ready. Poll every 250ms rather than
166+
// spinning hot — a tight loop would hammer fetch() and burn CPU for up
167+
// to SERVER_START_TIMEOUT.
110168
const deadline = Date.now() + SERVER_START_TIMEOUT;
111169
while (Date.now() < deadline) {
170+
if (spawnError) {
171+
throw new Error(`Failed to start OpenCode server: ${spawnError.message}`);
172+
}
173+
if (earlyExit) {
174+
const detail = earlyExit.signal
175+
? `signal ${earlyExit.signal}`
176+
: `exit code ${earlyExit.code ?? "unknown"}`;
177+
throw new Error(`OpenCode server process exited before becoming ready (${detail}).`);
178+
}
112179
if (await isServerRunning(host, port)) {
113-
return { url, pid: proc.pid, alreadyRunning: false };
180+
const state = loadServerState(opts.cwd);
181+
state.lastServerPid = proc.pid;
182+
state.lastServerStartedAt = Date.now();
183+
saveServerState(opts.cwd, state);
184+
return { url, alreadyRunning: false, pid: proc.pid };
114185
}
115-
await new Promise((r) => setTimeout(r, 500));
186+
await new Promise((r) => setTimeout(r, 250));
116187
}
117188

118189
throw new Error(`OpenCode server failed to start within ${SERVER_START_TIMEOUT / 1000}s`);
@@ -255,3 +326,56 @@ export async function connect(opts = {}) {
255326
const client = createClient(url, { directory: opts.cwd });
256327
return { ...client, serverInfo: { url, alreadyRunning } };
257328
}
329+
330+
/**
331+
* Reap the plugin-spawned OpenCode server on SessionEnd.
332+
*
333+
* Only kills what we started (tracked via server.json `lastServerPid`).
334+
* Guards against killing a server the user started independently by
335+
* re-checking isServerRunning after the kill attempt — if something
336+
* else owns the port, leave it alone.
337+
*
338+
* Defense-in-depth: if the server has been running longer than
339+
* SERVER_REAP_IDLE_TIMEOUT (5 min) without a successful health check
340+
* from a plugin client, it is also considered orphanable even if the
341+
* PID is still alive (user may have killed the Claude session while the
342+
* server sat idle).
343+
*
344+
* @param {string} workspacePath
345+
* @param {{ port?: number, host?: string }} [opts]
346+
* @returns {Promise<boolean>} true if a server was reaped
347+
*/
348+
export async function reapServerIfOurs(workspacePath, opts = {}) {
349+
const host = opts.host ?? DEFAULT_HOST;
350+
const port = opts.port ?? DEFAULT_PORT;
351+
352+
const state = loadServerState(workspacePath);
353+
if (!state.lastServerPid || !Number.isFinite(state.lastServerPid)) return false;
354+
355+
const pid = state.lastServerPid;
356+
const startedAt = state.lastServerStartedAt;
357+
358+
if (!isProcessAlive(pid)) {
359+
saveServerState(workspacePath, { lastServerPid: null, lastServerStartedAt: null });
360+
return false;
361+
}
362+
363+
const idleMs = startedAt ? Date.now() - startedAt : Infinity;
364+
if (idleMs < SERVER_REAP_IDLE_TIMEOUT) return false;
365+
366+
try {
367+
process.kill(pid, "SIGTERM");
368+
} catch {
369+
// best-effort — PID may have just exited
370+
}
371+
372+
await new Promise((r) => setTimeout(r, 1000));
373+
374+
const stillRunning = await isServerRunning(host, port);
375+
if (!stillRunning) {
376+
saveServerState(workspacePath, { lastServerPid: null, lastServerStartedAt: null });
377+
return true;
378+
}
379+
380+
return false;
381+
}

plugins/opencode/scripts/lib/process.mjs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,19 @@ export async function resolveOpencodeBinary() {
4545
windowsHide: true,
4646
});
4747
let out = "";
48+
let settled = false;
49+
const finish = (value) => {
50+
if (settled) return;
51+
settled = true;
52+
resolve(value);
53+
};
4854
proc.stdout.on("data", (d) => (out += d));
49-
proc.on("error", () => resolve(null));
55+
proc.on("error", () => finish(null));
5056
proc.on("close", (code) => {
51-
if (code !== 0) return resolve(null);
57+
if (code !== 0) return finish(null);
5258
// `where` returns all matches separated by CRLF; pick the first.
5359
const first = out.trim().split(/\r?\n/)[0] ?? "";
54-
resolve(first || null);
60+
finish(first || null);
5561
});
5662
});
5763
}
@@ -77,8 +83,15 @@ export async function getOpencodeVersion() {
7783
windowsHide: true,
7884
});
7985
let out = "";
86+
let settled = false;
87+
const finish = (value) => {
88+
if (settled) return;
89+
settled = true;
90+
resolve(value);
91+
};
8092
proc.stdout.on("data", (d) => (out += d));
81-
proc.on("close", (code) => resolve(code === 0 ? out.trim() : null));
93+
proc.on("error", () => finish(null));
94+
proc.on("close", (code) => finish(code === 0 ? out.trim() : null));
8295
});
8396
}
8497

@@ -113,6 +126,12 @@ export function runCommand(cmd, args, opts = {}) {
113126
let stdoutBytes = 0;
114127
let stderr = "";
115128
let overflowed = false;
129+
let settled = false;
130+
const finish = (result) => {
131+
if (settled) return;
132+
settled = true;
133+
resolve(result);
134+
};
116135

117136
proc.stdout.on("data", (chunk) => {
118137
if (overflowed) return;
@@ -133,8 +152,16 @@ export function runCommand(cmd, args, opts = {}) {
133152
stdout += buf.toString("utf8");
134153
});
135154
proc.stderr.on("data", (d) => (stderr += d));
155+
proc.on("error", (err) =>
156+
finish({
157+
stdout,
158+
stderr: stderr || err.message,
159+
exitCode: typeof err.errno === "number" ? err.errno : 1,
160+
overflowed,
161+
})
162+
);
136163
proc.on("close", (exitCode) =>
137-
resolve({
164+
finish({
138165
stdout,
139166
stderr,
140167
// When we killed the child for overflow the exit code is not
@@ -163,6 +190,7 @@ export function spawnDetached(cmd, args, opts = {}) {
163190
shell: platformShellOption(),
164191
windowsHide: true,
165192
});
193+
child.on("error", () => {});
166194
child.unref();
167195
return child;
168196
}

0 commit comments

Comments
 (0)