Browse Source

feat(hooks): add todo-continuation hook for auto-continuing orchestrator (#248)

* feat(hooks): add todo-continuation hook for auto-continuing orchestrator

Opt-in auto-continue for the orchestrator when it stops with incomplete
todos. Addresses #244 (orchestrator stops after small batch of todos).

- auto_continue tool toggled by agent or user
- /auto-continue command with on/off/toggle via direct interception
- Optional auto-enable when session has N+ todos (config-gated, default off)
- 6 safety gates: enabled, orchestrator session, incomplete todos,
  no question detected, under max limit, not suppressed after abort
- Countdown notification with Esc×2 cancel window
- Orchestrator prompt guidance on when to enable auto-continue
- Configurable: maxContinuations (5), cooldownMs (3000ms),
  autoEnable (false), autoEnableThreshold (4)
- 50 tests covering all gates, auto-enable, command, and edge cases

* docs: add todo continuation to configuration and tools references

* fix(hooks): address greptile review feedback

- P1: scope session.error handler to orchestrator session only
- P2: auto-enable counts incomplete todos, not all todos
- P2: remove consecutiveContinuations++ from /auto-continue command
- Revert unrelated formatting change in src/cli/config-io.ts

* fix(hooks): scope timer cancellation to orchestrator session in session.status and session.deleted

Same bug class as the prior session.error fix (P1 from Greptile v1).
cancelPendingTimer was called unconditionally in session.status:busy
and session.deleted handlers, meaning a background sub-agent going busy
or being deleted could silently cancel the orchestrator's pending
continuation timer.

Both now guard with isOrchestrator / orchestratorSessionId check.

* test(hooks): add sub-agent isolation tests for session.status and session.deleted scoping

Negative tests verifying that a sub-agent session going busy or being
deleted does not cancel the orchestrator's pending continuation timer.
Addresses Greptile P2 finding from v3 review.
ReqX 5 days ago
parent
commit
de0671d395

+ 1 - 0
.gitignore

@@ -53,6 +53,7 @@ opencode.jsonc
 # Planning docs (not for commit)
 TIMEOUT_PLAN.md
 GOAL.md
+GOALS.md
 PR-NOTES.md
 REVIEW.md
 

+ 4 - 0
docs/configuration.md

@@ -112,3 +112,7 @@ All config files support **JSONC** (JSON with Comments):
 | `council.councillors_timeout` | number | `180000` | Per-councillor timeout (ms) |
 | `council.master_fallback` | string[] | — | Fallback models for the council master |
 | `council.councillor_retries` | number | `3` | Max retries per councillor and master on empty provider response (0–5) |
+| `todoContinuation.maxContinuations` | integer | `5` | Max consecutive auto-continuations before stopping (1–50) |
+| `todoContinuation.cooldownMs` | integer | `3000` | Delay in ms before auto-continuing — gives user time to abort (0–30000) |
+| `todoContinuation.autoEnable` | boolean | `false` | Automatically enable auto-continue when session has enough todos |
+| `todoContinuation.autoEnableThreshold` | integer | `4` | Number of todos that triggers auto-enable (only used when `autoEnable` is true, 1–50) |

+ 42 - 0
docs/tools.md

@@ -54,3 +54,45 @@ OpenCode automatically formats files after they are written or edited, using lan
 Includes Prettier, Biome, `gofmt`, `rustfmt`, `ruff`, and 20+ others.
 
 > See the [official OpenCode docs](https://opencode.ai/docs/formatters/#built-in) for the complete list.
+
+---
+
+## Todo Continuation
+
+Auto-continue the orchestrator when it stops with incomplete todos. Opt-in — no automatic behavior unless enabled.
+
+| Tool / Command | Description |
+|----------------|-------------|
+| `auto_continue` | Toggle auto-continuation. Call with `{ enabled: true }` to activate, `{ enabled: false }` to disable |
+| `/auto-continue` | Slash command shortcut. Accepts `on`, `off`, or toggles with no argument |
+
+**How it works:**
+
+1. When the orchestrator goes idle with incomplete todos, a countdown notification appears
+2. After the cooldown (default 3s), a continuation prompt is injected — the orchestrator resumes work
+3. Press Esc×2 during cooldown or after injection to stop
+
+**Safety gates** (all must pass before continuation):
+
+- Auto-continue is enabled
+- Session is the orchestrator
+- Incomplete todos exist
+- Last assistant message is not a question
+- Consecutive continuation count is under the limit
+- Not in post-abort suppress window (5s)
+- No pending injection already in flight
+
+**Configuration** in `oh-my-opencode-slim.json`:
+
+```jsonc
+{
+  "todoContinuation": {
+    "maxContinuations": 5,      // Max consecutive auto-continuations (1–50)
+    "cooldownMs": 3000,         // Delay before each continuation (0–30000)
+    "autoEnable": false,        // Auto-enable when session has enough todos
+    "autoEnableThreshold": 4    // Number of todos to trigger auto-enable
+  }
+}
+```
+
+> See [Configuration](configuration.md) for the full option reference.

+ 31 - 0
oh-my-opencode-slim.schema.json

@@ -413,6 +413,37 @@
         }
       }
     },
+    "todoContinuation": {
+      "type": "object",
+      "properties": {
+        "maxContinuations": {
+          "default": 5,
+          "description": "Maximum consecutive auto-continuations before stopping to ask user",
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 50
+        },
+        "cooldownMs": {
+          "default": 3000,
+          "description": "Delay in ms before auto-continuing (gives user time to abort)",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 30000
+        },
+        "autoEnable": {
+          "default": false,
+          "description": "Automatically enable auto-continue when the orchestrator session has enough todos",
+          "type": "boolean"
+        },
+        "autoEnableThreshold": {
+          "default": 4,
+          "description": "Number of todos that triggers auto-enable (only used when autoEnable is true)",
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 50
+        }
+      }
+    },
     "fallback": {
       "type": "object",
       "properties": {

+ 7 - 0
src/agents/orchestrator.ts

@@ -114,6 +114,13 @@ Balance: respect dependencies, avoid parallelizing what must be sequential.
 4. Integrate results
 5. Adjust if needed
 
+### Auto-Continue
+When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
+- **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
+- **Don't enable when:** User is in an interactive/conversational flow, or each step needs explicit review
+- Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
+- The user can toggle this anytime via the \`/auto-continue\` command.
+
 ### Validation routing
 - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
 - Route UI/UX validation and review to @designer

+ 40 - 0
src/config/schema.ts

@@ -164,6 +164,45 @@ export const BackgroundTaskConfigSchema = z.object({
 
 export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>;
 
+// Todo continuation configuration
+export const TodoContinuationConfigSchema = z.object({
+  maxContinuations: z
+    .number()
+    .int()
+    .min(1)
+    .max(50)
+    .default(5)
+    .describe(
+      'Maximum consecutive auto-continuations before stopping to ask user',
+    ),
+  cooldownMs: z
+    .number()
+    .int()
+    .min(0)
+    .max(30_000)
+    .default(3000)
+    .describe('Delay in ms before auto-continuing (gives user time to abort)'),
+  autoEnable: z
+    .boolean()
+    .default(false)
+    .describe(
+      'Automatically enable auto-continue when the orchestrator session has enough todos',
+    ),
+  autoEnableThreshold: z
+    .number()
+    .int()
+    .min(1)
+    .max(50)
+    .default(4)
+    .describe(
+      'Number of todos that triggers auto-enable (only used when autoEnable is true)',
+    ),
+});
+
+export type TodoContinuationConfig = z.infer<
+  typeof TodoContinuationConfigSchema
+>;
+
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
   timeoutMs: z.number().min(0).default(15000),
@@ -197,6 +236,7 @@ export const PluginConfigSchema = z.object({
   tmux: TmuxConfigSchema.optional(),
   websearch: WebsearchConfigSchema.optional(),
   background: BackgroundTaskConfigSchema.optional(),
+  todoContinuation: TodoContinuationConfigSchema.optional(),
   fallback: FailoverConfigSchema.optional(),
   council: CouncilConfigSchema.optional(),
 });

+ 1 - 0
src/hooks/index.ts

@@ -10,3 +10,4 @@ export {
 export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
+export { createTodoContinuationHook } from './todo-continuation';

File diff suppressed because it is too large
+ 2200 - 0
src/hooks/todo-continuation/index.test.ts


+ 538 - 0
src/hooks/todo-continuation/index.ts

@@ -0,0 +1,538 @@
+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 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;
+  suppressUntil: number;
+  orchestratorSessionId: string | null;
+  // True while our auto-injection prompt is in flight — prevents counter reset
+  // on session.status→busy and blocks duplicate injections
+  isAutoInjecting: boolean;
+}
+
+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;
+  }
+}
+
+function resetState(state: ContinuationState): void {
+  cancelPendingTimer(state);
+  state.consecutiveContinuations = 0;
+  state.suppressUntil = 0;
+  state.isAutoInjecting = false;
+}
+
+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>;
+  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,
+    suppressUntil: 0,
+    orchestratorSessionId: null,
+    isAutoInjecting: false,
+  };
+
+  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') {
+      const sessionID = properties.sessionID as string;
+      if (!sessionID) {
+        return;
+      }
+
+      log(`[${HOOK_NAME}] Session idle`, { sessionID });
+
+      // Track orchestrator session (assumes orchestrator is the first
+      // session to go idle — correct for single-session main chat)
+      if (!state.orchestratorSessionId) {
+        state.orchestratorSessionId = sessionID;
+        log(`[${HOOK_NAME}] Tracked orchestrator session`, {
+          sessionID,
+        });
+      }
+
+      // Gate: session is orchestrator (needed before auto-enable check)
+      if (state.orchestratorSessionId !== 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)
+      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 */
+        });
+
+      state.pendingTimer = setTimeout(async () => {
+        state.pendingTimer = null;
+
+        // 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 = sessionID === state.orchestratorSessionId;
+
+        // Only cancel timer for orchestrator session — sub-agents going
+        // busy must not silently kill the orchestrator's continuation.
+        if (isOrchestrator) {
+          cancelPendingTimer(state);
+        }
+
+        // Only reset consecutive counter for user-initiated activity,
+        // not for our own auto-injection prompt. Scope to orchestrator only.
+        if (
+          !state.isAutoInjecting &&
+          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 = sessionID === state.orchestratorSessionId;
+      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);
+
+      // Only cancel timer if the orchestrator session itself was deleted.
+      // Background sub-agent deletion must not kill the orchestrator's timer.
+      if (state.orchestratorSessionId === deletedSessionId) {
+        cancelPendingTimer(state);
+        log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
+          sessionID: deletedSessionId,
+        });
+
+        resetState(state);
+        state.orchestratorSessionId = null;
+        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)
+    if (!state.orchestratorSessionId) {
+      state.orchestratorSessionId = 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,
+    handleCommandExecuteBefore,
+  };
+}

+ 42 - 0
src/index.ts

@@ -12,6 +12,7 @@ import {
   createJsonErrorRecoveryHook,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
+  createTodoContinuationHook,
   ForegroundFallbackManager,
 } from './hooks';
 import { createBuiltinMcps } from './mcp';
@@ -161,6 +162,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0,
   );
 
+  // Initialize todo-continuation hook (opt-in auto-continue for incomplete todos)
+  const todoContinuationHook = createTodoContinuationHook(ctx, {
+    maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
+    cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
+    autoEnable: config.todoContinuation?.autoEnable ?? false,
+    autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
+  });
+
   return {
     name: 'oh-my-opencode-slim',
 
@@ -169,6 +178,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     tool: {
       ...backgroundTools,
       ...councilTools,
+      ...todoContinuationHook.tool,
       lsp_goto_definition,
       lsp_find_references,
       lsp_diagnostics,
@@ -351,12 +361,32 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         // Update agent config with permissions
         agentConfigEntry.permission = agentPermission;
       }
+
+      // Register /auto-continue command so OpenCode recognizes it.
+      // Actual handling is done by command.execute.before hook below
+      // (no LLM round-trip — injected directly into output.parts).
+      const configCommand = opencodeConfig.command as
+        | Record<string, unknown>
+        | undefined;
+      if (!configCommand?.['auto-continue']) {
+        if (!opencodeConfig.command) {
+          opencodeConfig.command = {};
+        }
+        (opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
+          template: 'Call the auto_continue tool with enabled=true',
+          description:
+            'Enable auto-continuation — orchestrator keeps working through incomplete todos',
+        };
+      }
     },
 
     event: async (input) => {
       // Runtime model fallback for foreground agents (rate-limit detection)
       await foregroundFallback.handleEvent(input.event);
 
+      // Todo-continuation: auto-continue orchestrator on incomplete todos
+      await todoContinuationHook.handleEvent(input);
+
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
 
@@ -403,6 +433,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       );
     },
 
+    // Direct interception of /auto-continue command — bypasses LLM round-trip
+    'command.execute.before': async (input, output) => {
+      await todoContinuationHook.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
+    },
+
     'chat.headers': chatHeadersHook['chat.headers'],
 
     // Inject phase reminder and filter available skills before sending to API (doesn't show in UI)