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

1573 lines
57 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
*/
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();