fix(hooks): remove context window monitor

This commit is contained in:
YeonGyu-Kim
2026-05-31 05:31:23 +09:00
parent 8705bacd05
commit 1432a1141c
18 changed files with 35 additions and 843 deletions
+1 -1
View File
@@ -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.
-1
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+11
View File
@@ -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", () => {
-1
View File
@@ -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",
+4 -5
View File
@@ -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 |
@@ -1,268 +0,0 @@
/// <reference types="bun-types" />
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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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")
})
})
})
})
-423
View File
@@ -1,423 +0,0 @@
/// <reference types="bun-types" />
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<typeof createMockCtx>
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")
})
})
-126
View File
@@ -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<string>()
const tokenCache = new Map<string, CachedTokenState>()
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<string, unknown> | 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,
}
}
-1
View File
@@ -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";
@@ -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 () => {} },
-3
View File
@@ -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 () => {} },
-1
View File
@@ -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);
@@ -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 = {
-8
View File
@@ -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<typeof createContextWindowMonitorHook> | null
preemptiveCompaction: ReturnType<typeof createPreemptiveCompactionHook> | null
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
sessionNotification: ReturnType<typeof createSessionNotification> | null
@@ -80,11 +78,6 @@ export function createSessionHooks(args: {
const safeHook = <T>(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,
-1
View File
@@ -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)
@@ -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 () => {} },