Files
wehub-resource-sync adc62957a7
CI / Multi-repo Path Gate (AST-grep) (windows-latest) (push) Has been cancelled
CI / Version Consistency Check (push) Has been cancelled
CI / npm pack + install test (push) Has been cancelled
CI / Lint & Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Test (Windows path suite) (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / No Committed Build Artifacts (push) Has been cancelled
CI / Multi-repo Path Gate (AST-grep) (ubuntu-latest) (push) Has been cancelled
Upgrade Test / omc update + session-start hook (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:54 +08:00

1627 lines
54 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, ultragoal, autopilot, ultrapilot, swarm, ultrawork, ultraqa, pipeline, team
*/
import {
existsSync,
readFileSync,
writeFileSync,
readdirSync,
mkdirSync,
unlinkSync,
renameSync,
statSync,
openSync,
readSync,
closeSync,
} from "fs";
import { spawn } from "child_process";
import { join, dirname, resolve, normalize } from "path";
import { homedir } from "os";
import { fileURLToPath, pathToFileURL } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SAFE_CONTINUE = { continue: true, suppressOutput: true };
const DEFAULT_SAFETY_TIMEOUT_MS = 8500;
const SAFE_EXIT_FLUSH_TIMEOUT_MS = 100;
function getSafetyTimeoutMs() {
const parsed = Number.parseInt(process.env.OMC_PERSISTENT_MODE_TIMEOUT_MS || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SAFETY_TIMEOUT_MS;
}
function writeSafeContinue(onFlushed) {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (onFlushed) onFlushed();
};
try {
const ok = process.stdout.write(JSON.stringify(SAFE_CONTINUE) + "\n", finish);
if (!ok) {
process.stdout.once("drain", finish);
}
const timeout = setTimeout(finish, SAFE_EXIT_FLUSH_TIMEOUT_MS);
if (!onFlushed) timeout.unref?.();
} catch {
// If stdout is unavailable, exiting still prevents a wedged Stop hook.
finish();
}
}
function shouldSkipPersistentModeHook() {
const skipHooks = (process.env.OMC_SKIP_HOOKS || "")
.split(",")
.map((hook) => hook.trim())
.filter(Boolean);
return (
process.env.DISABLE_OMC === "1" ||
process.env.DISABLE_OMC === "true" ||
skipHooks.includes("persistent-mode") ||
skipHooks.includes("stop-continuation")
);
}
function forceSafeExit(message) {
try {
if (message) process.stderr.write(message + "\n");
} catch {
// Ignore stderr failures; the JSON decision is what matters.
}
writeSafeContinue(() => process.exit(0));
}
const safetyTimeout = setTimeout(() => {
forceSafeExit("[persistent-mode] Safety timeout reached, forcing exit");
}, getSafetyTimeoutMs());
process.on("uncaughtException", (error) => {
forceSafeExit(`[persistent-mode] Uncaught exception: ${error?.message || error}`);
});
process.on("unhandledRejection", (error) => {
forceSafeExit(`[persistent-mode] Unhandled rejection: ${error?.message || error}`);
});
const { getClaudeConfigDir } = await import(pathToFileURL(join(__dirname, "lib", "config-dir.mjs")).href);
const { readStdin } = await import(
pathToFileURL(join(__dirname, "lib", "stdin.mjs")).href
);
const { resolveOmcStateRoot } = await import(pathToFileURL(join(__dirname, "lib", "state-root.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;
}
}
function getIdleCooldownSeconds() {
const configPath = join(homedir(), ".omc", "config.json");
const config = readJsonFile(configPath);
const val = config?.notificationCooldown?.sessionIdleSeconds;
return typeof val === "number" ? val : 60;
}
function shouldSendIdleNotification(stateDir) {
const cooldownSecs = getIdleCooldownSeconds();
const cooldownPath = join(stateDir, "idle-notif-cooldown.json");
const data = readJsonFile(cooldownPath);
if (cooldownSecs === 0) return true;
if (data?.lastSentAt) {
const elapsed = (Date.now() - new Date(data.lastSentAt).getTime()) / 1000;
if (Number.isFinite(elapsed) && elapsed < cooldownSecs) return false;
}
return true;
}
function recordIdleNotificationSent(stateDir) {
const cooldownPath = join(stateDir, "idle-notif-cooldown.json");
writeJsonFile(cooldownPath, { lastSentAt: new Date().toISOString() });
}
function dispatchIdleNotificationInBackground(sessionId, directory) {
if (process.env.OMC_NOTIFY === "0") return false;
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) return false;
const notificationsModuleUrl = pathToFileURL(join(pluginRoot, "dist", "notifications", "index.js")).href;
const payload = {
sessionId,
projectPath: directory,
profileName: process.env.OMC_NOTIFY_PROFILE,
};
const childSource = `import(${JSON.stringify(notificationsModuleUrl)})\n` +
` .then(({ notify }) => notify("session-idle", ${JSON.stringify(payload)}))\n` +
` .catch(() => {});`;
try {
const child = spawn(process.execPath, ["--input-type=module", "-e", childSource], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
OMC_HOOK_BACKGROUND_CHILD: "1",
},
});
child.unref();
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 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;
const CANCEL_SIGNAL_TTL_MS = 30_000;
const TEAM_TERMINAL_PHASES = new Set([
"completed",
"complete",
"failed",
"cancelled",
"canceled",
"aborted",
"terminated",
"done",
]);
const ULTRAGOAL_TERMINAL_PHASES = new Set([
"complete",
"completed",
"done",
"all-done",
"all_done",
"failed",
"cancelled",
"canceled",
"aborted",
]);
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 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 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.
}
}
/**
* 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);
}
const WORKFLOW_SLOT_TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000;
function isWorkflowSlotTombstonedForMode(stateDir, mode, sessionId) {
const safeSessionId = sanitizeSessionId(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 = sanitizeSessionId(sessionId);
if (safeSessionId && state.session_id && state.session_id !== safeSessionId) return false;
return true;
}
function normalizePhaseValue(value) {
return typeof value === "string" && value.trim().length > 0
? value.trim().toLowerCase()
: "";
}
function isUltragoalTerminalState(state, omcRoot) {
if (!state || typeof state !== "object") return false;
if (state.active === false) return true;
if (typeof state.completed_at === "string" && state.completed_at.length > 0) return true;
if (state.all_done === true || state.done === true) return true;
const phase = normalizePhaseValue(state.current_phase ?? state.phase ?? state.status);
if (phase && ULTRAGOAL_TERMINAL_PHASES.has(phase)) return true;
const plan = readJsonFile(join(omcRoot, "ultragoal", "goals.json"));
if (!plan || typeof plan !== "object") return false;
if (plan.aggregateCompletion?.status === "complete") return true;
if (!Array.isArray(plan.goals) || plan.goals.length === 0) return false;
return plan.goals.every((goal) => {
const status = normalizePhaseValue(goal?.status);
return status === "complete" || status === "review_blocked";
});
}
function getUltragoalObjective(state, omcRoot) {
const candidates = [
state?.claude_goal_objective,
state?.claudeGoalObjective,
state?.codex_objective,
state?.codexObjective,
state?.goal_objective,
state?.goalObjective,
state?.objective,
state?.prompt,
state?.original_prompt,
];
for (const value of candidates) {
if (typeof value === "string" && value.trim()) return value.trim();
}
const plan = readJsonFile(join(omcRoot, "ultragoal", "goals.json"));
if (typeof plan?.claudeObjective === "string" && plan.claudeObjective.trim()) return plan.claudeObjective.trim();
if (typeof plan?.aggregateCompletion?.objective === "string" && plan.aggregateCompletion.objective.trim()) {
return plan.aggregateCompletion.objective.trim();
}
const activeGoal = Array.isArray(plan?.goals) ? plan.goals.find((goal) => goal?.status === "in_progress") : null;
if (typeof activeGoal?.objective === "string" && activeGoal.objective.trim()) return activeGoal.objective.trim();
return "";
}
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;
}
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;
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 {
if (shouldSkipPersistentModeHook()) {
writeSafeContinue();
return;
}
const input = await readStdin();
let data = {};
try {
data = JSON.parse(input);
} catch {
writeSafeContinue();
return;
}
// Claude Code sets stop_hook_active when a Stop hook is already running.
// Never emit another decision:block in that re-entrant path: doing so trips
// Claude Code's safety override for repeatedly blocked Stop hooks.
if (data.stop_hook_active === true) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
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;
}
if (hasPendingOwnedAsyncWork(stateDir, sessionId)) {
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 ultragoal = readStateFileWithSession(
stateDir,
globalStateDir,
"ultragoal-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 = await 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 (
isAuthoritativeModeActive(stateDir, "ralph", ralph, sessionId) && !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 1.5: Ultragoal durable goal execution
if (
isAuthoritativeModeActive(stateDir, "ultragoal", ultragoal, sessionId) && !isAwaitingConfirmation(ultragoal.state) &&
!isStaleState(ultragoal.state) &&
isStateForCurrentProject(ultragoal.state, directory, ultragoal.isGlobal)
) {
const sessionMatches = hasValidSessionId
? ultragoal.state.session_id === sessionId
: !ultragoal.state.session_id || ultragoal.state.session_id === sessionId;
if (sessionMatches && !isUltragoalTerminalState(ultragoal.state, omcRoot)) {
const newCount = (ultragoal.state.reinforcement_count || 0) + 1;
const maxReinforcements = ultragoal.state.max_reinforcements || 50;
if (newCount > maxReinforcements) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
const toolError = readLastToolError(stateDir);
const errorGuidance = getToolErrorRetryGuidance(toolError);
ultragoal.state.reinforcement_count = newCount;
ultragoal.state.last_checked_at = new Date().toISOString();
if (!shouldWriteStateBack(ultragoal.path)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
writeJsonFile(ultragoal.path, ultragoal.state);
let reason = `[ULTRAGOAL #${newCount}/${maxReinforcements}] Ultragoal mode is active. Continue the durable goal workflow, keep the matching Claude /goal active, and checkpoint .omc/ultragoal/ledger.jsonl before stopping. When all ultragoal stories are complete and the final quality gate passes, run /oh-my-claudecode:cancel to cleanly exit.`;
const objective = getUltragoalObjective(ultragoal.state, omcRoot);
if (objective) reason += `\nClaude /goal objective: ${objective}`;
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(JSON.stringify({ decision: "block", reason }));
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) &&
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 = getAutopilotPhase(autopilot.state);
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 (
isAuthoritativeModeActive(stateDir, "ultrawork", ultrawork, sessionId) && !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. When all work is complete, run /oh-my-claudecode:cancel to cleanly exit ultrawork mode and clean up state files.`;
} 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}`;
}
if (errorGuidance) {
reason = errorGuidance + reason;
}
console.log(JSON.stringify({ decision: "block", reason }));
return;
}
// No blocking needed
if (sessionId && shouldSendIdleNotification(stateDir)) {
if (dispatchIdleNotificationInBackground(sessionId, directory)) {
recordIdleNotificationSent(stateDir);
}
}
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
} catch (error) {
// On any error, allow stop rather than blocking forever
try {
process.stderr.write(`[persistent-mode] Error: ${error?.message || error}\n`);
} catch {
// Ignore stderr errors - we just need to return valid JSON
}
writeSafeContinue();
}
}
main().finally(() => {
clearTimeout(safetyTimeout);
});