From bd11ee58268cfcbe83d8b1bdb84f819037425a67 Mon Sep 17 00:00:00 2001 From: suharvest Date: Sat, 18 Apr 2026 07:15:00 +0800 Subject: [PATCH 1/5] feat: auto-start Monitor on rescue dispatch via PostToolUse hook When Claude dispatches an opencode rescue task (via Agent tool or direct companion Bash call), this hook detects the new task-xxx id in the tool response and injects a system-reminder instructing Claude to start a persistent Monitor covering that id. On terminal states the Monitor emits a READY line pointing to the companion result command so Claude fetches the full payload and summarizes it for the user without needing to be asked. - New plugins/opencode/scripts/post-tool-use-monitor-hook.mjs - hooks.json: register PostToolUse (matcher: Agent|Bash, timeout 5s) Gracefully no-ops on non-matching tool output or missing companion markers. --- plugins/opencode/hooks/hooks.json | 12 ++ .../scripts/post-tool-use-monitor-hook.mjs | 159 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 plugins/opencode/scripts/post-tool-use-monitor-hook.mjs 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/post-tool-use-monitor-hook.mjs b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs new file mode 100644 index 0000000..95d856d --- /dev/null +++ b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs @@ -0,0 +1,159 @@ +#!/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: + // - reads companion status JSON per id every 30s + // - emits a single line whenever status/phase changes + // - exits the loop as soon as every tracked id is terminal so the + // Monitor process ends cleanly; the main thread's Monitor tool sees + // exit and stops spawning events. + // + // stdout is the event stream — stay selective. On terminal states we + // emit a `READY: ` line so Claude knows the exact command to run + // to fetch the full result payload. + return [ + "set -u", + `COMP=${JSON.stringify(companionPath)}`, + `IDS=(${quoted})`, + "declare -A prev", + 'for id in "${IDS[@]}"; do prev[$id]=""; done', + "while true; do", + " all_done=1", + ' for id in "${IDS[@]}"; do', + ' json=$(node "$COMP" status "$id" --json 2>/dev/null || printf "{}")', + " st=$(printf '%s' \"$json\" | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{try{const j=JSON.parse(s);console.log((j.job?.status||\"unknown\")+\"/\"+(j.job?.phase||\"\"))}catch(e){console.log(\"parse-err/\")}}))')", + ' if [ "$st" != "${prev[$id]}" ]; then', + ' ts=$(date +%H:%M:%S)', + ' echo "[$ts] opencode $id: ${prev[$id]:-none} → $st"', + ' case "$st" in', + " completed/*|failed/*|cancelled/*)", + ` echo "[$ts] READY opencode $id: run \\\"node $COMP result $id\\\" to fetch the full report"`, + " ;;", + " esac", + ' prev[$id]="$st"', + " fi", + ' case "$st" in completed/*|failed/*|cancelled/*) ;; *) 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. When a Monitor event reports a task transitioning to `completed/…`, `failed/…`, or `cancelled/…`, immediately run the `READY` line's command (`node result `) in Bash, read the output, and surface a short summary of what the rescue task did (or failed at) to the user. Do not wait for the user to ask.", + "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); +} From 02160b84477928e3fa4327432ec997b5c5468283 Mon Sep 17 00:00:00 2001 From: suharvest Date: Sat, 18 Apr 2026 07:26:58 +0800 Subject: [PATCH 2/5] fix(monitor): inline result fetch + fix parse-status JS syntax On terminal state the Monitor script now calls companion result and emits the truncated summary inline (bounded by OPENCODE_MONITOR_RESULT_CHARS, default 1500). Claude sees the result summary directly in the Monitor event and no longer needs a follow-up Bash call. Also fixes an extra trailing ) in the inline node -e expression that would have caused the status parser to syntax-error at runtime. --- .../scripts/post-tool-use-monitor-hook.mjs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs index 95d856d..18a1766 100644 --- a/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs +++ b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs @@ -56,33 +56,41 @@ function resolveCompanionPath() { function buildMonitorScript(ids, companionPath) { const quoted = ids.map((id) => `"${id}"`).join(" "); - // The poll loop: - // - reads companion status JSON per id every 30s + // The poll loop runs inside a Monitor child process: + // - polls companion status JSON per id every 30s // - emits a single line whenever status/phase changes - // - exits the loop as soon as every tracked id is terminal so the - // Monitor process ends cleanly; the main thread's Monitor tool sees - // exit and stops spawning events. - // - // stdout is the event stream — stay selective. On terminal states we - // emit a `READY: ` line so Claude knows the exact command to run - // to fetch the full result payload. + // - on terminal state, fetches `companion result `, truncates to + // a bounded size, and prints it as multi-line output; Monitor batches + // lines within ~200ms into one notification, so the main thread + // sees a single event carrying "task done + full summary" without + // needing a follow-up tool call to fetch the result + // - exits when every tracked id is terminal (Monitor process ends + // cleanly, no runaway background poller) return [ "set -u", `COMP=${JSON.stringify(companionPath)}`, `IDS=(${quoted})`, + "RESULT_MAX_CHARS=${OPENCODE_MONITOR_RESULT_CHARS:-1500}", "declare -A prev", 'for id in "${IDS[@]}"; do prev[$id]=""; done', "while true; do", " all_done=1", ' for id in "${IDS[@]}"; do', ' json=$(node "$COMP" status "$id" --json 2>/dev/null || printf "{}")', - " st=$(printf '%s' \"$json\" | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{try{const j=JSON.parse(s);console.log((j.job?.status||\"unknown\")+\"/\"+(j.job?.phase||\"\"))}catch(e){console.log(\"parse-err/\")}}))')", + " st=$(printf '%s' \"$json\" | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{try{const j=JSON.parse(s);console.log((j.job?.status||\"unknown\")+\"/\"+(j.job?.phase||\"\"))}catch(e){console.log(\"parse-err/\")}})')", ' if [ "$st" != "${prev[$id]}" ]; then', ' ts=$(date +%H:%M:%S)', ' echo "[$ts] opencode $id: ${prev[$id]:-none} → $st"', ' case "$st" in', " completed/*|failed/*|cancelled/*)", - ` echo "[$ts] READY opencode $id: run \\\"node $COMP result $id\\\" to fetch the full report"`, + ' 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")', + ' echo "[$ts] opencode $id TERMINAL=$st — result summary:"', + ' echo "--- result-begin $id ---"', + ' printf "%s" "$summary"', + ' echo ""', + ' echo "--- result-end $id ---"', " ;;", " esac", ' prev[$id]="$st"', @@ -117,7 +125,7 @@ function buildReminder(ids, 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. When a Monitor event reports a task transitioning to `completed/…`, `failed/…`, or `cancelled/…`, immediately run the `READY` line's command (`node result `) in Bash, read the output, and surface a short summary of what the rescue task did (or failed at) to the user. Do not wait for the user to ask.", + "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"); From 31febd0b042ee20e50cc7b90d55c185323936c29 Mon Sep 17 00:00:00 2001 From: suharvest Date: Sat, 18 Apr 2026 07:31:23 +0800 Subject: [PATCH 3/5] fix(server): race sendPrompt against completion watcher + env-tunable timeouts OpenCode server's POST /session/:id/message occasionally fails to close its HTTP response after the session emits the terminal assistant message (observed with glm-5 backend, opencode 1.4.x). Without this fix, sendPrompt hangs until AbortSignal fires, leaving the companion job stuck in 'investigating' status until the (previously 5 min) timeout. Changes: - Race the POST fetch against a /session/:id/message polling watcher; whichever returns first aborts the other. Watcher only accepts a completion whose info.time.completed >= prompt startedAt. - Bump generic request() timeout and sendPrompt timeout to 30 min, configurable via OPENCODE_REQUEST_TIMEOUT_MS / OPENCODE_PROMPT_TIMEOUT_MS env vars. - Completion poll interval configurable via OPENCODE_COMPLETION_POLL_MS (default 5s). --- .../opencode/scripts/lib/opencode-server.mjs | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) 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); + } }, /** From 6d48b0fb274dbeca1f6a963a2a66a163187196b6 Mon Sep 17 00:00:00 2001 From: suharvest Date: Sat, 18 Apr 2026 08:34:17 +0800 Subject: [PATCH 4/5] fix(status): honor --json flag and single-task lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `status` handler was ignoring argv entirely — `--json` was silently dropped and positional task ids were never matched. Tooling that piped status through jq would choke on the markdown fallback with "parse error: Invalid numeric literal". Now: - `status --json` emits a workspace snapshot as JSON ({workspaceRoot, running, latestFinished, recent}) - `status [--json]` looks up a single job by id/prefix. JSON form is {workspaceRoot, job: } so callers can always read .job.status safely. - `status --all` widens from session-scoped to all-sessions (useful for cross-session observers like monitor scripts) - Markdown output unchanged for the no-flag case. --- .../opencode/scripts/opencode-companion.mjs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) 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)); } From c378dfdfc4d6e0abf2a514fd16f3616994f74b77 Mon Sep 17 00:00:00 2001 From: suharvest Date: Sat, 18 Apr 2026 09:27:33 +0800 Subject: [PATCH 5/5] feat(monitor): surface progress activity and heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Monitor script only emitted on status/phase transitions. For long-running tasks that sit in 'running/investigating' for many minutes, the user saw one initial event and then nothing — no way to tell if the task was still alive. Now: - Include the last line of progressPreview in the state signature so any new log activity inside the task triggers an event (with elapsed time + latest log snippet) - Emit a heartbeat every HEARTBEAT_POLLS ticks (default 10 = ~5min) with current status/phase/elapsed even when nothing has changed - Both tunable via OPENCODE_MONITOR_HEARTBEAT_POLLS env var --- .../scripts/post-tool-use-monitor-hook.mjs | 69 ++++++++++++------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs index 18a1766..77900a1 100644 --- a/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs +++ b/plugins/opencode/scripts/post-tool-use-monitor-hook.mjs @@ -58,44 +58,61 @@ 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 a single line whenever status/phase changes - // - on terminal state, fetches `companion result `, truncates to - // a bounded size, and prints it as multi-line output; Monitor batches - // lines within ~200ms into one notification, so the main thread - // sees a single event carrying "task done + full summary" without - // needing a follow-up tool call to fetch the result - // - exits when every tracked id is terminal (Monitor process ends - // cleanly, no runaway background poller) + // - 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", - 'for id in "${IDS[@]}"; do prev[$id]=""; done', + "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 "{}")', - " st=$(printf '%s' \"$json\" | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{try{const j=JSON.parse(s);console.log((j.job?.status||\"unknown\")+\"/\"+(j.job?.phase||\"\"))}catch(e){console.log(\"parse-err/\")}})')", - ' if [ "$st" != "${prev[$id]}" ]; then', + " 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)', - ' echo "[$ts] opencode $id: ${prev[$id]:-none} → $st"', - ' 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")', - ' echo "[$ts] opencode $id TERMINAL=$st — result summary:"', - ' echo "--- result-begin $id ---"', - ' printf "%s" "$summary"', - ' echo ""', - ' echo "--- result-end $id ---"', - " ;;", - " esac", - ' prev[$id]="$st"', + ' 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/*) ;; *) all_done=0 ;; esac', + ' 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\"",