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:
parent
ddfbdbb84e
commit
e63c568c4f
@ -80,7 +80,8 @@
|
||||
"prometheus-md-only",
|
||||
"sisyphus-junior-notepad",
|
||||
"start-work",
|
||||
"atlas"
|
||||
"atlas",
|
||||
"stop-continuation-guard"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@ -88,6 +88,7 @@ export const HookNameSchema = z.enum([
|
||||
"sisyphus-junior-notepad",
|
||||
"start-work",
|
||||
"atlas",
|
||||
"stop-continuation-guard",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
})
|
||||
})
|
||||
13
src/features/builtin-commands/templates/stop-continuation.ts
Normal file
13
src/features/builtin-commands/templates/stop-continuation.ts
Normal 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.`
|
||||
@ -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[]
|
||||
|
||||
@ -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";
|
||||
|
||||
106
src/hooks/stop-continuation-guard/index.test.ts
Normal file
106
src/hooks/stop-continuation-guard/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
54
src/hooks/stop-continuation-guard/index.ts
Normal file
54
src/hooks/stop-continuation-guard/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
38
src/index.ts
38
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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user