#!/usr/bin/env node /** * OMC Persistent Mode Hook (Node.js) * Minimal continuation enforcer for all OMC modes. * Stripped down for reliability — no optional imports, no PRD, no notepad pruning. * * Supported modes: ralph, autopilot, ultrapilot, swarm, ultrawork, ultraqa, pipeline, team */ const { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, openSync, readSync, closeSync, renameSync, statSync, } = require("fs"); const { execFileSync } = require("child_process"); const { homedir } = require("os"); const { join, dirname, resolve, normalize } = require("path"); const { getClaudeConfigDir } = require("./lib/config-dir.cjs"); const { resolveOmcStateRoot } = require("./lib/state-root.cjs"); async function readStdin(timeoutMs = 2000) { return new Promise((resolve) => { const chunks = []; let settled = false; const timeout = setTimeout(() => { if (!settled) { settled = true; process.stdin.removeAllListeners(); process.stdin.destroy(); resolve(Buffer.concat(chunks).toString("utf-8")); } }, timeoutMs); process.stdin.on("data", (chunk) => { chunks.push(chunk); }); process.stdin.on("end", () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString("utf-8")); } }); process.stdin.on("error", () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(""); } }); if (process.stdin.readableEnded) { if (!settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString("utf-8")); } } }); } function readJsonFile(path) { try { if (!existsSync(path)) return null; return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; } } function writeJsonFile(path, data) { try { // Ensure directory exists const dir = dirname(path); if (dir && dir !== "." && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tmp, JSON.stringify(data, null, 2)); renameSync(tmp, path); return true; } catch { return false; } } function shouldWriteStateBack(path) { return Boolean(path && existsSync(path)); } /** * Read the session-idle notification cooldown in seconds from ~/.omc/config.json. * Default: 60. 0 = disabled. */ function getIdleCooldownSeconds() { const configPath = join(homedir(), '.omc', 'config.json'); const config = readJsonFile(configPath); const val = config?.notificationCooldown?.sessionIdleSeconds; if (typeof val === 'number') return val; return 60; } const COMMAND_TIMEOUT_MS = 10_000; const MAX_LIST_RESULTS = 100; const FAILURE_CONCLUSIONS = new Set([ 'failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', ]); function runCommand(command, args, cwd) { try { return execFileSync(command, args, { cwd, encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { return null; } } function runJsonCommand(command, args, cwd) { const raw = runCommand(command, args, cwd); if (raw === null) return null; try { return JSON.parse(raw); } catch { return null; } } function parseGitHubRemote(remoteUrl) { const normalized = remoteUrl.trim(); const patterns = [ /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/, /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, ]; for (const pattern of patterns) { const match = normalized.match(pattern); if (match) { return { owner: match[1], repo: match[2] }; } } return null; } function toSortedNumbers(values) { return values .filter((value) => Number.isInteger(value)) .sort((left, right) => left - right); } function getIdleNotificationRepoState(directory) { const remoteUrl = runCommand('git', ['remote', 'get-url', 'origin'], directory); if (!remoteUrl) return null; const remote = parseGitHubRemote(remoteUrl); if (!remote) return null; const repo = `${remote.owner}/${remote.repo}`; const headSha = runCommand('git', ['rev-parse', 'HEAD'], directory); const porcelainStatus = runCommand('git', ['status', '--porcelain'], directory); if (!headSha || porcelainStatus === null) return null; const openPrs = runJsonCommand('gh', ['pr', 'list', '--repo', repo, '--state', 'open', '--limit', String(MAX_LIST_RESULTS), '--json', 'number'], directory); if (!openPrs) return null; const openIssues = runJsonCommand('gh', ['issue', 'list', '--repo', repo, '--state', 'open', '--limit', String(MAX_LIST_RESULTS), '--json', 'number'], directory); if (!openIssues) return null; const runs = runJsonCommand('gh', ['run', 'list', '--repo', repo, '--limit', String(MAX_LIST_RESULTS), '--json', 'databaseId,conclusion'], directory); if (!runs) return null; const failingRunIds = toSortedNumbers( runs .filter((run) => FAILURE_CONCLUSIONS.has(((run.conclusion || '') + '').toLowerCase())) .map((run) => run.databaseId), ); const openPrNumbers = toSortedNumbers(openPrs.map((entry) => entry.number)); const openIssueNumbers = toSortedNumbers(openIssues.map((entry) => entry.number)); const snapshot = { repo, headSha, dirty: porcelainStatus.length > 0, openPrNumbers, openIssueNumbers, failingRunIds, }; return { signature: JSON.stringify(snapshot), backlogZero: openPrNumbers.length === 0 && openIssueNumbers.length === 0 && failingRunIds.length === 0, }; } function isRepeatedZeroBacklog(record, repoState) { return Boolean( repoState?.backlogZero && record?.backlogZero === true && typeof record.repoSignature === 'string' && record.repoSignature === repoState.signature, ); } /** * Check whether the session-idle cooldown has elapsed. * Returns true if the notification should be sent. */ function shouldSendIdleNotification(stateDir, repoState) { const cooldownSecs = getIdleCooldownSeconds(); const cooldownPath = join(stateDir, 'idle-notif-cooldown.json'); const data = readJsonFile(cooldownPath); if (isRepeatedZeroBacklog(data, repoState)) { return false; } if (repoState && typeof data?.repoSignature === 'string' && data.repoSignature !== repoState.signature) { return true; } if (cooldownSecs === 0) return true; // cooldown disabled if (data?.lastSentAt) { const elapsed = (Date.now() - new Date(data.lastSentAt).getTime()) / 1000; if (Number.isFinite(elapsed) && elapsed < cooldownSecs) return false; } return true; } /** * Record that the session-idle notification was sent. */ function recordIdleNotificationSent(stateDir, repoState) { const cooldownPath = join(stateDir, 'idle-notif-cooldown.json'); const record = { lastSentAt: new Date().toISOString() }; if (repoState) { record.repoSignature = repoState.signature; record.backlogZero = repoState.backlogZero; } writeJsonFile(cooldownPath, record); } /** * Send stop notification (fire-and-forget, non-blocking). * Only notifies on first stop to avoid spam. */ async function sendStopNotification(modeName, stateData, sessionId, directory) { // Only notify once per mode activation if (stateData._stopNotified) return; try { const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; if (!pluginRoot) return; const { pathToFileURL } = require('url'); const { notify } = await import(pathToFileURL(join(pluginRoot, 'dist', 'notifications', 'index.js')).href); await notify('session-stop', { sessionId: sessionId, projectPath: directory, activeMode: modeName, iteration: stateData.iteration || stateData.reinforcement_count || 1, maxIterations: stateData.max_iterations || stateData.max_reinforcements || 100, incompleteTasks: undefined, // Caller can override }).catch(() => {}); // Mark as notified to prevent duplicate notifications stateData._stopNotified = true; } catch { // Notification module not available, skip silently } } /** * Staleness threshold for mode states (2 hours in milliseconds). * States older than this are treated as inactive to prevent stale state * from causing the stop hook to malfunction in new sessions. */ const STALE_STATE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours const PENDING_ASYNC_STATE_STALE_MS = 24 * 60 * 60 * 1000; // A delegated subagent counts as pending owned async work while its tracking // entry stays "running". Bound by 30 min so an orphaned entry (subagent killed // without SubagentStop) eventually releases the gate; over-suppression is the // benign direction (the agent stops cleanly instead of being nagged mid-work). const RUNNING_SUBAGENT_STALE_MS = 30 * 60 * 1000; // Stop breaker constants for first-class mode enforcement const TEAM_PIPELINE_STOP_BLOCKER_MAX = 20; const TEAM_PIPELINE_STOP_BLOCKER_TTL_MS = 5 * 60 * 1000; // 5 min const RALPLAN_STOP_BLOCKER_MAX = 30; const RALPLAN_STOP_BLOCKER_TTL_MS = 45 * 60 * 1000; // 45 min const TEAM_TERMINAL_PHASES = new Set([ "completed", "complete", "failed", "cancelled", "canceled", "aborted", "terminated", "done", ]); const RALPLAN_TERMINAL_PHASES = new Set([ "completed", "complete", "failed", "cancelled", "canceled", "aborted", "terminated", "done", "handoff", "pending approval", "pending-approval", "pending_approval", "awaiting approval", "awaiting-approval", "awaiting_approval", "approval-required", "approval_required", ]); const TEAM_ACTIVE_PHASES = new Set([ "team-plan", "team-prd", "team-exec", "team-verify", "team-fix", "planning", "executing", "verify", "verification", "fix", "fixing", ]); /** * Check if a state is stale based on its timestamps. * A state is considered stale if it hasn't been updated recently. * We check both `last_checked_at` and `started_at` - using whichever is more recent. */ function isStaleState(state) { if (!state) return true; const lastChecked = state.last_checked_at ? new Date(state.last_checked_at).getTime() : 0; const startedAt = state.started_at ? new Date(state.started_at).getTime() : 0; const mostRecent = Math.max(lastChecked, startedAt); if (mostRecent === 0) return true; // No valid timestamps const age = Date.now() - mostRecent; return age > STALE_STATE_THRESHOLD_MS; } function sanitizeSessionId(sessionId) { if (!sessionId || typeof sessionId !== "string") return ""; return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : ""; } function parseTimestamp(value) { if (typeof value !== "string" || value.length === 0) return null; const parsed = new Date(value).getTime(); return Number.isFinite(parsed) ? parsed : null; } function isFreshTimestamp(value, ttlMs = PENDING_ASYNC_STATE_STALE_MS) { const parsed = parseTimestamp(value); return parsed !== null && Date.now() - parsed <= ttlMs; } function hasPendingBackgroundTask(stateDir, sessionId) { const safeSessionId = sanitizeSessionId(sessionId); const hudPath = safeSessionId ? join(stateDir, "sessions", safeSessionId, "hud-state.json") : join(stateDir, "hud-state.json"); const hudState = readJsonFile(hudPath); return Boolean(hudState?.backgroundTasks?.some((task) => { if (task?.status !== "running") return false; return isFreshTimestamp(task.startedAt ?? task.startTime); })); } function readPendingWakeupStates(stateDir, sessionId) { const safeSessionId = sanitizeSessionId(sessionId); const dirs = safeSessionId ? [join(stateDir, "sessions", safeSessionId), stateDir] : [stateDir]; const fileNames = ["scheduled-wakeup-state.json", "schedule-wakeup-state.json", "wakeup-state.json"]; const states = []; for (const dir of dirs) { for (const fileName of fileNames) { const state = readJsonFile(join(dir, fileName)); if (state && typeof state === "object") states.push(state); } } return states; } function hasPendingScheduledWakeup(stateDir, sessionId) { const now = Date.now(); return readPendingWakeupStates(stateDir, sessionId).some((state) => { const status = typeof state.status === "string" ? state.status.toLowerCase() : ""; if (["completed", "complete", "cancelled", "canceled", "failed", "expired"].includes(status)) { return false; } const dueAt = parseTimestamp( state.due_at ?? state.wakeup_at ?? state.scheduled_for ?? state.deadline_at ?? state.expires_at, ); if (dueAt !== null) return dueAt > now; if (state.active === true || state.pending === true) { return isFreshTimestamp(state.created_at ?? state.updated_at ?? state.started_at); } return false; }); } function hasRunningSubagent(stateDir) { // subagent-tracking.json is per-directory (not session-scoped), written by the // wired SubagentStart/SubagentStop hooks. A "running" entry means a delegated // agent is still working, so persistent modes must not inject a "stalled" // reinforcement while we wait for it (mirrors the background-task gate). const tracking = readJsonFile(join(stateDir, "subagent-tracking.json")); const agents = Array.isArray(tracking?.agents) ? tracking.agents : []; return agents.some((agent) => { if (agent?.status !== "running") return false; return isFreshTimestamp(agent.started_at, RUNNING_SUBAGENT_STALE_MS); }); } function hasPendingOwnedAsyncWork(stateDir, sessionId) { return ( hasPendingBackgroundTask(stateDir, sessionId) || hasRunningSubagent(stateDir) || hasPendingScheduledWakeup(stateDir, sessionId) ); } function normalizeTeamPhase(state) { if (!state || typeof state !== "object") return null; const rawPhase = state.current_phase ?? state.phase ?? state.stage; if (typeof rawPhase !== "string") return null; const phase = rawPhase.trim().toLowerCase(); if (!phase || TEAM_TERMINAL_PHASES.has(phase)) return null; return TEAM_ACTIVE_PHASES.has(phase) ? phase : null; } function normalizeRalplanPhase(state) { if (!state || typeof state !== "object") return null; const rawPhase = state.current_phase ?? state.phase ?? state.status; if (typeof rawPhase !== "string") return null; const phase = rawPhase.trim().toLowerCase(); if (!phase) return null; if (phase === "handoff" || phase.startsWith("handoff:") || phase.startsWith("handoff-")) { return "handoff"; } return phase; } function getSafeReinforcementCount(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; } const AWAITING_CONFIRMATION_TTL_MS = 2 * 60 * 1000; function isAwaitingConfirmation(state) { if (!state || state.awaiting_confirmation !== true) { return false; } const setAt = state.awaiting_confirmation_set_at || state.started_at || null; if (!setAt) { return false; } const setAtMs = new Date(setAt).getTime(); if (!Number.isFinite(setAtMs)) { return false; } return Date.now() - setAtMs < AWAITING_CONFIRMATION_TTL_MS; } function getAutopilotPhase(state) { const rawPhase = state?.phase ?? state?.current_phase ?? "unspecified"; return typeof rawPhase === "string" && rawPhase.trim() ? rawPhase.trim().toLowerCase() : "unspecified"; } function isAutopilotRoutingEchoPrompt(promptText) { return /^\[MAGIC KEYWORDS?(?: DETECTED)?:\s*AUTOPILOT\s*\]\s*$/i.test(promptText) || /^\/(?:oh-my-claudecode:|omc:)?autopilot(?:\s+execute)?\s*$/i.test(promptText); } function isOrphanedAutopilotRoutingEchoState(state) { if (!state || typeof state !== "object") return false; const phase = getAutopilotPhase(state); if (phase && phase !== "unspecified") return false; const promptText = [ state.originalIdea, state.original_idea, state.original_prompt, state.prompt, state.task_description, ] .filter((value) => typeof value === "string") .join("\n") .trim(); return isAutopilotRoutingEchoPrompt(promptText); } function clearLoadedStateFile(loaded) { const statePath = loaded?.path; if (!statePath || !existsSync(statePath)) return; try { unlinkSync(statePath); } catch { // Best effort: failing to clean an orphan should not re-arm stop blocking. } } // --------------------------------------------------------------------------- // Stop Breaker helpers (shared by team pipeline and ralplan) // --------------------------------------------------------------------------- function readStopBreaker(stateDir, name, sessionId, ttlMs) { const dir = sessionId ? join(stateDir, "sessions", sessionId) : stateDir; const breakerPath = join(dir, `${name}-stop-breaker.json`); try { if (!existsSync(breakerPath)) return 0; const raw = JSON.parse(readFileSync(breakerPath, "utf-8")); if (ttlMs && raw.updated_at) { const updatedAt = new Date(raw.updated_at).getTime(); if (Number.isFinite(updatedAt) && Date.now() - updatedAt > ttlMs) { unlinkSync(breakerPath); return 0; } } return typeof raw.count === "number" ? raw.count : 0; } catch { return 0; } } function writeStopBreaker(stateDir, name, count, sessionId) { const dir = sessionId ? join(stateDir, "sessions", sessionId) : stateDir; try { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const breakerPath = join(dir, `${name}-stop-breaker.json`); writeJsonFile(breakerPath, { count, updated_at: new Date().toISOString() }); } catch { // Fail-open } } /** * Check if a cancel signal is in progress for the session. * Cancel signals are written by state_clear and expire after 30 seconds. * @param {string} stateDir - The .omc/state directory path * @param {string} sessionId - Optional session ID * @returns {boolean} true if cancel is in progress */ function isSessionCancelInProgress(stateDir, sessionId) { const CANCEL_SIGNAL_TTL_MS = 30000; // 30 seconds const isActiveSignal = (signalPath) => { const signal = readJsonFile(signalPath); if (!signal) { return false; } const now = Date.now(); const expiresAt = signal.expires_at ? new Date(signal.expires_at).getTime() : NaN; const requestedAt = signal.requested_at ? new Date(signal.requested_at).getTime() : NaN; const fallbackExpiry = Number.isFinite(requestedAt) ? requestedAt + CANCEL_SIGNAL_TTL_MS : NaN; const effectiveExpiry = Number.isFinite(expiresAt) ? expiresAt : fallbackExpiry; if (Number.isFinite(effectiveExpiry) && effectiveExpiry > now) { return true; } if (existsSync(signalPath)) { try { unlinkSync(signalPath); } catch {} } return false; }; // Try session-scoped path first if (sessionId) { const sessionSignalPath = join(stateDir, 'sessions', sessionId, 'cancel-signal-state.json'); if (isActiveSignal(sessionSignalPath)) { return true; } } // Fall back to legacy path const legacySignalPath = join(stateDir, 'cancel-signal-state.json'); return isActiveSignal(legacySignalPath); } /** * Normalize a path for comparison. */ function normalizePath(p) { if (!p) return ""; let normalized = resolve(p); normalized = normalize(normalized); normalized = normalized.replace(/[\/\\]+$/, ""); if (process.platform === "win32") { normalized = normalized.toLowerCase(); } return normalized; } /** * Check if a state belongs to the requesting session. * When sessionId is known: require exact match with state.session_id. * When sessionId is empty/unknown: only match state without session_id (legacy compat). */ function isSessionMatch(state, sessionId) { if (!state) return false; if (sessionId) { // Session is known: require exact match return state.session_id === sessionId; } // No session_id from hook: only match legacy state (no session_id in state) return !state.session_id; } /** * Check if a state belongs to the current project. */ function isStateForCurrentProject( state, currentDirectory, isGlobalState = false, ) { if (!state) return true; if (!state.project_path) { if (isGlobalState) { return false; } return true; } return normalizePath(state.project_path) === normalizePath(currentDirectory); } /** * Read state file from local location only. */ function readStateFile(stateDir, filename) { const localPath = join(stateDir, filename); const state = readJsonFile(localPath); return { state, path: localPath, isGlobal: false }; } /** * Read state file with session-scoped path support and fallback to legacy path. */ function readStateFileWithSession(stateDir, filename, sessionId) { // Try session-scoped path first (and ONLY) when sessionId is available if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) { const sessionsDir = join(stateDir, 'sessions', sessionId); const sessionPath = join(sessionsDir, filename); const state = readJsonFile(sessionPath); if (state) { return { state, path: sessionPath, isGlobal: false }; } // Session path not found — fallback: scan ALL session dirs for a state // whose session_id matches ours (handles path mismatches) try { const allSessionsDir = join(stateDir, 'sessions'); if (existsSync(allSessionsDir)) { const dirs = readdirSync(allSessionsDir).filter(d => /^[a-zA-Z0-9]/.test(d)); for (const dir of dirs) { const candidatePath = join(allSessionsDir, dir, filename); const candidateState = readJsonFile(candidatePath); if (candidateState && candidateState.session_id === sessionId) { return { state: candidateState, path: candidatePath, isGlobal: false }; } } } } catch { /* ignore scan errors */ } // Also check legacy path if its session_id matches const legacyResult = readStateFile(stateDir, filename); if (legacyResult.state && legacyResult.state.session_id === sessionId) { return legacyResult; } return { state: null, path: null, isGlobal: false }; } // No sessionId: fall back to legacy path (backward compat) return readStateFile(stateDir, filename); } const WORKFLOW_SLOT_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000; function isWorkflowSlotTombstonedForMode(stateDir, mode, sessionId) { const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : ""; const ledgerPath = safeSessionId ? join(stateDir, "sessions", safeSessionId, "skill-active-state.json") : join(stateDir, "skill-active-state.json"); const ledger = readJsonFile(ledgerPath); const slot = ledger?.active_skills?.[mode]; if (!slot || typeof slot !== "object") return false; if (typeof slot.completed_at !== "string" || !slot.completed_at) return false; const completedAt = new Date(slot.completed_at).getTime(); if (!Number.isFinite(completedAt)) return true; return Date.now() - completedAt < WORKFLOW_SLOT_TOMBSTONE_TTL_MS; } function isAuthoritativeModeActive(stateDir, mode, loaded, sessionId) { const state = loaded?.state; if (!state?.active) return false; if (isWorkflowSlotTombstonedForMode(stateDir, mode, sessionId)) return false; const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : ""; if (safeSessionId && state.session_id && state.session_id !== safeSessionId) return false; return true; } function getActiveSubagentCount(stateDir) { try { const tracking = readJsonFile(join(stateDir, "subagent-tracking.json")); if (!tracking || !Array.isArray(tracking.agents)) { return 0; } return tracking.agents.filter((agent) => agent?.status === "running").length; } catch { return 0; } } /** * Count incomplete Tasks from Claude Code's native Task system. */ function countIncompleteTasks(sessionId) { if (!sessionId || typeof sessionId !== "string") return 0; if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) return 0; const cfgDir = getClaudeConfigDir(); const taskDir = join(cfgDir, "tasks", sessionId); if (!existsSync(taskDir)) return 0; let count = 0; try { const files = readdirSync(taskDir).filter( (f) => f.endsWith(".json") && f !== ".lock", ); for (const file of files) { try { const content = readFileSync(join(taskDir, file), "utf-8"); const task = JSON.parse(content); if (task.status === "pending" || task.status === "in_progress") count++; } catch { /* skip */ } } } catch { /* skip */ } return count; } async function countIncompleteTodos(sessionId, projectDir) { let count = 0; // Session-specific todos only (no global scan) if ( sessionId && typeof sessionId === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ) { const sessionTodoPath = join( getClaudeConfigDir(), "todos", `${sessionId}.json`, ); try { const data = readJsonFile(sessionTodoPath); const todos = Array.isArray(data) ? data : Array.isArray(data?.todos) ? data.todos : []; count += todos.filter( (t) => t.status !== "completed" && t.status !== "cancelled", ).length; } catch { /* skip */ } } // Project-local todos only const omcRoot = await resolveOmcStateRoot(projectDir); for (const path of [ join(omcRoot, "todos.json"), join(projectDir, ".claude", "todos.json"), ]) { try { const data = readJsonFile(path); const todos = Array.isArray(data) ? data : Array.isArray(data?.todos) ? data.todos : []; count += todos.filter( (t) => t.status !== "completed" && t.status !== "cancelled", ).length; } catch { /* skip */ } } return count; } const ULTRAWORK_OBJECTIVE_MAX_CHARS = 140; function firstStringValue(source, keys) { if (!source || typeof source !== "object") return ""; for (const key of keys) { const value = source[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function formatConciseObjective(value, maxChars = ULTRAWORK_OBJECTIVE_MAX_CHARS) { if (typeof value !== "string") return ""; const compact = value.replace(/\s+/g, " ").trim(); if (!compact) return ""; const chars = [...compact]; if (chars.length <= maxChars) return compact; return `${chars.slice(0, maxChars).join("").trimEnd()}…`; } function getLiveUltraworkObjective(state) { const objective = firstStringValue(state, [ "current_objective", "currentObjective", "objective_summary", "objectiveSummary", "task_summary", "taskSummary", "current_task", "currentTask", "active_task", "activeTask", ]); return formatConciseObjective(objective); } /** * Detect if stop was triggered by context-limit related reasons. * When context is exhausted, Claude Code needs to stop so it can compact. * Blocking these stops causes a deadlock: can't compact because can't stop, * can't continue because context is full. * * See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213 */ 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))); } const CRITICAL_CONTEXT_STOP_PERCENT = 95; function estimateContextPercent(transcriptPath) { if (!transcriptPath || !existsSync(transcriptPath)) return 0; let fd = -1; try { const size = statSync(transcriptPath).size; const readSize = 4096; const offset = Math.max(0, size - readSize); const buf = Buffer.alloc(Math.min(readSize, size)); fd = openSync(transcriptPath, "r"); readSync(fd, buf, 0, buf.length, offset); closeSync(fd); fd = -1; const content = buf.toString("utf-8"); const windowMatch = content.match(/"context_window"\s{0,5}:\s{0,5}(\d+)/g); const inputMatch = content.match(/"input_tokens"\s{0,5}:\s{0,5}(\d+)/g); if (!windowMatch || !inputMatch) return 0; const lastWindow = parseInt(windowMatch[windowMatch.length - 1].match(/(\d+)/)[1], 10); const lastInput = parseInt(inputMatch[inputMatch.length - 1].match(/(\d+)/)[1], 10); if (!Number.isFinite(lastWindow) || lastWindow <= 0 || !Number.isFinite(lastInput)) return 0; return Math.round((lastInput / lastWindow) * 100); } catch { return 0; } finally { if (fd !== -1) try { closeSync(fd); } catch { /* best-effort */ } } } /** * Detect if stop was triggered by user abort (Ctrl+C, cancel button, etc.) */ function isUserAbort(data) { if (data.user_requested || data.userRequested) return true; const reason = (data.stop_reason || data.stopReason || "").toLowerCase(); // Exact-match patterns: short generic words that cause false positives with .includes() const exactPatterns = ["aborted", "abort", "cancel", "interrupt"]; // Substring patterns: compound words safe for .includes() matching const substringPatterns = [ "user_cancel", "user_interrupt", "ctrl_c", "manual_stop", ]; return ( exactPatterns.some((p) => reason === p) || substringPatterns.some((p) => reason.includes(p)) ); } const AUTHENTICATION_ERROR_PATTERNS = [ "authentication_error", "authentication_failed", "auth_error", "unauthorized", "unauthorised", "401", "403", "forbidden", "invalid_token", "token_invalid", "token_expired", "expired_token", "oauth_expired", "oauth_token_expired", "invalid_grant", "insufficient_scope", ]; function isAuthenticationError(data) { const reason = (data.stop_reason || data.stopReason || "").toLowerCase(); const endTurnReason = ( data.end_turn_reason || data.endTurnReason || "" ).toLowerCase(); return AUTHENTICATION_ERROR_PATTERNS.some( (pattern) => reason.includes(pattern) || endTurnReason.includes(pattern), ); } function isScheduledWakeupStop(data) { const stopPatterns = [ "schedulewakeup", "schedule_wakeup", "scheduled_wakeup", "scheduled_task", "scheduled_resume", "loop_resume", "loop_wakeup", ]; const toolName = String(data.tool_name || data.toolName || "").toLowerCase().replace(/[\s-]+/g, "_"); if (stopPatterns.some((pattern) => toolName.includes(pattern))) { return true; } 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, "_")); return reasons.some((reason) => stopPatterns.some((pattern) => reason.includes(pattern))); } async function main() { try { const input = await readStdin(); let data = {}; try { data = JSON.parse(input); } catch {} const directory = data.cwd || data.directory || process.cwd(); const sessionId = data.session_id || data.sessionId || ""; const omcRoot = await resolveOmcStateRoot(directory); const stateDir = join(omcRoot, "state"); // CRITICAL: Never block context-limit stops. // Blocking these causes a deadlock where Claude Code cannot compact. // See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/213 if (isContextLimitStop(data)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } const criticalTranscriptPath = data.transcript_path || data.transcriptPath || ""; if (estimateContextPercent(criticalTranscriptPath) >= CRITICAL_CONTEXT_STOP_PERCENT) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } // Respect user abort (Ctrl+C, cancel) if (isUserAbort(data)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } // Never block auth failures (401/403/expired OAuth): allow re-auth flow. if (isAuthenticationError(data)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } if (isScheduledWakeupStop(data)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } if (hasPendingOwnedAsyncWork(stateDir, sessionId)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } // Read all mode states (session-scoped with legacy fallback) const ralph = readStateFileWithSession(stateDir, "ralph-state.json", sessionId); const autopilot = readStateFileWithSession(stateDir, "autopilot-state.json", sessionId); const ultrapilot = readStateFileWithSession(stateDir, "ultrapilot-state.json", sessionId); const ultrawork = readStateFileWithSession(stateDir, "ultrawork-state.json", sessionId); const ultraqa = readStateFileWithSession(stateDir, "ultraqa-state.json", sessionId); const pipeline = readStateFileWithSession(stateDir, "pipeline-state.json", sessionId); const team = readStateFileWithSession(stateDir, "team-state.json", sessionId); const ralplan = readStateFileWithSession(stateDir, "ralplan-state.json", sessionId); const omcTeams = readStateFileWithSession(stateDir, "omc-teams-state.json", sessionId); // Swarm uses swarm-summary.json (not swarm-state.json) + marker file const swarmMarker = existsSync(join(stateDir, "swarm-active.marker")); const swarmSummary = readJsonFile(join(stateDir, "swarm-summary.json")); // Count incomplete items (session-specific + project-local only) const taskCount = countIncompleteTasks(sessionId); const todoCount = await countIncompleteTodos(sessionId, directory); const totalIncomplete = taskCount + todoCount; // Check if cancel is in progress - if so, allow stop immediately // Cache the result to pass to sub-checks (avoids TOCTOU re-reads, issue #1058) const cancelInProgress = isSessionCancelInProgress(stateDir, sessionId); if (cancelInProgress) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } // Priority 1: Ralph Loop (explicit persistence mode) // Skip if state is stale (older than 2 hours) - prevents blocking new sessions if (isAuthoritativeModeActive(stateDir, "ralph", ralph, sessionId) && !isAwaitingConfirmation(ralph.state) && !isStaleState(ralph.state) && isSessionMatch(ralph.state, sessionId)) { const iteration = ralph.state.iteration || 1; const maxIter = ralph.state.max_iterations || 100; if (iteration < maxIter) { ralph.state.iteration = iteration + 1; ralph.state.last_checked_at = new Date().toISOString(); if (!shouldWriteStateBack(ralph.path)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } writeJsonFile(ralph.path, ralph.state); // Fire-and-forget notification sendStopNotification('ralph', ralph.state, sessionId, directory).catch(() => {}); const ralphReason = `[RALPH LOOP - ITERATION ${iteration + 1}/${maxIter}] Work is NOT done. Continue working.\nWhen FULLY complete (after Architect verification), run /oh-my-claudecode:cancel to cleanly exit ralph mode and clean up all state files. If cancel fails, retry with /oh-my-claudecode:cancel --force.\n${ralph.state.prompt ? `Task: ${ralph.state.prompt}` : ""}`; console.log( JSON.stringify({ decision: "block", reason: ralphReason, }), ); return; } else { // Do not silently stop Ralph once it hits max iterations; extend and keep going. ralph.state.max_iterations = maxIter + 10; ralph.state.iteration = maxIter + 1; ralph.state.last_checked_at = new Date().toISOString(); if (!shouldWriteStateBack(ralph.path)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } writeJsonFile(ralph.path, ralph.state); const extendReason = `[RALPH LOOP - EXTENDED] Max iterations reached; extending to ${ralph.state.max_iterations} and continuing. When FULLY complete (after Architect verification), run /oh-my-claudecode:cancel (or --force).`; console.log(JSON.stringify({ decision: "block", reason: extendReason })); return; } } // Priority 2: Autopilot (high-level orchestration) if (isOrphanedAutopilotRoutingEchoState(autopilot.state)) { clearLoadedStateFile(autopilot); console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } if (autopilot.state?.active && !isAwaitingConfirmation(autopilot.state) && !isStaleState(autopilot.state) && isSessionMatch(autopilot.state, sessionId)) { const phase = getAutopilotPhase(autopilot.state); if (phase !== "complete") { const newCount = (autopilot.state.reinforcement_count || 0) + 1; if (newCount <= 20) { autopilot.state.reinforcement_count = newCount; autopilot.state.last_checked_at = new Date().toISOString(); writeJsonFile(autopilot.path, autopilot.state); // Fire-and-forget notification sendStopNotification('autopilot', autopilot.state, sessionId, directory).catch(() => {}); const cancelGuidance = typeof autopilot.state.session_id === "string" && autopilot.state.session_id === sessionId ? " When all phases are complete, run /oh-my-claudecode:cancel to cleanly exit and clean up this session's autopilot state files. If cancel fails, retry with /oh-my-claudecode:cancel --force." : ""; console.log( JSON.stringify({ decision: "block", reason: `[AUTOPILOT - Phase: ${phase}] Autopilot not complete. Continue working.${cancelGuidance}`, }), ); return; } } } // Priority 2.5: Team Pipeline (standalone team mode — first-class enforcement) // When team runs WITHOUT ralph, this provides stop-hook blocking. // When team runs WITH ralph, checkRalphLoop (Priority 1) handles it. let teamPipelineHandled = false; if (team.state && isSessionMatch(team.state, sessionId)) { if (!team.state.active) { // Inactive — reset breaker, allow stop, mark as handled writeStopBreaker(stateDir, "team-pipeline", 0, sessionId); teamPipelineHandled = true; } else if (!isStaleState(team.state)) { teamPipelineHandled = true; // Cancel-in-progress bypass (TOCTOU defense, issue #1058) if (!cancelInProgress) { // Read phase: canonical field priority matching bridge code const rawPhase = team.state.phase ?? team.state.current_phase ?? team.state.currentStage ?? team.state.current_stage ?? team.state.stage; if (typeof rawPhase !== "string") { // No valid phase — fail-open (don't block) } else { const phase = rawPhase.trim().toLowerCase(); if (TEAM_TERMINAL_PHASES.has(phase) || phase === "cancel") { // Terminal — reset breaker, allow stop writeStopBreaker(stateDir, "team-pipeline", 0, sessionId); } else if (!TEAM_ACTIVE_PHASES.has(phase)) { // Unknown phase — fail-open (don't block) } else { // Status-level terminal check const rawStatus = team.state.status; const status = typeof rawStatus === "string" ? rawStatus.trim().toLowerCase() : null; if (status && TEAM_TERMINAL_PHASES.has(status)) { writeStopBreaker(stateDir, "team-pipeline", 0, sessionId); } else if (team.state.cancel?.requested) { // Cancel requested — allow stop writeStopBreaker(stateDir, "team-pipeline", 0, sessionId); } else { // Active phase — block with circuit breaker const breakerCount = readStopBreaker(stateDir, "team-pipeline", sessionId, TEAM_PIPELINE_STOP_BLOCKER_TTL_MS) + 1; if (breakerCount > TEAM_PIPELINE_STOP_BLOCKER_MAX) { writeStopBreaker(stateDir, "team-pipeline", 0, sessionId); // Circuit breaker tripped — allow stop } else { writeStopBreaker(stateDir, "team-pipeline", breakerCount, sessionId); sendStopNotification("team", team.state, sessionId, directory).catch(() => {}); const teamPipelineReason = `[TEAM PIPELINE - PHASE: ${phase.toUpperCase()} | REINFORCEMENT ${breakerCount}/${TEAM_PIPELINE_STOP_BLOCKER_MAX}] The team pipeline is active in phase "${phase}". Continue working on the team workflow. Do not stop until the pipeline reaches a terminal state (complete/failed/cancelled). When done, run /oh-my-claudecode:cancel to cleanly exit.`; console.log(JSON.stringify({ decision: "block", reason: teamPipelineReason, })); return; } } } } } } } // Priority 2.6: Ralplan (standalone consensus planning — first-class enforcement) if (ralplan.state?.active && !isAwaitingConfirmation(ralplan.state) && !isStaleState(ralplan.state) && isSessionMatch(ralplan.state, sessionId)) { // Terminal phase detection const currentPhase = normalizeRalplanPhase(ralplan.state); const ralplanTerminal = currentPhase ? RALPLAN_TERMINAL_PHASES.has(currentPhase) : false; if (ralplanTerminal) { writeStopBreaker(stateDir, "ralplan", 0, sessionId); } if (!ralplanTerminal && !cancelInProgress) { // Circuit breaker const breakerCount = readStopBreaker(stateDir, "ralplan", sessionId, RALPLAN_STOP_BLOCKER_TTL_MS) + 1; if (breakerCount > RALPLAN_STOP_BLOCKER_MAX) { writeStopBreaker(stateDir, "ralplan", 0, sessionId); // Deactivate the stale ralplan state so a later Stop event cannot // start a brand-new reinforcement cycle (30/30 -> 1/30) after the // workflow has already exhausted its breaker budget. ralplan.state.active = false; ralplan.state.deactivated_reason = "stop_breaker_exhausted"; ralplan.state.completed_at = new Date().toISOString(); writeJsonFile(ralplan.path, ralplan.state); // Circuit breaker tripped — allow stop } else { writeStopBreaker(stateDir, "ralplan", breakerCount, sessionId); sendStopNotification("ralplan", ralplan.state, sessionId, directory).catch(() => {}); const ralplanReason = `[RALPLAN - CONSENSUS PLANNING | REINFORCEMENT ${breakerCount}/${RALPLAN_STOP_BLOCKER_MAX}] The ralplan consensus workflow is active. Continue the Planner/Architect/Critic planning loop only. Ralplan is read-only/planning mode: do not implement, invoke execution skills, edit source, commit, push, or open PRs from this continuation. When consensus is reached, stop at a pending-approval handoff and require explicit user approval before execution. When done, run /oh-my-claudecode:cancel to cleanly exit.`; console.log(JSON.stringify({ decision: "block", reason: ralplanReason, })); return; } } } // Priority 3: Ultrapilot (parallel autopilot) if (ultrapilot.state?.active && !isStaleState(ultrapilot.state) && isSessionMatch(ultrapilot.state, sessionId)) { const workers = ultrapilot.state.workers || []; const incomplete = workers.filter( (w) => w.status !== "complete" && w.status !== "failed", ).length; if (incomplete > 0) { const newCount = (ultrapilot.state.reinforcement_count || 0) + 1; if (newCount <= 20) { ultrapilot.state.reinforcement_count = newCount; ultrapilot.state.last_checked_at = new Date().toISOString(); writeJsonFile(ultrapilot.path, ultrapilot.state); // Fire-and-forget notification sendStopNotification('ultrapilot', ultrapilot.state, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[ULTRAPILOT] ${incomplete} workers still running. Continue working. When all workers complete, run /oh-my-claudecode:cancel to cleanly exit and clean up state files. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } } // Priority 4: Swarm (coordinated agents with SQLite) if (swarmMarker && swarmSummary?.active && !isStaleState(swarmSummary)) { const pending = (swarmSummary.tasks_pending || 0) + (swarmSummary.tasks_claimed || 0); if (pending > 0) { const newCount = (swarmSummary.reinforcement_count || 0) + 1; if (newCount <= 15) { swarmSummary.reinforcement_count = newCount; swarmSummary.last_checked_at = new Date().toISOString(); writeJsonFile(join(stateDir, "swarm-summary.json"), swarmSummary); // Fire-and-forget notification sendStopNotification('swarm', swarmSummary, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[SWARM ACTIVE] ${pending} tasks remain. Continue working. When all tasks are done, run /oh-my-claudecode:cancel to cleanly exit and clean up state files. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } } // Priority 5: Pipeline (sequential stages) if (pipeline.state?.active && !isStaleState(pipeline.state) && isSessionMatch(pipeline.state, sessionId)) { const currentStage = pipeline.state.current_stage || 0; const totalStages = pipeline.state.stages?.length || 0; if (currentStage < totalStages) { const newCount = (pipeline.state.reinforcement_count || 0) + 1; if (newCount <= 15) { pipeline.state.reinforcement_count = newCount; pipeline.state.last_checked_at = new Date().toISOString(); writeJsonFile(pipeline.path, pipeline.state); // Fire-and-forget notification sendStopNotification('pipeline', pipeline.state, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[PIPELINE - Stage ${currentStage + 1}/${totalStages}] Pipeline not complete. Continue working. When all stages complete, run /oh-my-claudecode:cancel to cleanly exit and clean up state files. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } } // Priority 6: Team (native Claude Code teams) — fallback for cases not handled by Priority 2.5 if (!teamPipelineHandled && team.state?.active && !isStaleState(team.state) && isSessionMatch(team.state, sessionId)) { const phase = normalizeTeamPhase(team.state); if (phase) { const newCount = getSafeReinforcementCount(team.state.reinforcement_count) + 1; if (newCount <= 20) { team.state.reinforcement_count = newCount; team.state.last_checked_at = new Date().toISOString(); writeJsonFile(team.path, team.state); // Fire-and-forget notification sendStopNotification('team', team.state, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[TEAM - Phase: ${phase}] Team mode active. Continue working. When all team tasks complete, run /oh-my-claudecode:cancel to cleanly exit. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } } // Priority 6.5: OMC Teams (tmux CLI workers — independent of native team state) if (omcTeams.state?.active && !isStaleState(omcTeams.state) && isSessionMatch(omcTeams.state, sessionId)) { const phase = normalizeTeamPhase(omcTeams.state); if (phase) { const newCount = getSafeReinforcementCount(omcTeams.state.reinforcement_count) + 1; if (newCount <= 20) { omcTeams.state.reinforcement_count = newCount; omcTeams.state.last_checked_at = new Date().toISOString(); writeJsonFile(omcTeams.path, omcTeams.state); // Fire-and-forget notification sendStopNotification('omc-teams', omcTeams.state, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[OMC TEAMS - Phase: ${phase}] OMC Teams workers active. Continue working. When all workers complete, run /oh-my-claudecode:cancel to cleanly exit. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } } // Priority 7: UltraQA (QA cycling) if (ultraqa.state?.active && !isStaleState(ultraqa.state) && isSessionMatch(ultraqa.state, sessionId)) { const cycle = ultraqa.state.cycle || 1; const maxCycles = ultraqa.state.max_cycles || 10; if (cycle < maxCycles && !ultraqa.state.all_passing) { ultraqa.state.cycle = cycle + 1; ultraqa.state.last_checked_at = new Date().toISOString(); writeJsonFile(ultraqa.path, ultraqa.state); // Fire-and-forget notification sendStopNotification('ultraqa', ultraqa.state, sessionId, directory).catch(() => {}); console.log( JSON.stringify({ decision: "block", reason: `[ULTRAQA - Cycle ${cycle + 1}/${maxCycles}] Tests not all passing. Continue fixing. When all tests pass, run /oh-my-claudecode:cancel to cleanly exit and clean up state files. If cancel fails, retry with /oh-my-claudecode:cancel --force.`, }), ); return; } } // Priority 8: Ultrawork - reinforce only while tracked work remains incomplete. // This prevents false stops from bash errors or transient failures mid-task. // Session isolation: only block if state belongs to this session (issue #311) // Project isolation: only block if state belongs to this project if ( isAuthoritativeModeActive(stateDir, "ultrawork", ultrawork, sessionId) && !isAwaitingConfirmation(ultrawork.state) && !isStaleState(ultrawork.state) && isSessionMatch(ultrawork.state, sessionId) && isStateForCurrentProject(ultrawork.state, directory, ultrawork.isGlobal) ) { if (totalIncomplete === 0) { // Issue #2419: once tracked work is complete, auto-clear ultrawork so // Stop can exit cleanly instead of forcing repeated cancel prompts. try { ultrawork.state.active = false; ultrawork.state.deactivated_reason = 'task_completion'; ultrawork.state.last_checked_at = new Date().toISOString(); writeJsonFile(ultrawork.path, ultrawork.state); } catch { /* best-effort cleanup */ } console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } const newCount = (ultrawork.state.reinforcement_count || 0) + 1; const maxReinforcements = ultrawork.state.max_reinforcements || 50; if (newCount > maxReinforcements) { // Max reinforcements reached - deactivate state before allowing stop // Without this, state stays active: true and HUD keeps showing ultrawork try { ultrawork.state.active = false; ultrawork.state.deactivated_reason = 'max_reinforcements_reached'; ultrawork.state.last_checked_at = new Date().toISOString(); writeJsonFile(ultrawork.path, ultrawork.state); } catch { /* best-effort cleanup */ } console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } ultrawork.state.reinforcement_count = newCount; ultrawork.state.last_checked_at = new Date().toISOString(); writeJsonFile(ultrawork.path, ultrawork.state); // Fire-and-forget notification sendStopNotification('ultrawork', ultrawork.state, sessionId, directory).catch(() => {}); let reason = `[ULTRAWORK #${newCount}/${maxReinforcements}] Mode active.`; if (totalIncomplete > 0) { const itemType = taskCount > 0 ? "Tasks" : "todos"; reason += ` ${totalIncomplete} incomplete ${itemType} remain. Continue working. When all work is complete, run /oh-my-claudecode:cancel to cleanly exit ultrawork mode and clean up state files.`; } else if (newCount >= 5) { // Strong directive: LLM must call cancel NOW reason += ` No incomplete tasks detected. You MUST invoke /oh-my-claudecode:cancel immediately to exit ultrawork mode and clean up state files. Call state_clear(mode="ultrawork") if the cancel skill is unavailable.`; } else if (newCount >= 3) { // Reinforce clean-exit guidance once no tracked work remains. reason += ` If all work is complete, run /oh-my-claudecode:cancel to cleanly exit ultrawork mode and clean up state files. If cancel fails, retry with /oh-my-claudecode:cancel --force. Otherwise, continue working.`; } else { // Early iterations with no tasks yet still need an immediately visible exit path. reason += ` No incomplete tasks detected. If all work is complete, run /oh-my-claudecode:cancel to cleanly exit ultrawork mode and clean up state files. Otherwise, continue working - create Tasks to track your progress.`; } const currentObjective = getLiveUltraworkObjective(ultrawork.state); if (currentObjective) { reason += `\nCurrent objective: ${currentObjective}`; } console.log(JSON.stringify({ decision: "block", reason })); return; } // Priority 9: Skill Active State (issue #1033) // Skills like code-review, plan, ralplan, tdd, etc. write skill-active-state.json // when invoked via the Skill tool. This prevents premature stops mid-skill. { const skillState = readStateFileWithSession(stateDir, "skill-active-state.json", sessionId); if (skillState.state?.active) { // Staleness check (per-skill TTL) const sLastChecked = skillState.state.last_checked_at ? new Date(skillState.state.last_checked_at).getTime() : 0; const sStartedAt = skillState.state.started_at ? new Date(skillState.state.started_at).getTime() : 0; const sMostRecent = Math.max(sLastChecked, sStartedAt); const sTtl = skillState.state.stale_ttl_ms || 5 * 60 * 1000; const sAge = sMostRecent > 0 ? Date.now() - sMostRecent : Infinity; const isStale = sMostRecent === 0 || sAge > sTtl; if (!isStale && isSessionMatch(skillState.state, sessionId)) { const count = skillState.state.reinforcement_count || 0; const maxReinforcements = skillState.state.max_reinforcements || 3; if (count < maxReinforcements) { if (getActiveSubagentCount(stateDir) > 0) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } skillState.state.reinforcement_count = count + 1; skillState.state.last_checked_at = new Date().toISOString(); writeJsonFile(skillState.path, skillState.state); const skillName = skillState.state.skill_name || "unknown"; const skillActiveReason = `[SKILL ACTIVE: ${skillName}] The "${skillName}" skill is still executing (reinforcement ${count + 1}/${maxReinforcements}). Continue working on the skill's instructions. Do not stop until the skill completes its workflow.`; console.log(JSON.stringify({ decision: "block", reason: skillActiveReason, })); return; } else { // Reinforcement limit reached - clear state and allow stop try { if (skillState.path && existsSync(skillState.path)) unlinkSync(skillState.path); } catch {} } } } } // No blocking needed — Claude is truly idle. // Send session-idle notification (fire-and-forget) so external integrations // (Telegram, Discord) know the session went idle without any active mode. // Back off repeated zero-backlog nudges until repo state changes. const idleRepoState = getIdleNotificationRepoState(directory); if (sessionId && shouldSendIdleNotification(stateDir, idleRepoState)) { try { const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; if (pluginRoot) { const { pathToFileURL } = require('url'); import(pathToFileURL(join(pluginRoot, 'dist', 'notifications', 'index.js')).href) .then(({ notify }) => notify('session-idle', { sessionId, projectPath: directory, }).catch(() => {}) ) .catch(() => {}); recordIdleNotificationSent(stateDir, idleRepoState); } } catch { // Notification module not available, skip silently } } console.log(JSON.stringify({ continue: true, suppressOutput: true })); } catch (error) { // On any error, allow stop rather than blocking forever console.error(`[persistent-mode] Error: ${error.message}`); console.log(JSON.stringify({ continue: true, suppressOutput: true })); } } main();