| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- import type { PluginInput } from '@opencode-ai/plugin';
- import { tool } from '@opencode-ai/plugin/tool';
- import { createInternalAgentTextPart, log } from '../../utils';
- const HOOK_NAME = 'todo-continuation';
- const COMMAND_NAME = 'auto-continue';
- const CONTINUATION_PROMPT =
- '[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]';
- // Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
- // re-continuing something the user explicitly stopped
- const SUPPRESS_AFTER_ABORT_MS = 5_000;
- const NOTIFICATION_BUSY_GRACE_MS = 250;
- const QUESTION_PHRASES = [
- 'would you like',
- 'should i',
- 'do you want',
- 'please review',
- 'let me know',
- 'what do you think',
- 'can you confirm',
- 'would you prefer',
- 'shall i',
- 'any thoughts',
- ];
- // Statuses that indicate a todo is terminal (won't be worked on further).
- // Uses denylist approach: any status not listed here is considered incomplete.
- const TERMINAL_TODO_STATUSES = ['completed', 'cancelled'];
- interface ContinuationState {
- enabled: boolean;
- consecutiveContinuations: number;
- pendingTimer: ReturnType<typeof setTimeout> | null;
- pendingTimerSessionId: string | null;
- suppressUntil: number;
- orchestratorSessionIds: Set<string>;
- sawChatMessage: boolean;
- // True while our auto-injection prompt is in flight — prevents counter reset
- // on session.status→busy and blocks duplicate injections
- isAutoInjecting: boolean;
- // session IDs with an in-flight noReply countdown notification.
- notifyingSessionIds: Set<string>;
- // sessionID → timestamp until which just-completed noReply countdown
- // notification busy transitions are ignored, covering HTTP/SSE reordering.
- notificationBusyUntilBySession: Map<string, number>;
- }
- function isQuestion(text: string): boolean {
- const lowerText = text.toLowerCase().trim();
- // Match trailing '?' with optional whitespace after it
- if (/\?\s*$/.test(lowerText)) {
- return true;
- }
- return QUESTION_PHRASES.some((phrase) => lowerText.includes(phrase));
- }
- interface TodoItem {
- id: string;
- content: string;
- status: string;
- priority: string;
- }
- interface MessageInfo {
- role?: string;
- [key: string]: unknown;
- }
- interface MessagePart {
- type?: string;
- text?: string;
- [key: string]: unknown;
- }
- interface Message {
- info?: MessageInfo;
- parts?: MessagePart[];
- }
- function cancelPendingTimer(state: ContinuationState): void {
- if (state.pendingTimer) {
- clearTimeout(state.pendingTimer);
- state.pendingTimer = null;
- }
- state.pendingTimerSessionId = null;
- }
- function resetState(state: ContinuationState): void {
- cancelPendingTimer(state);
- state.consecutiveContinuations = 0;
- state.suppressUntil = 0;
- state.isAutoInjecting = false;
- state.notifyingSessionIds.clear();
- state.notificationBusyUntilBySession.clear();
- }
- export function createTodoContinuationHook(
- ctx: PluginInput,
- config?: {
- maxContinuations?: number;
- cooldownMs?: number;
- autoEnable?: boolean;
- autoEnableThreshold?: number;
- },
- ): {
- tool: Record<string, unknown>;
- handleEvent: (input: {
- event: { type: string; properties?: Record<string, unknown> };
- }) => Promise<void>;
- handleChatMessage: (input: { sessionID: string; agent?: string }) => void;
- handleCommandExecuteBefore: (
- input: {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output: { parts: Array<{ type: string; text?: string }> },
- ) => Promise<void>;
- } {
- const maxContinuations = config?.maxContinuations ?? 5;
- const cooldownMs = config?.cooldownMs ?? 3000;
- const autoEnable = config?.autoEnable ?? false;
- const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
- const state: ContinuationState = {
- enabled: false,
- consecutiveContinuations: 0,
- pendingTimer: null,
- pendingTimerSessionId: null,
- suppressUntil: 0,
- orchestratorSessionIds: new Set<string>(),
- sawChatMessage: false,
- isAutoInjecting: false,
- notifyingSessionIds: new Set<string>(),
- notificationBusyUntilBySession: new Map<string, number>(),
- };
- function markNotificationStarted(sessionID: string): void {
- state.notifyingSessionIds.add(sessionID);
- }
- function markNotificationFinished(sessionID: string): void {
- state.notifyingSessionIds.delete(sessionID);
- state.notificationBusyUntilBySession.set(
- sessionID,
- Date.now() + NOTIFICATION_BUSY_GRACE_MS,
- );
- }
- function clearNotificationState(sessionID: string): void {
- state.notifyingSessionIds.delete(sessionID);
- state.notificationBusyUntilBySession.delete(sessionID);
- }
- function isNotificationBusy(sessionID: string): boolean {
- if (state.notifyingSessionIds.has(sessionID)) {
- return true;
- }
- const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
- if (until <= Date.now()) {
- state.notificationBusyUntilBySession.delete(sessionID);
- return false;
- }
- return true;
- }
- function isOrchestratorSession(sessionID: string): boolean {
- return state.orchestratorSessionIds.has(sessionID);
- }
- function registerOrchestratorSession(sessionID: string): void {
- state.orchestratorSessionIds.add(sessionID);
- }
- function handleChatMessage(input: {
- sessionID: string;
- agent?: string;
- }): void {
- if (!input.agent) {
- return;
- }
- state.sawChatMessage = true;
- if (input.agent === 'orchestrator') {
- registerOrchestratorSession(input.sessionID);
- }
- }
- const autoContinue = tool({
- description:
- 'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
- args: { enabled: tool.schema.boolean() },
- execute: async (args) => {
- const enabled = args.enabled;
- state.enabled = enabled;
- state.consecutiveContinuations = 0;
- if (enabled) {
- state.suppressUntil = 0;
- log(`[${HOOK_NAME}] Auto-continue enabled`, { maxContinuations });
- return `Auto-continue enabled. Will auto-continue for up to ${maxContinuations} consecutive injections.`;
- }
- // Cancel any pending timer on disable
- cancelPendingTimer(state);
- log(`[${HOOK_NAME}] Auto-continue disabled`);
- return 'Auto-continue disabled.';
- },
- });
- async function handleEvent(input: {
- event: { type: string; properties?: Record<string, unknown> };
- }): Promise<void> {
- const { event } = input;
- const properties = event.properties ?? {};
- if (
- event.type === 'session.idle' ||
- (event.type === 'session.status' &&
- (properties.status as { type?: string } | undefined)?.type === 'idle')
- ) {
- const sessionID = properties.sessionID as string;
- if (!sessionID) {
- return;
- }
- log(`[${HOOK_NAME}] Session idle`, { sessionID });
- // Backward compatibility: if no chat.message has identified the
- // orchestrator yet, fall back to the first idle session.
- if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
- registerOrchestratorSession(sessionID);
- log(`[${HOOK_NAME}] Tracked orchestrator session`, {
- sessionID,
- });
- }
- // Gate: session is orchestrator (needed before auto-enable check)
- if (!isOrchestratorSession(sessionID)) {
- log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
- sessionID,
- });
- return;
- }
- // Auto-enable check: if configured, not yet enabled, and enough
- // todos exist, automatically enable auto-continue.
- if (autoEnable && !state.enabled) {
- try {
- const todosResult = await ctx.client.session.todo({
- path: { id: sessionID },
- });
- const todos = todosResult.data as TodoItem[];
- const incompleteCount = todos.filter(
- (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
- ).length;
- if (incompleteCount >= autoEnableThreshold) {
- state.enabled = true;
- state.consecutiveContinuations = 0;
- state.suppressUntil = 0;
- log(
- `[${HOOK_NAME}] Auto-enabled: ${incompleteCount} incomplete todos >= threshold ${autoEnableThreshold}`,
- { sessionID },
- );
- } else {
- log(
- `[${HOOK_NAME}] Auto-enable skipped: ${incompleteCount} incomplete todos < threshold ${autoEnableThreshold}`,
- { sessionID },
- );
- }
- } catch (error) {
- log(
- `[${HOOK_NAME}] Warning: failed to fetch todos for auto-enable check`,
- {
- sessionID,
- error: error instanceof Error ? error.message : String(error),
- },
- );
- }
- }
- // Safety gate 1: enabled
- if (!state.enabled) {
- log(`[${HOOK_NAME}] Skipped: auto-continue not enabled`, {
- sessionID,
- });
- return;
- }
- // Safety gate 2: incomplete todos exist
- let hasIncompleteTodos = false;
- let incompleteCount = 0;
- try {
- const todosResult = await ctx.client.session.todo({
- path: { id: sessionID },
- });
- const todos = todosResult.data as TodoItem[];
- incompleteCount = todos.filter(
- (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
- ).length;
- hasIncompleteTodos = incompleteCount > 0;
- log(`[${HOOK_NAME}] Fetched todos`, {
- sessionID,
- hasIncompleteTodos,
- total: todos.length,
- });
- } catch (error) {
- log(`[${HOOK_NAME}] Warning: failed to fetch todos`, {
- sessionID,
- error: error instanceof Error ? error.message : String(error),
- });
- return;
- }
- if (!hasIncompleteTodos) {
- log(`[${HOOK_NAME}] Skipped: no incomplete todos`, { sessionID });
- return;
- }
- // Safety gate 3: last assistant message is not a question
- let lastAssistantIsQuestion = false;
- try {
- const messagesResult = await ctx.client.session.messages({
- path: { id: sessionID },
- });
- const messages = messagesResult.data as Message[];
- const lastAssistantMessage = messages
- .slice()
- .reverse()
- .find((m) => m.info?.role === 'assistant');
- if (lastAssistantMessage?.parts) {
- const lastText = lastAssistantMessage.parts
- .map((p) => p.text ?? '')
- .join(' ');
- lastAssistantIsQuestion = isQuestion(lastText);
- }
- log(`[${HOOK_NAME}] Fetched messages`, {
- sessionID,
- lastAssistantIsQuestion,
- });
- } catch (error) {
- log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
- sessionID,
- error: error instanceof Error ? error.message : String(error),
- });
- return;
- }
- if (lastAssistantIsQuestion) {
- log(`[${HOOK_NAME}] Skipped: last message is question`, {
- sessionID,
- });
- return;
- }
- // Safety gate 4: below max continuations
- if (state.consecutiveContinuations >= maxContinuations) {
- log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
- sessionID,
- consecutive: state.consecutiveContinuations,
- max: maxContinuations,
- });
- return;
- }
- // Safety gate 5: not in suppress window
- const now = Date.now();
- if (now < state.suppressUntil) {
- log(`[${HOOK_NAME}] Skipped: in suppress window`, {
- sessionID,
- suppressUntil: state.suppressUntil,
- });
- return;
- }
- // Safety gate 6: no pending timer AND no injection in flight
- if (state.pendingTimer !== null || state.isAutoInjecting) {
- log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
- sessionID,
- });
- return;
- }
- // Schedule continuation
- log(`[${HOOK_NAME}] Scheduling continuation`, {
- sessionID,
- delayMs: cooldownMs,
- });
- // Show countdown notification (noReply = agent doesn't respond)
- markNotificationStarted(sessionID);
- ctx.client.session
- .prompt({
- path: { id: sessionID },
- body: {
- noReply: true,
- parts: [
- {
- type: 'text',
- text: [
- `⎔ Auto-continue: ${incompleteCount} incomplete todos remaining — resuming in ${cooldownMs / 1000}s — Esc×2 to cancel`,
- '',
- '[system status: continue without acknowledging this notification]',
- ].join('\n'),
- },
- ],
- },
- })
- .catch(() => {
- /* best-effort notification */
- })
- .finally(() => {
- markNotificationFinished(sessionID);
- });
- state.pendingTimerSessionId = sessionID;
- state.pendingTimer = setTimeout(async () => {
- state.pendingTimer = null;
- state.pendingTimerSessionId = null;
- clearNotificationState(sessionID);
- // Guard: may have been disabled during cooldown
- if (!state.enabled) {
- log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
- sessionID,
- });
- return;
- }
- state.isAutoInjecting = true;
- try {
- await ctx.client.session.prompt({
- path: { id: sessionID },
- body: {
- parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
- },
- });
- state.consecutiveContinuations++;
- log(`[${HOOK_NAME}] Continuation injected`, {
- sessionID,
- consecutive: state.consecutiveContinuations,
- });
- } catch (error) {
- log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
- sessionID,
- error: error instanceof Error ? error.message : String(error),
- });
- } finally {
- state.isAutoInjecting = false;
- }
- }, cooldownMs);
- } else if (event.type === 'session.status') {
- const status = properties.status as { type: string };
- const sessionID = properties.sessionID as string;
- if (status?.type === 'busy') {
- const isOrchestrator = isOrchestratorSession(sessionID);
- const isNotification = isNotificationBusy(sessionID);
- // Only cancel timer for orchestrator session — sub-agents going
- // busy must not silently kill the orchestrator's continuation.
- if (
- isOrchestrator &&
- !isNotification &&
- state.pendingTimerSessionId === sessionID
- ) {
- cancelPendingTimer(state);
- }
- // Only reset consecutive counter for user-initiated activity,
- // not for our own auto-injection prompt. Scope to orchestrator only.
- if (
- !state.isAutoInjecting &&
- !isNotification &&
- isOrchestrator &&
- state.consecutiveContinuations > 0
- ) {
- state.consecutiveContinuations = 0;
- log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
- sessionID,
- });
- }
- }
- } else if (event.type === 'session.error') {
- const error = properties.error as { name?: string };
- const sessionID = properties.sessionID as string;
- const errorName = error?.name;
- const isOrchestrator = isOrchestratorSession(sessionID);
- if (
- isOrchestrator &&
- (errorName === 'MessageAbortedError' || errorName === 'AbortError')
- ) {
- state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
- log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
- sessionID,
- errorName,
- });
- }
- if (isOrchestrator) {
- cancelPendingTimer(state);
- log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
- sessionID,
- });
- }
- } else if (event.type === 'session.deleted') {
- // OpenCode sends sessionID in two shapes:
- // properties.info.id (from session store) or properties.sessionID (from event)
- const deletedSessionId =
- (properties.info as { id?: string })?.id ??
- (properties.sessionID as string);
- if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
- if (state.pendingTimerSessionId === deletedSessionId) {
- cancelPendingTimer(state);
- log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
- sessionID: deletedSessionId,
- });
- }
- state.orchestratorSessionIds.delete(deletedSessionId);
- clearNotificationState(deletedSessionId);
- if (state.orchestratorSessionIds.size === 0) {
- resetState(state);
- state.sawChatMessage = false;
- }
- log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
- sessionID: deletedSessionId,
- });
- }
- }
- }
- async function handleCommandExecuteBefore(
- input: {
- command: string;
- sessionID: string;
- arguments: string;
- },
- output: { parts: Array<{ type: string; text?: string }> },
- ): Promise<void> {
- if (input.command !== COMMAND_NAME) {
- return;
- }
- // Seed orchestrator session from slash command (more reliable than
- // first-idle heuristic — slash commands only fire in main chat)
- registerOrchestratorSession(input.sessionID);
- // Clear template text — hook handles everything directly
- output.parts.length = 0;
- // Accept explicit on/off argument, toggle only when no arg
- const arg = input.arguments.trim().toLowerCase();
- let newEnabled: boolean;
- if (arg === 'on') {
- newEnabled = true;
- } else if (arg === 'off') {
- newEnabled = false;
- } else {
- newEnabled = !state.enabled;
- }
- state.enabled = newEnabled;
- state.consecutiveContinuations = 0;
- if (!newEnabled) {
- // Cancel any pending timer on disable
- cancelPendingTimer(state);
- output.parts.push(
- createInternalAgentTextPart(
- '[Auto-continue: disabled by user command.]',
- ),
- );
- log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
- return;
- }
- // Clear suppress window on explicit re-enable
- state.suppressUntil = 0;
- log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
- maxContinuations,
- });
- // Check for incomplete todos to decide on immediate continuation
- let hasIncompleteTodos = false;
- try {
- const todosResult = await ctx.client.session.todo({
- path: { id: input.sessionID },
- });
- const todos = todosResult.data as TodoItem[];
- hasIncompleteTodos = todos.some(
- (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
- );
- } catch (error) {
- log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
- sessionID: input.sessionID,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- if (hasIncompleteTodos) {
- output.parts.push(
- createInternalAgentTextPart(
- `${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
- ),
- );
- } else {
- output.parts.push(
- createInternalAgentTextPart(
- `[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`,
- ),
- );
- }
- }
- return {
- tool: { auto_continue: autoContinue },
- handleEvent,
- handleChatMessage,
- handleCommandExecuteBefore,
- };
- }
|