Files
oh-my-claudecode/scripts/context-guard-stop.mjs
Bellman 7d2a7f3d5d fix(context-guard): fall back to persisted context cache (#3574)
* fix(context-guard): fall back to HUD's persisted context cache (#3574)

Real Claude Code transcripts never write a top-level `context_window`
field into `message.usage` (only `input_tokens` /
`cache_creation_input_tokens` / `cache_read_input_tokens` /
`output_tokens`), so the transcript-tail scan duplicated in
scripts/context-guard-stop.mjs and scripts/post-tool-verifier.mjs never
matched in production for any provider. The Stop guard had no fallback
beyond that dead scan and always silently allowed the stop; PostToolUse's
existing #2412 fallback only read `context_window` from the current hook
event's own payload, which is not guaranteed present on every route
(confirmed: CLIProxyAPI custom-model sessions where the HUD's statusLine
data was correct but the PostToolUse event's own data was not).

Add scripts/lib/context-usage.mjs, a shared, layered context-percent
resolver used by both the Stop recovery guard and the PostToolUse
preemptive-compaction warning path:

  1. context_window on the current hook event's own payload (existing
     #2412 behavior, extracted for reuse).
  2. The HUD's persisted stdin cache (state/sessions/{id}/hud-stdin-cache.json,
     written on every statusline render) -- the same source the HUD
     already renders correctly from, resolved via dist/hud/stdin.js's
     getContextPercent so percent math is never duplicated (avoids
     regressing #3489-style accounting drift). Session identity is
     resolved candidate-by-candidate (payload session_id/sessionId, then
     CLAUDE_SESSION_ID, then CLAUDECODE_SESSION_ID), matching
     src/hud/stdin.ts's own validation/skip-to-next-candidate contract;
     only after every candidate is exhausted does it fall through to the
     legacy flat cache, then a most-recently-modified session cache.
  3. A best-effort transcript-tail scan, kept for forward compatibility,
     now summing input_tokens + cache_creation_input_tokens +
     cache_read_input_tokens (matching HUD's getTotalTokens semantics)
     instead of input_tokens alone.

Both scripts/context-guard-stop.mjs and scripts/post-tool-verifier.mjs
now delegate to the shared resolver instead of their own dead-code
duplicates; the PostToolUse warning path is now async end-to-end.

No model-name allowlists or gpt-5.6-sol-specific branching anywhere --
fully provider-agnostic. scripts/persistent-mode.mjs (a separate
fail-open continuation-safety check sharing the same underlying
limitation) is intentionally out of scope for this PR.

Adds regression coverage for the exact reported scenario: a
production-shaped transcript with no context_window field, no
context_window on the hook payload, and the HUD cache as the only
signal -- proving the Stop guard now blocks and PostToolUse now warns
instead of staying silent, plus coverage for cache-token accounting,
session-identity candidate validation/fallback, and legacy-cache
recovery.

Closes #3574

* fix(context-guard): stop HUD-cache resolver at first valid session identity

Adversarial merge-readiness review of PR #3575 found a real session-bleed
bug: resolveHudCacheContextPercent treated a *valid* session identity
whose own cache was simply missing/unwritten yet the same as an
*invalid* identity, continuing the candidate loop and eventually falling
through to a legacy or most-recently-modified-session cache scan. That
scan could select a concurrent, unrelated session's high context
percentage, producing a false PostToolUse warning or Stop block for the
wrong session.

src/hud/stdin.ts's own getStdinCachePath never does this: it walks
candidates only to skip ones that FAIL VALIDATION, and commits to the
first candidate that validates, regardless of whether that identity's
cache file happens to exist yet. readStdinCache then returns null for a
missing scoped cache without falling through to another session.

Align resolveHudCacheContextPercent with that exact contract: once any
candidate passes getSessionStateDir validation, read only its own cache
and return that result (value or null) immediately. The legacy-flat/
mtime-scan fallback is now reached only when *every* candidate fails
validation (no real identity known at all), matching the HUD's own
env-less detached-reader behavior.

Replaces the now-incorrect "cache-less primary falls through to
secondary" test with three regression tests: a cache-less identity must
return null rather than adopting a different (foreign, newer, or
legacy) cache, covering payload identity, env-bound identity, and
env-bound identity against a populated legacy cache.
2026-07-28 04:05:16 +00:00

249 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
/**
* OMC Context Guard Hook (Stop)
*
* Suggests session refresh when context usage exceeds a warning threshold.
* This complements persistent-mode.cjs — it fires BEFORE modes like Ralph
* or Ultrawork process the stop, providing an early warning.
*
* Configurable via OMC_CONTEXT_GUARD_THRESHOLD env var (default: 75%).
*
* Safety rules:
* - Never block context_limit stops (would cause compaction deadlock)
* - Never block user-requested stops (respect Ctrl+C / cancel)
* - Max 2 blocks per transcript (retry guard prevents infinite loops)
*
* Hook output:
* - { decision: "block", reason: "..." } when context too high
* - { continue: true, suppressOutput: true } otherwise
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname, resolve, basename } from 'node:path';
import { execFileSync } from 'node:child_process';
import { getClaudeConfigDir } from './lib/config-dir.mjs';
import { encodeProjectPath } from './lib/encode-project-path.mjs';
import { readStdin } from './lib/stdin.mjs';
import { resolveContextPercent } from './lib/context-usage.mjs';
const THRESHOLD = parseInt(process.env.OMC_CONTEXT_GUARD_THRESHOLD || '75', 10);
const CRITICAL_THRESHOLD = 95;
const MAX_BLOCKS = 2;
const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
const GIT_PROBE_TIMEOUT_MS = 1000;
/**
* Detect if stop was triggered by context-limit related reasons.
* Mirrors the logic in persistent-mode.cjs to stay consistent.
*/
function isContextLimitStop(data) {
const reasons = [
data.stop_reason,
data.stopReason,
data.end_turn_reason,
data.endTurnReason,
data.reason,
]
.filter((value) => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.toLowerCase().replace(/[\s-]+/g, '_'));
const contextPatterns = [
'context_limit', 'context_window', 'context_exceeded',
'context_full', 'max_context', 'token_limit',
'max_tokens', 'conversation_too_long', 'input_too_long',
];
return reasons.some((reason) => contextPatterns.some(p => reason.includes(p)));
}
/**
* Detect if stop was triggered by user abort.
*/
function isUserAbort(data) {
if (data.user_requested || data.userRequested) return true;
const reason = (data.stop_reason || data.stopReason || '').toLowerCase();
const exactPatterns = ['aborted', 'abort', 'cancel', 'interrupt'];
const substringPatterns = ['user_cancel', 'user_interrupt', 'ctrl_c', 'manual_stop'];
return (
exactPatterns.some(p => reason === p) ||
substringPatterns.some(p => reason.includes(p))
);
}
function hasLocalGitMarker(startDir) {
if (!startDir) return false;
return existsSync(join(resolve(startDir), '.git'));
}
function runGitRevParse(args, cwd) {
return execFileSync('git', ['rev-parse', ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: GIT_PROBE_TIMEOUT_MS,
windowsHide: true,
}).trim();
}
/**
* Resolve a transcript path that may be mismatched in worktree sessions (issue #1094).
* When Claude Code runs inside .claude/worktrees/X, the encoded project directory
* contains `--claude-worktrees-X` which doesn't exist. Strip it to find the real path.
*/
function resolveTranscriptPath(transcriptPath, cwd) {
if (!transcriptPath) return transcriptPath;
try {
if (existsSync(transcriptPath)) return transcriptPath;
} catch { /* fallthrough */ }
// Strategy 1: Strip Claude worktree segment from encoded project directory
const worktreePattern = /--claude-worktrees-[^/\\]+/;
if (worktreePattern.test(transcriptPath)) {
const resolved = transcriptPath.replace(worktreePattern, '');
try {
if (existsSync(resolved)) return resolved;
} catch { /* fallthrough */ }
}
// Strategy 2: Detect native git worktree via git-common-dir.
// When CWD is a linked worktree (created by `git worktree add`), the
// transcript path encodes the worktree CWD, but the file lives under
// the main repo's encoded path.
const effectiveCwd = cwd || process.cwd();
if (!hasLocalGitMarker(effectiveCwd)) return transcriptPath;
try {
const gitCommonDir = runGitRevParse(['--git-common-dir'], effectiveCwd);
const absoluteCommonDir = resolve(effectiveCwd, gitCommonDir);
const mainRepoRoot = dirname(absoluteCommonDir);
const worktreeTop = runGitRevParse(['--show-toplevel'], effectiveCwd);
if (mainRepoRoot !== worktreeTop) {
const sessionFile = basename(transcriptPath);
if (sessionFile) {
const configDir = getClaudeConfigDir();
const projectsDir = join(configDir, 'projects');
if (existsSync(projectsDir)) {
const encodedMain = encodeProjectPath(mainRepoRoot);
const resolvedPath = join(projectsDir, encodedMain, sessionFile);
try {
if (existsSync(resolvedPath)) return resolvedPath;
} catch { /* fallthrough */ }
}
}
}
} catch { /* not in a git repo or git not available — skip */ }
return transcriptPath;
}
/**
* Retry guard: track how many times we've blocked this transcript.
* Prevents infinite block loops by capping at MAX_BLOCKS.
*/
function getGuardFilePath(sessionId) {
const configDir = getClaudeConfigDir();
const guardDir = join(configDir, 'projects', '.omc-guards');
try {
mkdirSync(guardDir, { recursive: true, mode: 0o700 });
} catch (err) {
// On Windows, concurrent hooks can throw EEXIST even with recursive:true
if (err?.code !== 'EEXIST') throw err;
}
return join(guardDir, `context-guard-${sessionId}.json`);
}
function getBlockCount(sessionId) {
if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) return 0;
const guardFile = getGuardFilePath(sessionId);
try {
if (existsSync(guardFile)) {
const data = JSON.parse(readFileSync(guardFile, 'utf-8'));
return data.blockCount || 0;
}
} catch { /* ignore */ }
return 0;
}
function incrementBlockCount(sessionId) {
if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) return;
const guardFile = getGuardFilePath(sessionId);
try {
let count = 0;
if (existsSync(guardFile)) {
const data = JSON.parse(readFileSync(guardFile, 'utf-8'));
count = data.blockCount || 0;
}
writeFileSync(guardFile, JSON.stringify({ blockCount: count + 1 }), { mode: 0o600 });
} catch { /* ignore */ }
}
function buildStopRecoveryAdvice(contextPercent, blockCount) {
const severity = contextPercent >= 90 ? 'CRITICAL' : 'HIGH';
return `[OMC ${severity}] Context at ${contextPercent}% (threshold: ${THRESHOLD}%). ` +
`Run /compact immediately before continuing. If /compact cannot complete, ` +
`stop spawning new agents and recover in a fresh session using existing checkpoints ` +
`(.omc/state, .omc/notepad.md). (Block ${blockCount}/${MAX_BLOCKS})`;
}
async function main() {
try {
const input = await readStdin();
const data = JSON.parse(input);
// CRITICAL: Never block context-limit stops (compaction deadlock)
if (isContextLimitStop(data)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
// Respect user abort
if (isUserAbort(data)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
const sessionId = data.session_id || data.sessionId || '';
const rawTranscriptPath = data.transcript_path || data.transcriptPath || '';
const transcriptPath = resolveTranscriptPath(rawTranscriptPath, data.cwd);
const pct = (await resolveContextPercent(data, transcriptPath, data.cwd)) ?? 0;
if (pct >= CRITICAL_THRESHOLD) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
if (pct >= THRESHOLD) {
// Check retry guard
const blockCount = getBlockCount(sessionId);
if (blockCount >= MAX_BLOCKS) {
// Already blocked enough times — let it through
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
incrementBlockCount(sessionId);
console.log(JSON.stringify({
continue: false,
decision: 'block',
reason: buildStopRecoveryAdvice(pct, blockCount + 1)
}));
return;
}
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
} catch {
// On any error, allow stop (never block on hook failure)
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
}
}
main();