diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index bc7f3243fd..90928fdf11 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -6,6 +6,7 @@ import { } from "./storage"; import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; import type { AgentUsageState } from "./types"; +import { appendToOutput } from "../hook-output-guard"; interface ToolExecuteInput { tool: string; @@ -77,7 +78,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { return; } - output.output += REMINDER_MESSAGE; + appendToOutput(output, REMINDER_MESSAGE); state.reminderCount++; state.updatedAt = Date.now(); saveAgentUsageState(state); diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index b15715cda0..65a82d8a8f 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" import { buildReminderMessage } from "./formatter" +import { appendToOutput } from "../hook-output-guard" /** * Target agents that should receive category+skill reminders. @@ -106,7 +107,7 @@ export function createCategorySkillReminderHook( state.toolCallCount++ if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { - output.output += reminderMessage + appendToOutput(output, reminderMessage) state.reminderShown = true log("[category-skill-reminder] Reminder injected", { sessionID, diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts index 0dc934ea22..4fc6b93029 100644 --- a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts @@ -10,6 +10,7 @@ import { getToolInput } from "../tool-input-cache" import { appendTranscriptEntry, getTranscriptPath } from "../transcript" import type { PluginConfig } from "../types" import { isHookDisabled, log } from "../../../shared" +import { appendToOutput } from "../../../hooks/hook-output-guard" export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) { return async ( @@ -80,11 +81,11 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo } if (result.warnings && result.warnings.length > 0) { - output.output = `${output.output}\n\n${result.warnings.join("\n")}` + appendToOutput(output, `\n\n${result.warnings.join("\n")}`) } if (result.message) { - output.output = `${output.output}\n\n${result.message}` + appendToOutput(output, `\n\n${result.message}`) } if (result.hookName) { diff --git a/src/hooks/comment-checker/cli-runner.ts b/src/hooks/comment-checker/cli-runner.ts index b546125fc7..7365c5324c 100644 --- a/src/hooks/comment-checker/cli-runner.ts +++ b/src/hooks/comment-checker/cli-runner.ts @@ -2,6 +2,7 @@ import type { PendingCall } from "./types" import { existsSync } from "fs" import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli" +import { appendToOutput } from "../hook-output-guard" let cliPathPromise: Promise | null = null @@ -52,7 +53,7 @@ export async function processWithCli( if (result.hasComments && result.message) { debugLog("CLI detected comments, appending message") - output.output += `\n\n${result.message}` + appendToOutput(output, `\n\n${result.message}`) } else { debugLog("CLI: no comments detected") } @@ -92,7 +93,7 @@ export async function processApplyPatchEditsWithCli( if (result.hasComments && result.message) { debugLog("CLI detected comments for apply_patch file:", edit.filePath) - output.output += `\n\n${result.message}` + appendToOutput(output, `\n\n${result.message}`) } } } diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 3b92191146..507186ddd7 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" +import { appendToOutput } from "./hook-output-guard" const ANTHROPIC_DISPLAY_LIMIT = 1_000_000 const ANTHROPIC_ACTUAL_LIMIT = @@ -74,8 +75,8 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { const usedTokens = totalInputTokens.toLocaleString() const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString() - output.output += `\n\n${CONTEXT_REMINDER} -[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]` + appendToOutput(output, `\n\n${CONTEXT_REMINDER} +[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`) } catch { // Graceful degradation - do not disrupt tool execution } diff --git a/src/hooks/delegate-task-retry/hook.ts b/src/hooks/delegate-task-retry/hook.ts index 915da323d8..31730d5a20 100644 --- a/src/hooks/delegate-task-retry/hook.ts +++ b/src/hooks/delegate-task-retry/hook.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { buildRetryGuidance } from "./guidance" import { detectDelegateTaskError } from "./patterns" +import { appendToOutput } from "../hook-output-guard" export function createDelegateTaskRetryHook(_ctx: PluginInput) { return { @@ -11,10 +12,10 @@ export function createDelegateTaskRetryHook(_ctx: PluginInput) { ) => { if (input.tool.toLowerCase() !== "task") return - const errorInfo = detectDelegateTaskError(output.output) + const errorInfo = detectDelegateTaskError(output.output ?? "") if (errorInfo) { const guidance = buildRetryGuidance(errorInfo) - output.output += `\n${guidance}` + appendToOutput(output, `\n${guidance}`) } }, } diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index dc6ca0810d..9b14195502 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -5,6 +5,7 @@ import { dirname } from "node:path"; import type { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { findAgentsMdUp, resolveFilePath } from "./finder"; import { loadInjectedPaths, saveInjectedPaths } from "./storage"; +import { appendToOutput } from "../hook-output-guard"; type DynamicTruncator = ReturnType; @@ -46,7 +47,7 @@ export async function processFilePathForAgentsInjection(input: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` : ""; - input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; + appendToOutput(input.output, `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`); cache.add(agentsDir); } catch {} } diff --git a/src/hooks/directory-readme-injector/injector.ts b/src/hooks/directory-readme-injector/injector.ts index 082165844b..0bfb75f3f8 100644 --- a/src/hooks/directory-readme-injector/injector.ts +++ b/src/hooks/directory-readme-injector/injector.ts @@ -5,6 +5,7 @@ import { dirname } from "node:path"; import type { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { findReadmeMdUp, resolveFilePath } from "./finder"; import { loadInjectedPaths, saveInjectedPaths } from "./storage"; +import { appendToOutput } from "../hook-output-guard"; type DynamicTruncator = ReturnType; @@ -46,7 +47,7 @@ export async function processFilePathForReadmeInjection(input: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]` : ""; - input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`; + appendToOutput(input.output, `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`); cache.add(readmeDir); } catch {} } diff --git a/src/hooks/edit-error-recovery/hook.ts b/src/hooks/edit-error-recovery/hook.ts index 7df8aa0ffd..6de9ef8673 100644 --- a/src/hooks/edit-error-recovery/hook.ts +++ b/src/hooks/edit-error-recovery/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { appendToOutput } from "../hook-output-guard" /** * Known Edit tool error patterns that indicate the AI made a mistake @@ -50,7 +51,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) { ) if (hasEditError) { - output.output += `\n${EDIT_ERROR_REMINDER}` + appendToOutput(output, `\n${EDIT_ERROR_REMINDER}`) } }, } diff --git a/src/hooks/hook-output-guard.test.ts b/src/hooks/hook-output-guard.test.ts new file mode 100644 index 0000000000..3145522657 --- /dev/null +++ b/src/hooks/hook-output-guard.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "bun:test" +import { appendToOutput } from "./hook-output-guard" + +describe("appendToOutput", () => { + describe("#given output.output is a normal string", () => { + it("#then should append text to existing output", () => { + const output = { output: "original" } + appendToOutput(output, " appended") + expect(output.output).toBe("original appended") + }) + }) + + describe("#given output.output is an empty string", () => { + it("#then should append text directly", () => { + const output = { output: "" } + appendToOutput(output, "new text") + expect(output.output).toBe("new text") + }) + }) + + describe("#given output.output is undefined (MCP tool response)", () => { + it("#then should initialize with the text instead of crashing", () => { + const output = { output: undefined as unknown as string } + appendToOutput(output, "reminder message") + expect(output.output).toBe("reminder message") + }) + }) + + describe("#given output.output is null", () => { + it("#then should initialize with the text", () => { + const output = { output: null as unknown as string } + appendToOutput(output, "context injection") + expect(output.output).toBe("context injection") + }) + }) + + describe("#given output.output is a non-string object", () => { + it("#then should not modify the output to preserve structured data", () => { + const structured = { key: "value" } + const output = { output: structured as unknown as string } + appendToOutput(output, "should not corrupt") + expect(output.output).toBe(structured as unknown as string) + }) + }) + + describe("#given output.output is a number", () => { + it("#then should not modify the output", () => { + const output = { output: 42 as unknown as string } + appendToOutput(output, " text") + expect(output.output).toBe(42 as unknown as string) + }) + }) + + describe("#given multiple sequential appends", () => { + it("#then should accumulate all text", () => { + const output = { output: "base" } + appendToOutput(output, "\nfirst") + appendToOutput(output, "\nsecond") + expect(output.output).toBe("base\nfirst\nsecond") + }) + }) + + describe("#given multiple appends starting from undefined", () => { + it("#then should initialize once and accumulate", () => { + const output = { output: undefined as unknown as string } + appendToOutput(output, "first") + appendToOutput(output, " second") + expect(output.output).toBe("first second") + }) + }) +}) diff --git a/src/hooks/hook-output-guard.ts b/src/hooks/hook-output-guard.ts new file mode 100644 index 0000000000..120b43bce8 --- /dev/null +++ b/src/hooks/hook-output-guard.ts @@ -0,0 +1,23 @@ +/** + * Safely appends text to a hook output's `output` field. + * + * MCP tools (Atlassian, Exa, Chrome DevTools, grep.app, etc.) can return + * responses where `output.output` is `undefined` instead of a string. + * This helper normalizes `null`/`undefined` to an empty string before + * appending, preserving context injection while avoiding TypeError crashes. + * + * Non-string values (objects, arrays) are left untouched to prevent + * corrupting structured tool responses. + */ +export function appendToOutput( + output: { output: string }, + text: string, +): void { + if (output.output == null) { + output.output = text + return + } + if (typeof output.output === "string") { + output.output += text + } +} diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts index 1d45a6b0a9..2bf3b19ee6 100644 --- a/src/hooks/interactive-bash-session/hook.ts +++ b/src/hooks/interactive-bash-session/hook.ts @@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types"; import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; import { subagentSessions } from "../../features/claude-code-session-state"; +import { appendToOutput } from "../hook-output-guard"; interface ToolExecuteInput { tool: string; @@ -97,7 +98,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { Array.from(state.tmuxSessions), ); if (reminder) { - output.output += reminder; + appendToOutput(output, reminder); } } }; diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts index b3ba0976bc..448adcc2ed 100644 --- a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts +++ b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker"; import { parseTmuxCommand } from "./tmux-command-parser"; +import { appendToOutput } from "../hook-output-guard"; interface ToolExecuteInput { tool: string; @@ -57,7 +58,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { toolOutput, }) if (reminderToAppend) { - output.output += reminderToAppend + appendToOutput(output, reminderToAppend) } }; diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 9ba0324bab..38b29ffd11 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -11,6 +11,7 @@ import { import { parseRuleFrontmatter } from "./parser"; import { saveInjectedRules } from "./storage"; import type { SessionInjectedRulesCache } from "./cache"; +import { appendToOutput } from "../hook-output-guard"; type ToolExecuteOutput = { title: string; @@ -116,7 +117,7 @@ export function createRuleInjectionProcessor(deps: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` : ""; - output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; + appendToOutput(output, `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`); } saveInjectedRules(sessionID, cache); diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts index 4e795018d0..254df0c8c9 100644 --- a/src/hooks/task-reminder/hook.ts +++ b/src/hooks/task-reminder/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { appendToOutput } from "../hook-output-guard" const TASK_TOOLS = new Set([ "task", @@ -39,7 +40,7 @@ export function createTaskReminderHook(_ctx: PluginInput) { const newCount = currentCount + 1 if (newCount >= TURN_THRESHOLD) { - output.output += REMINDER_MESSAGE + appendToOutput(output, REMINDER_MESSAGE) sessionCounters.set(sessionID, 0) } else { sessionCounters.set(sessionID, newCount)