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