Selaa lähdekoodia

Merge pull request #869 from alvinunreal/fix/continuation-beta-opt-in

fix(continuation): make idle nudges opt-in beta
Alvin 1 kuukausi sitten
vanhempi
sitoutus
94daab6acf

+ 8 - 7
docs/agents/build-agent-empty-input-diagnosis.md

@@ -9,7 +9,7 @@
 
 The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
 
-**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is enabled by default via `backgroundJobs.continueOnIdle` (opt out with `false`), and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
+**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is an opt-in beta via `backgroundJobs.continueOnIdle: true`, and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
 
 ## Root cause (causal chain, cross-validated)
 
@@ -40,7 +40,7 @@ The `build` agent turn with empty input is **the same class of bug** as the orig
 
 | File:line | Trigger | Body omits `agent`? | Gate |
 |---|---|---|---|
-| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when `backgroundJobs.continueOnIdle` is `true` (default **on**) | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
+| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when the opt-in beta `backgroundJobs.continueOnIdle` is `true` | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
 | `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
 | `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
 | `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
@@ -65,10 +65,11 @@ This is the pattern every `promptAsync` caller in omos should follow.
 The gate exists and works in the common case (see `continuation-evaluator.ts` and
 `task-session-manager/index.test.ts` continuation cases). Notes:
 
-1. **Continuation is on by default.** `backgroundJobs.continueOnIdle` defaults
-   to `true`; set `false` to keep idle reconciliation without continuation SDK
-   calls. When enabled, a process-local reserve/commit gate allows at most one
-   `promptAsync` per session epoch between real user messages.
+1. **Continuation is opt-in beta.** `backgroundJobs.continueOnIdle` defaults
+   to `false`; set it to `true` to enable continuation SDK calls. Idle
+   reconciliation remains active either way. When enabled, a process-local
+   reserve/commit gate allows at most one `promptAsync` per session epoch
+   between real user messages.
 
 2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`.
    The idle-reconciliation comment admits late completions can still race the
@@ -111,7 +112,7 @@ opencode's `default_agent` resolution, eliminating the path to `build`.
 ## Evidence index
 
 ### omos source
-- **Continuation nudge (fixed agent + default-on + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
+- **Continuation nudge (fixed agent + opt-in beta + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
 - **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
 - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`

+ 8 - 8
docs/background-orchestration.md

@@ -321,22 +321,22 @@ multiplexer panes attached while the parent orchestrator continues scheduling.
 
 ### Incomplete-todo continuation nudge
 
-Automatic incomplete-todo continuation is **enabled by default**. Idle
-reconciliation and background-job orchestration always run; set
-`continueOnIdle` to `false` to keep those without hidden continuation prompts:
+Automatic incomplete-todo continuation is an **opt-in beta feature**. Idle
+reconciliation and background-job orchestration always run without it. Enable
+the beta only when you want hidden continuation prompts:
 
 ```jsonc
 {
   "backgroundJobs": {
-    "continueOnIdle": false
+    "continueOnIdle": true
   }
 }
 ```
 
-When `backgroundJobs.continueOnIdle` is `true` (the default), after an
-orchestrator session becomes idle the plugin may send **at most one** internal,
-delayed continuation prompt when OpenCode reports incomplete todos. That limit
-is per session between real external user messages (text/file/image).
+When `backgroundJobs.continueOnIdle` is `true`, after an orchestrator session
+becomes idle the plugin may send **at most one** internal, delayed continuation
+prompt when OpenCode reports incomplete todos. That limit is per session between
+real external user messages (text/file/image).
 Synthetic/internal inputs and subsequent idle/busy events do not rearm it. A
 real user message rearms the one-shot nudge once per message identity
 (`chat.message` `messageID` / `message.id`), shared across hook instances in the

+ 14 - 3
docs/configuration.md

@@ -149,7 +149,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss |
 | `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart |
-| `backgroundJobs.continueOnIdle` | boolean | `true` | When `true` (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set `false` to keep idle reconciliation and background-job orchestration without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
+| `backgroundJobs.continueOnIdle` | boolean | `false` | **Beta opt-in.** Set `true` to let idle orchestrator sessions with incomplete todos receive one automatic hidden continuation prompt. When omitted or `false`, idle reconciliation and background-job orchestration remain active without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -261,8 +261,19 @@ major is available, the plugin shows a migration command instead.
 Background job management is enabled by default and does not need to be present
 in the starter config. Add `backgroundJobs` only if you want to tune how many
 completed/reconciled child-agent sessions are reusable, how much read context is
-shown, how board snapshots are injected, or to disable automatic incomplete-todo
-continuation prompts on idle (`continueOnIdle`, default `true`). See the
+shown, how board snapshots are injected, or to opt into beta automatic
+incomplete-todo continuation prompts on idle:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "continueOnIdle": true
+  }
+}
+```
+
+Without that opt-in, idle reconciliation and background-job orchestration remain
+enabled but no hidden continuation prompts are sent. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
 

+ 2 - 2
oh-my-opencode-slim.schema.json

@@ -1055,8 +1055,8 @@
           "maximum": 100
         },
         "continueOnIdle": {
-          "default": true,
-          "description": "When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.",
+          "default": false,
+          "description": "Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.",
           "type": "boolean"
         }
       }

+ 2 - 2
src/config/schema.test.ts

@@ -51,12 +51,12 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
-  it('defaults continueOnIdle to true', () => {
+  it('defaults continueOnIdle to false', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
 
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
     }
   });
 

+ 2 - 2
src/config/schema.ts

@@ -218,9 +218,9 @@ export const BackgroundJobsConfigSchema = z.object({
     ),
   continueOnIdle: z
     .boolean()
-    .default(true)
+    .default(false)
     .describe(
-      'When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.',
+      'Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.',
     ),
 });
 

+ 70 - 61
src/hooks/task-session-manager/index.test.ts

@@ -35,14 +35,13 @@ async function flushChildIdleReconcile(): Promise<void> {
   await new Promise((resolve) => setTimeout(resolve, 5));
 }
 
-function createHook(options?: {
+type HookOptions = {
   shouldManageSession?: (sessionID: string) => boolean;
   registerSessionAsOrchestrator?: (sessionID: string) => void;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
   maxRetainedSnapshots?: number;
-  /** Matches production default true; set false to exercise opt-out. */
   continueOnIdle?: boolean;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
@@ -50,7 +49,9 @@ function createHook(options?: {
   idleReconcileDelayMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
-}) {
+};
+
+function createHook(options?: HookOptions) {
   const hook = createTaskSessionManagerHook(
     {
       client: {
@@ -69,7 +70,7 @@ function createHook(options?: {
       strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
-      continueOnIdle: options?.continueOnIdle ?? true,
+      continueOnIdle: options?.continueOnIdle ?? false,
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
@@ -82,6 +83,13 @@ function createHook(options?: {
   return { hook };
 }
 
+function createContinuationHook(options?: HookOptions) {
+  return createHook({
+    ...options,
+    continueOnIdle: options?.continueOnIdle ?? true,
+  });
+}
+
 function createMessages(sessionID: string, text = 'user message') {
   return {
     messages: [
@@ -3471,7 +3479,7 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
   });
 
-  test('defaults continueOnIdle on: continuation SDK calls run', async () => {
+  test('defaults continueOnIdle off: continuation SDK calls do not run', async () => {
     const promptAsync = mock(async () => ({}));
     const todo = mock(async () => ({ data: [{ status: 'in_progress' }] }));
     const hook = createTaskSessionManagerHook(
@@ -3500,8 +3508,8 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
 
-    expect(todo).toHaveBeenCalled();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
   });
 
   test('explicit continueOnIdle false reconciles parent terminal job without continuation', async () => {
@@ -3552,7 +3560,8 @@ describe('task-session-manager hook', () => {
 
   test('nudges once for incomplete todos when parent and children are inactive', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
+      continueOnIdle: true,
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
@@ -3579,7 +3588,7 @@ describe('task-session-manager hook', () => {
 
   test('paired idle events submit at most one continuation', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -3716,7 +3725,7 @@ describe('task-session-manager hook', () => {
           resolvePrompt = resolve;
         }),
     );
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -3740,7 +3749,7 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(1);
 
     await hook.event({ event: { type: 'server.instance.disposed' } });
-    const { hook: nextHook } = createHook({
+    const { hook: nextHook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -3764,7 +3773,7 @@ describe('task-session-manager hook', () => {
     const promptAsync = mock(async () => {
       throw new Error('prompt rejected');
     });
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -3804,7 +3813,7 @@ describe('task-session-manager hook', () => {
         }),
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -3912,7 +3921,7 @@ describe('task-session-manager hook', () => {
       promptAsync,
     };
     let fallbackInProgress = false;
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       isFallbackInProgress: () => fallbackInProgress,
       sessionClient,
@@ -3951,7 +3960,7 @@ describe('task-session-manager hook', () => {
       if (todoCalls === 1) return { data: undefined };
       return { data: [{ status: 'pending' }] };
     });
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -3985,7 +3994,7 @@ describe('task-session-manager hook', () => {
       }
       return { data: {} };
     });
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4011,7 +4020,7 @@ describe('task-session-manager hook', () => {
   test('does not evaluate or nudge while a question or permission waits', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4045,7 +4054,7 @@ describe('task-session-manager hook', () => {
   test('does not evaluate or nudge after wait_for_user requests text-only HITL', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4067,7 +4076,7 @@ describe('task-session-manager hook', () => {
 
   test('a distinct external user message releases wait_for_user', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4100,7 +4109,7 @@ describe('task-session-manager hook', () => {
   test('a duplicate external message cannot clear a newer user wait', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4142,7 +4151,7 @@ describe('task-session-manager hook', () => {
   test('synthetic and internal messages do not clear wait_for_user', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4179,7 +4188,7 @@ describe('task-session-manager hook', () => {
   test('a foreground-fallback replay marker does not clear wait_for_user', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4218,7 +4227,7 @@ describe('task-session-manager hook', () => {
     const children = mock(async () => ({ data: [] }));
     const status = mock(async () => ({ data: {} }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: { todo, children, status, promptAsync },
     });
@@ -4244,7 +4253,7 @@ describe('task-session-manager hook', () => {
         }),
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4282,11 +4291,11 @@ describe('task-session-manager hook', () => {
       status: mock(async () => ({ data: {} })),
       promptAsync,
     };
-    const owner = createHook({
+    const owner = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient,
     }).hook;
-    const waiter = createHook({
+    const waiter = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient,
     }).hook;
@@ -4306,7 +4315,7 @@ describe('task-session-manager hook', () => {
 
   test('external user input clears only the explicit wait while a question remains', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4363,7 +4372,7 @@ describe('task-session-manager hook', () => {
       promptAsync,
     };
     const makeHook = () =>
-      createHook({ idleReconcileDelayMs: 0, sessionClient }).hook;
+      createContinuationHook({ idleReconcileDelayMs: 0, sessionClient }).hook;
     const owner = makeHook();
 
     owner.beginUserWait('parent-1');
@@ -4391,7 +4400,7 @@ describe('task-session-manager hook', () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
     let fallbackInProgress = true;
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       isFallbackInProgress: () => fallbackInProgress,
       sessionClient: {
@@ -4421,7 +4430,7 @@ describe('task-session-manager hook', () => {
     const children = mock(async () => ({ data: [] }));
     const status = mock(async () => ({ data: {} }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: { todo, children, status, promptAsync },
     });
@@ -4446,7 +4455,7 @@ describe('task-session-manager hook', () => {
   test('fails closed when an id-less ask races a scheduled continuation', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4487,7 +4496,7 @@ describe('task-session-manager hook', () => {
 
   test('clears only the resolved input wait and resumes on a later idle', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4562,7 +4571,7 @@ describe('task-session-manager hook', () => {
 
   test('resumes on a later idle after a question rejection', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4605,7 +4614,7 @@ describe('task-session-manager hook', () => {
         }),
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4636,7 +4645,7 @@ describe('task-session-manager hook', () => {
   test('internal and synthetic messages do not clear an input wait', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4678,7 +4687,7 @@ describe('task-session-manager hook', () => {
   test('retains input waits across a session error', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -4723,7 +4732,7 @@ describe('task-session-manager hook', () => {
       },
     ] as const) {
       const promptAsync = mock(async () => ({}));
-      const { hook } = createHook({
+      const { hook } = createContinuationHook({
         idleReconcileDelayMs: 0,
         sessionClient: {
           todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4752,7 +4761,7 @@ describe('task-session-manager hook', () => {
   test('coalesces paired idle events and suppresses active children', async () => {
     const promptAsync = mock(async () => ({}));
     const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4779,7 +4788,7 @@ describe('task-session-manager hook', () => {
 
   test('runtime-shaped external messages rearm a consumed nudge', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4814,7 +4823,7 @@ describe('task-session-manager hook', () => {
 
   test('output.message.id rearms when input.messageID is missing', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4850,7 +4859,7 @@ describe('task-session-manager hook', () => {
 
   test('ID-less output.message object identity rearms once', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4942,7 +4951,7 @@ describe('task-session-manager hook', () => {
 
   test('distinct ID-less message objects each open a new epoch', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4988,7 +4997,7 @@ describe('task-session-manager hook', () => {
 
   test('missing id and output.message fails closed without rearm', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5149,7 +5158,7 @@ describe('task-session-manager hook', () => {
 
   test('file-only external messages rearm a consumed nudge', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5184,7 +5193,7 @@ describe('task-session-manager hook', () => {
 
   test('synthetic completion messages do not rearm a consumed nudge', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5225,7 +5234,7 @@ describe('task-session-manager hook', () => {
 
   test('nudge busy-to-idle cycle does not send a second unchanged nudge', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5262,7 +5271,7 @@ describe('task-session-manager hook', () => {
         }),
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo,
@@ -5294,7 +5303,7 @@ describe('task-session-manager hook', () => {
     const board = new BackgroundJobBoard();
     setupCompletedJob(board);
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       backgroundJobBoard: board,
       idleReconcileDelayMs: 0,
       sessionClient: {
@@ -5319,7 +5328,7 @@ describe('task-session-manager hook', () => {
 
   test('missing SDK response data fails closed without nudging', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: undefined })),
@@ -5339,7 +5348,7 @@ describe('task-session-manager hook', () => {
 
   test('does not nudge when todos are completed or cancelled only', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({
@@ -5361,7 +5370,7 @@ describe('task-session-manager hook', () => {
 
   test('does not nudge while the parent is active', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5381,7 +5390,7 @@ describe('task-session-manager hook', () => {
 
   test('does not nudge while a child is retrying', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5403,7 +5412,7 @@ describe('task-session-manager hook', () => {
 
   test('does not rearm a consumed nudge for its actual internal part', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5440,7 +5449,7 @@ describe('task-session-manager hook', () => {
     const promptAsync = mock(async () => {
       throw new Error('prompt failed');
     });
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5464,7 +5473,7 @@ describe('task-session-manager hook', () => {
 
   test('keeps a failed prompt response consumed', async () => {
     const promptAsync = mock(async () => ({ error: 'prompt failed' }));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -5488,12 +5497,12 @@ describe('task-session-manager hook', () => {
 
   test('fails closed for missing or throwing SDK endpoints', async () => {
     const missingPrompt = mock(async () => ({}));
-    const { hook: missingHook } = createHook({
+    const { hook: missingHook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: { promptAsync: missingPrompt },
     });
     const throwingPrompt = mock(async () => ({}));
-    const { hook: throwingHook } = createHook({
+    const { hook: throwingHook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => {
@@ -5518,7 +5527,7 @@ describe('task-session-manager hook', () => {
 
   test('does not nudge when fallback is already in progress', async () => {
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       isFallbackInProgress: () => true,
       sessionClient: {
@@ -5546,7 +5555,7 @@ describe('task-session-manager hook', () => {
       },
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       isFallbackInProgress: () => fallbackInProgress,
       sessionClient: {
@@ -5576,7 +5585,7 @@ describe('task-session-manager hook', () => {
       releaseLatestChildren = () => resolve({ data: [] });
     });
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       backgroundJobBoard: board,
       idleReconcileDelayMs: 0,
       sessionClient: {
@@ -5609,7 +5618,7 @@ describe('task-session-manager hook', () => {
       },
     );
     const promptAsync = mock(async () => ({}));
-    const { hook } = createHook({
+    const { hook } = createContinuationHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => todos),

+ 4 - 4
src/hooks/task-session-manager/index.ts

@@ -47,9 +47,9 @@ export function createTaskSessionManagerHook(
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     /**
-     * When true (default), idle orchestrator sessions with incomplete todos may
-     * receive one automatic continuation promptAsync. Set false to keep idle
-     * reconciliation without continuation SDK calls.
+     * Beta opt-in. When true, idle orchestrator sessions with incomplete todos
+     * may receive one automatic continuation promptAsync. Disabled by default;
+     * idle reconciliation continues without continuation SDK calls.
      */
     continueOnIdle?: boolean;
     backgroundJobBoard?: BackgroundJobStore;
@@ -68,7 +68,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
   },
 ) {
-  const continueOnIdle = options.continueOnIdle !== false;
+  const continueOnIdle = options.continueOnIdle === true;
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     new BackgroundJobBoard({

+ 1 - 1
src/index.ts

@@ -352,7 +352,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles:
         config.backgroundJobs?.readContextMaxFiles ??
         DEFAULT_READ_CONTEXT_MAX_FILES,
-      continueOnIdle: config.backgroundJobs?.continueOnIdle !== false,
+      continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',