Skip to content
Open
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
12 changes: 12 additions & 0 deletions plugins/opencode/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@
}
]
}
],
"PostToolUse": [
{
"matcher": "Agent|Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use-monitor-hook.mjs\"",
"timeout": 5
}
]
}
]
}
}
96 changes: 84 additions & 12 deletions plugins/opencode/scripts/lib/opencode-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const DEFAULT_PORT = 4096;
const DEFAULT_HOST = "127.0.0.1";
const SERVER_START_TIMEOUT = 30_000;

// Long-running tasks (e.g. engine builds, large refactors) can easily exceed
// the old 5-10 min caps, causing `fetch failed` at a fixed deadline. Default
// to 30 min; override via env for even longer workloads.
const REQUEST_TIMEOUT_MS = Number(process.env.OPENCODE_REQUEST_TIMEOUT_MS) || 1_800_000;
const PROMPT_TIMEOUT_MS = Number(process.env.OPENCODE_PROMPT_TIMEOUT_MS) || 1_800_000;

/**
* Check if an OpenCode server is already running on the given port.
* @param {string} host
Expand Down Expand Up @@ -87,7 +93,7 @@ export function createClient(baseUrl, opts = {}) {
method,
headers,
body: body != null ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(300_000),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
Expand Down Expand Up @@ -127,6 +133,17 @@ export function createClient(baseUrl, opts = {}) {
/**
* Send a prompt (synchronous / streaming).
* Returns the full response text from SSE stream.
*
* NOTE: OpenCode's POST /session/:id/message occasionally fails to close
* its HTTP response body after the session emits its terminal assistant
* message (observed against glm-5 backend, opencode 1.4.x). Relying on
* res.json() alone means the caller hangs until AbortSignal fires, which
* breaks downstream job-completion detection in the companion.
*
* Workaround: race the fetch against a session-completion watcher that
* polls GET /session/:id/message. When the latest assistant message has
* info.time.completed set AND finish !== undefined, the session is done;
* we abort the hanging fetch and synthesize the response from the poll.
*/
sendPrompt: async (sessionId, promptText, opts = {}) => {
const body = {
Expand All @@ -136,19 +153,74 @@ export function createClient(baseUrl, opts = {}) {
if (opts.model) body.model = opts.model;
if (opts.system) body.system = opts.system;

const res = await fetch(`${baseUrl}/session/${sessionId}/message`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(600_000), // 10 min for long tasks
});
const ac = new AbortController();
const timeoutId = setTimeout(() => ac.abort(new Error("prompt timeout")), PROMPT_TIMEOUT_MS);
const startedAt = Date.now();
// Grace period so we don't mistake "session had no prior activity" for
// completion before the new prompt has even begun generating.
const MIN_POLL_DELAY_MS = 5_000;
const POLL_INTERVAL_MS = Number(process.env.OPENCODE_COMPLETION_POLL_MS) || 5_000;

if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`OpenCode prompt failed ${res.status}: ${text}`);
}
const fetchPromise = (async () => {
const res = await fetch(`${baseUrl}/session/${sessionId}/message`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: ac.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`OpenCode prompt failed ${res.status}: ${text}`);
}
return { source: "fetch", data: await res.json() };
})();

return res.json();
const watcherPromise = (async () => {
// Wait briefly so the new generation has a chance to start and we
// don't latch onto a stale completed message from before this prompt.
await new Promise((r) => setTimeout(r, MIN_POLL_DELAY_MS));
while (!ac.signal.aborted) {
try {
const params = new URLSearchParams({ limit: "1" });
const r = await fetch(
`${baseUrl}/session/${sessionId}/message?${params.toString()}`,
{ headers, signal: AbortSignal.timeout(10_000) },
);
if (r.ok) {
const arr = await r.json();
const last = Array.isArray(arr) ? arr[arr.length - 1] : null;
const info = last?.info;
// Only treat assistant messages created *after* this prompt
// started as a completion signal for this call.
if (
info &&
info.role === "assistant" &&
typeof info.time?.completed === "number" &&
info.time.completed >= startedAt &&
typeof info.finish === "string"
) {
return { source: "watcher", data: last };
}
}
} catch {
// Ignore transient poll errors; keep waiting.
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error("watcher aborted");
})();

try {
const winner = await Promise.race([fetchPromise, watcherPromise]);
// Whichever arrived first, cancel the other.
ac.abort();
// Swallow the loser's rejection to avoid unhandled rejection noise.
fetchPromise.catch(() => {});
watcherPromise.catch(() => {});
return winner.data;
} finally {
clearTimeout(timeoutId);
}
},

/**
Expand Down
53 changes: 51 additions & 2 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { isOpencodeInstalled, getOpencodeVersion, spawnDetached } from "./lib/pr
import { isServerRunning, ensureServer, createClient, connect } from "./lib/opencode-server.mjs";
import { resolveWorkspace } from "./lib/workspace.mjs";
import { loadState, updateState, upsertJob, generateJobId, jobDataPath } from "./lib/state.mjs";
import { buildStatusSnapshot, resolveResultJob, resolveCancelableJob, enrichJob } from "./lib/job-control.mjs";
import { buildStatusSnapshot, resolveResultJob, resolveCancelableJob, enrichJob, matchJobReference } from "./lib/job-control.mjs";
import { createJobRecord, runTrackedJob, getClaudeSessionId } from "./lib/tracked-jobs.mjs";
import { renderStatus, renderResult, renderReview, renderSetup } from "./lib/render.mjs";
import { buildReviewPrompt, buildTaskPrompt } from "./lib/prompts.mjs";
Expand Down Expand Up @@ -424,11 +424,60 @@ async function handleTaskResumeCandidate(argv) {
// ------------------------------------------------------------------

async function handleStatus(argv) {
const { options, positional } = parseArgs(argv ?? [], {
booleanOptions: ["json", "all"],
});

const workspace = await resolveWorkspace();
const state = loadState(workspace);
const sessionId = getClaudeSessionId();
const jobs = state.jobs ?? [];
const wantJson = !!options.json;
// --all widens the snapshot filter to every session's jobs; without --all we
// still filter to the current Claude session for the existing markdown UX.
const sessionFilter = options.all ? undefined : sessionId;
const ref = positional?.[0];

// Single-task query — `status <tid> [--json]`.
if (ref) {
const { job, ambiguous } = matchJobReference(jobs, ref);
if (ambiguous) {
if (wantJson) {
console.log(JSON.stringify({ workspaceRoot: workspace, job: null, error: "ambiguous" }));
} else {
console.error(`Ambiguous job reference "${ref}". Please provide a more specific ID prefix.`);
}
process.exit(ambiguous ? 2 : 0);
return;
}
if (wantJson) {
const enriched = job ? enrichJob(job, workspace) : null;
console.log(JSON.stringify({ workspaceRoot: workspace, job: enriched }));
return;
}
if (!job) {
console.log(`No job found for "${ref}" in workspace ${workspace}.`);
return;
}
console.log(renderStatus({ running: [], latestFinished: null, recent: [enrichJob(job, workspace)] }));
return;
}

const snapshot = buildStatusSnapshot(jobs, workspace, { sessionId: sessionFilter });

if (wantJson) {
// Machine-readable shape mirrors the single-task case so callers can treat
// both uniformly: a `.job` field is present for single-task, otherwise
// `.running`/`.recent` arrays describe the whole workspace snapshot.
console.log(JSON.stringify({
workspaceRoot: workspace,
running: snapshot.running,
latestFinished: snapshot.latestFinished,
recent: snapshot.recent,
}));
return;
}

const snapshot = buildStatusSnapshot(state.jobs ?? [], workspace, { sessionId });
console.log(renderStatus(snapshot));
}

Expand Down
Loading