mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-09-03 06:25:33 +08:00
* 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.
249 lines
8.4 KiB
JavaScript
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();
|