diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cc827a0cf..7c4f31ebf 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -530,7 +530,7 @@ Disable built-in hooks via `disabled_hooks`: { "disabled_hooks": ["comment-checker"] } ``` -Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `tool-output-truncator`, `question-label-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `model-fallback`, `anthropic-context-window-limit-recovery`, `preemptive-compaction`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `thinking-block-validator`, `tool-pair-validator`, `ralph-loop`, `category-skill-reminder`, `compaction-context-injector`, `compaction-todo-preserver`, `claude-code-hooks`, `auto-slash-command`, `edit-error-recovery`, `json-error-recovery`, `delegate-task-retry`, `prometheus-md-only`, `sisyphus-junior-notepad`, `team-tool-gating`, `no-sisyphus-gpt`, `no-hephaestus-non-gpt`, `start-work`, `atlas`, `unstable-agent-babysitter`, `task-resume-info`, `stop-continuation-guard`, `tasks-todowrite-disabler`, `runtime-fallback`, `write-existing-file-guard`, `bash-file-read-guard`, `anthropic-effort`, `hashline-read-enhancer`, `read-image-resizer`, `todo-description-override`, `webfetch-redirect-guard`, `fsync-skip-warning`, `legacy-plugin-toast` +Available hooks: `todo-continuation-enforcer`, `session-recovery`, `session-notification`, `comment-checker`, `tool-output-truncator`, `question-label-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `model-fallback`, `anthropic-context-window-limit-recovery`, `preemptive-compaction`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `thinking-block-validator`, `tool-pair-validator`, `ralph-loop`, `category-skill-reminder`, `compaction-context-injector`, `compaction-todo-preserver`, `claude-code-hooks`, `auto-slash-command`, `edit-error-recovery`, `json-error-recovery`, `delegate-task-retry`, `prometheus-md-only`, `sisyphus-junior-notepad`, `team-tool-gating`, `no-sisyphus-gpt`, `no-hephaestus-non-gpt`, `start-work`, `atlas`, `unstable-agent-babysitter`, `task-resume-info`, `stop-continuation-guard`, `tasks-todowrite-disabler`, `runtime-fallback`, `write-existing-file-guard`, `bash-file-read-guard`, `anthropic-effort`, `hashline-read-enhancer`, `read-image-resizer`, `todo-description-override`, `webfetch-redirect-guard`, `fsync-skip-warning`, `legacy-plugin-toast` Guard hooks such as `team-tool-gating`, `write-existing-file-guard`, `bash-file-read-guard`, `webfetch-redirect-guard`, `prometheus-md-only`, `rules-injector`, `tool-pair-validator`, and `thinking-block-validator` protect safety, permissions, or provider protocol correctness. Disable them only for audited local debugging in a trusted environment. diff --git a/docs/reference/features.md b/docs/reference/features.md index 3a8abce12..766e3ea9b 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -785,7 +785,6 @@ Current composition counts: | **directory-readme-injector** | PreToolUse + PostToolUse | Auto-injects README.md for directory context. | | **rules-injector** | PreToolUse + PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. | | **compaction-context-injector** | Event | Preserves critical context during session compaction. | -| **context-window-monitor** | Event | Monitors context window usage and tracks token consumption. | | **preemptive-compaction** | Event | Proactively compacts sessions before hitting token limits. | #### Productivity & Control diff --git a/src/AGENTS.md b/src/AGENTS.md index 1232c1bc7..6c70ed846 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -55,7 +55,7 @@ Counts verified from each composer's return object. Numbers in brackets show cou ``` createHooks() ├─→ createCoreHooks() - │ ├─ createSessionHooks() # 24: contextWindowMonitor, preemptiveCompaction, sessionRecovery, + │ ├─ createSessionHooks() # 23: preemptiveCompaction, sessionRecovery, │ │ sessionNotification, thinkMode, modelFallback, │ │ anthropicContextWindowLimitRecovery, autoUpdateChecker, │ │ agentUsageReminder, nonInteractiveEnv, interactiveBashSession, diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 276a99ca3..fb096bc7f 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -15,7 +15,7 @@ config/schema/ ├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent) ├── agent-definitions.ts # custom agent definition schema ├── categories.ts # 8 built-in + custom categories -├── hooks.ts # HookNameSchema (53 enum values; `team-tool-gating` is the only team-* one in schema — others are wired by direct config gates) +├── hooks.ts # HookNameSchema (52 enum values; `team-tool-gating` is the only team-* one in schema — others are wired by direct config gates) ├── skills.ts # SkillsConfigSchema (sources, paths, recursive) ├── commands.ts # BuiltinCommandNameSchema ├── experimental.ts # Feature flags incl plugin_load_timeout_ms (min 1000), task_system, max_tools diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index d36e277d9..00c7beefd 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -469,6 +469,17 @@ describe("HookNameSchema", () => { //#then expect(result.success).toBe(false) }) + + test("rejects removed context-window-monitor hook name", () => { + //#given + const input = "context-window-monitor" + + //#when + const result = HookNameSchema.safeParse(input) + + //#then + expect(result.success).toBe(false) + }) }) describe("Sisyphus-Junior agent override", () => { diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 72a43722a..42ad1776e 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -2,7 +2,6 @@ import { z } from "zod" export const HookNameSchema = z.enum([ "todo-continuation-enforcer", - "context-window-monitor", "session-recovery", "session-notification", "comment-checker", diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 0828e3803..5a51f0af6 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -52 registered hooks. The 57 directories break down as: 48 registered hook dirs (with `index.ts`) + 6 standalone hook `.ts` files (bash-file-read-guard, context-window-monitor, empty-task-response-detector, preemptive-compaction, session-notification, tool-output-truncator) + support dirs (`shared/`, `team-session-events/`, 5 `zauc-mocks-*`/`zauc-sync-mocks`, `.sisyphus/` legacy state). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. +51 registered hooks. The 56 directories break down as: 48 registered hook dirs (with `index.ts`) + 5 standalone hook `.ts` files (bash-file-read-guard, empty-task-response-detector, preemptive-compaction, session-notification, tool-output-truncator) + support dirs (`shared/`, `team-session-events/`, 5 `zauc-mocks-*`/`zauc-sync-mocks`, `.sisyphus/` legacy state). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) -> HookFunction` factory pattern. **Unwired WIP (do not modify casually):** `task-reminder/` (has `index.ts` + `createTaskReminderHook` but NOT exported from barrel, NOT imported by any composer) and `hashline-edit-diff-enhancer/` (has only `hook.ts`, NOT registered). Treat as orphaned until wired in. @@ -12,22 +12,21 @@ | Tier | Composer | Base | With team-mode | Where | |------|----------|------|----------------|-------| -| **Session** | `create-session-hooks.ts` | 24 | 24 | OpenCode session lifecycle + chat.params + chat.message | +| **Session** | `create-session-hooks.ts` | 23 | 23 | OpenCode session lifecycle + chat.params + chat.message | | **Tool Guard** | `create-tool-guard-hooks.ts` | 16 | 17 | Pre/post tool execution (+1: `team-tool-gating`) | | **Transform** | `create-transform-hooks.ts` | 5 | 7 | `experimental.chat.messages.transform` (+2: `team-mode-status-injector`, `team-mailbox-injector`) | | **Continuation** | `create-continuation-hooks.ts` | 7 | 7 | Boulder/atlas/compaction/notification | | **Skill** | `create-skill-hooks.ts` | 2 | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) | | **Direct event handlers** | `src/plugin/event.ts` | 0 | +4 | `team-session-events/` sub-files: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` | -Total exposed hooks: **54 base, 61 with team-mode** (counts the 4 team-session-events handlers individually). +Total exposed hooks: **53 base, 60 with team-mode** (counts the 4 team-session-events handlers individually). Hook name allowlist for `disabled_hooks`: all configurable hook names enumerated in [`src/config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`. Team-session-event sub-hooks are not individually listed in the schema — they activate together with `team_mode.enabled`. -### Tier 1: Session Hooks (24) +### Tier 1: Session Hooks (23) | Hook | Event | Purpose | |------|-------|---------| -| `contextWindowMonitor` | session.idle | Track context usage | | `preemptiveCompaction` | session.idle | Trigger compaction before limit | | `sessionRecovery` | session.error | Recover from structural errors (tool_result_missing, thinking_block_order) | | `sessionNotification` | session.idle | OS notifications on completion | diff --git a/src/hooks/context-window-monitor.model-context-limits.test.ts b/src/hooks/context-window-monitor.model-context-limits.test.ts deleted file mode 100644 index 57fc524ab..000000000 --- a/src/hooks/context-window-monitor.model-context-limits.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/// - -import { describe, expect, it } from "bun:test" -import { createContextWindowMonitorHook } from "./context-window-monitor" - -function createOutput() { - return { title: "", output: "original", metadata: null } -} - -describe("context-window-monitor modelContextLimitsCache", () => { - it("does not append reminder below cached non-anthropic threshold", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: false, - modelContextLimitsCache, - }) - const sessionID = "ses_non_anthropic_below_threshold" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "opencode", - modelID: "kimi-k2.5-free", - finish: true, - tokens: { - input: 150000, - output: 0, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - // then - expect(output.output).toBe("original") - }) - - it("appends reminder above cached non-anthropic threshold", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: false, - modelContextLimitsCache, - }) - const sessionID = "ses_non_anthropic_above_threshold" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "opencode", - modelID: "kimi-k2.5-free", - finish: true, - tokens: { - input: 180000, - output: 0, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - // then - expect(output.output).toContain("context remaining") - expect(output.output).toContain("262,144-token context window") - expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]") - expect(output.output).not.toContain("1,000,000") - }) - - describe("#given Anthropic provider with cached context limit and 1M mode enabled", () => { - describe("#when cached usage would exceed 200K but stay below 1M", () => { - it("#then should ignore the cached limit and skip the reminder", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200000) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: true, - modelContextLimitsCache, - }) - const sessionID = "ses_anthropic_1m_overrides_cached_limit" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - finish: true, - tokens: { - input: 300000, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - // then - expect(output.output).toBe("original") - }) - }) - }) - - describe("#given Anthropic 4.6 provider with cached context limit and 1M mode disabled", () => { - describe("#when cached usage is below threshold of cached limit", () => { - it("#then should respect the cached limit and skip the reminder", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: false, - modelContextLimitsCache, - }) - const sessionID = "ses_anthropic_cached_limit_respected" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - finish: true, - tokens: { - input: 150000, - output: 0, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - // then - 160K/500K = 32%, well below 70% threshold - expect(output.output).toBe("original") - }) - }) - - describe("#when cached usage exceeds threshold of cached limit", () => { - it("#then should use the cached limit for the reminder", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: false, - modelContextLimitsCache, - }) - const sessionID = "ses_anthropic_cached_limit_exceeded" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - finish: true, - tokens: { - input: 350000, - output: 0, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - expect(output.output).toContain("context remaining") - expect(output.output).toContain("500,000-token context window") - }) - }) - }) - - describe("#given older Anthropic provider with cached context limit and 1M mode disabled", () => { - describe("#when cached usage would only exceed the incorrect cached limit", () => { - it("#then should ignore the cached limit and use the 200K default", async () => { - // given - const modelContextLimitsCache = new Map() - modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000) - - const hook = createContextWindowMonitorHook({} as never, { - anthropicContext1MEnabled: false, - modelContextLimitsCache, - }) - const sessionID = "ses_anthropic_older_model_ignores_cached_limit" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - finish: true, - tokens: { - input: 150000, - output: 0, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - // when - const output = createOutput() - await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output) - - // then - expect(output.output).toContain("context remaining") - expect(output.output).toContain("200,000-token context window") - }) - }) - }) -}) diff --git a/src/hooks/context-window-monitor.test.ts b/src/hooks/context-window-monitor.test.ts deleted file mode 100644 index b664717a6..000000000 --- a/src/hooks/context-window-monitor.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -/// - -import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" -import { createContextWindowMonitorHook } from "./context-window-monitor" - -const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT" -const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT" - -const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY] -const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY] - -function resetContextLimitEnv(): void { - if (originalAnthropicContextEnv === undefined) { - delete process.env[ANTHROPIC_CONTEXT_ENV_KEY] - } else { - process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv - } - - if (originalVertexContextEnv === undefined) { - delete process.env[VERTEX_CONTEXT_ENV_KEY] - } else { - process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv - } -} - -function createMockCtx() { - return { - client: { - session: { - messages: mock(() => Promise.resolve({ data: [] })), - }, - }, - directory: "/tmp/test", - } -} - -describe("context-window-monitor", () => { - let ctx: ReturnType - - beforeEach(() => { - ctx = createMockCtx() - delete process.env[ANTHROPIC_CONTEXT_ENV_KEY] - delete process.env[VERTEX_CONTEXT_ENV_KEY] - }) - - afterEach(() => { - resetContextLimitEnv() - }) - - // #given event caches token info from message.updated - // #when tool.execute.after is called - // #then session.messages() should NOT be called - it("should use cached token info instead of fetching session.messages()", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_test1" - - // Simulate message.updated event with token info - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { - input: 50000, - output: 1000, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - const output = { title: "", output: "test output", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - // session.messages() should NOT have been called - expect(ctx.client.session.messages).not.toHaveBeenCalled() - }) - - // #given no cached token info exists - // #when tool.execute.after is called - // #then should skip gracefully without fetching - it("should skip gracefully when no cached token info exists", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_no_cache" - - const output = { title: "", output: "test output", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - // No fetch, no crash - expect(ctx.client.session.messages).not.toHaveBeenCalled() - expect(output.output).toBe("test output") - }) - - // #given token usage exceeds 70% threshold - // #when tool.execute.after is called - // #then context reminder should be appended to output - it("should append context reminder with actual token counts when usage exceeds threshold", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_high_usage" - - // 150K input + 10K cache read = 160K, which is 80% of 200K limit - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { - input: 150000, - output: 1000, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - expect(output.output).toContain("context remaining") - expect(output.output).toContain("200,000-token context window") - expect(output.output).toContain("[Context Status: 80.0% used (160,000/200,000 tokens), 20.0% remaining]") - expect(ctx.client.session.messages).not.toHaveBeenCalled() - }) - - // #given total input tokens exceed the resolved actualLimit (e.g. 1M-context - // Anthropic model where resolveActualContextLimit falls back to the - // 200K default for the model family) - // #when tool.execute.after appends the context status block - // #then the displayed used% must be clamped to 100 and remaining% must not go - // negative. Safety-tuned models flag the >100% / negative-remaining - // block as prompt injection (issue #3655). - it("should clamp displayed percentages when input exceeds actualLimit (regression #3655)", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_overflow" - - // 289,370 input + 0 cache against a 200K resolved limit -> 144.7% raw, - // -44.7% remaining if not clamped. - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { - input: 289370, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - }, - }, - }, - }) - - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - // The block must still be emitted (we are above the 70% threshold). - expect(output.output).toContain("[Context Status:") - - // Extract the displayed percentages and assert clamping. - const match = output.output.match( - /\[Context Status: ([\d.-]+)% used \([\d,]+\/[\d,]+ tokens\), ([\d.-]+)% remaining\]/, - ) - expect(match).not.toBeNull() - const usedPct = Number(match![1]) - const remainingPct = Number(match![2]) - - expect(usedPct).toBeLessThanOrEqual(100) - expect(usedPct).toBeGreaterThanOrEqual(0) - expect(remainingPct).toBeGreaterThanOrEqual(0) - expect(remainingPct).toBeLessThanOrEqual(100) - }) - - it("should append context reminder for google-vertex-anthropic provider", async () => { - //#given cached usage for google-vertex-anthropic above threshold - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_vertex_anthropic_high_usage" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "google-vertex-anthropic", - finish: true, - tokens: { - input: 150000, - output: 1000, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - //#when tool.execute.after runs - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - //#then context reminder should be appended - expect(output.output).toContain("context remaining") - }) - - // #given only a compaction agent summary message update is seen - // #when tool.execute.after checks context usage - // #then stale pre-compaction tokens should not create a context reminder - it("should ignore compaction-agent message updates when caching context usage", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_compaction_agent_context" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - agent: "compaction", - role: "assistant", - sessionID, - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - finish: true, - tokens: { - input: 150000, - output: 1000, - reasoning: 0, - cache: { read: 10000, write: 0 }, - }, - }, - }, - }, - }) - - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - expect(output.output).toBe("original") - expect(ctx.client.session.messages).not.toHaveBeenCalled() - }) - - // #given session is deleted - // #when session.deleted event fires - // #then cached data should be cleaned up - it("should clean up cache on session.deleted", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_deleted" - - // Cache some data - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { input: 150000, output: 0, reasoning: 0, cache: { read: 10000, write: 0 } }, - }, - }, - }, - }) - - // Delete session - await hook.event({ - event: { - type: "session.deleted", - properties: { info: { id: sessionID } }, - }, - }) - - // After deletion, no reminder should fire (cache gone, reminded set gone) - const output = { title: "", output: "test", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - expect(output.output).toBe("test") - }) - - // #given non-anthropic provider - // #when message.updated fires - // #then should not trigger reminder - it("should ignore non-anthropic providers", async () => { - const hook = createContextWindowMonitorHook(ctx as never) - const sessionID = "ses_openai" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "openai", - finish: true, - tokens: { input: 200000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, - }, - }, - }) - - const output = { title: "", output: "test", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - expect(output.output).toBe("test") - }) - - it("should use 1M limit when model cache flag is enabled", async () => { - //#given - const hook = createContextWindowMonitorHook(ctx as never, { - anthropicContext1MEnabled: true, - }) - const sessionID = "ses_1m_flag" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { - input: 300000, - output: 1000, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - }, - }, - }, - }) - - //#when - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - //#then - expect(output.output).toBe("original") - }) - - it("should keep env var fallback when model cache flag is disabled", async () => { - //#given - process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true" - const hook = createContextWindowMonitorHook(ctx as never, { - anthropicContext1MEnabled: false, - }) - const sessionID = "ses_env_fallback" - - await hook.event({ - event: { - type: "message.updated", - properties: { - info: { - role: "assistant", - sessionID, - providerID: "anthropic", - finish: true, - tokens: { - input: 300000, - output: 1000, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - }, - }, - }, - }) - - //#when - const output = { title: "", output: "original", metadata: null } - await hook["tool.execute.after"]( - { tool: "bash", sessionID, callID: "call_1" }, - output - ) - - //#then - expect(output.output).toBe("original") - }) -}) diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts deleted file mode 100644 index 507ea10b7..000000000 --- a/src/hooks/context-window-monitor.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { - resolveActualContextLimit, - type ContextLimitModelCacheState, -} from "../shared/context-limit-resolver" -import { isCompactionAgent } from "../shared/compaction-marker" -import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id" -import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" - -const CONTEXT_WARNING_THRESHOLD = 0.70 - -function createContextReminder(actualLimit: number): string { - const limitTokens = actualLimit.toLocaleString() - - return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)} - -You are using a ${limitTokens}-token context window. -You still have context remaining - do NOT rush or skip tasks. -Complete your work thoroughly and methodically.` -} - -interface TokenInfo { - input: number - output: number - reasoning: number - cache: { read: number; write: number } -} - -interface CachedTokenState { - providerID: string - modelID: string - tokens: TokenInfo -} - -export function createContextWindowMonitorHook( - _ctx: PluginInput, - modelCacheState?: ContextLimitModelCacheState, -) { - const remindedSessions = new Set() - const tokenCache = new Map() - - const toolExecuteAfter = async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ) => { - const { sessionID } = input - - if (remindedSessions.has(sessionID)) return - - const cached = tokenCache.get(sessionID) - if (!cached) return - - const actualLimit = resolveActualContextLimit( - cached.providerID, - cached.modelID, - modelCacheState, - ) - - if (!actualLimit) return - - const lastTokens = cached.tokens - const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0) - - const actualUsagePercentage = totalInputTokens / actualLimit - - if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return - - remindedSessions.add(sessionID) - - // Clamp the displayed percentages so the block stays trustworthy when the - // resolved actualLimit underestimates the model's real context window - // (e.g. a 1M-context Anthropic model that falls back to the 200K default). - // Without clamping, the block would advertise >100% used and a negative - // "remaining" - safety-tuned models flag exactly that pattern as a prompt - // injection and refuse to follow the directive (issue #3655). - const clampedPercentage = Math.min(Math.max(actualUsagePercentage, 0), 1) - const usedPct = (clampedPercentage * 100).toFixed(1) - const remainingPct = ((1 - clampedPercentage) * 100).toFixed(1) - const usedTokens = totalInputTokens.toLocaleString() - const limitTokens = actualLimit.toLocaleString() - - output.output += `\n\n${createContextReminder(actualLimit)} -[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]` - } - - const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { - const props = event.properties as Record | undefined - - if (event.type === "session.deleted") { - const sessionID = resolveSessionEventID(props) - if (sessionID) { - remindedSessions.delete(sessionID) - tokenCache.delete(sessionID) - } - } - - if (event.type === "message.updated") { - const info = props?.info as { - agent?: unknown - role?: string - sessionID?: string - providerID?: string - modelID?: string - finish?: unknown - tokens?: TokenInfo - } | undefined - - const finish = info?.finish - if (!info || info.role !== "assistant" || !finish) return - if (isCompactionAgent(info.agent)) return - const sessionID = resolveMessageEventSessionID(props) - if (!sessionID || !info.providerID || !info.tokens) return - - tokenCache.set(sessionID, { - providerID: info.providerID, - modelID: info.modelID ?? "", - tokens: info.tokens, - }) - } - } - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - } -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 493414d34..4b446f5c5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,4 @@ export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; -export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createSessionNotification } from "./session-notification"; export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender"; export { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting"; diff --git a/src/plugin/event-compaction-agent.test.ts b/src/plugin/event-compaction-agent.test.ts index b5888d7fe..0a52b5322 100644 --- a/src/plugin/event-compaction-agent.test.ts +++ b/src/plugin/event-compaction-agent.test.ts @@ -29,7 +29,6 @@ function createMinimalEventHandler() { sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, - contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index df3eb27d9..8f32bf102 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -828,7 +828,6 @@ describe("createEventHandler - idle deduplication", () => { sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, - contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, @@ -913,7 +912,6 @@ describe("createEventHandler - idle deduplication", () => { sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, - contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, @@ -970,7 +968,6 @@ describe("createEventHandler - idle deduplication", () => { sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, - contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, diff --git a/src/plugin/event.ts b/src/plugin/event.ts index cb653f8d7..edafe7bb5 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -310,7 +310,6 @@ export function createEventHandler(args: { await runEventHookSafely("sessionNotification", hooks.sessionNotification, input); await runEventHookSafely("todoContinuationEnforcer", hooks.todoContinuationEnforcer?.handler, input); await runEventHookSafely("unstableAgentBabysitter", hooks.unstableAgentBabysitter?.event, input); - await runEventHookSafely("contextWindowMonitor", hooks.contextWindowMonitor?.event, input); await runEventHookSafely("preemptiveCompaction", hooks.preemptiveCompaction?.event, input); await runEventHookSafely("directoryAgentsInjector", hooks.directoryAgentsInjector?.event, input); await runEventHookSafely("directoryReadmeInjector", hooks.directoryReadmeInjector?.event, input); diff --git a/src/plugin/hooks/create-session-hooks.test.ts b/src/plugin/hooks/create-session-hooks.test.ts index 42a091f4f..d443a5a78 100644 --- a/src/plugin/hooks/create-session-hooks.test.ts +++ b/src/plugin/hooks/create-session-hooks.test.ts @@ -55,6 +55,23 @@ describe("createSessionHooks", () => { expect(result.modelFallback).not.toBeNull() }) + it("does not create removed context window monitor hook", () => { + // given + const pluginConfig = {} as OhMyOpenCodeConfig + + // when + const result = createSessionHooks({ + ctx: mockContext, + pluginConfig, + modelCacheState: mockModelCacheState, + isHookEnabled: (hookName) => hookName === "context-window-monitor", + safeHookEnabled: true, + }) + + // then + expect("contextWindowMonitor" in result).toBe(false) + }) + it("skips interactive bash session hook when tmux integration is disabled", () => { // given const pluginConfig = { diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 69208820a..f29e1b23e 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -5,7 +5,6 @@ import type { ModelCacheState } from "../../plugin-state" import type { PluginContext } from "../types" import { - createContextWindowMonitorHook, createSessionRecoveryHook, createSessionNotification, createThinkModeHook, @@ -41,7 +40,6 @@ import { sessionExists } from "../../tools" import { isTmuxIntegrationEnabled } from "../../create-runtime-tmux-config" export type SessionHooks = { - contextWindowMonitor: ReturnType | null preemptiveCompaction: ReturnType | null sessionRecovery: ReturnType | null sessionNotification: ReturnType | null @@ -80,11 +78,6 @@ export function createSessionHooks(args: { const safeHook = (hookName: HookName, factory: () => T): T | null => safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) - const contextWindowMonitor = isHookEnabled("context-window-monitor") - ? safeHook("context-window-monitor", () => - createContextWindowMonitorHook(ctx, modelCacheState)) - : null - const preemptiveCompaction = isHookEnabled("preemptive-compaction") && pluginConfig.experimental?.preemptive_compaction @@ -277,7 +270,6 @@ export function createSessionHooks(args: { : null return { - contextWindowMonitor, preemptiveCompaction, sessionRecovery, sessionNotification, diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 75bd1c0ee..9b4617461 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -162,7 +162,6 @@ export function createToolExecuteAfterHandler(args: { await hooks.toolOutputTruncator?.["tool.execute.after"]?.(hookInput, output) await hooks.claudeCodeHooks?.["tool.execute.after"]?.(hookInput, output) await hooks.preemptiveCompaction?.["tool.execute.after"]?.(hookInput, output) - await hooks.contextWindowMonitor?.["tool.execute.after"]?.(hookInput, output) await hooks.commentChecker?.["tool.execute.after"]?.(hookInput, output) await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(hookInput, output) await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(hookInput, output) diff --git a/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts b/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts index cfaf9f497..0967ccbad 100644 --- a/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts +++ b/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts @@ -31,7 +31,6 @@ function createMinimalEventHandler() { sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, - contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} },