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
3 changes: 2 additions & 1 deletion src/hooks/agent-usage-reminder/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/category-skill-reminder/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/comment-checker/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> | null = null

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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}`)
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/context-window-monitor.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/delegate-task-retry/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}`)
}
},
}
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/directory-agents-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createDynamicTruncator>;

Expand Down Expand Up @@ -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 {}
}
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/directory-readme-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createDynamicTruncator>;

Expand Down Expand Up @@ -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 {}
}
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/edit-error-recovery/hook.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -50,7 +51,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
)

if (hasEditError) {
output.output += `\n${EDIT_ERROR_REMINDER}`
appendToOutput(output, `\n${EDIT_ERROR_REMINDER}`)
}
},
}
Expand Down
71 changes: 71 additions & 0 deletions src/hooks/hook-output-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
23 changes: 23 additions & 0 deletions src/hooks/hook-output-guard.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion src/hooks/interactive-bash-session/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,7 +98,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
Array.from(state.tmuxSessions),
);
if (reminder) {
output.output += reminder;
appendToOutput(output, reminder);
}
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -57,7 +58,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
toolOutput,
})
if (reminderToAppend) {
output.output += reminderToAppend
appendToOutput(output, reminderToAppend)
}
};

Expand Down
3 changes: 2 additions & 1 deletion src/hooks/rules-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/task-reminder/hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendToOutput } from "../hook-output-guard"

const TASK_TOOLS = new Set([
"task",
Expand Down Expand Up @@ -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)
Expand Down