mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-10-22 03:02:58 -05:00
Resolve malformed security configuration values without weakening hard-max protections. Mirror validation in standalone and plugin Stop hooks, reject array security sections, and add paired regression coverage.
1875 lines
75 KiB
JavaScript
1875 lines
75 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, pipeline, team
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
mkdirSync,
|
|
unlinkSync,
|
|
statSync,
|
|
realpathSync,
|
|
openSync,
|
|
readSync,
|
|
closeSync,
|
|
} from "fs";
|
|
import { createHash } from "node:crypto";
|
|
import { spawn } from "child_process";
|
|
import { join, dirname, resolve, normalize, sep } from "path";
|
|
import { homedir } from "os";
|
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
import { isJevShadowOptedIn, recordJevShadow } from "./lib/jev-shadow.mjs";
|
|
|
|
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;
|
|
const LOOP_CONTINUATION_NOUL_QUESTIONS = {
|
|
task_complete: {
|
|
type: "noul",
|
|
instructions: "Is the task complete — is there no substantive work left for this mode?",
|
|
criteria: {},
|
|
},
|
|
};
|
|
const LEARNER_EXTRACTION_QUESTIONS = {
|
|
extractable_moment: {
|
|
type: "noul",
|
|
instructions: "Does this assistant message contain an extractable memory-worthy moment?",
|
|
criteria: {
|
|
true: "Contains a reusable pattern, decision, or correction worth persisting",
|
|
false: "Routine work with nothing worth extracting",
|
|
},
|
|
},
|
|
};
|
|
|
|
function recordLoopContinuationShadow(state, heuristic, permissionMode) {
|
|
recordJevShadow({
|
|
point: "loop-continuation",
|
|
state,
|
|
questions: LOOP_CONTINUATION_NOUL_QUESTIONS,
|
|
heuristic,
|
|
permissionMode,
|
|
});
|
|
}
|
|
|
|
async function recordLearnerExtractionShadow(data) {
|
|
const assistantMessage = data.last_assistant_message;
|
|
if (typeof assistantMessage !== "string" || !assistantMessage.trim() || !isJevShadowOptedIn("learner-extraction", process.env, data.permission_mode)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const detectorPath = join(__dirname, "..", "dist", "hooks", "learner", "detector.js");
|
|
if (!existsSync(detectorPath)) return;
|
|
const { detectExtractableMoment } = await import(pathToFileURL(detectorPath).href);
|
|
const heuristic = detectExtractableMoment(assistantMessage);
|
|
recordJevShadow({
|
|
point: "learner-extraction",
|
|
state: { assistant_message: assistantMessage, user_message: null },
|
|
questions: LEARNER_EXTRACTION_QUESTIONS,
|
|
heuristic,
|
|
permissionMode: data.permission_mode,
|
|
});
|
|
} catch {
|
|
// Missing optional detector output or resolver plumbing must not affect Stop.
|
|
}
|
|
}
|
|
|
|
|
|
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 { advanceWorkflowOnStop, isValidWorkflowDescriptor, isValidWorkflowTrackingState, isWorkflowRuntimeSupported, refreshWorkflowBoundaryForCommit, resolveWorkflowStagePrompt, takeWorkflowTranscriptFailure } = await import(pathToFileURL(join(__dirname, "lib", "workflow-profile-runtime.mjs")).href);
|
|
const { acquireStateFileLockSync, atomicWriteFileSync, getStateFileLockFailureMessage, isExclusiveStateLockingAvailable, isStateFileLockingSupported, releaseStateFileLockSync, withStateFileLockSync } = await import(pathToFileURL(join(__dirname, "lib", "atomic-write.mjs")).href);
|
|
|
|
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 for persistent modes.
|
|
* Mirrors src/lib/security-config.ts getSecurityConfig(): default 500;
|
|
* OMC_SECURITY=strict caps at 200 and a config value may only tighten it.
|
|
* Returns 0 for unlimited (only reachable outside strict mode).
|
|
*/
|
|
function getHardMaxIterations() {
|
|
const raw = readSecurityConfigValue("hardMaxIterations");
|
|
// Mirror sanitizeSecurityOverrides(): only non-negative integers are honored.
|
|
const configValue = typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : undefined;
|
|
if (process.env.OMC_SECURITY === "strict") {
|
|
return Math.min(200, typeof configValue === "number" && configValue > 0 ? configValue : 200);
|
|
}
|
|
return typeof configValue === "number" ? configValue : 500;
|
|
}
|
|
|
|
/**
|
|
* Read a single value from the security section of omc config files.
|
|
* Mirrors src/lib/security-config.ts loadSecurityFromConfigFiles(): the first
|
|
* config file with a security section wins.
|
|
*/
|
|
function readSecurityConfigValue(key) {
|
|
const paths = [
|
|
join(process.cwd(), ".claude", "omc.jsonc"),
|
|
join(getOmcUserConfigDir(), "claude-omc", "config.jsonc"),
|
|
];
|
|
for (const p of paths) {
|
|
try {
|
|
if (!existsSync(p)) continue;
|
|
const parsed = JSON.parse(stripJsoncComments(readFileSync(p, "utf-8")));
|
|
if (parsed?.security && typeof parsed.security === "object" && !Array.isArray(parsed.security)) {
|
|
return parsed.security[key];
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Inlined so the standalone hook stays build-independent
|
|
// (mirrors scripts/keyword-detector.mjs and src/utils/jsonc.ts).
|
|
function getOmcUserConfigDir() {
|
|
if (process.platform === 'win32') {
|
|
return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
}
|
|
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
}
|
|
|
|
// Mirrors src/utils/jsonc.ts:stripJsoncComments (strips comments AND trailing commas)
|
|
function stripJsoncComments(content) {
|
|
return stripTrailingCommas(stripComments(content));
|
|
}
|
|
|
|
function stripComments(content) {
|
|
let result = '';
|
|
let i = 0;
|
|
while (i < content.length) {
|
|
if (content[i] === '/' && content[i + 1] === '/') {
|
|
while (i < content.length && content[i] !== '\n') i++;
|
|
continue;
|
|
}
|
|
if (content[i] === '/' && content[i + 1] === '*') {
|
|
i += 2;
|
|
while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
|
|
if (i >= content.length) throw new SyntaxError('Unterminated JSONC block comment');
|
|
i += 2;
|
|
result += ' ';
|
|
continue;
|
|
}
|
|
if (content[i] === '"') {
|
|
result += content[i++];
|
|
while (i < content.length && content[i] !== '"') {
|
|
if (content[i] === '\\') {
|
|
result += content[i++];
|
|
if (i < content.length) result += content[i++];
|
|
continue;
|
|
}
|
|
result += content[i++];
|
|
}
|
|
if (i < content.length) result += content[i++];
|
|
continue;
|
|
}
|
|
result += content[i++];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Mirrors src/utils/jsonc.ts:stripTrailingCommas (comma before a closing } or ]).
|
|
function stripTrailingCommas(content) {
|
|
let result = '';
|
|
let i = 0;
|
|
while (i < content.length) {
|
|
if (content[i] === '"') {
|
|
result += content[i++];
|
|
while (i < content.length && content[i] !== '"') {
|
|
if (content[i] === '\\') {
|
|
result += content[i++];
|
|
if (i < content.length) result += content[i++];
|
|
continue;
|
|
}
|
|
result += content[i++];
|
|
}
|
|
if (i < content.length) result += content[i++];
|
|
continue;
|
|
}
|
|
if (content[i] === ',') {
|
|
let j = i + 1;
|
|
while (j < content.length && /\s/.test(content[j])) j++;
|
|
if (content[j] === '}' || content[j] === ']') {
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
result += content[i++];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function writeJsonFile(path, data) {
|
|
try {
|
|
atomicWriteFileSync(path, JSON.stringify(data, null, 2));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function lockFailureReason(operation) {
|
|
const message = getStateFileLockFailureMessage();
|
|
return `[OMC] ${operation} failed: ${message} Retry the operation after the lock holder exits.`;
|
|
}
|
|
|
|
/**
|
|
* A contended or unverifiable state lock is diagnostic, never a turn blocker.
|
|
* The uncommitted transition is re-derived on the next invocation, so the
|
|
* stage/phase prompt must still reach the agent; surfacing the shortfall on
|
|
* stderr keeps #4016's actionable message without losing that contract.
|
|
*/
|
|
function reportLockFailure(operation) {
|
|
try {
|
|
process.stderr.write(`${lockFailureReason(operation)}\n`);
|
|
} catch {
|
|
// Diagnostics never affect control flow.
|
|
}
|
|
}
|
|
|
|
function workflowStopResponse(state) {
|
|
const stage = state?.workflow?.stages?.[state?.pipelineTracking?.currentStageIndex];
|
|
if (!stage) return { continue: false, decision: "block", reason: "[AUTOPILOT WORKFLOW] All selected stages are complete." };
|
|
const prompt = resolveWorkflowStagePrompt(state, stage);
|
|
return { continue: false, decision: "block", reason: prompt || "[AUTOPILOT WORKFLOW] workflow_stage_dispatch_failed. Run /cancel and re-invoke the workflow." };
|
|
}
|
|
|
|
function commitWorkflowAdvance(path, advance) {
|
|
const lock = acquireStateFileLockSync(path);
|
|
if (!lock) { reportLockFailure('Autopilot workflow state transition'); return { committed: false, state: readJsonFile(path) }; }
|
|
let result;
|
|
try {
|
|
const current = readJsonFile(path);
|
|
const currentStage = current?.pipelineTracking?.stages?.[advance.expectedStageIndex];
|
|
if (!isValidWorkflowDescriptor(current?.workflow) || !isValidWorkflowTrackingState(current, advance.expectedSessionId) || current.workflowRunId !== advance.expectedWorkflowRunId || current?.pipelineTracking?.trackingRevision !== advance.expectedRevision || current.workflow.profileHash !== advance.expectedProfileHash || current?.session_id !== advance.expectedSessionId || current?.active !== true || current?.pipelineTracking?.currentStageIndex !== advance.expectedStageIndex || currentStage?.id !== advance.expectedStageId || currentStage?.status !== 'active') {
|
|
result = { committed: false, state: current };
|
|
} else if (!refreshWorkflowBoundaryForCommit(advance)) {
|
|
result = { committed: false, state: current };
|
|
} else if (!writeJsonFile(path, advance.updated)) {
|
|
result = { committed: false, state: readJsonFile(path) };
|
|
} else {
|
|
result = { committed: true, state: advance.updated };
|
|
}
|
|
} finally {
|
|
if (!releaseStateFileLockSync(lock)) { reportLockFailure('Autopilot workflow state transition release'); result = { committed: false, state: readJsonFile(path) }; }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function refreshNamedWorkflowDispatch(path, expected) {
|
|
const lock = acquireStateFileLockSync(path);
|
|
if (!lock) { reportLockFailure('Autopilot workflow state refresh'); return { committed: false, state: readJsonFile(path) }; }
|
|
let result;
|
|
try {
|
|
const current = readJsonFile(path);
|
|
const currentStage = current?.pipelineTracking?.stages?.[expected.stageIndex];
|
|
if (!isValidWorkflowDescriptor(current?.workflow) || !isValidWorkflowTrackingState(current, expected.sessionId)) {
|
|
result = { committed: false, state: current, integrityFailed: true };
|
|
} else if (current?.workflowRunId !== expected.workflowRunId || current?.session_id !== expected.sessionId || current?.workflow?.profileHash !== expected.profileHash || current?.pipelineTracking?.trackingRevision !== expected.trackingRevision || current?.pipelineTracking?.currentStageIndex !== expected.stageIndex || currentStage?.id !== expected.stageId || currentStage?.status !== 'active' || current?.phase !== expected.phase || current?.active !== true) {
|
|
result = { committed: false, state: current };
|
|
} else {
|
|
const now = new Date().toISOString();
|
|
const refreshed = { ...current, last_checked_at: now, updated_at: now };
|
|
result = writeJsonFile(path, refreshed)
|
|
? { committed: true, state: refreshed }
|
|
: { committed: false, state: readJsonFile(path) };
|
|
}
|
|
} finally {
|
|
if (!releaseStateFileLockSync(lock)) { reportLockFailure('Autopilot workflow state refresh release'); result = { committed: false, state: readJsonFile(path) }; }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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 CANCEL_SIGNAL_CLOCK_SKEW_MS = 5_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 preferred = state.awaiting_confirmation_set_at;
|
|
const timestamp = typeof preferred === "string" && preferred.trim()
|
|
? preferred
|
|
: typeof state.started_at === "string" && state.started_at.trim()
|
|
? state.started_at
|
|
: null;
|
|
if (!timestamp) {
|
|
return false;
|
|
}
|
|
|
|
const timestampMs = new Date(timestamp).getTime();
|
|
const ageMs = Date.now() - timestampMs;
|
|
return Number.isFinite(ageMs) && ageMs >= 0 && ageMs < 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";
|
|
}
|
|
|
|
// keyword-detector persists this in place of a prompt that was only a pasted
|
|
// system echo; a state carrying it has no user request to resume (#4250).
|
|
const PASTED_ECHO_PROMPT_SENTINEL = "(prompt omitted: pasted system echo)";
|
|
|
|
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) ||
|
|
promptText === PASTED_ECHO_PROMPT_SENTINEL;
|
|
}
|
|
|
|
function isOrphanedAutopilotRoutingEchoState(state) {
|
|
if (!state || typeof state !== "object") return false;
|
|
if (hasNamedWorkflowMarkers(state)) 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;
|
|
const expectedSnapshot = loaded?.state ? JSON.stringify(loaded.state) : null;
|
|
if (!statePath || !expectedSnapshot || !existsSync(statePath)) return false;
|
|
|
|
let cleared = false;
|
|
try {
|
|
const locked = withStateFileLockSync(statePath, () => {
|
|
const current = readJsonFile(statePath);
|
|
if (current && JSON.stringify(current) === expectedSnapshot && existsSync(statePath)) {
|
|
unlinkSync(statePath);
|
|
cleared = true;
|
|
}
|
|
});
|
|
if (!locked.acquired) process.stderr.write(`${lockFailureReason('Orphaned state cleanup')}\n`);
|
|
} catch {
|
|
// Best effort: failing to clean an orphan should not re-arm stop blocking.
|
|
}
|
|
return cleared;
|
|
}
|
|
|
|
/**
|
|
* 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, currentAutopilotPath, cancellationContext) {
|
|
let authenticatedAutopilot = null;
|
|
const validateSignal = (signalPath, currentAutopilot) => {
|
|
let active = false;
|
|
const locked = withStateFileLockSync(signalPath, () => {
|
|
const signal = readJsonFile(signalPath);
|
|
if (!signal || typeof signal !== "object" || Array.isArray(signal) || signal.active !== true) return;
|
|
const now = Date.now();
|
|
const requestedAt = typeof signal.requested_at === "string" ? new Date(signal.requested_at).getTime() : NaN;
|
|
const expiresAt = typeof signal.expires_at === "string" ? new Date(signal.expires_at).getTime() : NaN;
|
|
if (!Number.isFinite(requestedAt)) return;
|
|
const isFreshRequest = requestedAt <= now + CANCEL_SIGNAL_CLOCK_SKEW_MS && now - requestedAt <= CANCEL_SIGNAL_TTL_MS;
|
|
if (!currentAutopilot) {
|
|
const effectiveExpiry = Number.isFinite(expiresAt) ? expiresAt : requestedAt + CANCEL_SIGNAL_TTL_MS;
|
|
if (Number.isFinite(effectiveExpiry) && effectiveExpiry <= now && existsSync(signalPath)) unlinkSync(signalPath);
|
|
if (signal.mode === "autopilot" || Object.prototype.hasOwnProperty.call(signal, "target_state_sha256") || Object.prototype.hasOwnProperty.call(signal, "target_workflow_run_id")) return;
|
|
if (isFreshRequest && effectiveExpiry > requestedAt && effectiveExpiry - requestedAt <= CANCEL_SIGNAL_TTL_MS && effectiveExpiry > now) active = true;
|
|
return;
|
|
}
|
|
if (!isFreshRequest) {
|
|
if (Number.isFinite(expiresAt) && expiresAt <= now && existsSync(signalPath)) unlinkSync(signalPath);
|
|
return;
|
|
}
|
|
if (signal.mode !== "autopilot" || typeof signal.source !== "string" || signal.source.length === 0) return;
|
|
if (!Number.isFinite(expiresAt) || expiresAt <= requestedAt || expiresAt - requestedAt > CANCEL_SIGNAL_TTL_MS) return;
|
|
if (expiresAt <= now) {
|
|
if (existsSync(signalPath)) unlinkSync(signalPath);
|
|
return;
|
|
}
|
|
const stateDigest = createHash("sha256").update(JSON.stringify(currentAutopilot)).digest("hex");
|
|
if (typeof signal.target_state_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(signal.target_state_sha256) || signal.target_state_sha256 !== stateDigest) return;
|
|
if (currentAutopilot.workflowRunId && signal.target_workflow_run_id !== currentAutopilot.workflowRunId) return;
|
|
if (!currentAutopilot.workflowRunId && signal.target_workflow_run_id) return;
|
|
active = true;
|
|
}, currentAutopilot !== null);
|
|
return locked.acquired && active;
|
|
};
|
|
const isActiveSignal = (signalPath) => {
|
|
if (!existsSync(signalPath)) return false;
|
|
if (!currentAutopilotPath || !cancellationContext) return validateSignal(signalPath, null);
|
|
// The flock-absent contract lives here rather than inside the lock
|
|
// primitive: with no exclusive backend to fail closed against, this path
|
|
// must not authenticate state through the best-effort fallback lock, while
|
|
// emergency recovery still gets its SQLite-backed exclusive claim.
|
|
const stateLock = isExclusiveStateLockingAvailable()
|
|
? acquireStateFileLockSync(currentAutopilotPath, 50, true)
|
|
: null;
|
|
if (!stateLock) {
|
|
// A real contender holds the lock only when exclusive acquisition is
|
|
// actually available; without it, fall through to the enforceability check.
|
|
if (isExclusiveStateLockingAvailable()) return false;
|
|
const currentAutopilot = readJsonFile(currentAutopilotPath);
|
|
if (isEnforceableAutopilotCancellationTarget(currentAutopilot, cancellationContext.directory, cancellationContext.isGlobal, cancellationContext.hasValidSessionId, sessionId)) return false;
|
|
return validateSignal(signalPath, null);
|
|
}
|
|
try {
|
|
const currentAutopilot = readJsonFile(currentAutopilotPath);
|
|
if (!isEnforceableAutopilotCancellationTarget(currentAutopilot, cancellationContext.directory, cancellationContext.isGlobal, cancellationContext.hasValidSessionId, sessionId)) return validateSignal(signalPath, null);
|
|
authenticatedAutopilot = currentAutopilot;
|
|
return validateSignal(signalPath, currentAutopilot);
|
|
} finally {
|
|
releaseStateFileLockSync(stateLock);
|
|
}
|
|
};
|
|
|
|
const localSignalPath = currentAutopilotPath && join(dirname(currentAutopilotPath), "cancel-signal-state.json");
|
|
if (localSignalPath && isActiveSignal(localSignalPath)) return { active: true, currentAutopilot: authenticatedAutopilot };
|
|
if (sessionId) {
|
|
const sessionSignalPath = join(stateDir, "sessions", sessionId, "cancel-signal-state.json");
|
|
if (sessionSignalPath !== localSignalPath && isActiveSignal(sessionSignalPath)) return { active: true, currentAutopilot: authenticatedAutopilot };
|
|
}
|
|
const legacySignalPath = join(stateDir, "cancel-signal-state.json");
|
|
if (legacySignalPath !== localSignalPath && isActiveSignal(legacySignalPath)) return { active: true, currentAutopilot: authenticatedAutopilot };
|
|
return { active: false, currentAutopilot: authenticatedAutopilot };
|
|
}
|
|
|
|
function hasNamedWorkflowMarkers(state) {
|
|
return Boolean(
|
|
state &&
|
|
typeof state === "object" &&
|
|
['workflow', 'workflowRunId', 'pipelineTracking'].some((marker) => Object.prototype.hasOwnProperty.call(state, marker)),
|
|
);
|
|
}
|
|
|
|
function hasExactWorkflowKeys(value, keys) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
}
|
|
|
|
function isWorkflowTimestamp(value) {
|
|
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
}
|
|
|
|
function isWorkflowFileIdentity(value) {
|
|
return hasExactWorkflowKeys(value, ["device", "inode", "size", "mtimeNs", "ctimeNs", "contentSha256"]) &&
|
|
[value.device, value.inode, value.size].every((field) => Number.isSafeInteger(field) && field >= 0) &&
|
|
/^\d+$/.test(value.mtimeNs) && /^\d+$/.test(value.ctimeNs) && /^[a-f0-9]{64}$/.test(value.contentSha256);
|
|
}
|
|
|
|
|
|
function hasWorkflowBoundaryTopology(value, sessionId) {
|
|
let root;
|
|
try { root = realpathSync(resolve(getClaudeConfigDir(), "projects")); } catch { root = resolve(getClaudeConfigDir(), "projects"); }
|
|
if (!hasExactWorkflowKeys(value, ["transcriptPath", "transcriptRoot", "transcriptBasename", "sessionId", "byteOffset", "fileIdentity"]) ||
|
|
typeof value.transcriptPath !== "string" || value.transcriptRoot !== root || value.transcriptBasename !== `${sessionId}.jsonl` || value.sessionId !== sessionId ||
|
|
!Number.isSafeInteger(value.byteOffset) || value.byteOffset < 0 || !isWorkflowFileIdentity(value.fileIdentity) ||
|
|
value.fileIdentity.size !== value.byteOffset) return false;
|
|
if (resolve(value.transcriptPath) !== value.transcriptPath || !value.transcriptPath.startsWith(root + sep)) return false;
|
|
const relativePath = value.transcriptPath.slice(root.length + sep.length);
|
|
return relativePath.length > 0 && relativePath.split(sep).every((component) => component && component !== "." && component !== "..") &&
|
|
value.transcriptPath.endsWith(`${sep}${sessionId}.jsonl`);
|
|
}
|
|
|
|
function workflowFileIdentityEquals(left, right) {
|
|
return left.device === right.device && left.inode === right.inode && left.size === right.size &&
|
|
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs && left.contentSha256 === right.contentSha256;
|
|
}
|
|
|
|
function isStructurallyValidNamedWorkflowState(state, sessionId) {
|
|
const workflow = state?.workflow;
|
|
const tracking = state?.pipelineTracking;
|
|
if (!isValidWorkflowDescriptor(workflow) || typeof state?.prompt !== "string" || state.prompt.trim().length === 0 ||
|
|
typeof state?.workflowRunId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(state.workflowRunId) || state?.session_id !== sessionId) return false;
|
|
const terminal = state.active === false && state.phase === "complete";
|
|
const maxIndex = terminal ? workflow.stages.length : workflow.stages.length - 1;
|
|
if (!hasExactWorkflowKeys(tracking, ["stages", "currentStageIndex", "trackingRevision", "activationBoundary", "completionObservations"]) ||
|
|
!Array.isArray(tracking.stages) || !Array.isArray(tracking.completionObservations) || !Number.isSafeInteger(tracking.currentStageIndex) || tracking.currentStageIndex < 0 || tracking.currentStageIndex > maxIndex ||
|
|
!Number.isSafeInteger(tracking.trackingRevision) || tracking.trackingRevision !== tracking.currentStageIndex ||
|
|
(terminal && (tracking.currentStageIndex !== workflow.stages.length || tracking.completionObservations.length !== workflow.stages.length)) ||
|
|
(!terminal && !((state.active === true || state.active === false) && state.phase === workflow.stages[tracking.currentStageIndex])) ||
|
|
tracking.stages.length !== workflow.stages.length || tracking.completionObservations.length !== tracking.currentStageIndex || !hasWorkflowBoundaryTopology(tracking.activationBoundary, sessionId)) return false;
|
|
for (let index = 0; index < tracking.stages.length; index += 1) {
|
|
const stage = tracking.stages[index];
|
|
const status = terminal || index < tracking.currentStageIndex ? "complete" : index === tracking.currentStageIndex ? "active" : "pending";
|
|
const keys = status === "complete" ? ["id", "status", "iterations", "startedAt", "completedAt"] : status === "active" ? ["id", "status", "iterations", "startedAt"] : ["id", "status", "iterations"];
|
|
if (!hasExactWorkflowKeys(stage, keys) || stage.id !== workflow.stages[index] || stage.status !== status || !Number.isSafeInteger(stage.iterations) || stage.iterations < 0 || (stage.startedAt !== undefined && !isWorkflowTimestamp(stage.startedAt)) || (stage.completedAt !== undefined && !isWorkflowTimestamp(stage.completedAt))) return false;
|
|
}
|
|
let previous;
|
|
for (let index = 0; index < tracking.completionObservations.length; index += 1) {
|
|
const observation = tracking.completionObservations[index];
|
|
if (!hasExactWorkflowKeys(observation, ["stageId", "sessionId", "signalId", "lineNumber", "byteOffset", "recordContentSha256", "stableFile", "activationBoundary", "observedAt"]) || observation.stageId !== workflow.stages[index] || observation.sessionId !== sessionId || observation.signalId !== `PIPELINE_${observation.stageId.toUpperCase()}_COMPLETE` || !Number.isSafeInteger(observation.lineNumber) || observation.lineNumber < 0 || !Number.isSafeInteger(observation.byteOffset) || !/^[a-f0-9]{64}$/.test(observation.recordContentSha256) || !isWorkflowTimestamp(observation.observedAt) || !isWorkflowFileIdentity(observation.stableFile) || !hasWorkflowBoundaryTopology(observation.activationBoundary, sessionId) || observation.byteOffset < observation.activationBoundary.byteOffset || observation.byteOffset >= observation.stableFile.size || (previous && (observation.activationBoundary.transcriptPath !== previous.activationBoundary.transcriptPath || observation.activationBoundary.byteOffset !== previous.stableFile.size || !workflowFileIdentityEquals(observation.activationBoundary.fileIdentity, previous.stableFile)))) return false;
|
|
previous = observation;
|
|
}
|
|
const latest = tracking.completionObservations.at(-1);
|
|
return !latest || (tracking.activationBoundary.transcriptPath === latest.activationBoundary.transcriptPath && tracking.activationBoundary.byteOffset === latest.stableFile.size && workflowFileIdentityEquals(tracking.activationBoundary.fileIdentity, latest.stableFile));
|
|
}
|
|
|
|
function isValidNamedWorkflowState(state, sessionId) {
|
|
return isWorkflowRuntimeSupported()
|
|
? isValidWorkflowDescriptor(state?.workflow) && isValidWorkflowTrackingState(state, sessionId)
|
|
: isStructurallyValidNamedWorkflowState(state, sessionId);
|
|
}
|
|
|
|
function isEnforceableAutopilotCancellationTarget(state, directory, isGlobal, hasValidSessionId, sessionId) {
|
|
if (!state?.active || isAwaitingConfirmation(state) || (isStaleState(state) && !hasNamedWorkflowMarkers(state))) return false;
|
|
if (!isStateForCurrentProject(state, directory, isGlobal)) return false;
|
|
if (hasValidSessionId ? state.session_id !== sessionId : state.session_id && state.session_id !== sessionId) return false;
|
|
return getAutopilotPhase(state) !== "complete";
|
|
}
|
|
|
|
function isCurrentAutopilotState(state, directory, isGlobal, hasValidSessionId, sessionId) {
|
|
if (!isStateForCurrentProject(state, directory, isGlobal)) return false;
|
|
return hasValidSessionId ? state?.session_id === sessionId : !state?.session_id || state.session_id === sessionId;
|
|
}
|
|
|
|
function shouldWriteStateBack(path) {
|
|
return Boolean(path && existsSync(path));
|
|
}
|
|
|
|
function isValidSessionId(sessionId) {
|
|
return typeof sessionId === "string" && SESSION_ID_ALLOWLIST.test(sessionId);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Stop supplies the assistant response text directly; only load the
|
|
// existing detector when this advisory point is explicitly enabled.
|
|
await recordLearnerExtractionShadow(data);
|
|
|
|
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 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,
|
|
);
|
|
|
|
const currentAutopilot = isCurrentAutopilotState(autopilot.state, directory, autopilot.isGlobal, hasValidSessionId, sessionId)
|
|
? autopilot.state
|
|
: null;
|
|
if (currentAutopilot && hasNamedWorkflowMarkers(currentAutopilot) && !isValidNamedWorkflowState(currentAutopilot, sessionId)) {
|
|
const transcriptFailure = takeWorkflowTranscriptFailure(sessionId);
|
|
const reason = transcriptFailure === "workflow_transcript_record_too_large"
|
|
? `[AUTOPILOT WORKFLOW] ${transcriptFailure}. Run /cancel and re-invoke the workflow.`
|
|
: "[AUTOPILOT WORKFLOW] workflow_descriptor_integrity_failed. Run /cancel and re-invoke the workflow.";
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason }));
|
|
return;
|
|
}
|
|
if (currentAutopilot && hasNamedWorkflowMarkers(currentAutopilot) && !isWorkflowRuntimeSupported()) {
|
|
console.log(JSON.stringify(SAFE_CONTINUE));
|
|
return;
|
|
}
|
|
const cancellation = isSessionCancelInProgress(stateDir, sessionId, autopilot.path, { directory, isGlobal: autopilot.isGlobal, hasValidSessionId });
|
|
if (cancellation.currentAutopilot) autopilot.state = cancellation.currentAutopilot;
|
|
if (cancellation.active) {
|
|
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"));
|
|
|
|
// 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;
|
|
|
|
// Hard max: check the iteration count directly against the security
|
|
// limit before continuing or extending, so a high max_iterations value
|
|
// cannot bypass it (mirrors src/hooks/persistent-mode/index.ts).
|
|
const hardMax = getHardMaxIterations();
|
|
if (hardMax > 0 && iteration >= 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const heuristic = {
|
|
mode: "ralph",
|
|
shouldBlock: true,
|
|
iteration: ralph.state.iteration,
|
|
maxIterations: maxIter,
|
|
};
|
|
const judgmentState = {
|
|
mode_name: "ralph",
|
|
session_id: sessionId || null,
|
|
iteration: ralph.state.iteration,
|
|
phase: null,
|
|
should_block: true,
|
|
continuation_excerpt: reason,
|
|
};
|
|
recordLoopContinuationShadow(judgmentState, heuristic, data.permission_mode);
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
decision: "block",
|
|
reason,
|
|
}),
|
|
);
|
|
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).`;
|
|
recordLoopContinuationShadow({
|
|
mode_name: "ralph",
|
|
session_id: sessionId || null,
|
|
iteration: ralph.state.iteration,
|
|
phase: null,
|
|
should_block: true,
|
|
continuation_excerpt: ralphExtendedReason,
|
|
}, {
|
|
mode: "ralph",
|
|
shouldBlock: true,
|
|
iteration: ralph.state.iteration,
|
|
maxIterations: ralph.state.max_iterations,
|
|
}, data.permission_mode);
|
|
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)
|
|
const orphanSessionMatches = hasValidSessionId
|
|
? autopilot.state?.session_id === sessionId
|
|
: !autopilot.state?.session_id || autopilot.state.session_id === sessionId;
|
|
if (
|
|
isOrphanedAutopilotRoutingEchoState(autopilot.state) &&
|
|
orphanSessionMatches &&
|
|
isStateForCurrentProject(autopilot.state, directory, autopilot.isGlobal)
|
|
) {
|
|
clearLoadedStateFile(autopilot);
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
autopilot.state?.active && !isAwaitingConfirmation(autopilot.state) &&
|
|
(!isStaleState(autopilot.state) || autopilot.state.workflow) &&
|
|
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) {
|
|
if (hasNamedWorkflowMarkers(autopilot.state) && !isValidNamedWorkflowState(autopilot.state, sessionId)) {
|
|
const transcriptFailure = takeWorkflowTranscriptFailure(sessionId);
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason: transcriptFailure === 'workflow_transcript_record_too_large' ? '[AUTOPILOT WORKFLOW] workflow_transcript_record_too_large. Run /cancel and re-invoke the workflow.' : "[AUTOPILOT WORKFLOW] workflow_descriptor_integrity_failed. Run /cancel and re-invoke the workflow." }));
|
|
return;
|
|
}
|
|
if (hasNamedWorkflowMarkers(autopilot.state) && !isWorkflowRuntimeSupported()) {
|
|
console.log(JSON.stringify(SAFE_CONTINUE));
|
|
return;
|
|
}
|
|
const workflowAdvance = advanceWorkflowOnStop(autopilot.state, data, sessionId);
|
|
if (workflowAdvance) {
|
|
const commit = commitWorkflowAdvance(autopilot.path, workflowAdvance);
|
|
if (commit.committed) {
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason: workflowAdvance.nextStage
|
|
? workflowAdvance.nextStagePrompt
|
|
: "[AUTOPILOT WORKFLOW] All selected stages are complete." }));
|
|
} else if (takeWorkflowTranscriptFailure(sessionId) === 'workflow_transcript_record_too_large') {
|
|
console.log(JSON.stringify({ continue: false, decision: 'block', reason: '[AUTOPILOT WORKFLOW] workflow_transcript_record_too_large. Run /cancel and re-invoke the workflow.' }));
|
|
} else if (hasNamedWorkflowMarkers(commit.state) && (!isValidWorkflowDescriptor(commit.state.workflow) || !isValidWorkflowTrackingState(commit.state, sessionId))) {
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason: "[AUTOPILOT WORKFLOW] workflow_descriptor_integrity_failed. Run /cancel and re-invoke the workflow." }));
|
|
} else {
|
|
console.log(JSON.stringify(hasNamedWorkflowMarkers(commit.state) ? workflowStopResponse(commit.state) : SAFE_CONTINUE));
|
|
}
|
|
return;
|
|
}
|
|
if (takeWorkflowTranscriptFailure(sessionId) === 'workflow_transcript_record_too_large') {
|
|
console.log(JSON.stringify({ continue: false, decision: 'block', reason: '[AUTOPILOT WORKFLOW] workflow_transcript_record_too_large. Run /cancel and re-invoke the workflow.' }));
|
|
return;
|
|
}
|
|
if (hasNamedWorkflowMarkers(autopilot.state) && (!isValidWorkflowDescriptor(autopilot.state.workflow) || !isValidWorkflowTrackingState(autopilot.state, sessionId))) {
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason: "[AUTOPILOT WORKFLOW] workflow_descriptor_integrity_failed. Run /cancel and re-invoke the workflow." }));
|
|
return;
|
|
}
|
|
if (hasNamedWorkflowMarkers(autopilot.state)) {
|
|
const expected = {
|
|
workflowRunId: autopilot.state.workflowRunId,
|
|
sessionId: autopilot.state.session_id,
|
|
profileHash: autopilot.state.workflow.profileHash,
|
|
trackingRevision: autopilot.state.pipelineTracking.trackingRevision,
|
|
stageIndex: autopilot.state.pipelineTracking.currentStageIndex,
|
|
stageId: autopilot.state.pipelineTracking.stages?.[autopilot.state.pipelineTracking.currentStageIndex]?.id,
|
|
phase: autopilot.state.phase,
|
|
};
|
|
const refresh = refreshNamedWorkflowDispatch(autopilot.path, expected);
|
|
if (refresh.integrityFailed || (hasNamedWorkflowMarkers(refresh.state) && (!isValidWorkflowDescriptor(refresh.state.workflow) || !isValidWorkflowTrackingState(refresh.state, sessionId)))) {
|
|
console.log(JSON.stringify({ continue: false, decision: "block", reason: "[AUTOPILOT WORKFLOW] workflow_descriptor_integrity_failed. Run /cancel and re-invoke the workflow." }));
|
|
} else {
|
|
console.log(JSON.stringify(refresh.committed ? workflowStopResponse(refresh.state) : SAFE_CONTINUE));
|
|
}
|
|
return;
|
|
}
|
|
const phase = getAutopilotPhase(autopilot.state);
|
|
if (phase !== "complete") {
|
|
const loadedSnapshot = JSON.stringify(autopilot.state);
|
|
const newCount = (autopilot.state.reinforcement_count || 0) + 1;
|
|
if (newCount <= 20) {
|
|
const toolError = readLastToolError(stateDir);
|
|
const errorGuidance = getToolErrorRetryGuidance(toolError);
|
|
const reinforced = { ...autopilot.state, reinforcement_count: newCount, last_checked_at: new Date().toISOString() };
|
|
let committed = false;
|
|
const locked = withStateFileLockSync(autopilot.path, () => {
|
|
const current = readJsonFile(autopilot.path);
|
|
if (!current || JSON.stringify(current) !== loadedSnapshot) return;
|
|
committed = writeJsonFile(autopilot.path, reinforced);
|
|
});
|
|
if (!locked.acquired || !committed) {
|
|
if (!locked.acquired) reportLockFailure('Autopilot state reinforcement');
|
|
console.log(JSON.stringify(SAFE_CONTINUE));
|
|
return;
|
|
}
|
|
autopilot.state = reinforced;
|
|
|
|
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;
|
|
}
|
|
|
|
recordLoopContinuationShadow({
|
|
mode_name: "autopilot",
|
|
session_id: sessionId || null,
|
|
iteration: newCount,
|
|
phase,
|
|
should_block: true,
|
|
continuation_excerpt: reason,
|
|
}, {
|
|
mode: "autopilot",
|
|
shouldBlock: true,
|
|
iteration: newCount,
|
|
phase,
|
|
}, data.permission_mode);
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// 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);
|
|
});
|