feat(hooks): add /stop-continuation command to halt all continuation mechanisms (#1316)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim 2026-01-31 15:09:05 +09:00 committed by GitHub
parent ddfbdbb84e
commit e63c568c4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 321 additions and 10 deletions

View File

@ -80,7 +80,8 @@
"prometheus-md-only",
"sisyphus-junior-notepad",
"start-work",
"atlas"
"atlas",
"stop-continuation-guard"
]
}
},

View File

@ -88,6 +88,7 @@ export const HookNameSchema = z.enum([
"sisyphus-junior-notepad",
"start-work",
"atlas",
"stop-continuation-guard",
])
export const BuiltinCommandNameSchema = z.enum([

View File

@ -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
</user-request>`,
argumentHint: "[plan-name]",
},
"stop-continuation": {
description: "(builtin) Stop all continuation mechanisms (ralph loop, todo continuation, boulder) for this session",
template: `<command-instruction>
${STOP_CONTINUATION_TEMPLATE}
</command-instruction>`,
},
}
export function loadBuiltinCommands(

View File

@ -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")
})
})

View File

@ -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.`

View File

@ -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[]

View File

@ -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";

View File

@ -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)
})
})

View File

@ -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<void>
stop: (sessionID: string) => void
isStopped: (sessionID: string) => boolean
clear: (sessionID: string) => void
}
export function createStopContinuationGuardHook(
_ctx: PluginInput
): StopContinuationGuard {
const stoppedSessions = new Set<string>()
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<void> => {
const props = event.properties as Record<string, unknown> | 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,
}
}

View File

@ -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)
})
})

View File

@ -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<void>
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<string, SessionState>()
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,
}
}

View File

@ -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) => {