Files
oh-my-claudecode/scripts/persistent-mode.mjs
Codex Review c26a334e40 Prevent scheduled wakeup resumes from self-cancelling persistent hooks
ScheduleWakeup-style resume events are not real persistence work, but the Stop hook treated them like ordinary idle stops and could inject continuation text that tells Claude to run /oh-my-claudecode:cancel. That let scheduled /loop turns cancel themselves before the scheduled task executed.

This change adds a narrow scheduled-wakeup bypass alongside the existing non-reinforceable stop guards and mirrors the same detector in the shipped script/template hook surfaces so installed runtime behavior stays aligned with the shared TypeScript path.

Constraint: Installed Stop hook behavior is shipped via script/template surfaces as well as shared TypeScript logic
Rejected: Tighten global persistent-mode freshness rules | broader behavioral change across legitimate long-running sessions
Confidence: medium
Scope-risk: narrow
Directive: Keep scheduled-resume stop marker detection aligned across shared logic and script/template mirrors if Claude Code changes its native wakeup payloads
Tested: ./node_modules/.bin/vitest run src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts src/hooks/persistent-mode/stop-hook-blocking.test.ts
Tested: ./node_modules/.bin/eslint src/hooks/todo-continuation/index.ts src/hooks/persistent-mode/index.ts src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts src/hooks/persistent-mode/stop-hook-blocking.test.ts
Tested: ./node_modules/.bin/tsc --noEmit --pretty false
Not-tested: Live Claude Code /loop ScheduleWakeup reproduction on macOS
2026-04-17 08:28:51 +00:00

1163 lines
37 KiB
JavaScript

#!/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
*/
import {
existsSync,
readFileSync,
writeFileSync,
readdirSync,
mkdirSync,
unlinkSync,
renameSync,
statSync,
openSync,
readSync,
closeSync,
} from "fs";
import { join, dirname, resolve, normalize } from "path";
import { homedir } from "os";
import { fileURLToPath, pathToFileURL } from "url";
import { getClaudeConfigDir } from "./lib/config-dir.mjs";
import { resolveOmcStateRoot } from "./lib/state-root.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Dynamic import for the shared stdin module
const { readStdin } = await import(
pathToFileURL(join(__dirname, "lib", "stdin.mjs")).href
);
function readJsonFile(path) {
try {
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, "utf-8"));
} catch {
return null;
}
}
/**
* Get hard max iterations from OMC_SECURITY / config file.
* Returns 0 if unlimited (default).
*/
function getHardMaxIterations() {
// OMC_SECURITY=strict → default hard max 200
if (process.env.OMC_SECURITY === "strict") {
// Check config file for override
const configOverride = readSecurityConfigValue("hardMaxIterations");
return typeof configOverride === "number" ? configOverride : 200;
}
// Check config file only
const configValue = readSecurityConfigValue("hardMaxIterations");
return typeof configValue === "number" ? configValue : 0;
}
/**
* Read a single value from the security section of omc config files.
*/
function readSecurityConfigValue(key) {
const paths = [
join(process.cwd(), ".claude", "omc.jsonc"),
join(homedir(), ".config", "claude-omc", "config.jsonc"),
];
for (const p of paths) {
try {
if (!existsSync(p)) continue;
const raw = readFileSync(p, "utf-8");
// Strip JSONC comments (// and /* */)
const json = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
const parsed = JSON.parse(json);
if (parsed?.security && parsed.security[key] !== undefined) {
return parsed.security[key];
}
} catch {
// ignore
}
}
return undefined;
}
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;
}
}
/**
* Read last tool error from state directory.
* Returns null if file doesn't exist or error is stale (>60 seconds old).
*/
function readLastToolError(stateDir) {
const errorPath = join(stateDir, "last-tool-error.json");
const toolError = readJsonFile(errorPath);
if (!toolError || !toolError.timestamp) return null;
// Check staleness - errors older than 60 seconds are ignored
const parsedTime = new Date(toolError.timestamp).getTime();
if (!Number.isFinite(parsedTime)) {
return null; // Invalid timestamp = stale
}
const age = Date.now() - parsedTime;
if (age > 60000) return null;
return toolError;
}
/**
* Clear tool error state file atomically.
*/
function clearToolErrorState(stateDir) {
const errorPath = join(stateDir, "last-tool-error.json");
try {
if (existsSync(errorPath)) {
unlinkSync(errorPath);
}
} catch {
// Ignore errors - file may have been removed already
}
}
/**
* Generate retry guidance message for tool errors.
* After 5+ retries, suggests alternative approaches.
*/
function getToolErrorRetryGuidance(toolError) {
if (!toolError) return "";
const retryCount = toolError.retry_count || 1;
const toolName = toolError.tool_name || "unknown";
const error = toolError.error || "Unknown error";
if (retryCount >= 5) {
return `[TOOL ERROR - ALTERNATIVE APPROACH NEEDED]
The "${toolName}" operation has failed ${retryCount} times.
STOP RETRYING THE SAME APPROACH. Instead:
1. Try a completely different command or approach
2. Check if the environment/dependencies are correct
3. Consider breaking down the task differently
4. If stuck, ask the user for guidance
`;
}
return `[TOOL ERROR - RETRY REQUIRED]
The previous "${toolName}" operation failed.
Error: ${error}
REQUIRED ACTIONS:
1. Analyze why the command failed
2. Fix the issue (wrong path? permission? syntax? missing dependency?)
3. RETRY the operation with corrected parameters
4. Continue with your original task after success
Do NOT skip this step. Do NOT move on without fixing the error.
`;
}
/**
* 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 CANCEL_SIGNAL_TTL_MS = 30_000;
const TEAM_TERMINAL_PHASES = new Set([
"completed",
"complete",
"failed",
"cancelled",
"canceled",
"aborted",
"terminated",
"done",
]);
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 `last_checked_at`, `updated_at`, and `started_at` - using whichever is more recent.
*/
function isStaleState(state) {
if (!state) return true;
const timestamps = [state.last_checked_at, state.updated_at, state.started_at].filter(
(value) => typeof value === "string" && value.length > 0,
);
const mostRecent = timestamps.reduce((max, value) => {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) && parsed > max ? parsed : max;
}, 0);
if (mostRecent === 0) return true; // No valid timestamps
const age = Date.now() - mostRecent;
return age > STALE_STATE_THRESHOLD_MS;
}
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 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;
}
/**
* 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 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 or global location, tracking the source.
* Returns { state, path, isGlobal } to track where the state was loaded from.
*/
function readStateFile(stateDir, globalStateDir, filename) {
const localPath = join(stateDir, filename);
const globalPath = join(globalStateDir, filename);
let state = readJsonFile(localPath);
if (state) return { state, path: localPath, isGlobal: false };
state = readJsonFile(globalPath);
if (state) return { state, path: globalPath, isGlobal: true };
return { state: null, path: localPath, isGlobal: false }; // Default to local for new writes
}
const SESSION_ID_ALLOWLIST = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
function sanitizeSessionId(sessionId) {
if (!sessionId || typeof sessionId !== "string") return "";
return SESSION_ID_ALLOWLIST.test(sessionId) ? sessionId : "";
}
/**
* Read state file with session-scoped path support.
* If sessionId is provided, prefers the session-scoped path, then scans other
* session directories and legacy state for matching ownership.
*/
function readStateFileWithSession(stateDir, globalStateDir, filename, sessionId) {
const safeSessionId = sanitizeSessionId(sessionId);
if (safeSessionId) {
const sessionsDir = join(stateDir, "sessions", safeSessionId);
const sessionPath = join(sessionsDir, filename);
const state = readJsonFile(sessionPath);
if (state) {
return { state, path: sessionPath, isGlobal: false };
}
try {
const allSessionsDir = join(stateDir, "sessions");
if (existsSync(allSessionsDir)) {
const dirs = readdirSync(allSessionsDir).filter((dir) => SESSION_ID_ALLOWLIST.test(dir));
for (const dir of dirs) {
const candidatePath = join(allSessionsDir, dir, filename);
const candidateState = readJsonFile(candidatePath);
if (candidateState && candidateState.session_id === safeSessionId) {
return { state: candidateState, path: candidatePath, isGlobal: false };
}
}
}
} catch {
// ignore scan failures
}
const legacyResult = readStateFile(stateDir, globalStateDir, filename);
if (legacyResult.state && legacyResult.state.session_id === safeSessionId) {
return legacyResult;
}
return { state: null, path: sessionPath, isGlobal: false };
}
return readStateFile(stateDir, globalStateDir, filename);
}
function isSessionCancelInProgress(stateDir, sessionId) {
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 {
// best effort cleanup
}
}
return false;
};
if (sessionId) {
const sessionSignalPath = join(stateDir, "sessions", sessionId, "cancel-signal-state.json");
if (isActiveSignal(sessionSignalPath)) {
return true;
}
}
return isActiveSignal(join(stateDir, "cancel-signal-state.json"));
}
function shouldWriteStateBack(path) {
return Boolean(path && existsSync(path));
}
function isValidSessionId(sessionId) {
return typeof sessionId === "string" && SESSION_ID_ALLOWLIST.test(sessionId);
}
/**
* 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;
}
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
for (const path of [
join(projectDir, ".omc", "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;
}
/**
* 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;
if (size === 0) return 0;
// Read only the last 4KB to avoid OOM on large transcripts (10-100MB)
const readSize = Math.min(4096, size);
const buf = Buffer.alloc(readSize);
fd = openSync(transcriptPath, "r");
readSync(fd, buf, 0, readSize, size - readSize);
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 {
if (fd !== -1) try { closeSync(fd); } catch { /* best-effort */ }
return 0;
}
}
/**
* 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 sessionIdRaw = data.sessionId || data.session_id || data.sessionid || "";
const sessionId = sanitizeSessionId(sessionIdRaw);
const hasValidSessionId = isValidSessionId(sessionIdRaw);
const omcRoot = await resolveOmcStateRoot(directory);
const stateDir = join(omcRoot, "state");
const globalStateDir = join(homedir(), ".omc", "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;
}
// Read all mode states (session-scoped when sessionId provided)
const ralph = readStateFileWithSession(
stateDir,
globalStateDir,
"ralph-state.json",
sessionId,
);
const autopilot = readStateFileWithSession(
stateDir,
globalStateDir,
"autopilot-state.json",
sessionId,
);
const ultrapilot = readStateFileWithSession(
stateDir,
globalStateDir,
"ultrapilot-state.json",
sessionId,
);
const ultrawork = readStateFileWithSession(
stateDir,
globalStateDir,
"ultrawork-state.json",
sessionId,
);
const ultraqa = readStateFileWithSession(
stateDir,
globalStateDir,
"ultraqa-state.json",
sessionId,
);
const pipeline = readStateFileWithSession(
stateDir,
globalStateDir,
"pipeline-state.json",
sessionId,
);
const team = readStateFileWithSession(
stateDir,
globalStateDir,
"team-state.json",
sessionId,
);
const omcTeams = readStateFileWithSession(
stateDir,
globalStateDir,
"omc-teams-state.json",
sessionId,
);
if (isSessionCancelInProgress(stateDir, sessionId)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
// 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 = countIncompleteTodos(sessionId, directory);
const totalIncomplete = taskCount + todoCount;
// Priority 1: Ralph Loop (explicit persistence mode)
// Skip if state is stale (older than 2 hours) - prevents blocking new sessions
if (
ralph.state?.active && !isAwaitingConfirmation(ralph.state) &&
!isStaleState(ralph.state) &&
isStateForCurrentProject(ralph.state, directory, ralph.isGlobal)
) {
const sessionMatches = hasValidSessionId
? ralph.state.session_id === sessionId
: !ralph.state.session_id || ralph.state.session_id === sessionId;
if (sessionMatches) {
const iteration = ralph.state.iteration || 1;
const maxIter = ralph.state.max_iterations || 100;
if (iteration < maxIter) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
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);
let reason = `[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}` : ""}`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
// Check hard max before extending
const hardMax = getHardMaxIterations();
if (hardMax > 0 && maxIter >= hardMax) {
ralph.state.active = false;
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);
console.log(
JSON.stringify({
decision: "block",
reason: `[RALPH LOOP - HARD LIMIT] Reached hard max iterations (${hardMax}). Mode auto-disabled. Restart with /oh-my-claudecode:ralph if needed.`,
}),
);
return;
}
// Extend and keep going.
ralph.state.max_iterations = maxIter + 10;
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 ralphExtendedReason = `[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: ralphExtendedReason,
}),
);
return;
}
}
// Priority 2: Autopilot (high-level orchestration)
if (
autopilot.state?.active && !isAwaitingConfirmation(autopilot.state) &&
!isStaleState(autopilot.state) &&
isStateForCurrentProject(autopilot.state, directory, autopilot.isGlobal)
) {
const sessionMatches = hasValidSessionId
? autopilot.state.session_id === sessionId
: !autopilot.state.session_id || autopilot.state.session_id === sessionId;
if (sessionMatches) {
const phase = autopilot.state.phase || "unspecified";
if (phase !== "complete") {
const newCount = (autopilot.state.reinforcement_count || 0) + 1;
if (newCount <= 20) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
autopilot.state.reinforcement_count = newCount;
autopilot.state.last_checked_at = new Date().toISOString();
writeJsonFile(autopilot.path, autopilot.state);
const cancelGuidance = hasValidSessionId && 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."
: "";
let reason = `[AUTOPILOT - Phase: ${phase}] Autopilot not complete. Continue working.${cancelGuidance}`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
}
}
// Priority 3: Ultrapilot (parallel autopilot)
if (
ultrapilot.state?.active &&
!isStaleState(ultrapilot.state) &&
(hasValidSessionId
? ultrapilot.state.session_id === sessionId
: !ultrapilot.state.session_id || ultrapilot.state.session_id === sessionId) &&
isStateForCurrentProject(ultrapilot.state, directory, ultrapilot.isGlobal)
) {
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) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
ultrapilot.state.reinforcement_count = newCount;
ultrapilot.state.last_checked_at = new Date().toISOString();
writeJsonFile(ultrapilot.path, ultrapilot.state);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
}
// Priority 4: Swarm (coordinated agents with SQLite)
if (
swarmMarker &&
swarmSummary?.active &&
!isStaleState(swarmSummary) &&
isStateForCurrentProject(swarmSummary, directory, false)
) {
const pending =
(swarmSummary.tasks_pending || 0) + (swarmSummary.tasks_claimed || 0);
if (pending > 0) {
const newCount = (swarmSummary.reinforcement_count || 0) + 1;
if (newCount <= 15) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
swarmSummary.reinforcement_count = newCount;
swarmSummary.last_checked_at = new Date().toISOString();
writeJsonFile(join(stateDir, "swarm-summary.json"), swarmSummary);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
}
// Priority 5: Pipeline (sequential stages)
if (
pipeline.state?.active &&
!isStaleState(pipeline.state) &&
(hasValidSessionId
? pipeline.state.session_id === sessionId
: !pipeline.state.session_id || pipeline.state.session_id === sessionId) &&
isStateForCurrentProject(pipeline.state, directory, pipeline.isGlobal)
) {
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) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
pipeline.state.reinforcement_count = newCount;
pipeline.state.last_checked_at = new Date().toISOString();
writeJsonFile(pipeline.path, pipeline.state);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
}
// Priority 6: Team (native Claude Code teams / staged pipeline)
if (
team.state?.active &&
!isStaleState(team.state) &&
isStateForCurrentProject(team.state, directory, team.isGlobal)
) {
const sessionMatches = hasValidSessionId
? team.state.session_id === sessionId
: !team.state.session_id || team.state.session_id === sessionId;
if (sessionMatches) {
const phase = normalizeTeamPhase(team.state);
if (phase) {
const newCount = getSafeReinforcementCount(team.state.reinforcement_count) + 1;
if (newCount <= 20) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
team.state.reinforcement_count = newCount;
team.state.last_checked_at = new Date().toISOString();
writeJsonFile(team.path, team.state);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
}
}
// Priority 6.5: OMC Teams (tmux CLI workers — independent of native team state)
if (
omcTeams.state?.active &&
!isStaleState(omcTeams.state) &&
isStateForCurrentProject(omcTeams.state, directory, omcTeams.isGlobal)
) {
const sessionMatches = hasValidSessionId
? omcTeams.state.session_id === sessionId
: !omcTeams.state.session_id || omcTeams.state.session_id === sessionId;
if (sessionMatches) {
const phase = normalizeTeamPhase(omcTeams.state);
if (phase) {
const newCount = getSafeReinforcementCount(omcTeams.state.reinforcement_count) + 1;
if (newCount <= 20) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
omcTeams.state.reinforcement_count = newCount;
omcTeams.state.last_checked_at = new Date().toISOString();
writeJsonFile(omcTeams.path, omcTeams.state);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(JSON.stringify({ decision: "block", reason }));
return;
}
}
}
}
// Priority 7: UltraQA (QA cycling)
if (
ultraqa.state?.active &&
!isStaleState(ultraqa.state) &&
(hasValidSessionId
? ultraqa.state.session_id === sessionId
: !ultraqa.state.session_id || ultraqa.state.session_id === sessionId) &&
isStateForCurrentProject(ultraqa.state, directory, ultraqa.isGlobal)
) {
const cycle = ultraqa.state.cycle || 1;
const maxCycles = ultraqa.state.max_cycles || 10;
if (cycle < maxCycles && !ultraqa.state.all_passing) {
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
ultraqa.state.cycle = cycle + 1;
ultraqa.state.last_checked_at = new Date().toISOString();
writeJsonFile(ultraqa.path, ultraqa.state);
let 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.`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(
JSON.stringify({
decision: "block",
reason,
}),
);
return;
}
}
// Priority 8: Ultrawork - ALWAYS continue while active (not just when tasks exist)
// This prevents false stops from bash errors, transient failures, etc.
// Session isolation: only block if state belongs to this session (issue #311)
// If state has session_id, it must match. If no session_id (legacy), allow.
// Project isolation: only block if state belongs to this project
if (
ultrawork.state?.active && !isAwaitingConfirmation(ultrawork.state) &&
!isStaleState(ultrawork.state) &&
(hasValidSessionId
? ultrawork.state.session_id === sessionId
: !ultrawork.state.session_id || ultrawork.state.session_id === sessionId) &&
isStateForCurrentProject(ultrawork.state, directory, ultrawork.isGlobal)
) {
const newCount = (ultrawork.state.reinforcement_count || 0) + 1;
const maxReinforcements = ultrawork.state.max_reinforcements || 50;
if (newCount > maxReinforcements) {
// Max reinforcements reached - allow stop
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
ultrawork.state.reinforcement_count = newCount;
ultrawork.state.last_checked_at = new Date().toISOString();
writeJsonFile(ultrawork.path, ultrawork.state);
let reason = `[ULTRAWORK #${newCount}/${maxReinforcements}] Mode active.`;
if (totalIncomplete > 0) {
const itemType = taskCount > 0 ? "Tasks" : "todos";
reason += ` ${totalIncomplete} incomplete ${itemType} remain. Continue working.`;
} else if (newCount >= 3) {
// Only suggest cancel after minimum iterations (guard against no-tasks-created scenario)
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 - just tell LLM to continue
reason += ` Continue working - create Tasks to track your progress.`;
}
if (ultrawork.state.original_prompt) {
reason += `\nTask: ${ultrawork.state.original_prompt}`;
}
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(JSON.stringify({ decision: "block", reason }));
return;
}
// No blocking needed
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();