diff --git a/plugins/opencode/hooks/hooks.json b/plugins/opencode/hooks/hooks.json index c76f993..8c193dd 100644 --- a/plugins/opencode/hooks/hooks.json +++ b/plugins/opencode/hooks/hooks.json @@ -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 + } + ] + } ] } } diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index f4192a8..1d32943 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -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 @@ -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(() => ""); @@ -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 = { @@ -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); + } }, /** diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 48e526b..7d57a19 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -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"; @@ -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 [--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)); } diff --git a/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs new file mode 100644 index 0000000..77900a1 --- /dev/null +++ b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +// PostToolUse hook: watches for rescue-task dispatch in tool responses and +// injects a reminder that tells Claude to (a) start/refresh a Monitor +// covering the new task id(s), and (b) fetch + summarize the companion +// `result` payload when Monitor reports a terminal state. +// +// Why in a hook: the main Claude thread has no built-in way to observe +// background codex/opencode tasks. Without this, dispatching a rescue is +// fire-and-forget — the user has to ask for progress manually. The hook +// makes every rescue dispatch automatically get monitored and reported +// on, matching the UX of in-process subagents. + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +function readHookInput() { + try { + const raw = fs.readFileSync(0, "utf8").trim(); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +// Companion task ids look like `task-moNNNNNN-NNNNNN`. +const TASK_ID_RE = /\btask-[a-z0-9]{6,}-[a-z0-9]{4,}\b/g; + +// Only react to responses that are unambiguously from the opencode companion, +// to avoid false positives on arbitrary text containing a task-like token. +const OPENCODE_MARKERS = [ + /OpenCode task started/i, + /opencode-companion\.mjs/, + /opencode:opencode-rescue/, + /opencode rescue/i, +]; + +function extractResponseText(response) { + if (response == null) return ""; + if (typeof response === "string") return response; + if (typeof response === "object") { + if (typeof response.result === "string") return response.result; + if (typeof response.content === "string") return response.content; + return JSON.stringify(response); + } + return String(response); +} + +function resolveCompanionPath() { + const here = fileURLToPath(import.meta.url); + return path.join(path.dirname(here), "opencode-companion.mjs"); +} + +function buildMonitorScript(ids, companionPath) { + const quoted = ids.map((id) => `"${id}"`).join(" "); + // The poll loop runs inside a Monitor child process: + // - polls companion status JSON per id every 30s + // - emits an event when status/phase OR the latest progressPreview log + // line changes, so long-running tasks surface intermediate activity + // - emits a heartbeat every HEARTBEAT_POLLS ticks (default 10 = ~5min) + // so the user sees signs of life even when nothing has changed + // - on terminal state, fetches `companion result `, truncates, and + // prints a multi-line summary so the main thread gets a single batched + // event carrying the full report + // - exits when every tracked id is terminal + return [ + "set -u", + `COMP=${JSON.stringify(companionPath)}`, + `IDS=(${quoted})`, + "RESULT_MAX_CHARS=${OPENCODE_MONITOR_RESULT_CHARS:-1500}", + "HEARTBEAT_POLLS=${OPENCODE_MONITOR_HEARTBEAT_POLLS:-10}", + "declare -A prev", + "declare -A hb", + 'for id in "${IDS[@]}"; do prev[$id]=""; hb[$id]=0; done', + "while true; do", + " all_done=1", + ' for id in "${IDS[@]}"; do', + ' json=$(node "$COMP" status "$id" --json 2>/dev/null || printf "{}")', + " fields=$(printf '%s' \"$json\" | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{try{const j=JSON.parse(s);const jb=j.job||{};const prog=String(jb.progressPreview||\"\").split(\"\\n\").filter(Boolean);const last=(prog[prog.length-1]||\"\").replace(/[|\\r\\n]/g,\" \").slice(0,200);process.stdout.write([jb.status||\"unknown\",jb.phase||\"\",jb.elapsed||\"\",last].join(\"|\"))}catch(e){process.stdout.write(\"parse-err|||\")}})')", + " IFS='|' read -r st phase elapsed last <<< \"$fields\"", + ' sig="${st}/${phase}|${last}"', + ' if [ "$sig" != "${prev[$id]}" ]; then', + ' ts=$(date +%H:%M:%S)', + ' if [ -n "$last" ]; then', + ' echo "[$ts] opencode $id: $st/$phase (elapsed $elapsed) — $last"', + " else", + ' echo "[$ts] opencode $id: $st/$phase (elapsed $elapsed)"', + " fi", + ' prev[$id]="$sig"', + " hb[$id]=0", + " else", + ' hb[$id]=$(( ${hb[$id]} + 1 ))', + ' if [ "${hb[$id]}" -ge "$HEARTBEAT_POLLS" ]; then', + ' ts=$(date +%H:%M:%S)', + ' echo "[$ts] opencode $id: heartbeat — still $st/$phase (elapsed $elapsed)"', + " hb[$id]=0", + " fi", + " fi", + ' case "$st" in', + " completed|failed|cancelled)", + ' result=$(node "$COMP" result "$id" 2>/dev/null || true)', + " # Truncate defensively so Monitor output stays bounded.", + ' summary=$(printf "%s" "$result" | head -c "$RESULT_MAX_CHARS")', + ' ts=$(date +%H:%M:%S)', + ' echo "[$ts] opencode $id TERMINAL=$st — result summary:"', + ' echo "--- result-begin $id ---"', + ' printf "%s" "$summary"', + ' echo ""', + ' echo "--- result-end $id ---"', + " ;;", + ' *) all_done=0 ;;', + " esac", + " done", + " if [ $all_done -eq 1 ]; then", + " echo \"[$(date +%H:%M:%S)] opencode: all tracked tasks terminal — exiting monitor\"", + " break", + " fi", + " sleep 30", + "done", + ].join("\n"); +} + +function buildReminder(ids, script) { + const idList = ids.join(", "); + return [ + "", + `You just dispatched ${ids.length} opencode rescue task(s): ${idList}.`, + "", + "Before you respond to the user, start a persistent Monitor so this session can observe task completion automatically. Use the `Monitor` tool:", + "", + '- description: "opencode rescue task state"', + "- persistent: true", + "- timeout_ms: 3600000", + "- command: the shell loop below, verbatim", + "", + "```bash", + script, + "```", + "", + "Behavior contract — follow this without being asked:", + "1. If a Monitor is already running in this session covering other task ids, stop it (`TaskStop`) and start a new one whose IDS array is the union of all currently-running rescue task ids. One Monitor per session.", + "2. The Monitor script above already fetches `companion result ` and emits the summary inline on terminal state — each terminal event carries the full result block between `--- result-begin ---` and `--- result-end ---` markers. You do NOT need to run a follow-up Bash call to get the result; just read the Monitor event and surface a short summary of what the rescue task did (or failed at) to the user.", + "3. If the user explicitly asked you to skip monitoring, do nothing here.", + "", + ].join("\n"); +} + +function main() { + const input = readHookInput(); + const toolName = input.tool_name || ""; + // Rescue dispatch happens through the Agent tool wrapper; main thread may + // also call companion directly via Bash. Ignore other tools. + if (toolName !== "Agent" && toolName !== "Bash") return; + + const response = extractResponseText(input.tool_response); + if (!response) return; + if (!OPENCODE_MARKERS.some((r) => r.test(response))) return; + + const ids = [...new Set(response.match(TASK_ID_RE) || [])]; + if (ids.length === 0) return; + + const companionPath = resolveCompanionPath(); + const script = buildMonitorScript(ids, companionPath); + const additionalContext = buildReminder(ids, script); + + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext, + }, + }), + ); +} + +try { + main(); +} catch { + // Best-effort — never block tool use on hook failure. + process.exit(0); +}