diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json
index d9498a73..416acff9 100644
--- a/assets/oh-my-opencode.schema.json
+++ b/assets/oh-my-opencode.schema.json
@@ -80,7 +80,8 @@
"prometheus-md-only",
"sisyphus-junior-notepad",
"start-work",
- "atlas"
+ "atlas",
+ "stop-continuation-guard"
]
}
},
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 1c887b75..1598ed71 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -88,6 +88,7 @@ export const HookNameSchema = z.enum([
"sisyphus-junior-notepad",
"start-work",
"atlas",
+ "stop-continuation-guard",
])
export const BuiltinCommandNameSchema = z.enum([
diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts
index eec67a9a..998ce253 100644
--- a/src/features/builtin-commands/commands.ts
+++ b/src/features/builtin-commands/commands.ts
@@ -2,6 +2,7 @@ import type { CommandDefinition } from "../claude-code-command-loader"
import type { BuiltinCommandName, BuiltinCommands } from "./types"
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
+import { STOP_CONTINUATION_TEMPLATE } from "./templates/stop-continuation"
import { REFACTOR_TEMPLATE } from "./templates/refactor"
import { START_WORK_TEMPLATE } from "./templates/start-work"
@@ -70,6 +71,12 @@ $ARGUMENTS
`,
argumentHint: "[plan-name]",
},
+ "stop-continuation": {
+ description: "(builtin) Stop all continuation mechanisms (ralph loop, todo continuation, boulder) for this session",
+ template: `
+${STOP_CONTINUATION_TEMPLATE}
+`,
+ },
}
export function loadBuiltinCommands(
diff --git a/src/features/builtin-commands/templates/stop-continuation.test.ts b/src/features/builtin-commands/templates/stop-continuation.test.ts
new file mode 100644
index 00000000..7aa5b714
--- /dev/null
+++ b/src/features/builtin-commands/templates/stop-continuation.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { STOP_CONTINUATION_TEMPLATE } from "./stop-continuation"
+
+describe("stop-continuation template", () => {
+ test("should export a non-empty template string", () => {
+ // #given - the stop-continuation template
+
+ // #when - we access the template
+
+ // #then - it should be a non-empty string
+ expect(typeof STOP_CONTINUATION_TEMPLATE).toBe("string")
+ expect(STOP_CONTINUATION_TEMPLATE.length).toBeGreaterThan(0)
+ })
+
+ test("should describe the stop-continuation behavior", () => {
+ // #given - the stop-continuation template
+
+ // #when - we check the content
+
+ // #then - it should mention key behaviors
+ expect(STOP_CONTINUATION_TEMPLATE).toContain("todo-continuation-enforcer")
+ expect(STOP_CONTINUATION_TEMPLATE).toContain("Ralph Loop")
+ expect(STOP_CONTINUATION_TEMPLATE).toContain("boulder state")
+ })
+})
diff --git a/src/features/builtin-commands/templates/stop-continuation.ts b/src/features/builtin-commands/templates/stop-continuation.ts
new file mode 100644
index 00000000..036d007b
--- /dev/null
+++ b/src/features/builtin-commands/templates/stop-continuation.ts
@@ -0,0 +1,13 @@
+export const STOP_CONTINUATION_TEMPLATE = `Stop all continuation mechanisms for the current session.
+
+This command will:
+1. Stop the todo-continuation-enforcer from automatically continuing incomplete tasks
+2. Cancel any active Ralph Loop
+3. Clear the boulder state for the current project
+
+After running this command:
+- The session will not auto-continue when idle
+- You can manually continue work when ready
+- The stop state is per-session and clears when the session ends
+
+Use this when you need to pause automated continuation and take manual control.`
diff --git a/src/features/builtin-commands/types.ts b/src/features/builtin-commands/types.ts
index c626092c..1b148774 100644
--- a/src/features/builtin-commands/types.ts
+++ b/src/features/builtin-commands/types.ts
@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"
-export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work"
+export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" | "stop-continuation"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 7206def3..c8f3c0c3 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -34,3 +34,4 @@ export { createAtlasHook } from "./atlas";
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
+export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
diff --git a/src/hooks/stop-continuation-guard/index.test.ts b/src/hooks/stop-continuation-guard/index.test.ts
new file mode 100644
index 00000000..028d2cb6
--- /dev/null
+++ b/src/hooks/stop-continuation-guard/index.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, test } from "bun:test"
+import { createStopContinuationGuardHook } from "./index"
+
+describe("stop-continuation-guard", () => {
+ function createMockPluginInput() {
+ return {
+ client: {
+ tui: {
+ showToast: async () => ({}),
+ },
+ },
+ directory: "/tmp/test",
+ } as never
+ }
+
+ test("should mark session as stopped", () => {
+ // #given - a guard hook with no stopped sessions
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+ const sessionID = "test-session-1"
+
+ // #when - we stop continuation for the session
+ guard.stop(sessionID)
+
+ // #then - session should be marked as stopped
+ expect(guard.isStopped(sessionID)).toBe(true)
+ })
+
+ test("should return false for non-stopped sessions", () => {
+ // #given - a guard hook with no stopped sessions
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+
+ // #when - we check a session that was never stopped
+
+ // #then - it should return false
+ expect(guard.isStopped("non-existent-session")).toBe(false)
+ })
+
+ test("should clear stopped state for a session", () => {
+ // #given - a session that was stopped
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+ const sessionID = "test-session-2"
+ guard.stop(sessionID)
+
+ // #when - we clear the session
+ guard.clear(sessionID)
+
+ // #then - session should no longer be stopped
+ expect(guard.isStopped(sessionID)).toBe(false)
+ })
+
+ test("should handle multiple sessions independently", () => {
+ // #given - multiple sessions with different stop states
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+ const session1 = "session-1"
+ const session2 = "session-2"
+ const session3 = "session-3"
+
+ // #when - we stop some sessions but not others
+ guard.stop(session1)
+ guard.stop(session2)
+
+ // #then - each session has its own state
+ expect(guard.isStopped(session1)).toBe(true)
+ expect(guard.isStopped(session2)).toBe(true)
+ expect(guard.isStopped(session3)).toBe(false)
+ })
+
+ test("should clear session on session.deleted event", async () => {
+ // #given - a session that was stopped
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+ const sessionID = "test-session-3"
+ guard.stop(sessionID)
+
+ // #when - session is deleted
+ await guard.event({
+ event: {
+ type: "session.deleted",
+ properties: { info: { id: sessionID } },
+ },
+ })
+
+ // #then - session should no longer be stopped (cleaned up)
+ expect(guard.isStopped(sessionID)).toBe(false)
+ })
+
+ test("should not affect other sessions on session.deleted", async () => {
+ // #given - multiple stopped sessions
+ const guard = createStopContinuationGuardHook(createMockPluginInput())
+ const session1 = "session-keep"
+ const session2 = "session-delete"
+ guard.stop(session1)
+ guard.stop(session2)
+
+ // #when - one session is deleted
+ await guard.event({
+ event: {
+ type: "session.deleted",
+ properties: { info: { id: session2 } },
+ },
+ })
+
+ // #then - other session should remain stopped
+ expect(guard.isStopped(session1)).toBe(true)
+ expect(guard.isStopped(session2)).toBe(false)
+ })
+})
diff --git a/src/hooks/stop-continuation-guard/index.ts b/src/hooks/stop-continuation-guard/index.ts
new file mode 100644
index 00000000..e08e9969
--- /dev/null
+++ b/src/hooks/stop-continuation-guard/index.ts
@@ -0,0 +1,54 @@
+import type { PluginInput } from "@opencode-ai/plugin"
+import { log } from "../../shared/logger"
+
+const HOOK_NAME = "stop-continuation-guard"
+
+export interface StopContinuationGuard {
+ event: (input: { event: { type: string; properties?: unknown } }) => Promise
+ stop: (sessionID: string) => void
+ isStopped: (sessionID: string) => boolean
+ clear: (sessionID: string) => void
+}
+
+export function createStopContinuationGuardHook(
+ _ctx: PluginInput
+): StopContinuationGuard {
+ const stoppedSessions = new Set()
+
+ const stop = (sessionID: string): void => {
+ stoppedSessions.add(sessionID)
+ log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
+ }
+
+ const isStopped = (sessionID: string): boolean => {
+ return stoppedSessions.has(sessionID)
+ }
+
+ const clear = (sessionID: string): void => {
+ stoppedSessions.delete(sessionID)
+ log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID })
+ }
+
+ const event = async ({
+ event,
+ }: {
+ event: { type: string; properties?: unknown }
+ }): Promise => {
+ const props = event.properties as Record | undefined
+
+ if (event.type === "session.deleted") {
+ const sessionInfo = props?.info as { id?: string } | undefined
+ if (sessionInfo?.id) {
+ clear(sessionInfo.id)
+ log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
+ }
+ }
+ }
+
+ return {
+ event,
+ stop,
+ isStopped,
+ clear,
+ }
+}
diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer.test.ts
index fa33b84f..de0ded44 100644
--- a/src/hooks/todo-continuation-enforcer.test.ts
+++ b/src/hooks/todo-continuation-enforcer.test.ts
@@ -1178,4 +1178,68 @@ describe("todo-continuation-enforcer", () => {
// #then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1)
})
+
+ test("should not inject when isContinuationStopped returns true", async () => {
+ // #given - session with continuation stopped
+ const sessionID = "main-stopped"
+ setMainSession(sessionID)
+
+ const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
+ isContinuationStopped: (id) => id === sessionID,
+ })
+
+ // #when - session goes idle
+ await hook.handler({
+ event: { type: "session.idle", properties: { sessionID } },
+ })
+
+ await fakeTimers.advanceBy(3000)
+
+ // #then - no continuation injected (stopped flag is true)
+ expect(promptCalls).toHaveLength(0)
+ })
+
+ test("should inject when isContinuationStopped returns false", async () => {
+ // #given - session with continuation not stopped
+ const sessionID = "main-not-stopped"
+ setMainSession(sessionID)
+
+ const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
+ isContinuationStopped: () => false,
+ })
+
+ // #when - session goes idle
+ await hook.handler({
+ event: { type: "session.idle", properties: { sessionID } },
+ })
+
+ await fakeTimers.advanceBy(3000)
+
+ // #then - continuation injected (stopped flag is false)
+ expect(promptCalls.length).toBe(1)
+ })
+
+ test("should cancel all countdowns via cancelAllCountdowns", async () => {
+ // #given - multiple sessions with running countdowns
+ const session1 = "main-cancel-all-1"
+ const session2 = "main-cancel-all-2"
+ setMainSession(session1)
+
+ const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
+
+ // #when - first session goes idle
+ await hook.handler({
+ event: { type: "session.idle", properties: { sessionID: session1 } },
+ })
+ await fakeTimers.advanceBy(500)
+
+ // #when - cancel all countdowns
+ hook.cancelAllCountdowns()
+
+ // #when - advance past countdown time
+ await fakeTimers.advanceBy(3000)
+
+ // #then - no continuation injected (all countdowns cancelled)
+ expect(promptCalls).toHaveLength(0)
+ })
})
diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts
index d93bd16d..c3ed8389 100644
--- a/src/hooks/todo-continuation-enforcer.ts
+++ b/src/hooks/todo-continuation-enforcer.ts
@@ -18,12 +18,14 @@ const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
+ isContinuationStopped?: (sessionID: string) => boolean
}
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
+ cancelAllCountdowns: () => void
}
interface Todo {
@@ -95,7 +97,7 @@ export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
- const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS } = options
+ const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options
const sessions = new Map()
function getState(sessionID: string): SessionState {
@@ -420,6 +422,11 @@ export function createTodoContinuationEnforcer(
return
}
+ if (isContinuationStopped?.(sessionID)) {
+ log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
+ return
+ }
+
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
return
}
@@ -485,9 +492,17 @@ export function createTodoContinuationEnforcer(
}
}
+ const cancelAllCountdowns = (): void => {
+ for (const sessionID of sessions.keys()) {
+ cancelCountdown(sessionID)
+ }
+ log(`[${HOOK_NAME}] All countdowns cancelled`)
+ }
+
return {
handler,
markRecovering,
markRecoveryComplete,
+ cancelAllCountdowns,
}
}
diff --git a/src/index.ts b/src/index.ts
index f97410e1..1e1d2083 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -35,6 +35,7 @@ import {
createSisyphusJuniorNotepadHook,
createQuestionLabelTruncatorHook,
createSubagentQuestionBlockerHook,
+ createStopContinuationGuardHook,
} from "./hooks";
import {
contextCollector,
@@ -77,6 +78,7 @@ import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager";
import { TmuxSessionManager } from "./features/tmux-subagent";
+import { clearBoulderState } from "./features/boulder-state";
import { type HookName } from "./config";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared";
import { loadPluginConfig } from "./plugin-config";
@@ -277,8 +279,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
initTaskToastManager(ctx.client);
+ const stopContinuationGuard = isHookEnabled("stop-continuation-guard")
+ ? createStopContinuationGuardHook(ctx)
+ : null;
+
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
- ? createTodoContinuationEnforcer(ctx, { backgroundManager })
+ ? createTodoContinuationEnforcer(ctx, {
+ backgroundManager,
+ isContinuationStopped: stopContinuationGuard?.isStopped,
+ })
: null;
if (sessionRecovery && todoContinuationEnforcer) {
@@ -521,6 +530,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await categorySkillReminder?.event(input);
await interactiveBashSession?.event(input);
await ralphLoop?.event(input);
+ await stopContinuationGuard?.event(input);
await atlasHook?.handler(input);
const { event } = input;
@@ -664,14 +674,28 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
);
ralphLoop.startLoop(sessionID, prompt, {
- ultrawork: true,
- maxIterations: maxIterMatch
- ? parseInt(maxIterMatch[1], 10)
- : undefined,
- completionPromise: promiseMatch?.[1],
- });
+ ultrawork: true,
+ maxIterations: maxIterMatch
+ ? parseInt(maxIterMatch[1], 10)
+ : undefined,
+ completionPromise: promiseMatch?.[1],
+ });
}
}
+
+ if (input.tool === "slashcommand") {
+ const args = output.args as { command?: string } | undefined;
+ const command = args?.command?.replace(/^\//, "").toLowerCase();
+ const sessionID = input.sessionID || getMainSessionID();
+
+ if (command === "stop-continuation" && sessionID) {
+ stopContinuationGuard?.stop(sessionID);
+ todoContinuationEnforcer?.cancelAllCountdowns();
+ ralphLoop?.cancelLoop(sessionID);
+ clearBoulderState(ctx.directory);
+ log("[stop-continuation] All continuation mechanisms stopped", { sessionID });
+ }
+ }
},
"tool.execute.after": async (input, output) => {