Skip to content

Commit 23e47c9

Browse files
author
chenbo
committed
Extract OpenCode sidecar plugin runtime
1 parent ee2bbae commit 23e47c9

13 files changed

Lines changed: 410 additions & 334 deletions

src/cli/opencode-plusplus-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, readFileSync, statSync } from "node:fs";
22
import path from "node:path";
3-
import { runOpencodeDoctor, type OpencodeDoctorCheck, type OpencodeDoctorReport } from "./opencode-preset.js";
4-
import { OPENCODE_SIDECAR_PLUGIN_PATH } from "../integrations/opencode/sidecar-plugin-template.js";
3+
import { runOpencodeDoctor, type OpencodeDoctorCheck } from "./opencode-preset.js";
4+
import { OPENCODE_SIDECAR_PLUGIN_PATH } from "../integrations/opencode/plugin-template.js";
55
import { verifyOpencodeSidecar } from "../integrations/opencode/sidecar.js";
66

77
export interface OpenCodePlusplusStatusReport {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { spawnSync } from "node:child_process";
2+
import type { OpenCodeSidecarRecorder } from "./events.js";
3+
import { commandFromTool, pathsFromTool } from "./paths.js";
4+
5+
export function runCommandGuard(directory: string, recorder: OpenCodeSidecarRecorder, tool: unknown, args: unknown): void {
6+
const command = commandFromTool(tool, args);
7+
const paths = pathsFromTool(args);
8+
if (!command && paths.length === 0) return;
9+
10+
const cliArgs = ["sidecar", "check-command", directory, "--json"];
11+
if (command) cliArgs.push("--command", command);
12+
else cliArgs.push("--command", "path-check");
13+
for (const file of paths) cliArgs.push("--path", file);
14+
15+
const check = spawnSync("opencode-plusplus", cliArgs, {
16+
cwd: directory,
17+
encoding: "utf8",
18+
shell: process.platform === "win32"
19+
});
20+
recorder.record("sidecar.check-command", { tool, command, paths, exitCode: check.status ?? 1 });
21+
if ((check.status ?? 1) !== 0) {
22+
const output = (check.stdout || check.stderr || "OpenCode++ blocked a command or protected path.").trim();
23+
recorder.log("error", "blocked tool execution", { tool, command, paths });
24+
throw new Error(output);
25+
}
26+
recorder.log("debug", "tool execution allowed", { tool, command, paths });
27+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { appendFileSync, mkdirSync } from "node:fs";
2+
import path from "node:path";
3+
4+
export interface OpenCodeSidecarRuntimeContext {
5+
directory: string;
6+
worktree?: string;
7+
client?: {
8+
app?: {
9+
log?: (input: { service: string; level: string; message: string; extra?: Record<string, unknown> }) => void;
10+
};
11+
};
12+
}
13+
14+
export interface OpenCodeSidecarRecorder {
15+
eventLog: string;
16+
record: (type: string, payload?: Record<string, unknown>) => void;
17+
log: (level: string, message: string, extra?: Record<string, unknown>) => void;
18+
}
19+
20+
export function createSidecarRecorder(context: OpenCodeSidecarRuntimeContext): OpenCodeSidecarRecorder {
21+
const eventLog = path.join(context.directory, ".agent-context", "traces", "opencode-sidecar-events.jsonl");
22+
23+
function record(type: string, payload: Record<string, unknown> = {}): void {
24+
try {
25+
mkdirSync(path.dirname(eventLog), { recursive: true });
26+
appendFileSync(
27+
eventLog,
28+
`${JSON.stringify({
29+
type,
30+
ts: new Date().toISOString(),
31+
directory: context.directory,
32+
worktree: context.worktree,
33+
...payload
34+
})}\n`,
35+
"utf8"
36+
);
37+
} catch {
38+
// The sidecar must never break OpenCode. Verification can still run manually.
39+
}
40+
}
41+
42+
function log(level: string, message: string, extra: Record<string, unknown> = {}): void {
43+
record("sidecar.log", { level, message, ...extra });
44+
try {
45+
context.client?.app?.log?.({
46+
service: "opencode-plusplus",
47+
level,
48+
message,
49+
extra
50+
});
51+
} catch {
52+
// Structured logging is best-effort and must never interrupt OpenCode.
53+
}
54+
}
55+
56+
return { eventLog, record, log };
57+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createHash } from "node:crypto";
2+
3+
export function hashText(text: unknown): string {
4+
return createHash("sha256")
5+
.update(String(text ?? ""))
6+
.digest("hex");
7+
}
8+
9+
export function stableJson(value: unknown): string {
10+
try {
11+
return JSON.stringify(value ?? null);
12+
} catch {
13+
return String(value);
14+
}
15+
}
16+
17+
export function toolKey(tool: unknown, args: unknown): string {
18+
return `${String(tool ?? "unknown")}:${hashText(stableJson(args))}`;
19+
}
20+
21+
export function outputText(output: unknown, keys: string[]): string {
22+
if (!output || typeof output !== "object") return "";
23+
const record = output as Record<string, unknown>;
24+
const properties = record.properties && typeof record.properties === "object" ? (record.properties as Record<string, unknown>) : {};
25+
for (const key of keys) {
26+
const value = record[key] ?? properties[key];
27+
if (typeof value === "string") return value;
28+
}
29+
return "";
30+
}
31+
32+
export function exitCodeFromOutput(output: unknown): number | null {
33+
if (!output || typeof output !== "object") return null;
34+
const record = output as Record<string, unknown>;
35+
const properties = record.properties && typeof record.properties === "object" ? (record.properties as Record<string, unknown>) : {};
36+
for (const key of ["exitCode", "status", "code"]) {
37+
const value = record[key] ?? properties[key];
38+
if (typeof value === "number") return value;
39+
if (typeof value === "string" && /^-?\d+$/.test(value)) return Number.parseInt(value, 10);
40+
}
41+
return null;
42+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { spawnSync } from "node:child_process";
2+
import type { OpenCodeSidecarRecorder } from "./events.js";
3+
4+
export interface IdleVerifier {
5+
markDirty: (type: string, payload?: Record<string, unknown>) => void;
6+
maybeVerifyOnIdle: () => void;
7+
}
8+
9+
export function createIdleVerifier(directory: string, recorder: OpenCodeSidecarRecorder, debounceMs = 2000): IdleVerifier {
10+
let dirty = false;
11+
let verifying = false;
12+
let lastVerifyAt = 0;
13+
14+
function markDirty(type: string, payload: Record<string, unknown> = {}): void {
15+
dirty = true;
16+
recorder.record(type, payload);
17+
recorder.log("debug", "repository marked dirty", { type, ...payload });
18+
}
19+
20+
function maybeVerifyOnIdle(): void {
21+
const now = Date.now();
22+
if (!dirty) {
23+
recorder.log("debug", "idle verification skipped", { reason: "clean" });
24+
return;
25+
}
26+
if (verifying) {
27+
recorder.log("debug", "idle verification skipped", { reason: "already verifying" });
28+
return;
29+
}
30+
if (now - lastVerifyAt < debounceMs) {
31+
recorder.log("debug", "idle verification skipped", { reason: "debounced", elapsedMs: now - lastVerifyAt });
32+
return;
33+
}
34+
35+
verifying = true;
36+
dirty = false;
37+
try {
38+
const verify = spawnSync("opencode-plusplus", ["sidecar", "verify", directory, "--quiet"], {
39+
cwd: directory,
40+
encoding: "utf8",
41+
shell: process.platform === "win32"
42+
});
43+
recorder.record("sidecar.verify", { exitCode: verify.status ?? 1 });
44+
if ((verify.status ?? 1) !== 0) {
45+
const output = (verify.stdout || verify.stderr || "OpenCode++ sidecar found blockers. Run opencode-plusplus report.").trim();
46+
recorder.log("error", "sidecar verification blocked", { exitCode: verify.status ?? 1 });
47+
console.log(output);
48+
} else {
49+
recorder.log("debug", "sidecar verification passed");
50+
}
51+
} finally {
52+
verifying = false;
53+
lastVerifyAt = Date.now();
54+
}
55+
}
56+
57+
return { markDirty, maybeVerifyOnIdle };
58+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { spawnSync } from "node:child_process";
2+
import { runCommandGuard } from "./command-guard.js";
3+
import { createSidecarRecorder, type OpenCodeSidecarRuntimeContext } from "./events.js";
4+
import { exitCodeFromOutput, outputText, toolKey } from "./evidence.js";
5+
import { createIdleVerifier } from "./idle-verify.js";
6+
import { commandFromTool, pathsFromTool } from "./paths.js";
7+
import { currentSidecarWorkingTreeHash } from "./worktree-hash.js";
8+
9+
export async function OpenCodePlusplusSidecar(context: OpenCodeSidecarRuntimeContext): Promise<Record<string, unknown>> {
10+
return createOpenCodePlusplusSidecar(context);
11+
}
12+
13+
export async function createOpenCodePlusplusSidecar(context: OpenCodeSidecarRuntimeContext): Promise<Record<string, unknown>> {
14+
const recorder = createSidecarRecorder(context);
15+
const idle = createIdleVerifier(context.directory, recorder);
16+
const toolStarts = new Map<string, { startedAt: string; workingTreeHashBefore: string }>();
17+
18+
function rememberToolStart(tool: unknown, args: unknown): void {
19+
toolStarts.set(toolKey(tool, args), {
20+
startedAt: new Date().toISOString(),
21+
workingTreeHashBefore: currentSidecarWorkingTreeHash(context.directory)
22+
});
23+
}
24+
25+
function recordToolAfter(input: unknown, output: unknown): void {
26+
try {
27+
const inputRecord = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
28+
const outputRecord = output && typeof output === "object" ? (output as Record<string, unknown>) : {};
29+
const tool = inputRecord.tool ?? inputRecord.name ?? "unknown";
30+
const args = inputRecord.args ?? inputRecord.arguments ?? {};
31+
const command = commandFromTool(tool, args);
32+
const paths = pathsFromTool(args);
33+
const key = toolKey(tool, args);
34+
const started = toolStarts.get(key) ?? {
35+
startedAt: new Date().toISOString(),
36+
workingTreeHashBefore: currentSidecarWorkingTreeHash(context.directory)
37+
};
38+
toolStarts.delete(key);
39+
40+
const cliArgs = [
41+
"sidecar",
42+
"record-tool",
43+
context.directory,
44+
"--json",
45+
"--tool",
46+
String(tool),
47+
"--exit-code",
48+
String(exitCodeFromOutput(output) ?? 0),
49+
"--started-at",
50+
started.startedAt,
51+
"--finished-at",
52+
new Date().toISOString(),
53+
"--working-tree-hash-before",
54+
started.workingTreeHashBefore,
55+
"--working-tree-hash-after",
56+
currentSidecarWorkingTreeHash(context.directory)
57+
];
58+
if (command) cliArgs.push("--command", command);
59+
const sessionId = inputRecord.sessionID ?? inputRecord.sessionId ?? outputRecord.sessionID ?? outputRecord.sessionId;
60+
if (sessionId) cliArgs.push("--session-id", String(sessionId));
61+
const stdout = outputText(output, ["stdout", "output", "text"]);
62+
const stderr = outputText(output, ["stderr", "error"]);
63+
if (stdout) cliArgs.push("--stdout", stdout);
64+
if (stderr) cliArgs.push("--stderr", stderr);
65+
for (const file of paths) cliArgs.push("--path", file);
66+
67+
const recordResult = spawnSync("opencode-plusplus", cliArgs, {
68+
cwd: context.directory,
69+
encoding: "utf8",
70+
shell: process.platform === "win32",
71+
maxBuffer: 10 * 1024 * 1024
72+
});
73+
recorder.record("sidecar.record-tool", { tool, command, paths, exitCode: recordResult.status ?? 1 });
74+
if ((recordResult.status ?? 1) !== 0) {
75+
recorder.log("debug", "tool evidence record failed", { tool, command, status: recordResult.status ?? 1 });
76+
}
77+
} catch (error) {
78+
recorder.log("debug", "tool evidence record failed", { message: error instanceof Error ? error.message : String(error) });
79+
}
80+
}
81+
82+
return {
83+
name: "opencode-plusplus-sidecar",
84+
"tool.execute.before": async ({ tool, args }: { tool: unknown; args: unknown }) => {
85+
rememberToolStart(tool, args);
86+
runCommandGuard(context.directory, recorder, tool, args);
87+
},
88+
"tool.execute.after": async (input: unknown, output: unknown) => {
89+
recordToolAfter(input, output);
90+
},
91+
event: async ({ event }: { event?: Record<string, unknown> }) => {
92+
const eventRecord = event ?? {};
93+
const type = eventRecord.type;
94+
if (type === "session.created") {
95+
recorder.record("session.created");
96+
recorder.log("debug", "sidecar active", { directory: context.directory, worktree: context.worktree });
97+
}
98+
99+
if (type === "file.edited") {
100+
const properties = eventRecord.properties && typeof eventRecord.properties === "object" ? (eventRecord.properties as Record<string, unknown>) : {};
101+
const file = properties.file ?? properties.path ?? eventRecord.file ?? eventRecord.path ?? "unknown";
102+
idle.markDirty("file.edited", { file });
103+
}
104+
105+
if (type === "file.watcher.updated") {
106+
const properties = eventRecord.properties && typeof eventRecord.properties === "object" ? (eventRecord.properties as Record<string, unknown>) : {};
107+
const file = properties.file ?? properties.path ?? eventRecord.file ?? eventRecord.path ?? "unknown";
108+
idle.markDirty("file.watcher.updated", { file });
109+
}
110+
111+
if (type === "session.idle") {
112+
recorder.record("session.idle");
113+
idle.maybeVerifyOnIdle();
114+
}
115+
}
116+
};
117+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export function commandFromTool(tool: unknown, args: unknown): string | null {
2+
if (!args || typeof args !== "object") return null;
3+
const record = args as Record<string, unknown>;
4+
if (typeof record.command === "string") return record.command;
5+
if (typeof record.cmd === "string") return record.cmd;
6+
if (typeof record.shell === "string") return record.shell;
7+
if (typeof record.input === "string" && /^(bash|shell|terminal|run)$/i.test(String(tool ?? ""))) return record.input;
8+
return null;
9+
}
10+
11+
export function pathsFromTool(args: unknown): string[] {
12+
if (!args || typeof args !== "object") return [];
13+
const record = args as Record<string, unknown>;
14+
const values: string[] = [];
15+
for (const key of ["path", "file", "filepath", "filePath", "target", "destination"]) {
16+
if (typeof record[key] === "string") values.push(record[key]);
17+
}
18+
if (Array.isArray(record.files)) {
19+
for (const file of record.files) if (typeof file === "string") values.push(file);
20+
}
21+
return values;
22+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { spawnSync } from "node:child_process";
2+
import { hashText } from "./evidence.js";
3+
4+
export function gitOutput(directory: string, args: string[]): string {
5+
const result = spawnSync("git", args, {
6+
cwd: directory,
7+
encoding: "utf8",
8+
shell: process.platform === "win32"
9+
});
10+
return [
11+
`$ git ${args.join(" ")}`,
12+
`status=${typeof result.status === "number" ? result.status : "unknown"}`,
13+
typeof result.stdout === "string" ? result.stdout : "",
14+
typeof result.stderr === "string" ? result.stderr : "",
15+
result.error?.message ?? ""
16+
].join("\n");
17+
}
18+
19+
export function currentSidecarWorkingTreeHash(directory: string): string {
20+
const pathspec = ["--", ".", ":(exclude).agent-context/**", ":(exclude)AGENTS.md"];
21+
return hashText(
22+
[gitOutput(directory, ["status", "--porcelain=v1", "--untracked-files=all", ...pathspec]), gitOutput(directory, ["diff", "--binary", ...pathspec])].join(
23+
"\n"
24+
)
25+
);
26+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const OPENCODE_SIDECAR_PLUGIN_PATH = ".opencode/plugins/opencode-plusplus.ts";
2+
3+
export function opencodeSidecarPluginTemplate(runtimeImport = opencodeSidecarRuntimeImport()): string {
4+
return `/**
5+
* OpenCode++ OpenCode Sidecar Plugin
6+
*
7+
* Generated by \`opencode-plusplus\`. Edit only when you want to customize how OpenCode
8+
* presents the OpenCode++ harness inside this repository.
9+
*/
10+
11+
import { createOpenCodePlusplusSidecar } from ${JSON.stringify(runtimeImport)};
12+
13+
export const OpenCodePlusplusSidecar = createOpenCodePlusplusSidecar;
14+
export default OpenCodePlusplusSidecar;
15+
`;
16+
}
17+
18+
export function opencodeSidecarRuntimeImport(): string {
19+
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
20+
return new URL(`./plugin-runtime/index.${extension}`, import.meta.url).href;
21+
}

0 commit comments

Comments
 (0)