Procházet zdrojové kódy

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 před 4 měsíci
rodič
revize
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';

+ 2200 - 0
src/hooks/todo-continuation/index.test.ts

@@ -0,0 +1,2200 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
+import { createTodoContinuationHook } from './index';
+
+describe('createTodoContinuationHook', () => {
+  function createMockContext(overrides?: {
+    todoResult?: {
+      data?: Array<{
+        id: string;
+        content: string;
+        status: string;
+        priority: string;
+      }>;
+    };
+    messagesResult?: {
+      data?: Array<{
+        info?: { role?: string };
+        parts?: Array<{ type?: string; text?: string }>;
+      }>;
+    };
+  }) {
+    return {
+      client: {
+        session: {
+          todo: mock(async () => overrides?.todoResult ?? { data: [] }),
+          messages: mock(async () => overrides?.messagesResult ?? { data: [] }),
+          prompt: mock(async () => ({})),
+        },
+      },
+    } as any;
+  }
+
+  async function delay(ms: number): Promise<void> {
+    await new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
+  // Notification prompts (noReply:true, no marker) fire immediately when
+  // scheduling a continuation. These helpers check only for actual
+  // continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER).
+  function hasContinuation(m: ReturnType<typeof mock>): boolean {
+    return m.mock.calls.some((c: any[]) =>
+      (c[0]?.body?.parts as any[])?.some((p: any) =>
+        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
+      ),
+    );
+  }
+  function contCount(m: ReturnType<typeof mock>): number {
+    return m.mock.calls.filter((c: any[]) =>
+      (c[0]?.body?.parts as any[])?.some((p: any) =>
+        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
+      ),
+    ).length;
+  }
+  function contCall(m: ReturnType<typeof mock>): any[] {
+    return m.mock.calls.find((c: any[]) =>
+      (c[0]?.body?.parts as any[])?.some((p: any) =>
+        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
+      ),
+    )!;
+  }
+
+  describe('tool toggle', () => {
+    test('calling auto_continue execute with { enabled: true } sets state', async () => {
+      const ctx = createMockContext();
+      const hook = createTodoContinuationHook(ctx);
+
+      const result = await hook.tool.auto_continue.execute({ enabled: true });
+
+      expect(result).toContain('Auto-continue enabled');
+      expect(result).toContain('up to 5');
+    });
+
+    test('calling auto_continue execute with { enabled: false } disables', async () => {
+      const ctx = createMockContext();
+      const hook = createTodoContinuationHook(ctx);
+
+      const result = await hook.tool.auto_continue.execute({ enabled: false });
+
+      expect(result).toBe('Auto-continue disabled.');
+    });
+  });
+
+  describe('continuation scheduling', () => {
+    test('session idle + enabled + incomplete todos → schedules continuation', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+            { id: '2', content: 'todo2', status: 'completed', priority: 'low' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Here is the result' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+      });
+
+      // Enable auto-continue
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Fire session.idle event
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait for cooldown
+      await delay(60);
+
+      // Verify session.prompt was called with continuation prompt
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      const promptCall = contCall(ctx.client.session.prompt);
+      expect(promptCall[0].path.id).toBe('session-123');
+      expect(promptCall[0].body.parts[0].text).toContain(
+        '[Auto-continue: enabled - there are incomplete todos remaining.',
+      );
+      expect(promptCall[0].body.parts[0].text).toContain(
+        SLIM_INTERNAL_INITIATOR_MARKER,
+      );
+    });
+
+    test('disabled → no continuation', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Done' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      // Do NOT enable auto-continue
+
+      // Fire session.idle event
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait for cooldown
+      await delay(60);
+
+      // Verify session.prompt was NOT called
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('last message is a question → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [
+                { type: 'text', text: 'Should I proceed with the next step?' },
+              ],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      // Enable auto-continue
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Fire session.idle event
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait for cooldown
+      await delay(60);
+
+      // Verify continuation NOT scheduled
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('question detection with question mark → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Ready to continue?' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('question detection with "would you like" phrase → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [
+                {
+                  type: 'text',
+                  text: 'Would you like me to proceed?',
+                },
+              ],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('max continuations reached → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 2,
+        cooldownMs: 50,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Fire idle events up to maxContinuations
+      for (let i = 0; i < 2; i++) {
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 'session-123' },
+          },
+        });
+        await delay(60);
+      }
+
+      // Reset mock for the 3rd attempt
+      ctx.client.session.prompt.mockClear();
+
+      // On the N+1th idle, verify no continuation scheduled
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('abort suppress window → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      // Seed orchestrator session
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Fire session.error with MessageAbortedError
+      await hook.handleEvent({
+        event: {
+          type: 'session.error',
+          properties: {
+            sessionID: 'session-123',
+            error: { name: 'MessageAbortedError' },
+          },
+        },
+      });
+
+      // Immediately fire session.idle
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait less than suppress window (5s) - just enough to verify it's working
+      await delay(100);
+
+      // Verify no continuation within suppress window
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('session busy → cancel pending timer', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 100,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Schedule a continuation
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Before cooldown expires, fire session.status with busy
+      await delay(50);
+      await hook.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-123',
+            status: { type: 'busy' },
+          },
+        },
+      });
+
+      // Advance past original cooldown
+      await delay(60);
+
+      // Verify timer was cancelled and prompt NOT called
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+    });
+
+    test('sub-agent session.busy does NOT cancel orchestrator timer', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 100,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Schedule a continuation for orchestrator session
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // A sub-agent (different session) goes busy
+      await delay(50);
+      await hook.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'sub-agent-456',
+            status: { type: 'busy' },
+          },
+        },
+      });
+
+      // Advance past original cooldown
+      await delay(60);
+
+      // Orchestrator timer should still fire — prompt was called
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('all todos complete → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'completed',
+              priority: 'high',
+            },
+            { id: '2', content: 'todo2', status: 'cancelled', priority: 'low' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'All done' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('non-orchestrator session → skip', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // First idle from session A (becomes orchestrator)
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-A' },
+        },
+      });
+
+      await delay(60);
+
+      // Verify prompt was called for session A
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+
+      // Reset mock
+      ctx.client.session.prompt.mockClear();
+
+      // Second idle from session B (different sessionID)
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-B' },
+        },
+      });
+
+      await delay(60);
+
+      // Verify no continuation for session B
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('cooldownMs from config', async () => {
+      const customCooldownMs = 100;
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: customCooldownMs,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Advance timer by less than custom cooldown
+      await delay(customCooldownMs - 1);
+
+      // Verify prompt not called yet
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+
+      // Advance timer by remaining 1ms
+      await delay(1);
+
+      // Now prompt should be called
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+  });
+
+  describe('event handling - session.error', () => {
+    test('MessageAbortedError sets suppress window', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      // Seed orchestrator session
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Fire session.error with MessageAbortedError
+      await hook.handleEvent({
+        event: {
+          type: 'session.error',
+          properties: {
+            sessionID: 'session-123',
+            error: { name: 'MessageAbortedError' },
+          },
+        },
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait less than suppress window
+      await delay(100);
+
+      // Verify no continuation within suppress window
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('AbortError sets suppress window', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      // Seed orchestrator session (disabled, so no continuation fires)
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.error',
+          properties: {
+            sessionID: 'session-123',
+            error: { name: 'AbortError' },
+          },
+        },
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait less than suppress window
+      await delay(100);
+
+      // Verify no continuation within suppress window
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('other errors do not set suppress window', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.error',
+          properties: {
+            sessionID: 'session-123',
+            error: { name: 'NetworkError' },
+          },
+        },
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      // Prompt should be called immediately (no suppress window)
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+  });
+
+  describe('event handling - session.deleted', () => {
+    test('clears pending timer on session delete', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 100,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Schedule continuation
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Delete session before timer fires
+      await delay(50);
+      await hook.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: {
+            sessionID: 'session-123',
+          },
+        },
+      });
+
+      // Advance past original cooldown
+      await delay(60);
+
+      // Verify timer was cancelled and prompt NOT called
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+    });
+
+    test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 100,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Schedule continuation for orchestrator session
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // A sub-agent (different session) gets deleted
+      await delay(50);
+      await hook.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: {
+            sessionID: 'sub-agent-456',
+          },
+        },
+      });
+
+      // Advance past original cooldown
+      await delay(60);
+
+      // Orchestrator timer should still fire — prompt was called
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('resets orchestrator session when deleted session matches', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // First idle sets orchestrator
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-A' },
+        },
+      });
+
+      await delay(60);
+
+      // Delete orchestrator session
+      await hook.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: {
+            sessionID: 'session-A',
+          },
+        },
+      });
+
+      // Second idle from new session should become orchestrator
+      ctx.client.session.prompt.mockClear();
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-B' },
+        },
+      });
+
+      await delay(60);
+
+      // Prompt should be called for session-B (new orchestrator)
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      const promptCall = contCall(ctx.client.session.prompt);
+      expect(promptCall[0].path.id).toBe('session-B');
+    });
+  });
+
+  describe('error handling', () => {
+    test('fetch todos failure → skips continuation', async () => {
+      const ctx = createMockContext({
+        todoResult: undefined as any,
+      });
+      ctx.client.session.todo = mock(async () => {
+        throw new Error('Failed to fetch todos');
+      });
+
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('fetch messages failure → skips continuation', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      ctx.client.session.messages = mock(async () => {
+        throw new Error('Failed to fetch messages');
+      });
+
+      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('command.execute.before interception', () => {
+    test('unrelated command → no interception', async () => {
+      const ctx = createMockContext();
+      const hook = createTodoContinuationHook(ctx);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await hook.handleCommandExecuteBefore(
+        { command: 'help', sessionID: 'session-123', arguments: '' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(0);
+    });
+
+    test('/auto-continue enables and injects continuation when incomplete todos', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'pending',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(1);
+      expect(output.parts[0].text).toContain(
+        '[Auto-continue: enabled - there are incomplete todos remaining.',
+      );
+      expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
+    });
+
+    test('/auto-continue enables but no continuation when all todos complete', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'completed',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(1);
+      expect(output.parts[0].text).toContain('No incomplete todos right now');
+    });
+
+    test('/auto-continue toggles off when already enabled', async () => {
+      const ctx = createMockContext();
+      const hook = createTodoContinuationHook(ctx);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      // Enable via tool
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Toggle off via command
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(1);
+      expect(output.parts[0].text).toContain('disabled by user command');
+    });
+
+    test('/auto-continue resets consecutive continuations on toggle', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'pending',
+              priority: 'high',
+            },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 2,
+        cooldownMs: 50,
+      });
+
+      // Enable and run up to max
+      await hook.tool.auto_continue.execute({ enabled: true });
+      for (let i = 0; i < 2; i++) {
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 'session-123' },
+          },
+        });
+        await delay(60);
+      }
+
+      // Toggle off then on via command (resets count)
+      const outputOff = {
+        parts: [] as Array<{ type: string; text?: string }>,
+      };
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        outputOff,
+      );
+      expect(outputOff.parts[0].text).toContain('disabled');
+
+      const outputOn = {
+        parts: [] as Array<{ type: string; text?: string }>,
+      };
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        outputOn,
+      );
+      // Should have continuation prompt again (count was reset)
+      expect(outputOn.parts[0].text).toContain(
+        '[Auto-continue: enabled - there are incomplete todos remaining.',
+      );
+    });
+
+    test('/auto-continue with todo fetch failure → enables without continuation', async () => {
+      const ctx = createMockContext();
+      ctx.client.session.todo = mock(async () => {
+        throw new Error('Network error');
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await hook.handleCommandExecuteBefore(
+        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
+        output,
+      );
+
+      // Should still enable but skip continuation (no todos fetched)
+      expect(output.parts).toHaveLength(1);
+      expect(output.parts[0].text).toContain('No incomplete todos right now');
+    });
+  });
+
+  describe('config defaults', () => {
+    test('default config: maxContinuations = 5, cooldownMs = 3000', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx); // No config passed
+
+      const result = await hook.tool.auto_continue.execute({ enabled: true });
+
+      expect(result).toContain('up to 5');
+
+      // Test default cooldown - we'll just verify it waits before calling
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+
+      // Wait less than default cooldown
+      await delay(100);
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+
+      // Wait past default cooldown
+      await delay(2900);
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+  });
+
+  describe('council review findings', () => {
+    describe('CRITICAL-1: counter bypass via session.status→busy', () => {
+      test('counter persists when busy fires during auto-injection', async () => {
+        let promptResolve!: () => void;
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+
+        // Make prompt hang so isAutoInjecting stays true
+        ctx.client.session.prompt = mock(async () => {
+          await new Promise<void>((r) => {
+            promptResolve = r;
+          });
+        });
+
+        const hook = createTodoContinuationHook(ctx, {
+          maxContinuations: 2,
+          cooldownMs: 50,
+        });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Cycle 1: idle → timer → prompt hangs
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        // Session goes busy from prompt — isAutoInjecting is true,
+        // so counter should NOT be reset
+        await hook.handleEvent({
+          event: {
+            type: 'session.status',
+            properties: {
+              sessionID: 's1',
+              status: { type: 'busy' },
+            },
+          },
+        });
+
+        // Resolve prompt → counter = 1
+        promptResolve();
+        await delay(10);
+
+        // Cycle 2: idle → timer → prompt hangs
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        // Session goes busy again — counter still not reset
+        await hook.handleEvent({
+          event: {
+            type: 'session.status',
+            properties: {
+              sessionID: 's1',
+              status: { type: 'busy' },
+            },
+          },
+        });
+
+        // Resolve prompt → counter = 2
+        promptResolve();
+        await delay(10);
+
+        // Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED
+        ctx.client.session.prompt = mock(async () => ({}));
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+      });
+    });
+
+    describe('CRITICAL-2: disable cancels pending timer', () => {
+      test('tool disable during cooldown prevents injection', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire idle → timer scheduled (100ms cooldown)
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+
+        // Disable before timer fires
+        await delay(50);
+        await hook.tool.auto_continue.execute({ enabled: false });
+
+        // Wait past original cooldown
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+      });
+
+      test('command disable during cooldown prevents injection', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
+
+        // Enable via command
+        const outputOn = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: 'on',
+          },
+          outputOn,
+        );
+
+        // Fire idle → timer scheduled
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+
+        // Disable via command before timer fires
+        await delay(50);
+        const outputOff = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: 'off',
+          },
+          outputOff,
+        );
+
+        // Wait past original cooldown
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
+      });
+    });
+
+    describe('MAJOR-1: session.deleted resets counter', () => {
+      test('deleted orchestrator session resets counter for next session', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, {
+          maxContinuations: 2,
+          cooldownMs: 50,
+        });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Cycle 1: idle → inject → counter = 1
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        // Delete orchestrator session → counter should reset
+        await hook.handleEvent({
+          event: {
+            type: 'session.deleted',
+            properties: { sessionID: 's1' },
+          },
+        });
+
+        // New session becomes orchestrator — counter starts from 0
+        ctx.client.session.prompt.mockClear();
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's2' },
+          },
+        });
+        await delay(60); // counter = 1
+
+        // One more cycle → counter = 2 (reaches max)
+        ctx.client.session.prompt.mockClear();
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's2' },
+          },
+        });
+        await delay(60);
+
+        // Third cycle blocked (counter = 2 >= max = 2)
+        ctx.client.session.prompt.mockClear();
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's2' },
+          },
+        });
+        await delay(60);
+
+        expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('MAJOR-2: suppressUntil cleared on re-enable', () => {
+      test('tool re-enable clears suppress window', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire abort → sets suppress window
+        await hook.handleEvent({
+          event: {
+            type: 'session.error',
+            properties: {
+              sessionID: 's1',
+              error: { name: 'AbortError' },
+            },
+          },
+        });
+
+        // Re-enable within suppress window → clears suppressUntil
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire idle → should NOT be suppressed
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      });
+
+      test('command re-enable clears suppress window', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire abort → sets suppress window
+        await hook.handleEvent({
+          event: {
+            type: 'session.error',
+            properties: {
+              sessionID: 's1',
+              error: { name: 'AbortError' },
+            },
+          },
+        });
+
+        // Re-enable via command → clears suppressUntil
+        const output = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: 'on',
+          },
+          output,
+        );
+
+        // Fire idle → should NOT be suppressed
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      });
+    });
+
+    describe('error paths', () => {
+      test('prompt failure in timer callback is handled gracefully', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        ctx.client.session.prompt = mock(async () => {
+          throw new Error('API error');
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+
+        // Seed orchestrator session
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        // Error caught; isAutoInjecting should be cleared via finally.
+        // Verify by checking a second idle still works.
+        ctx.client.session.prompt = mock(async () => ({}));
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      });
+    });
+
+    describe('edge cases', () => {
+      test('session.idle with missing sessionID returns early', async () => {
+        const ctx = createMockContext();
+        const hook = createTodoContinuationHook(ctx);
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire idle without sessionID — should not throw
+        await hook.handleEvent({
+          event: { type: 'session.idle', properties: {} },
+        });
+
+        expect(ctx.client.session.todo).not.toHaveBeenCalled();
+      });
+
+      test('session.deleted with properties.info.id path', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Set orchestrator via idle
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+
+        // Delete via info.id path (alternative shape from session store)
+        await hook.handleEvent({
+          event: {
+            type: 'session.deleted',
+            properties: { info: { id: 's1' } },
+          },
+        });
+
+        // New session should become orchestrator
+        ctx.client.session.prompt.mockClear();
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's2' },
+          },
+        });
+        await delay(60);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+        expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2');
+      });
+
+      test('cooldownMs = 0 fires on next tick', async () => {
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        const hook = createTodoContinuationHook(ctx, {
+          cooldownMs: 0,
+          maxContinuations: 5,
+        });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(10);
+
+        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+      });
+    });
+
+    describe('MAJOR-3: double-fire prevention', () => {
+      test('rapid idle events during prompt delivery — single continuation', async () => {
+        let promptResolve!: () => void;
+        const ctx = createMockContext({
+          todoResult: {
+            data: [
+              {
+                id: '1',
+                content: 't1',
+                status: 'pending',
+                priority: 'high',
+              },
+            ],
+          },
+          messagesResult: {
+            data: [
+              {
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Work' }],
+              },
+            ],
+          },
+        });
+        ctx.client.session.prompt = mock(async () => {
+          await new Promise<void>((r) => {
+            promptResolve = r;
+          });
+        });
+
+        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // Fire idle → timer → prompt hangs (isAutoInjecting = true)
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        // Fire another idle while prompt is in flight
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+
+        // Only one prompt call (blocked by isAutoInjecting gate)
+        expect(contCount(ctx.client.session.prompt)).toBe(1);
+
+        // Resolve prompt
+        promptResolve();
+        await delay(10);
+
+        // Now idle should schedule a new timer
+        ctx.client.session.prompt = mock(async () => ({}));
+        await hook.handleEvent({
+          event: {
+            type: 'session.idle',
+            properties: { sessionID: 's1' },
+          },
+        });
+        await delay(60);
+
+        expect(contCount(ctx.client.session.prompt)).toBe(1);
+      });
+    });
+
+    describe('MAJOR-4: command explicit on|off arguments', () => {
+      test('command "on" keeps enabled state when already enabled', async () => {
+        const ctx = createMockContext();
+        const hook = createTodoContinuationHook(ctx);
+
+        // Enable via tool
+        await hook.tool.auto_continue.execute({ enabled: true });
+
+        // /auto-continue on → should KEEP enabled (not toggle to off)
+        const output = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: 'on',
+          },
+          output,
+        );
+
+        expect(output.parts[0].text).not.toContain('disabled');
+      });
+
+      test('command "off" keeps disabled state when already disabled', async () => {
+        const ctx = createMockContext();
+        const hook = createTodoContinuationHook(ctx);
+
+        // Start disabled (default)
+        const output = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: 'off',
+          },
+          output,
+        );
+
+        expect(output.parts[0].text).toContain('disabled');
+      });
+
+      test('command with no argument toggles state', async () => {
+        const ctx = createMockContext();
+        const hook = createTodoContinuationHook(ctx);
+
+        // First toggle: disabled → enabled
+        const output1 = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: '',
+          },
+          output1,
+        );
+        expect(output1.parts[0].text).not.toContain('disabled');
+
+        // Second toggle: enabled → disabled
+        const output2 = {
+          parts: [] as Array<{ type: string; text?: string }>,
+        };
+        await hook.handleCommandExecuteBefore(
+          {
+            command: 'auto-continue',
+            sessionID: 's1',
+            arguments: '',
+          },
+          output2,
+        );
+        expect(output2.parts[0].text).toContain('disabled');
+      });
+    });
+  });
+
+  describe('auto-enable on todo count', () => {
+    function createAutoEnableCtx(
+      todos: Array<{
+        id: string;
+        content: string;
+        status: string;
+        priority: string;
+      }>,
+    ) {
+      return createMockContext({
+        todoResult: { data: todos },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Working...' }],
+            },
+          ],
+        },
+      });
+    }
+
+    test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+        { id: '3', content: 't3', status: 'pending', priority: 'high' },
+        { id: '4', content: 't4', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      // Do NOT manually enable — auto-enable should trigger
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // Should have scheduled continuation (auto-enabled)
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+        { id: '3', content: 't3', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // Should NOT auto-enable or continue
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => {
+      const ctx = createAutoEnableCtx(
+        Array.from({ length: 10 }, (_, i) => ({
+          id: String(i),
+          content: `t${i}`,
+          status: 'pending',
+          priority: 'high',
+        })),
+      );
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        // autoEnable defaults to false
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('auto-enable does not re-enable if already manually enabled', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      // Manually enable first
+      await hook.tool.auto_continue.execute({ enabled: true });
+
+      // Only 2 todos (< threshold) — but already enabled, so should continue
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // Continues because already manually enabled (auto-enable check skipped)
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('auto-enable respects custom threshold', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 2,
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // 2 todos >= threshold 2 → auto-enables
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('auto-enable skipped for non-orchestrator session', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+        { id: '3', content: 't3', status: 'pending', priority: 'high' },
+        { id: '4', content: 't4', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      // First idle sets orchestrator to session-A
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-A' },
+        },
+      });
+      await delay(60);
+
+      // Reset mock
+      ctx.client.session.prompt.mockClear();
+
+      // Second idle from session-B — not orchestrator, should skip
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-B' },
+        },
+      });
+      await delay(60);
+
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => {
+      const ctx = createMockContext();
+      ctx.client.session.todo = mock(async () => {
+        throw new Error('Network error');
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      // Should not throw
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // No auto-enable, no continuation
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+
+    test('auto-enable resets consecutive counter and suppress window', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'pending', priority: 'high' },
+        { id: '2', content: 't2', status: 'pending', priority: 'high' },
+        { id: '3', content: 't3', status: 'pending', priority: 'high' },
+        { id: '4', content: 't4', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      // Manually enable, run a continuation, disable
+      await hook.tool.auto_continue.execute({ enabled: true });
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+      await delay(60);
+
+      // Fire abort to set suppress window
+      await hook.handleEvent({
+        event: {
+          type: 'session.error',
+          properties: {
+            sessionID: 's1',
+            error: { name: 'AbortError' },
+          },
+        },
+      });
+
+      // Disable
+      await hook.tool.auto_continue.execute({ enabled: false });
+
+      // Reset mock
+      ctx.client.session.prompt.mockClear();
+
+      // Fire idle again — auto-enable should trigger (4 todos >= 4),
+      // resetting counter and suppress window
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // Should continue (suppressed window was cleared by auto-enable)
+      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
+    });
+
+    test('auto-enable counts incomplete todos only, not completed', async () => {
+      const ctx = createAutoEnableCtx([
+        { id: '1', content: 't1', status: 'completed', priority: 'high' },
+        { id: '2', content: 't2', status: 'completed', priority: 'high' },
+        { id: '3', content: 't3', status: 'pending', priority: 'high' },
+        { id: '4', content: 't4', status: 'pending', priority: 'high' },
+      ]);
+      const hook = createTodoContinuationHook(ctx, {
+        maxContinuations: 5,
+        cooldownMs: 50,
+        autoEnable: true,
+        autoEnableThreshold: 4,
+      });
+
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 's1' },
+        },
+      });
+
+      await delay(60);
+
+      // Only 2 incomplete todos < threshold 4 → does NOT auto-enable
+      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    });
+  });
+});

+ 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)