Browse Source

feat(orchestration): add periodic wake scheduler

Alvin Unreal 1 week ago
parent
commit
86d6a77b57

+ 55 - 40
docs/background-orchestration.md

@@ -37,7 +37,7 @@ The required native/background-control tools are:
 | `task(..., background: true)` | Start a specialist in the background and immediately return a task ID |
 | hook-driven completion | OpenCode injects terminal background task results automatically |
 | `cancel_task` | Plugin-provided tool to cancel a tracked background task by task ID or Background Job Board alias |
-| `wait_for_user` | Plugin-provided orchestrator tool that pauses automatic continuation while the user performs external manual work |
+| `wait_for_user` | Plugin-provided orchestrator tool that pauses automatic orchestrator wakes while the user performs external manual work |
 
 If these are not available, the scheduler cannot use the default background
 workflow. Configure the environment variable through the installer or use the
@@ -164,9 +164,9 @@ changes before launching a replacement lane.
 Terminal jobs are reconciled automatically after their result is injected into
 the orchestrator session. That lifecycle state is not proof the output was used;
 the orchestrator must still verify it consumed the relevant result before
-finalizing. When idle reconciliation performs that reconciliation, the opt-in
-continuation evaluator can run in the same idle cycle, subject to its existing
-guards.
+finalizing. Separately, the default-on orchestrator wake scheduler may prompt an
+idle parent with incomplete todos after continuous idle time; it does not depend
+on the local job board.
 
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.
@@ -317,41 +317,66 @@ rather than "work complete". It tracks running task IDs, exposes recent work in
 the background job board, updates aliases from task results, and keeps
 multiplexer panes attached while the parent orchestrator continues scheduling.
 
-### Incomplete-todo continuation nudge
+### Orchestrator wake scheduler
 
-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:
+When an orchestrator parent stays continuously idle, the plugin may send a
+periodic internal wake prompt so incomplete TODOs are not abandoned. This is
+**enabled by default** with a **5-minute** interval:
 
 ```jsonc
 {
   "backgroundJobs": {
-    "continueOnIdle": true
+    "orchestratorWake": {
+      "enabled": true,
+      "intervalMs": 300000
+    }
   }
 }
 ```
 
-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
-process; observing the same message twice does not open a second epoch. Internal
-prompts and todo updates do not rearm.
-
-Continuation is suppressed when the SDK reports the parent or any direct child
-as active, when a terminal child result has not yet been reconciled, during
-foreground fallback, while OpenCode is waiting for a question or permission
-response, after the orchestrator calls `wait_for_user`, or whenever SDK data is
-unavailable or malformed. A matching reply, or a rejected question, clears its
-tool-backed wait but does not itself inject a nudge; the normal session lifecycle
-decides whether a later nudge is needed.
-
-When idle reconciliation first reconciles an injected terminal result, the
-opt-in evaluator may run in that same idle cycle; the existing liveness,
-wait, fallback, and one-attempt guards still apply.
+`intervalMs` must be an integer from `60000` to `2147483647`. `0` is invalid.
+Set `enabled: false` to disable wakes while keeping idle reconciliation and
+background-job orchestration.
+
+Behavior:
+
+- Per-session recursive `setTimeout(...).unref()` after continuous parent-idle
+  time (never a global interval).
+- Only sessions known as the parent `orchestrator` via session metadata.
+- Host client APIs are authoritative (`session.get`, `todo`, `children`,
+  `status`, `promptAsync` with the nested directory request shape). The local
+  Background Job Board is never read or used as a gate.
+- Wake requires valid host response shapes, parent currently idle, and at least
+  one TODO with status `pending` or `in_progress`. Unknown/malformed status
+  fails closed. **Active children do not suppress a wake.**
+- Suppress/clear on question/permission input waits, `wait_for_user`, foreground
+  fallback, session busy, session deletion, external user messages, and server
+  disposal.
+- One in-flight evaluation/wake per session. Status/waits/generation are
+  rechecked immediately before `promptAsync`. Cooldown/reservation is recorded
+  before the call so a failed `promptAsync` cannot storm retries.
+- Default-on safety: the scheduler evaluates a bounded host-progress fingerprint
+  (TODO statuses plus child status/update evidence) to decide whether to keep
+  waking. After **two** successful wakes with an unchanged fingerprint, further
+  wakes stop for that continuous idle spell. A real external user message or
+  host-observed progress re-arms the cap. Busy caused by the wake itself does
+  **not** rearm the cap; unrelated busy/error lifecycle events do. The wake
+  prompt text is static and does **not** include a fingerprint or snapshot.
+- Static wake text (internal initiator part via `promptAsync` only — no message
+  transform injection or history rewrite):
+
+```text
+<system-reminder>
+Finish any incomplete TODOs. Await running agents; if one appears stuck, assess it and cancel/respawn only when justified. Do not respond to this reminder.
+</system-reminder>
+```
+
+The scheduler does **not** perform automatic cancellation and does not rely on
+the local job board. When no incomplete TODOs remain, it ends the current idle
+spell and stops polling until new activity.
+
+**v2 availability:** the v2 shim lacks the required session APIs, so this
+capability-gated feature remains inactive there.
 
 For external manual work, the orchestrator first gives the user concrete steps,
 then calls `wait_for_user` as its final tool action. This explicit signal covers
@@ -365,16 +390,6 @@ and pasted command output continue to use the `question` tool. If
 `wait_for_user` is intentionally listed in `disabled_tools`, the orchestrator
 uses the `question` tool as the blocking boundary instead.
 
-This is a best-effort runtime check, not a scheduler or persisted state. The
-one-attempt guard and explicit `wait_for_user` state are process-local (shared
-across hook instances in the same JS process via an internal gate). They do not
-survive process restart or cross-process boundaries. Recreating a hook/plugin
-instance inside the same process does **not** rearm a consumed or waiting epoch;
-only a distinct real external user message (or genuine session deletion) does.
-After a process restart, the in-memory job board cannot establish prior result
-reconciliation, and the SDK's current session/todo status remains the liveness
-authority.
-
 ### Background Job Board Injection
 
 By default, each prompt uses the `latest` board strategy. The hook removes prior

+ 13 - 9
docs/configuration.md

@@ -151,7 +151,8 @@ 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) See [Background Job Management](#background-job-management). |
 | `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 See [Background Job Management](#background-job-management). |
 | `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 See [Background Job Management](#background-job-management). |
-| `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) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.orchestratorWake.enabled` | boolean | `true` | When true, idle orchestrator sessions with incomplete todos may receive periodic internal wake prompts (default every 5 minutes of continuous parent idle). Requires host session APIs; inactive on the v2 shim. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.orchestratorWake.intervalMs` | integer | `300000` | Continuous parent-idle interval between wake evaluations (`60000`–`2147483647` ms). `0` is invalid. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
 | `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
@@ -285,11 +286,10 @@ 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 opt into beta automatic
-incomplete-todo continuation prompts on idle. For glossary definitions of
-background-job terms (board snapshot, checkpoint cache epoch, injection
-strategy, etc.), see [CONTEXT.md — Background
-Jobs](../CONTEXT.md#background-jobs).
+shown, how board snapshots are injected, or to change the default-on
+orchestrator wake interval. For glossary definitions of background-job terms
+(board snapshot, checkpoint cache epoch, injection strategy, etc.), see
+[CONTEXT.md — Background Jobs](../CONTEXT.md#background-jobs).
 The wall-clock supervisor is separately opt-in and remains disabled unless
 `wallClockTimeoutMs` is set:
 
@@ -299,15 +299,19 @@ The wall-clock supervisor is separately opt-in and remains disabled unless
     "maxSessionsPerAgent": 3,
     "strategy": "checkpoint-compatible",
     "maxRetainedSnapshots": 10,
-    "continueOnIdle": true,
+    "orchestratorWake": {
+      "enabled": true,
+      "intervalMs": 300000
+    },
     "wallClockTimeoutMs": 900000,
     "abortGraceMs": 10000
   }
 }
 ```
 
-Without `continueOnIdle`, idle reconciliation and background-job orchestration
-remain enabled but no hidden continuation prompts are sent. See the
+`orchestratorWake` defaults to enabled with a 5-minute continuous-idle interval.
+Set `enabled: false` to keep idle reconciliation and background-job orchestration
+without periodic wake prompts. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
 `wallClockTimeoutMs` is a hard deadline that only supervises explicitly

+ 1 - 0
docs/opencode-v2-compatibility.md

@@ -77,6 +77,7 @@ the rest.
 | Built-in MCPs (context7, grep.app) | ✅ | ⚠️ config-only | v2 has no programmatic MCP hook; add 2 lines to `opencode.json` — see [below](#restoring-built-in-mcps-on-v2) |
 | `/preset` (interactive switcher) | ✅ | ❌ at load only | the switcher is a v1-TUI 3-level UI; on v2 set `"preset"` in the config file (applies at load) |
 | Foreground model fallback (rate-limit failover) | ✅ | ❌ | v2 locks the model at session creation; the plugin API has no per-prompt model override, session model-setter, or `/model` command, so mid-flight switching is impossible |
+| Orchestrator wake scheduler (`backgroundJobs.orchestratorWake`) | ✅ | ❌ | Requires host `session.get` / `todo` / `children` / `status` / `promptAsync`; the v2 shim lacks these APIs so the capability-gated feature stays inactive |
 | Multiplexer (tmux/zellij/herdr/cmux panes) | ✅ | ❌ | v1-TUI-pane integration; v2 renders subagents natively instead |
 | Companion app | ✅ | ⚠️ unverified | independent desktop app; test separately against v2 |
 | Default agent on new session | ✅ orchestrator | ⚠️ TUI shows `build` | v1 sets `default_agent`; v2's TUI ignores that field and defaults to the first agent in its list (`build`). `run`/API still default to orchestrator. See [limitations](#limitations) |

+ 1 - 1
docs/tools.md

@@ -45,7 +45,7 @@ Fast, structural code search and refactoring - more powerful than plain text gre
 | Tool | Description |
 |------|-------------|
 | `cancel_task` | Cancel a tracked background specialist task by native task ID or Background Job Board alias |
-| `wait_for_user` | Pause automatic incomplete-todo continuation until the next distinct external user message |
+| `wait_for_user` | Pause automatic orchestrator wake prompts until the next distinct external user message |
 
 `cancel_task` is orchestrator-only. It only cancels background tasks tracked for
 the current orchestrator session, and it does not roll back partial edits. After

+ 21 - 4
oh-my-opencode-slim.schema.json

@@ -1024,10 +1024,27 @@
           "minimum": 1,
           "maximum": 100
         },
-        "continueOnIdle": {
-          "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"
+        "orchestratorWake": {
+          "default": {
+            "enabled": true,
+            "intervalMs": 300000
+          },
+          "description": "Periodic orchestrator wake scheduler for idle sessions with incomplete todos. Default enabled at a 5-minute interval. Requires host session APIs (session.get, todo, children, status, promptAsync); inactive on the v2 shim.",
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "default": true,
+              "description": "When true, idle orchestrator sessions with incomplete todos may receive periodic internal wake prompts. Default enabled.",
+              "type": "boolean"
+            },
+            "intervalMs": {
+              "default": 300000,
+              "description": "Continuous parent-idle interval between orchestrator wake evaluations (60,000–2,147,483,647ms). Default 300,000 (5 minutes). 0 is invalid.",
+              "type": "integer",
+              "minimum": 60000,
+              "maximum": 2147483647
+            }
+          }
         },
         "wallClockTimeoutMs": {
           "default": 0,

+ 1 - 1
src/config/runtime.ts

@@ -79,7 +79,7 @@ const DEFAULT_BACKGROUND_JOBS: BackgroundJobsConfig = {
   readContextMinLines: DEFAULT_READ_CONTEXT_MIN_LINES,
   readContextMaxFiles: DEFAULT_READ_CONTEXT_MAX_FILES,
   maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-  continueOnIdle: false,
+  orchestratorWake: { enabled: true, intervalMs: 300_000 },
   wallClockTimeoutMs: 0,
   abortGraceMs: 10_000,
 };

+ 33 - 12
src/config/schema.test.ts

@@ -75,34 +75,55 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
-  it('defaults continueOnIdle to false', () => {
+  it('defaults orchestratorWake to enabled with a 5-minute interval', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
 
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
+      expect(result.data.backgroundJobs?.orchestratorWake).toEqual({
+        enabled: true,
+        intervalMs: 300_000,
+      });
     }
   });
 
-  it('accepts explicit continueOnIdle true', () => {
+  it('accepts explicit orchestratorWake overrides', () => {
     const result = PluginConfigSchema.safeParse({
-      backgroundJobs: { continueOnIdle: true },
+      backgroundJobs: {
+        orchestratorWake: { enabled: false, intervalMs: 120_000 },
+      },
     });
 
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+      expect(result.data.backgroundJobs?.orchestratorWake).toEqual({
+        enabled: false,
+        intervalMs: 120_000,
+      });
     }
   });
 
-  it('accepts explicit continueOnIdle false', () => {
-    const result = PluginConfigSchema.safeParse({
-      backgroundJobs: { continueOnIdle: false },
-    });
+  it('rejects orchestratorWake.intervalMs below 60_000 including 0', () => {
+    for (const intervalMs of [0, 1, 59_999, 60_000.5, -1]) {
+      expect(
+        PluginConfigSchema.safeParse({
+          backgroundJobs: { orchestratorWake: { intervalMs } },
+        }).success,
+      ).toBe(false);
+    }
+  });
 
-    expect(result.success).toBe(true);
-    if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
+  it('accepts orchestratorWake.intervalMs bounds', () => {
+    for (const intervalMs of [60_000, 300_000, 2_147_483_647]) {
+      const result = PluginConfigSchema.safeParse({
+        backgroundJobs: { orchestratorWake: { intervalMs } },
+      });
+      expect(result.success).toBe(true);
+      if (result.success) {
+        expect(result.data.backgroundJobs?.orchestratorWake?.intervalMs).toBe(
+          intervalMs,
+        );
+      }
     }
   });
 

+ 20 - 4
src/config/schema.ts

@@ -151,11 +151,27 @@ export const BackgroundJobsConfigSchema = z.object({
     .describe(
       'Maximum board snapshots retained per checkpoint cache epoch (1–100). Exceeding the limit starts a new epoch with the current snapshot and intentionally creates one cache miss.',
     ),
-  continueOnIdle: z
-    .boolean()
-    .default(false)
+  orchestratorWake: z
+    .object({
+      enabled: z
+        .boolean()
+        .default(true)
+        .describe(
+          'When true, idle orchestrator sessions with incomplete todos may receive periodic internal wake prompts. Default enabled.',
+        ),
+      intervalMs: z
+        .number()
+        .int()
+        .min(60_000)
+        .max(2_147_483_647)
+        .default(300_000)
+        .describe(
+          'Continuous parent-idle interval between orchestrator wake evaluations (60,000–2,147,483,647ms). Default 300,000 (5 minutes). 0 is invalid.',
+        ),
+    })
+    .default({ enabled: true, intervalMs: 300_000 })
     .describe(
-      '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.',
+      'Periodic orchestrator wake scheduler for idle sessions with incomplete todos. Default enabled at a 5-minute interval. Requires host session APIs (session.get, todo, children, status, promptAsync); inactive on the v2 shim.',
     ),
   wallClockTimeoutMs: z
     .union([z.literal(0), z.number().int().min(60_000).max(2_147_483_647)])

+ 5 - 0
src/hooks/index.ts

@@ -26,6 +26,11 @@ export {
 export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery/hook';
 export { createLoopCommandHook } from './loop-command';
+export {
+  createOrchestratorWakeScheduler,
+  ORCHESTRATOR_WAKE_TEXT,
+  ORCHESTRATOR_WAKE_UNCHANGED_CAP,
+} from './orchestrator-wake';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
 export { createReflectCommandHook } from './reflect';

+ 841 - 0
src/hooks/orchestrator-wake/index.test.ts

@@ -0,0 +1,841 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { createInternalAgentTextPart } from '../../utils';
+import { SessionLifecycle } from '../session-lifecycle';
+import { resetUserWaitGateForTests } from '../task-session-manager/user-wait-gate';
+import {
+  buildOrchestratorWakeFingerprint,
+  createOrchestratorWakeScheduler,
+  ORCHESTRATOR_WAKE_TEXT,
+  ORCHESTRATOR_WAKE_UNCHANGED_CAP,
+} from './index';
+import {
+  getWakeProgress,
+  resetOrchestratorWakeGateForTests,
+} from './wake-gate';
+
+type SessionClient = {
+  get?: ReturnType<typeof mock>;
+  todo?: ReturnType<typeof mock>;
+  children?: ReturnType<typeof mock>;
+  status?: ReturnType<typeof mock>;
+  promptAsync?: ReturnType<typeof mock>;
+};
+
+function createClock() {
+  let now = 0;
+  let nextID = 1;
+  const timers = new Map<number, { at: number; callback: () => void }>();
+
+  const setTimeoutImpl = ((callback: () => void, delay?: number) => {
+    const id = nextID++;
+    timers.set(id, { at: now + (delay ?? 0), callback });
+    const handle = {
+      __id: id,
+      unref() {
+        return handle;
+      },
+    };
+    return handle as unknown as ReturnType<typeof setTimeout>;
+  }) as unknown as typeof setTimeout;
+
+  const clearTimeoutImpl = ((handle: unknown) => {
+    if (handle == null) return;
+    const id =
+      typeof handle === 'object' &&
+      handle !== null &&
+      '__id' in handle &&
+      typeof (handle as { __id: unknown }).__id === 'number'
+        ? (handle as { __id: number }).__id
+        : Number(handle);
+    timers.delete(id);
+  }) as unknown as typeof clearTimeout;
+
+  async function flushMicrotasks(times = 30): Promise<void> {
+    for (let i = 0; i < times; i++) {
+      await Promise.resolve();
+    }
+  }
+
+  return {
+    setTimeout: setTimeoutImpl,
+    clearTimeout: clearTimeoutImpl,
+    async advance(ms: number) {
+      now += ms;
+      for (let round = 0; round < 5; round++) {
+        const due = [...timers.entries()]
+          .filter(([, t]) => t.at <= now)
+          .sort((a, b) => a[1].at - b[1].at);
+        if (due.length === 0) break;
+        for (const [id, timer] of due) {
+          timers.delete(id);
+          timer.callback();
+        }
+        await flushMicrotasks();
+      }
+      await flushMicrotasks();
+    },
+    pendingCount() {
+      return timers.size;
+    },
+  };
+}
+
+type SessionClientFactory = Partial<SessionClient> & {
+  todos?: Array<Record<string, unknown>>;
+  childrenData?: Array<Record<string, unknown>>;
+  statusData?: Record<string, unknown>;
+  model?: unknown;
+};
+
+function makeClient(overrides?: SessionClientFactory): SessionClient {
+  const todos = overrides?.todos ?? [{ id: 't1', status: 'pending' }];
+  const childrenData = overrides?.childrenData ?? [];
+  const statusData = overrides?.statusData ?? {};
+  return {
+    get:
+      overrides?.get ??
+      mock(async () => ({
+        data: {
+          model: overrides?.model ?? {
+            providerID: 'test',
+            id: 'model-a',
+            variant: 'high',
+          },
+        },
+      })),
+    todo: overrides?.todo ?? mock(async () => ({ data: todos })),
+    children: overrides?.children ?? mock(async () => ({ data: childrenData })),
+    status: overrides?.status ?? mock(async () => ({ data: statusData })),
+    promptAsync: overrides?.promptAsync ?? mock(async () => ({})),
+  };
+}
+
+function createScheduler(options?: {
+  enabled?: boolean;
+  intervalMs?: number;
+  sessionClient?: SessionClient | null;
+  shouldManageSession?: (id: string) => boolean;
+  hasInputWait?: (id: string) => boolean;
+  isFallbackInProgress?: (id: string) => boolean;
+  coordinator?: SessionLifecycle;
+  directory?: string;
+}) {
+  const client = options?.sessionClient;
+  const session = client === null ? undefined : (client ?? makeClient());
+  const ctx = {
+    directory: options?.directory ?? '/project',
+    client: { session },
+  } as never;
+
+  const scheduler = createOrchestratorWakeScheduler(ctx, {
+    config: {
+      enabled: options?.enabled ?? true,
+      intervalMs: options?.intervalMs ?? 60_000,
+    },
+    intervalMs: options?.intervalMs ?? 60_000,
+    shouldManageSession: options?.shouldManageSession ?? (() => true),
+    hasInputWait: options?.hasInputWait ?? (() => false),
+    isFallbackInProgress: options?.isFallbackInProgress,
+    coordinator: options?.coordinator,
+  });
+
+  return { scheduler, session: session as SessionClient | undefined };
+}
+
+const originalSetTimeout = globalThis.setTimeout;
+const originalClearTimeout = globalThis.clearTimeout;
+let clock = createClock();
+
+beforeEach(() => {
+  resetUserWaitGateForTests();
+  resetOrchestratorWakeGateForTests();
+  clock = createClock();
+  globalThis.setTimeout = clock.setTimeout;
+  globalThis.clearTimeout = clock.clearTimeout;
+});
+
+afterEach(() => {
+  globalThis.setTimeout = originalSetTimeout;
+  globalThis.clearTimeout = originalClearTimeout;
+});
+
+describe('buildOrchestratorWakeFingerprint', () => {
+  test('includes todo statuses and child status/update evidence', () => {
+    const fp = buildOrchestratorWakeFingerprint(
+      [
+        { id: 'b', status: 'pending' },
+        { id: 'a', status: 'in_progress' },
+      ],
+      [{ id: 'child-1', time: { updated: 42 } }],
+      { 'child-1': { type: 'busy' } },
+    );
+    expect(fp).toContain('a:in_progress');
+    expect(fp).toContain('b:pending');
+    expect(fp).toContain('child-1:busy:42');
+  });
+});
+
+describe('orchestrator wake scheduler', () => {
+  test('does nothing when disabled', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      enabled: false,
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(120_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('is inactive when required session APIs are missing', async () => {
+    const { scheduler } = createScheduler({
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+      },
+    });
+    expect(scheduler._test.hasRequiredSessionApis()).toBe(false);
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(120_000);
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('wakes after continuous idle interval with exact prompt text and directory query', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler, session } = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(1);
+
+    await clock.advance(59_999);
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await clock.advance(1);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[unknown]>
+    )[0]?.[0] as {
+      path: { id: string };
+      query: { directory: string };
+      body: {
+        agent: string;
+        model?: { providerID: string; modelID: string };
+        variant?: string;
+        parts: Array<{ text: string }>;
+      };
+    };
+    expect(call.path).toEqual({ id: 'p1' });
+    expect(call.query).toEqual({ directory: '/project' });
+    expect(call.body.agent).toBe('orchestrator');
+    expect(call.body.model).toEqual({
+      providerID: 'test',
+      modelID: 'model-a',
+    });
+    expect(call.body.variant).toBeUndefined();
+    expect(call.body.parts[0]?.text).toBe(
+      `${ORCHESTRATOR_WAKE_TEXT}\n<!-- SLIM_INTERNAL_INITIATOR -->`,
+    );
+
+    expect(session?.todo).toHaveBeenCalledWith(
+      expect.objectContaining({
+        path: { id: 'p1' },
+        query: { directory: '/project' },
+      }),
+    );
+    expect(session?.status).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: { directory: '/project' },
+      }),
+    );
+  });
+
+  test('targets only orchestrator-managed sessions', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      shouldManageSession: (id) => id === 'orch',
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child' } },
+    });
+    await clock.advance(120_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'orch' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('does not consult a job board and wakes while children are active', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        childrenData: [{ id: 'child-1', time: { updated: 1 } }],
+        statusData: { 'child-1': { type: 'busy' } },
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('does not wake when parent is busy according to host status', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        statusData: { p1: { type: 'busy' } },
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not wake when todos are only completed or cancelled', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        todos: [
+          { id: 't1', status: 'completed' },
+          { id: 't2', status: 'cancelled' },
+        ],
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('fails closed on unknown todo status', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        todos: [
+          { id: 't1', status: 'pending' },
+          { id: 't2', status: 'blocked' },
+        ],
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('fails closed on malformed host responses', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        todo: mock(async () => ({ data: 'not-array' })),
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('suppresses on input wait, fallback, busy, and disposal without stuck in-flight', async () => {
+    const promptAsync = mock(async () => ({}));
+    let waiting = false;
+    let fallback = false;
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+      hasInputWait: () => waiting,
+      isFallbackInProgress: () => fallback,
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    expect(clock.pendingCount()).toBe(1);
+
+    waiting = true;
+    scheduler.suppress('p1');
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+
+    waiting = false;
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    fallback = true;
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    fallback = false;
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await scheduler.event({
+      event: { type: 'server.instance.disposed' },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('disposal releases a reservation blocked on host reads', async () => {
+    let releaseReads!: () => void;
+    const blockedReads = new Promise<void>((resolve) => {
+      releaseReads = resolve;
+    });
+    const a = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({
+        todo: mock(async () => {
+          await blockedReads;
+          return { data: [{ id: 't1', status: 'pending' }] };
+        }),
+        children: mock(async () => {
+          await blockedReads;
+          return { data: [] };
+        }),
+        status: mock(async () => {
+          await blockedReads;
+          return { data: {} };
+        }),
+      }),
+    });
+    const promptAsync = mock(async () => ({}));
+    const b = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await a.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    await a.scheduler.event({ event: { type: 'server.instance.disposed' } });
+
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+
+    releaseReads();
+  });
+
+  test('clears in-flight ownership when suppress races an evaluation', async () => {
+    let release!: () => void;
+    const gate = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const promptAsync = mock(async () => {
+      await gate;
+      return {};
+    });
+    const todo = mock(async () => {
+      await gate;
+      return { data: [{ id: 't1', status: 'pending' }] };
+    });
+    const { scheduler } = createScheduler({
+      intervalMs: 10_000,
+      sessionClient: makeClient({
+        promptAsync,
+        todo,
+        children: mock(async () => {
+          await gate;
+          return { data: [] };
+        }),
+        status: mock(async () => {
+          await gate;
+          return { data: {} };
+        }),
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(10_000);
+    // Evaluation is blocked on host reads.
+    scheduler.suppress('p1');
+    release();
+    await Promise.resolve();
+    await Promise.resolve();
+    await Promise.resolve();
+
+    // A later idle must be able to claim in-flight again.
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(10_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('session deletion clears scheduled wakes via coordinator', async () => {
+    const promptAsync = mock(async () => ({}));
+    const coordinator = new SessionLifecycle(() => {});
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+      coordinator,
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    expect(clock.pendingCount()).toBe(1);
+    coordinator.dispatchSessionDeleted('p1');
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('external user message re-arms and cancels pending wake', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    scheduler.observeChatMessage(
+      { sessionID: 'p1', messageID: 'm1' },
+      {
+        message: { id: 'm1', role: 'user', sessionID: 'p1' },
+        parts: [{ type: 'text', text: 'continue please' }],
+      },
+    );
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(getWakeProgress('p1').stopped).toBe(false);
+    expect(getWakeProgress('p1').unchangedWakeCount).toBe(0);
+  });
+
+  test('internal initiator parts do not re-arm as external user messages', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    scheduler.observeChatMessage(
+      { sessionID: 'p1', messageID: 'm-internal' },
+      {
+        message: { id: 'm-internal', role: 'user', sessionID: 'p1' },
+        parts: [createInternalAgentTextPart(ORCHESTRATOR_WAKE_TEXT)],
+      },
+    );
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('wake→busy→idle preserves the two-wake no-progress cap', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+
+    // Realistic host reaction to promptAsync: busy then idle again.
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(ORCHESTRATOR_WAKE_UNCHANGED_CAP);
+
+    // Cap stops further wakes even after another busy→idle from the second wake.
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(180_000);
+    expect(promptAsync).toHaveBeenCalledTimes(ORCHESTRATOR_WAKE_UNCHANGED_CAP);
+  });
+
+  test('external busy (not wake-initiated) rearms the no-progress cap', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+    expect(getWakeProgress('p1').stopped).toBe(true);
+
+    // External user message rearms.
+    scheduler.observeChatMessage(
+      { sessionID: 'p1', messageID: 'user-rearm' },
+      {
+        message: { id: 'user-rearm', role: 'user', sessionID: 'p1' },
+        parts: [{ type: 'text', text: 'keep going' }],
+      },
+    );
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(3);
+  });
+
+  test('host-observed progress rearms the unchanged cap', async () => {
+    const promptAsync = mock(async () => ({}));
+    let todos: Array<Record<string, unknown>> = [
+      { id: 't1', status: 'pending' },
+    ];
+    const { scheduler } = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({
+        promptAsync,
+        todo: mock(async () => ({ data: todos })),
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    // Simulate wake busy→idle without rearm (cap preserved at 1).
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    todos = [{ id: 't1', status: 'in_progress' }];
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    // Progress reset count; this is wake #1 of the new fingerprint.
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+    expect(getWakeProgress('p1').unchangedWakeCount).toBe(1);
+    expect(getWakeProgress('p1').stopped).toBe(false);
+  });
+
+  test('failed promptAsync does not storm retries within the interval', async () => {
+    let calls = 0;
+    const promptAsync = mock(async () => {
+      calls += 1;
+      throw new Error('boom');
+    });
+    const { scheduler } = createScheduler({
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(calls).toBe(1);
+    await clock.advance(1_000);
+    expect(calls).toBe(1);
+    await clock.advance(59_000);
+    expect(calls).toBe(2);
+  });
+
+  test('two hook instances share process-global in-flight and progress', async () => {
+    const promptAsync = mock(async () => ({}));
+    const client = makeClient({ promptAsync });
+    const a = createScheduler({ sessionClient: client, intervalMs: 60_000 });
+    const b = createScheduler({ sessionClient: client, intervalMs: 60_000 });
+
+    await a.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    // Two local timers may exist; process gate dedupes wakes.
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+
+    await a.scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await b.scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await a.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+
+    await a.scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await a.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(180_000);
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+  });
+
+  test('disposing one hook leaves another hook’s shared progress cap intact', async () => {
+    const promptAsync = mock(async () => ({}));
+    const client = makeClient({ promptAsync });
+    const a = createScheduler({ sessionClient: client, intervalMs: 60_000 });
+    const b = createScheduler({ sessionClient: client, intervalMs: 60_000 });
+
+    await a.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+
+    await a.scheduler.event({ event: { type: 'server.instance.disposed' } });
+    await b.scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+
+    await b.scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    await b.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(180_000);
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+  });
+
+  test('uses observed external model when session.get model is unavailable', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({
+        promptAsync,
+        get: mock(async () => {
+          throw new Error('no model field');
+        }),
+      }),
+    });
+    scheduler.observeChatMessage(
+      {
+        sessionID: 'p1',
+        messageID: 'm1',
+        model: { providerID: 'obs', modelID: 'seen' },
+        variant: 'low',
+      },
+      {
+        message: { id: 'm1', role: 'user', sessionID: 'p1' },
+        parts: [{ type: 'text', text: 'go' }],
+      },
+    );
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: { directory: '/project' },
+        body: expect.objectContaining({
+          model: { providerID: 'obs', modelID: 'seen' },
+        }),
+      }),
+    );
+    const call = (
+      promptAsync.mock.calls as unknown as Array<
+        [{ body: { variant?: string } }]
+      >
+    )[0]?.[0];
+    expect(call?.body.variant).toBeUndefined();
+  });
+
+  test('paired idle events do not create duplicate timers on one instance', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      sessionClient: makeClient({ promptAsync }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'idle' } },
+      },
+    });
+    expect(clock.pendingCount()).toBe(1);
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+});

+ 705 - 0
src/hooks/orchestrator-wake/index.ts

@@ -0,0 +1,705 @@
+/**
+ * Periodic orchestrator wake scheduler.
+ *
+ * After continuous parent-idle time, capability-gated host session APIs may
+ * receive a static internal wake prompt when incomplete todos remain. Active
+ * children do not suppress wakes. Host responses are authoritative; the local
+ * job board is never consulted. Progress/reservation state is process-global
+ * so independently created hook instances share one-flight and the two-wake
+ * no-progress cap.
+ */
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { OpencodeClient } from '@opencode-ai/sdk';
+import {
+  createInternalAgentTextPart,
+  isInternalInitiatorPart,
+} from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import type { SessionLifecycle } from '../session-lifecycle';
+import {
+  type ContinuationModelSelection,
+  parseContinuationModelSelection,
+} from '../task-session-manager/continuation-model-selection';
+import { isActiveStatus } from '../task-session-manager/status-utils';
+import {
+  clearExpectingWakeBusy,
+  clearWakeSession,
+  commitWakeReservation,
+  getObservedWakeModel,
+  getWakeProgress,
+  isExpectingWakeBusy,
+  noteHostProgress,
+  rearmWakeProgress,
+  releaseWakeEvaluation,
+  retryAfterWakeEvaluation,
+  setObservedWakeModel,
+  tryBeginWakeEvaluation,
+} from './wake-gate';
+
+export const ORCHESTRATOR_WAKE_TEXT =
+  '<system-reminder>\nFinish any incomplete TODOs. Await running agents; if one appears stuck, assess it and cancel/respawn only when justified. Do not respond to this reminder.\n</system-reminder>';
+
+/** After this many successful wakes with an unchanged fingerprint, stop. */
+export const ORCHESTRATOR_WAKE_UNCHANGED_CAP = 2;
+
+const SUPPORTED_TODO_STATUSES = new Set([
+  'pending',
+  'in_progress',
+  'completed',
+  'cancelled',
+]);
+
+type SessionClient = OpencodeClient['session'];
+
+type LocalSessionState = {
+  /** Invalidates local timers/async work for this hook instance. */
+  generation: symbol;
+  timer: ReturnType<typeof setTimeout> | undefined;
+  continuousIdle: boolean;
+};
+
+export type OrchestratorWakeConfig = {
+  enabled: boolean;
+  intervalMs: number;
+};
+
+export type OrchestratorWakeOptions = {
+  config: OrchestratorWakeConfig;
+  shouldManageSession: (sessionID: string) => boolean;
+  hasInputWait: (sessionID: string) => boolean;
+  isFallbackInProgress?: (sessionID: string) => boolean;
+  coordinator?: SessionLifecycle;
+  /** Test seam: override interval without changing config validation. */
+  intervalMs?: number;
+};
+
+function hasRequiredSessionApis(
+  session: SessionClient | undefined,
+): session is SessionClient & {
+  get: NonNullable<SessionClient['get']>;
+  todo: NonNullable<SessionClient['todo']>;
+  children: NonNullable<SessionClient['children']>;
+  status: NonNullable<SessionClient['status']>;
+  promptAsync: NonNullable<SessionClient['promptAsync']>;
+} {
+  return (
+    typeof session?.get === 'function' &&
+    typeof session.todo === 'function' &&
+    typeof session.children === 'function' &&
+    typeof session.status === 'function' &&
+    typeof session.promptAsync === 'function'
+  );
+}
+
+function isIncompleteTodoStatus(status: string): boolean {
+  return status === 'pending' || status === 'in_progress';
+}
+
+function todosHaveValidStatuses(
+  todos: Array<Record<string, unknown>>,
+): boolean {
+  return todos.every(
+    (todo) =>
+      typeof todo.status === 'string' &&
+      SUPPORTED_TODO_STATUSES.has(todo.status),
+  );
+}
+
+function hasIncompleteTodos(todos: Array<Record<string, unknown>>): boolean {
+  return todos.some(
+    (todo) =>
+      typeof todo.status === 'string' && isIncompleteTodoStatus(todo.status),
+  );
+}
+
+function todoFingerprint(todos: Array<Record<string, unknown>>): string {
+  return todos
+    .map((todo) => {
+      const id =
+        typeof todo.id === 'string'
+          ? todo.id
+          : typeof todo.content === 'string'
+            ? todo.content
+            : '';
+      return `${id}:${String(todo.status)}`;
+    })
+    .sort()
+    .join('\n');
+}
+
+function childUpdateEvidence(child: Record<string, unknown>): string {
+  const time = isObjectRecord(child.time) ? child.time : undefined;
+  const candidates = [
+    time?.updated,
+    time?.completed,
+    child.updatedAt,
+    child.updated,
+    time?.created,
+    child.createdAt,
+  ];
+  for (const value of candidates) {
+    if (typeof value === 'number' || typeof value === 'string') {
+      return String(value);
+    }
+  }
+  return '';
+}
+
+function childStatusEvidence(
+  childID: string,
+  status: Record<string, unknown>,
+): string {
+  if (!Object.hasOwn(status, childID)) return 'absent';
+  const entry = status[childID];
+  if (!isObjectRecord(entry)) return 'malformed';
+  return typeof entry.type === 'string' ? entry.type : 'active';
+}
+
+function childrenFingerprint(
+  children: Array<Record<string, unknown>>,
+  status: Record<string, unknown>,
+): string {
+  return children
+    .map((child) => {
+      const id = String(child.id);
+      return `${id}:${childStatusEvidence(id, status)}:${childUpdateEvidence(child)}`;
+    })
+    .sort()
+    .join('\n');
+}
+
+export function buildOrchestratorWakeFingerprint(
+  todos: Array<Record<string, unknown>>,
+  children: Array<Record<string, unknown>>,
+  status: Record<string, unknown>,
+): string {
+  return `${todoFingerprint(todos)}\n--\n${childrenFingerprint(children, status)}`;
+}
+
+function extractSessionID(event: {
+  properties?: { info?: { id?: string }; sessionID?: string };
+}): string | undefined {
+  return event.properties?.info?.id || event.properties?.sessionID;
+}
+
+function isIdleEvent(
+  type: string,
+  properties?: { status?: { type?: string } },
+) {
+  return (
+    type === 'session.idle' ||
+    (type === 'session.status' && properties?.status?.type === 'idle')
+  );
+}
+
+function isBusyEvent(
+  type: string,
+  properties?: { status?: { type?: string } },
+): boolean {
+  return type === 'session.status' && properties?.status?.type === 'busy';
+}
+
+function isInputWaitAskEvent(type: string): boolean {
+  return type === 'permission.asked' || type === 'question.asked';
+}
+
+export function createOrchestratorWakeScheduler(
+  ctx: PluginInput,
+  options: OrchestratorWakeOptions,
+) {
+  const intervalMs = options.intervalMs ?? options.config.intervalMs;
+  const enabled = options.config.enabled === true;
+  const directory = ctx.directory;
+  const sessionSdk = (ctx.client as OpencodeClient).session;
+
+  /** Local timer/generation state only; progress lives in the process gate. */
+  const localSessions = new Map<string, LocalSessionState>();
+  /** Reservations this hook owns and must release when it is disposed. */
+  const localWakeOwners = new Map<string, symbol>();
+
+  function touchLocal(sessionID: string): LocalSessionState {
+    const existing = localSessions.get(sessionID);
+    if (existing) return existing;
+    const created: LocalSessionState = {
+      generation: Symbol(sessionID),
+      timer: undefined,
+      continuousIdle: false,
+    };
+    localSessions.set(sessionID, created);
+    return created;
+  }
+
+  function clearTimer(state: LocalSessionState): void {
+    if (state.timer !== undefined) {
+      clearTimeout(state.timer);
+      state.timer = undefined;
+    }
+  }
+
+  function bumpGeneration(state: LocalSessionState): void {
+    state.generation = Symbol('wake-generation');
+  }
+
+  function clearLocalSession(sessionID: string): void {
+    const state = localSessions.get(sessionID);
+    if (!state) return;
+    clearTimer(state);
+    bumpGeneration(state);
+    localSessions.delete(sessionID);
+  }
+
+  function releaseLocalWakeOwner(sessionID: string): void {
+    const owner = localWakeOwners.get(sessionID);
+    if (!owner) return;
+    localWakeOwners.delete(sessionID);
+    releaseWakeEvaluation(sessionID, owner);
+  }
+
+  function clearSession(sessionID: string): void {
+    releaseLocalWakeOwner(sessionID);
+    clearLocalSession(sessionID);
+    clearWakeSession(sessionID);
+  }
+
+  /**
+   * Suppress scheduling without dropping process-global progress.
+   * Used for input waits and temporary blocks.
+   */
+  function suppress(sessionID: string): void {
+    const state = localSessions.get(sessionID);
+    if (!state) return;
+    clearTimer(state);
+    bumpGeneration(state);
+    state.continuousIdle = false;
+    releaseLocalWakeOwner(sessionID);
+  }
+
+  /**
+   * End a continuous idle spell. When `rearmProgress` is true, reset the
+   * process-global no-progress cap (external busy / lifecycle). Wake-initiated
+   * busy must pass false so the two-wake cap survives busy→idle.
+   */
+  function endIdleSpell(sessionID: string, rearmProgress: boolean): void {
+    const state = localSessions.get(sessionID);
+    if (state) {
+      clearTimer(state);
+      bumpGeneration(state);
+      state.continuousIdle = false;
+    }
+    releaseLocalWakeOwner(sessionID);
+    if (rearmProgress) rearmWakeProgress(sessionID);
+  }
+
+  function canSchedule(sessionID: string): boolean {
+    if (!enabled) return false;
+    if (!hasRequiredSessionApis(sessionSdk)) return false;
+    if (!options.shouldManageSession(sessionID)) return false;
+    if (options.hasInputWait(sessionID)) return false;
+    if (options.isFallbackInProgress?.(sessionID)) return false;
+    if (getWakeProgress(sessionID).stopped) return false;
+    return true;
+  }
+
+  function schedule(sessionID: string): void {
+    if (!canSchedule(sessionID)) return;
+    const state = touchLocal(sessionID);
+    if (!state.continuousIdle || state.timer !== undefined) return;
+    if (getWakeProgress(sessionID).stopped) return;
+
+    const generation = state.generation;
+    const timer = setTimeout(() => {
+      state.timer = undefined;
+      if (state.generation !== generation) return;
+      void evaluate(sessionID, generation);
+    }, intervalMs);
+    timer.unref?.();
+    state.timer = timer;
+  }
+
+  function beginContinuousIdle(sessionID: string): void {
+    if (!canSchedule(sessionID)) return;
+    const state = touchLocal(sessionID);
+    if (state.continuousIdle && state.timer !== undefined) return;
+    state.continuousIdle = true;
+    if (getWakeProgress(sessionID).stopped) return;
+    if (state.timer === undefined) schedule(sessionID);
+  }
+
+  async function readHostSnapshot(sessionID: string): Promise<
+    | {
+        todos: Array<Record<string, unknown>>;
+        children: Array<Record<string, unknown>>;
+        status: Record<string, unknown>;
+        model?: ContinuationModelSelection;
+      }
+    | undefined
+  > {
+    if (!hasRequiredSessionApis(sessionSdk)) return undefined;
+
+    const dirQuery = { directory };
+    const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
+      sessionSdk.todo({
+        path: { id: sessionID },
+        query: dirQuery,
+        throwOnError: true,
+      }),
+      sessionSdk.children({
+        path: { id: sessionID },
+        query: dirQuery,
+        throwOnError: true,
+      }),
+      sessionSdk.status({
+        query: dirQuery,
+        throwOnError: true,
+      }),
+    ]);
+
+    if (
+      !Array.isArray(todoResponse.data) ||
+      !Array.isArray(childrenResponse.data) ||
+      !isObjectRecord(statusResponse.data)
+    ) {
+      return undefined;
+    }
+
+    const todos = todoResponse.data;
+    const children = childrenResponse.data;
+    const status = statusResponse.data;
+
+    if (
+      !todos.every(
+        (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
+      ) ||
+      !todosHaveValidStatuses(todos as Array<Record<string, unknown>>) ||
+      !children.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      )
+    ) {
+      return undefined;
+    }
+
+    let model: ContinuationModelSelection | undefined;
+    try {
+      const sessionResponse = await sessionSdk.get({
+        path: { id: sessionID },
+        query: dirQuery,
+        throwOnError: true,
+      });
+      // Session.model is version-dependent; read via record shape.
+      const session = isObjectRecord(sessionResponse?.data)
+        ? sessionResponse.data
+        : undefined;
+      model = parseContinuationModelSelection(
+        session ? (session as Record<string, unknown>).model : undefined,
+      );
+    } catch {
+      // Model enrichment is fail-soft.
+    }
+
+    return {
+      todos: todos as Array<Record<string, unknown>>,
+      children: children as Array<Record<string, unknown>>,
+      status,
+      model,
+    };
+  }
+
+  async function evaluate(
+    sessionID: string,
+    generation: symbol,
+  ): Promise<void> {
+    const state = localSessions.get(sessionID);
+    if (!state || state.generation !== generation) return;
+    if (!state.continuousIdle) return;
+    if (!canSchedule(sessionID)) {
+      suppress(sessionID);
+      return;
+    }
+
+    const owner = tryBeginWakeEvaluation(sessionID);
+    if (!owner) {
+      retryAfterWakeEvaluation(sessionID, () => {
+        const current = localSessions.get(sessionID);
+        if (
+          current === state &&
+          current.generation === generation &&
+          current.continuousIdle
+        ) {
+          void evaluate(sessionID, generation);
+        }
+      });
+      return;
+    }
+    localWakeOwners.set(sessionID, owner);
+
+    try {
+      const snapshot = await readHostSnapshot(sessionID);
+      if (!snapshot || state.generation !== generation) return;
+      if (!state.continuousIdle) return;
+      if (!canSchedule(sessionID)) {
+        suppress(sessionID);
+        return;
+      }
+
+      if (isActiveStatus(snapshot.status, sessionID)) {
+        endIdleSpell(sessionID, true);
+        return;
+      }
+      if (!hasIncompleteTodos(snapshot.todos)) {
+        // No incomplete work: end the spell; do not keep polling.
+        endIdleSpell(sessionID, false);
+        return;
+      }
+
+      const fingerprint = buildOrchestratorWakeFingerprint(
+        snapshot.todos,
+        snapshot.children,
+        snapshot.status,
+      );
+      noteHostProgress(sessionID, fingerprint);
+
+      const progress = getWakeProgress(sessionID);
+      if (
+        progress.stopped ||
+        (progress.lastFingerprint === fingerprint &&
+          progress.unchangedWakeCount >= ORCHESTRATOR_WAKE_UNCHANGED_CAP)
+      ) {
+        progress.stopped = true;
+        state.continuousIdle = false;
+        return;
+      }
+
+      // Recheck host status/waits immediately before promptAsync.
+      const latest = await readHostSnapshot(sessionID);
+      if (!latest || state.generation !== generation) return;
+      if (!state.continuousIdle) return;
+      if (!canSchedule(sessionID)) {
+        suppress(sessionID);
+        return;
+      }
+      if (isActiveStatus(latest.status, sessionID)) {
+        endIdleSpell(sessionID, true);
+        return;
+      }
+      if (!hasIncompleteTodos(latest.todos)) {
+        endIdleSpell(sessionID, false);
+        return;
+      }
+
+      const latestFingerprint = buildOrchestratorWakeFingerprint(
+        latest.todos,
+        latest.children,
+        latest.status,
+      );
+      noteHostProgress(sessionID, latestFingerprint);
+
+      const latestProgress = getWakeProgress(sessionID);
+      if (
+        latestProgress.stopped ||
+        latestProgress.unchangedWakeCount >= ORCHESTRATOR_WAKE_UNCHANGED_CAP
+      ) {
+        latestProgress.stopped = true;
+        state.continuousIdle = false;
+        return;
+      }
+
+      const modelSelection =
+        latest.model ?? snapshot.model ?? getObservedWakeModel(sessionID);
+
+      // Reserve before promptAsync so a failed call cannot storm retries and
+      // concurrent hook instances cannot double-wake.
+      if (!commitWakeReservation(sessionID, owner, latestFingerprint)) {
+        return;
+      }
+
+      if (!hasRequiredSessionApis(sessionSdk)) return;
+
+      await sessionSdk.promptAsync({
+        path: { id: sessionID },
+        query: { directory },
+        body: {
+          agent: 'orchestrator',
+          ...(modelSelection ? { model: modelSelection.model } : {}),
+          parts: [createInternalAgentTextPart(ORCHESTRATOR_WAKE_TEXT)],
+        },
+        throwOnError: true,
+      });
+    } catch (error) {
+      // Failed promptAsync already reserved; clear expecting-busy so a later
+      // unrelated busy can rearm normally.
+      clearExpectingWakeBusy(sessionID);
+      log('[orchestrator-wake] wake suppressed after SDK error', {
+        sessionID,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    } finally {
+      // Always release ownership — even when suppress/endIdle bumped generation.
+      releaseWakeEvaluation(sessionID, owner);
+      if (localWakeOwners.get(sessionID) === owner) {
+        localWakeOwners.delete(sessionID);
+      }
+
+      const current = localSessions.get(sessionID);
+      if (
+        current &&
+        current.generation === generation &&
+        current.continuousIdle &&
+        current.timer === undefined &&
+        !getWakeProgress(sessionID).stopped
+      ) {
+        schedule(sessionID);
+      }
+    }
+  }
+
+  function observeChatMessage(input: unknown, output: unknown): void {
+    const inputMessage = isObjectRecord(input) ? input : undefined;
+    const outputRecord = isObjectRecord(output) ? output : undefined;
+    const outputMessage = isObjectRecord(outputRecord?.message)
+      ? outputRecord.message
+      : undefined;
+    const sessionID =
+      typeof outputMessage?.sessionID === 'string'
+        ? outputMessage.sessionID
+        : typeof inputMessage?.sessionID === 'string'
+          ? inputMessage.sessionID
+          : undefined;
+    const parts = Array.isArray(outputRecord?.parts)
+      ? outputRecord.parts
+      : inputMessage?.parts;
+    if (
+      !sessionID ||
+      (typeof outputMessage?.role === 'string' &&
+        outputMessage.role !== 'user') ||
+      !options.shouldManageSession(sessionID) ||
+      !Array.isArray(parts) ||
+      parts.some(isInternalInitiatorPart) ||
+      !parts.some(
+        (part) =>
+          isObjectRecord(part) &&
+          part.synthetic !== true &&
+          !isInternalInitiatorPart(part) &&
+          ((part.type === 'text' && typeof part.text === 'string') ||
+            part.type === 'file' ||
+            part.type === 'image'),
+      )
+    ) {
+      return;
+    }
+
+    const outputModel = isObjectRecord(outputMessage?.model)
+      ? outputMessage.model
+      : undefined;
+    const variant =
+      typeof inputMessage?.variant === 'string'
+        ? inputMessage.variant
+        : outputModel?.variant;
+    const modelSelection =
+      parseContinuationModelSelection(inputMessage?.model, variant) ??
+      parseContinuationModelSelection(outputModel, variant);
+
+    setObservedWakeModel(sessionID, modelSelection);
+
+    const state = touchLocal(sessionID);
+    clearTimer(state);
+    bumpGeneration(state);
+    state.continuousIdle = false;
+    // External user activity rearms the process-global no-progress cap.
+    rearmWakeProgress(sessionID);
+  }
+
+  async function event(input: {
+    event: {
+      type: string;
+      properties?: {
+        info?: { id?: string };
+        sessionID?: string;
+        status?: { type?: string };
+      };
+    };
+  }): Promise<void> {
+    const { type, properties } = input.event;
+
+    if (type === 'server.instance.disposed') {
+      for (const sessionID of [...localWakeOwners.keys()]) {
+        releaseLocalWakeOwner(sessionID);
+      }
+      for (const sessionID of [...localSessions.keys()]) {
+        clearLocalSession(sessionID);
+      }
+      return;
+    }
+
+    const sessionID = extractSessionID(input.event);
+    if (!sessionID) return;
+
+    if (type === 'session.deleted') {
+      clearSession(sessionID);
+      return;
+    }
+
+    if (isInputWaitAskEvent(type)) {
+      if (options.shouldManageSession(sessionID)) {
+        suppress(sessionID);
+      }
+      return;
+    }
+
+    if (isIdleEvent(type, properties)) {
+      if (options.shouldManageSession(sessionID)) {
+        clearExpectingWakeBusy(sessionID);
+        beginContinuousIdle(sessionID);
+      }
+      return;
+    }
+
+    if (isBusyEvent(type, properties)) {
+      if (options.shouldManageSession(sessionID)) {
+        // Wake-initiated busy preserves the no-progress cap; external busy rearms.
+        const wakeBusy = isExpectingWakeBusy(sessionID);
+        endIdleSpell(sessionID, !wakeBusy);
+      }
+      return;
+    }
+
+    if (type === 'session.error' || type === 'session.status') {
+      if (
+        type === 'session.error' ||
+        (type === 'session.status' &&
+          properties?.status?.type !== 'idle' &&
+          properties?.status?.type !== 'busy')
+      ) {
+        if (options.shouldManageSession(sessionID)) {
+          // Errors / retry are external lifecycle — rearm.
+          clearExpectingWakeBusy(sessionID);
+          endIdleSpell(sessionID, true);
+        }
+      }
+    }
+  }
+
+  if (options.coordinator) {
+    options.coordinator.onSessionDeleted((sessionID) => {
+      clearSession(sessionID);
+    });
+  }
+
+  return {
+    event,
+    observeChatMessage,
+    /** Clear timers when wait_for_user or fallback begins. */
+    suppress,
+    /** Test seam */
+    _test: {
+      localSessions,
+      intervalMs,
+      enabled,
+      hasRequiredSessionApis: () => hasRequiredSessionApis(sessionSdk),
+    },
+  };
+}
+
+export type OrchestratorWakeScheduler = ReturnType<
+  typeof createOrchestratorWakeScheduler
+>;

+ 227 - 0
src/hooks/orchestrator-wake/wake-gate.ts

@@ -0,0 +1,227 @@
+/**
+ * Process-local gate for orchestrator-wake reservation, progress cap, and
+ * in-flight ownership. Shared across independently created hook instances in
+ * the same JS process via globalThis + Symbol.for.
+ */
+import type { ContinuationModelSelection } from '../task-session-manager/continuation-model-selection';
+
+export type WakeProgressState = {
+  unchangedWakeCount: number;
+  lastFingerprint: string | undefined;
+  stopped: boolean;
+  /** Set when a wake was reserved; next busy preserves the cap. */
+  expectingWakeBusy: boolean;
+  observedModel: ContinuationModelSelection | undefined;
+};
+
+type InFlightState = { owner: symbol; wakeCommitted: boolean };
+
+type WakeGateStore = {
+  progress: Map<string, WakeProgressState>;
+  inFlight: Map<string, InFlightState>;
+  releaseWaiters: Map<string, Set<() => void>>;
+  /** Insertion-ordered session keys for bounded eviction. */
+  order: string[];
+};
+
+const STORE_KEY = Symbol.for('oh-my-opencode-slim.orchestrator-wake-gate');
+const MAX_TRACKED_SESSIONS = 256;
+
+function getStore(): WakeGateStore {
+  const globalWithStore = globalThis as typeof globalThis & {
+    [STORE_KEY]?: WakeGateStore;
+  };
+  globalWithStore[STORE_KEY] ??= {
+    progress: new Map(),
+    inFlight: new Map(),
+    releaseWaiters: new Map(),
+    order: [],
+  };
+  return globalWithStore[STORE_KEY];
+}
+
+function touchOrder(sessionID: string): void {
+  const store = getStore();
+  const idx = store.order.indexOf(sessionID);
+  if (idx >= 0) store.order.splice(idx, 1);
+  store.order.push(sessionID);
+  while (store.order.length > MAX_TRACKED_SESSIONS) {
+    const oldest = store.order.shift();
+    if (!oldest) break;
+    clearWakeSession(oldest);
+  }
+}
+
+function emptyProgress(): WakeProgressState {
+  return {
+    unchangedWakeCount: 0,
+    lastFingerprint: undefined,
+    stopped: false,
+    expectingWakeBusy: false,
+    observedModel: undefined,
+  };
+}
+
+export function getWakeProgress(sessionID: string): WakeProgressState {
+  const store = getStore();
+  const existing = store.progress.get(sessionID);
+  if (existing) {
+    touchOrder(sessionID);
+    return existing;
+  }
+  const created = emptyProgress();
+  store.progress.set(sessionID, created);
+  touchOrder(sessionID);
+  return created;
+}
+
+/**
+ * Atomically claim the single in-flight evaluation slot for a session.
+ * Returns an owner token, or null if another evaluation owns the slot.
+ */
+export function tryBeginWakeEvaluation(sessionID: string): symbol | null {
+  const store = getStore();
+  if (store.inFlight.has(sessionID)) return null;
+  const owner = Symbol(sessionID);
+  store.inFlight.set(sessionID, { owner, wakeCommitted: false });
+  touchOrder(sessionID);
+  return owner;
+}
+
+/**
+ * Release an in-flight evaluation only when still owned by `owner`.
+ */
+export function releaseWakeEvaluation(sessionID: string, owner: symbol): void {
+  const store = getStore();
+  const state = store.inFlight.get(sessionID);
+  if (state?.owner === owner) {
+    store.inFlight.delete(sessionID);
+    const waiters = store.releaseWaiters.get(sessionID);
+    store.releaseWaiters.delete(sessionID);
+    if (!state.wakeCommitted) {
+      for (const waiter of waiters ?? []) waiter();
+    }
+  }
+}
+
+/**
+ * Retry an evaluation that lost the shared in-flight reservation. Registering
+ * and checking the reservation happen against the same store, so an owner
+ * release cannot be missed between them.
+ */
+export function retryAfterWakeEvaluation(
+  sessionID: string,
+  retry: () => void,
+): () => void {
+  const store = getStore();
+  if (!store.inFlight.has(sessionID)) {
+    queueMicrotask(retry);
+    return () => {};
+  }
+  const waiters = store.releaseWaiters.get(sessionID) ?? new Set<() => void>();
+  waiters.add(retry);
+  store.releaseWaiters.set(sessionID, waiters);
+  return () => {
+    const current = store.releaseWaiters.get(sessionID);
+    current?.delete(retry);
+    if (current?.size === 0) store.releaseWaiters.delete(sessionID);
+  };
+}
+
+/**
+ * Record a wake reservation before promptAsync. Owner-safe: only the current
+ * in-flight owner may commit. Updates fingerprint accounting and marks that
+ * the next busy should preserve (not rearm) the no-progress cap.
+ */
+export function commitWakeReservation(
+  sessionID: string,
+  owner: symbol,
+  fingerprint: string,
+): boolean {
+  const store = getStore();
+  const flight = store.inFlight.get(sessionID);
+  if (flight?.owner !== owner) return false;
+  flight.wakeCommitted = true;
+
+  const progress = getWakeProgress(sessionID);
+  if (progress.lastFingerprint !== fingerprint) {
+    progress.unchangedWakeCount = 0;
+    progress.lastFingerprint = fingerprint;
+  }
+  progress.unchangedWakeCount += 1;
+  progress.expectingWakeBusy = true;
+  if (progress.unchangedWakeCount >= 2) {
+    progress.stopped = true;
+  }
+  return true;
+}
+
+/** Host fingerprint changed: reset the two-wake no-progress cap. */
+export function noteHostProgress(sessionID: string, fingerprint: string): void {
+  const progress = getWakeProgress(sessionID);
+  if (progress.lastFingerprint === fingerprint) return;
+  progress.lastFingerprint = fingerprint;
+  progress.unchangedWakeCount = 0;
+  progress.stopped = false;
+}
+
+/**
+ * Whether busy belongs to a scheduler wake. The marker persists through
+ * duplicate status delivery from independently-created hook instances.
+ */
+export function isExpectingWakeBusy(sessionID: string): boolean {
+  const progress = getWakeProgress(sessionID);
+  return progress.expectingWakeBusy;
+}
+
+/** Clear the scheduler busy marker once the corresponding idle arrives. */
+export function clearExpectingWakeBusy(sessionID: string): void {
+  const progress = getWakeProgress(sessionID);
+  progress.expectingWakeBusy = false;
+}
+
+/** External user activity or genuine lifecycle cleanup rearms the cap. */
+export function rearmWakeProgress(sessionID: string): void {
+  const progress = getWakeProgress(sessionID);
+  progress.unchangedWakeCount = 0;
+  progress.lastFingerprint = undefined;
+  progress.stopped = false;
+  progress.expectingWakeBusy = false;
+}
+
+export function setObservedWakeModel(
+  sessionID: string,
+  model: ContinuationModelSelection | undefined,
+): void {
+  getWakeProgress(sessionID).observedModel = model;
+}
+
+export function getObservedWakeModel(
+  sessionID: string,
+): ContinuationModelSelection | undefined {
+  return getStore().progress.get(sessionID)?.observedModel;
+}
+
+/** Full session cleanup (deletion or disposal). */
+export function clearWakeSession(sessionID: string): void {
+  const store = getStore();
+  store.progress.delete(sessionID);
+  store.inFlight.delete(sessionID);
+  store.releaseWaiters.delete(sessionID);
+  const idx = store.order.indexOf(sessionID);
+  if (idx >= 0) store.order.splice(idx, 1);
+}
+
+/** Server/instance disposal: drop all process-local wake state. */
+export function clearAllWakeSessions(): void {
+  const store = getStore();
+  store.progress.clear();
+  store.inFlight.clear();
+  store.releaseWaiters.clear();
+  store.order.length = 0;
+}
+
+/** Test seam. */
+export function resetOrchestratorWakeGateForTests(): void {
+  clearAllWakeSessions();
+}

+ 0 - 55
src/hooks/task-session-manager/continuation-attempt-gate.test.ts

@@ -1,55 +0,0 @@
-import { beforeEach, describe, expect, test } from 'bun:test';
-import {
-  beginUserWait,
-  resetContinuationAttemptGateForTests,
-} from './continuation-attempt-gate';
-
-type LegacyAttemptState =
-  | { status: 'reserved'; owner: symbol }
-  | { status: 'consumed' };
-
-type LegacyStore = {
-  attempts: Map<string, LegacyAttemptState>;
-};
-
-const STORE_KEY = Symbol.for('oh-my-opencode-slim.continuation-attempt-gate');
-
-function getLegacyStore(): LegacyStore {
-  return (
-    globalThis as typeof globalThis & {
-      [STORE_KEY]: LegacyStore;
-    }
-  )[STORE_KEY];
-}
-
-function legacyTryReserve(sessionID: string): symbol | null {
-  const { attempts } = getLegacyStore();
-  if (attempts.has(sessionID)) return null;
-  const owner = Symbol(sessionID);
-  attempts.set(sessionID, { status: 'reserved', owner });
-  return owner;
-}
-
-function legacyCommit(sessionID: string, owner: symbol): boolean {
-  const { attempts } = getLegacyStore();
-  const state = attempts.get(sessionID);
-  if (state?.status !== 'reserved' || state.owner !== owner) return false;
-  attempts.set(sessionID, { status: 'consumed' });
-  return true;
-}
-
-describe('continuation attempt gate compatibility', () => {
-  beforeEach(() => {
-    resetContinuationAttemptGateForTests();
-  });
-
-  test('wait_for_user blocks a pre-upgrade hook sharing the global store', () => {
-    const staleOwner = legacyTryReserve('parent-1');
-    expect(staleOwner).not.toBeNull();
-
-    beginUserWait('parent-1');
-
-    expect(legacyTryReserve('parent-1')).toBeNull();
-    expect(legacyCommit('parent-1', staleOwner as symbol)).toBe(false);
-  });
-});

+ 0 - 152
src/hooks/task-session-manager/continuation-attempt-gate.ts

@@ -1,152 +0,0 @@
-/**
- * Process-local gate for incomplete-todo continuation promptAsync attempts.
- *
- * Scoped via globalThis + Symbol.for so independently created hook instances
- * in the same JS process share one-attempt-per-session protection. Does not
- * claim cross-process or restart durability.
- */
-
-type AttemptState =
-  | { status: 'reserved'; owner: symbol }
-  | { status: 'consumed' }
-  | { status: 'waiting-for-user' };
-
-type RearmIdentity = string | symbol;
-
-type ContinuationAttemptStore = {
-  attempts: Map<string, AttemptState>;
-  /**
-   * Last external user-message identity that rearmed each session.
-   * string = chat.message ID; symbol = same-process object identity fallback.
-   */
-  lastRearmIdentity: Map<string, RearmIdentity>;
-  /** Stable symbols for ID-less output.message object identity (same process). */
-  messageObjectIdentity: WeakMap<object, symbol>;
-};
-
-const STORE_KEY = Symbol.for('oh-my-opencode-slim.continuation-attempt-gate');
-
-function getStore(): ContinuationAttemptStore {
-  const globalWithStore = globalThis as typeof globalThis & {
-    [STORE_KEY]?: ContinuationAttemptStore;
-  };
-  globalWithStore[STORE_KEY] ??= {
-    attempts: new Map(),
-    lastRearmIdentity: new Map(),
-    messageObjectIdentity: new WeakMap(),
-  };
-  return globalWithStore[STORE_KEY];
-}
-
-/**
- * Block continuation for a text-only HITL boundary until a distinct real
- * external user message opens the next continuation epoch.
- *
- * Deleting an existing attempt also revokes an in-flight reservation: its
- * owner can no longer commit a prompt after this wait begins.
- */
-export function beginUserWait(sessionID: string): void {
-  getStore().attempts.set(sessionID, { status: 'waiting-for-user' });
-}
-
-export function hasUserWait(sessionID: string): boolean {
-  return getStore().attempts.get(sessionID)?.status === 'waiting-for-user';
-}
-
-function resolveRearmIdentity(identity: string | object): RearmIdentity {
-  if (typeof identity === 'string') return identity;
-  const store = getStore();
-  const existing = store.messageObjectIdentity.get(identity);
-  if (existing) return existing;
-  const token = Symbol('continuation-rearm-message');
-  store.messageObjectIdentity.set(identity, token);
-  return token;
-}
-
-/**
- * Atomically reserve a continuation attempt.
- * Returns an owner token on success, or null if already reserved/consumed.
- */
-export function tryReserveContinuationAttempt(
-  sessionID: string,
-): symbol | null {
-  const { attempts } = getStore();
-  if (attempts.has(sessionID)) return null;
-  const owner = Symbol(sessionID);
-  attempts.set(sessionID, { status: 'reserved', owner });
-  return owner;
-}
-
-/**
- * Commit a reserved attempt owned by `owner`. Returns true if this owner
- * committed; false if the reservation is missing or owned by someone else.
- */
-export function commitContinuationAttempt(
-  sessionID: string,
-  owner: symbol,
-): boolean {
-  const { attempts } = getStore();
-  const state = attempts.get(sessionID);
-  if (state?.status !== 'reserved' || state.owner !== owner) {
-    return false;
-  }
-  attempts.set(sessionID, { status: 'consumed' });
-  return true;
-}
-
-/**
- * Release an uncommitted reservation only when still owned by `owner`.
- * Consumed attempts and foreign reservations are left intact.
- */
-export function releaseContinuationAttempt(
-  sessionID: string,
-  owner: symbol,
-): void {
-  const { attempts } = getStore();
-  const state = attempts.get(sessionID);
-  if (state?.status === 'reserved' && state.owner === owner) {
-    attempts.delete(sessionID);
-  }
-}
-
-/**
- * Open a new continuation epoch for a real external user message.
- * Idempotent per (sessionID, identity): string message IDs or same-process
- * object identity (WeakMap→symbol). A second observe of the same identity
- * does not rearm again. Returns true when this call cleared attempt state.
- */
-export function rearmContinuationForUserMessage(
-  sessionID: string,
-  identity: string | object,
-): boolean {
-  const store = getStore();
-  const resolved = resolveRearmIdentity(identity);
-  if (store.lastRearmIdentity.get(sessionID) === resolved) {
-    return false;
-  }
-  store.lastRearmIdentity.set(sessionID, resolved);
-  store.attempts.delete(sessionID);
-  return true;
-}
-
-/**
- * Full session cleanup (genuine deletion). Clears attempt state and rearm
- * identity so a later session id reuse is not pinned to a prior message.
- */
-export function clearContinuationAttempt(sessionID: string): void {
-  const store = getStore();
-  store.attempts.delete(sessionID);
-  store.lastRearmIdentity.delete(sessionID);
-}
-
-export function hasConsumedContinuationAttempt(sessionID: string): boolean {
-  return getStore().attempts.get(sessionID)?.status === 'consumed';
-}
-
-/** Test seam: wipe process-local gate state between cases. */
-export function resetContinuationAttemptGateForTests(): void {
-  const store = getStore();
-  store.attempts.clear();
-  store.lastRearmIdentity.clear();
-  // WeakMap entries are not enumerable; leave for GC. Tests use fresh objects.
-}

+ 0 - 302
src/hooks/task-session-manager/continuation-evaluator.ts

@@ -1,302 +0,0 @@
-/**
- * Continuation evaluator subsystem for task session manager.
- *
- * Evaluates whether a parent session needs a continuation nudge
- * when its children complete but todos remain unfinished.
- *
- * Exported as a pure function taking explicit dependency objects
- * to avoid circular dependency issues with the other subsystems.
- */
-import { createInternalAgentTextPart } from '../../utils';
-import type { BackgroundJobStore } from '../../utils/background-job-store';
-import { isRecord as isObjectRecord } from '../../utils/guards';
-import { log } from '../../utils/logger';
-import {
-  type ContinuationModelSelection,
-  parseContinuationModelSelection,
-} from './continuation-model-selection';
-import { isActiveStatus } from './status-utils';
-
-const CONTINUATION_NUDGE =
-  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
-
-/**
- * Shared 5-condition guard that appears (in identical form) after
- * each async liveness re-check inside the main evaluation loop.
- * Deduplicated here to avoid repeating the same short-circuit chain.
- *
- * Does NOT include the SDK-availability checks (first guard only);
- * those remain inline since they only run once before any I/O.
- */
-function isEvaluationAborted(
-  parentSessionID: string,
-  sessionToken: symbol,
-  evaluationToken: symbol,
-  deps: {
-    continuationTokens: {
-      consumed: { has: (sessionID: string) => boolean };
-      isCurrentContinuation: (
-        sessionID: string,
-        sessionToken: symbol,
-        evaluationToken?: symbol,
-      ) => boolean;
-    };
-    inputWaits: {
-      hasInputWait: (sessionID: string) => boolean;
-    };
-    options: {
-      isFallbackInProgress?: (sessionID: string) => boolean;
-    };
-    backgroundJobBoard: BackgroundJobStore;
-  },
-): boolean {
-  return (
-    deps.continuationTokens.consumed.has(parentSessionID) ||
-    deps.inputWaits.hasInputWait(parentSessionID) ||
-    !deps.continuationTokens.isCurrentContinuation(
-      parentSessionID,
-      sessionToken,
-      evaluationToken,
-    ) ||
-    deps.options.isFallbackInProgress?.(parentSessionID) ||
-    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
-  );
-}
-
-function cleanupEvaluationToken(
-  parentSessionID: string,
-  evaluationToken: symbol,
-  evaluations: Map<string, Set<symbol>>,
-): void {
-  const active = evaluations.get(parentSessionID);
-  active?.delete(evaluationToken);
-  if (active?.size === 0) {
-    evaluations.delete(parentSessionID);
-  }
-}
-
-export async function evaluateContinuation(
-  parentSessionID: string,
-  sessionToken: symbol,
-  deps: {
-    continueOnIdle: boolean;
-    backgroundJobBoard: BackgroundJobStore;
-    continuationTokens: {
-      evaluations: Map<string, Set<symbol>>;
-      consumed: { has: (sessionID: string) => boolean };
-      isCurrentContinuation: (
-        sessionID: string,
-        sessionToken: symbol,
-        evaluationToken?: symbol,
-      ) => boolean;
-      tryReserveAttempt: (sessionID: string) => symbol | null;
-      commitAttempt: (sessionID: string, owner: symbol) => boolean;
-      releaseAttempt: (sessionID: string, owner: symbol) => void;
-    };
-    inputWaits: {
-      hasInputWait: (sessionID: string) => boolean;
-    };
-    options: {
-      isFallbackInProgress?: (sessionID: string) => boolean;
-    };
-    getObservedModelSelection: (
-      sessionID: string,
-    ) => ContinuationModelSelection | undefined;
-    sessionSdk?: {
-      todo?: (input: unknown) => Promise<{ data?: unknown }>;
-      children?: (input: unknown) => Promise<{ data?: unknown }>;
-      status?: (input: unknown) => Promise<{ data?: unknown }>;
-      get?: (input: unknown) => Promise<{ data?: unknown }>;
-      promptAsync?: (input: unknown) => Promise<unknown>;
-    };
-  },
-): Promise<void> {
-  // Explicit opt-out: idle reconciliation still runs; continuation SDK calls do not.
-  if (!deps.continueOnIdle) {
-    return;
-  }
-
-  const evaluationToken = Symbol(parentSessionID);
-  const activeEvaluations =
-    deps.continuationTokens.evaluations.get(parentSessionID) ??
-    new Set<symbol>();
-  activeEvaluations.add(evaluationToken);
-  deps.continuationTokens.evaluations.set(parentSessionID, activeEvaluations);
-
-  // Guard 1: pre-flight checks (includes SDK availability — only once)
-  if (
-    deps.continuationTokens.consumed.has(parentSessionID) ||
-    deps.inputWaits.hasInputWait(parentSessionID) ||
-    !deps.continuationTokens.isCurrentContinuation(
-      parentSessionID,
-      sessionToken,
-      evaluationToken,
-    ) ||
-    deps.options.isFallbackInProgress?.(parentSessionID) ||
-    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID) ||
-    !deps.sessionSdk?.todo ||
-    !deps.sessionSdk.children ||
-    !deps.sessionSdk.status ||
-    !deps.sessionSdk.promptAsync
-  ) {
-    cleanupEvaluationToken(
-      parentSessionID,
-      evaluationToken,
-      deps.continuationTokens.evaluations,
-    );
-    return;
-  }
-
-  // Reserve before any async SDK liveness reads so concurrent idle events and
-  // independently created hook instances cannot both proceed to promptAsync.
-  const reservationOwner =
-    deps.continuationTokens.tryReserveAttempt(parentSessionID);
-  if (!reservationOwner) {
-    cleanupEvaluationToken(
-      parentSessionID,
-      evaluationToken,
-      deps.continuationTokens.evaluations,
-    );
-    return;
-  }
-
-  let committed = false;
-  try {
-    const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
-      deps.sessionSdk.todo({
-        path: { id: parentSessionID },
-        throwOnError: true,
-      }),
-      deps.sessionSdk.children({
-        path: { id: parentSessionID },
-        throwOnError: true,
-      }),
-      deps.sessionSdk.status({ throwOnError: true }),
-    ]);
-    if (
-      !Array.isArray(todoResponse.data) ||
-      !Array.isArray(childrenResponse.data) ||
-      !isObjectRecord(statusResponse.data)
-    ) {
-      return;
-    }
-    const todos = todoResponse.data;
-    const children = childrenResponse.data;
-    const status = statusResponse.data;
-    if (
-      !todos.every(
-        (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
-      ) ||
-      !children.every(
-        (child) => isObjectRecord(child) && typeof child.id === 'string',
-      )
-    ) {
-      return;
-    }
-    if (
-      !todos.some(
-        (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
-      )
-    ) {
-      return;
-    }
-    const childIDs = children.map((child) => child.id as string);
-    if (
-      isActiveStatus(status, parentSessionID) ||
-      childIDs.some((childID) => isActiveStatus(status, childID))
-    ) {
-      return;
-    }
-
-    // Re-read liveness immediately before queuing work; board state is only
-    // authoritative for terminal results observed by this plugin instance.
-    const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
-      deps.sessionSdk.children({
-        path: { id: parentSessionID },
-        throwOnError: true,
-      }),
-      deps.sessionSdk.status({ throwOnError: true }),
-    ]);
-    if (
-      !Array.isArray(latestChildrenResponse.data) ||
-      !isObjectRecord(latestStatusResponse.data) ||
-      !latestChildrenResponse.data.every(
-        (child) => isObjectRecord(child) && typeof child.id === 'string',
-      ) ||
-      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
-    ) {
-      return;
-    }
-    const latestChildIDs = latestChildrenResponse.data.map(
-      (child) => child.id as string,
-    );
-    const latestStatus = latestStatusResponse.data;
-    if (
-      isActiveStatus(latestStatus, parentSessionID) ||
-      latestChildIDs.some((childID) => isActiveStatus(latestStatus, childID))
-    ) {
-      return;
-    }
-
-    let currentModelSelection: ContinuationModelSelection | undefined;
-    if (deps.sessionSdk.get) {
-      try {
-        const sessionResponse = await deps.sessionSdk.get({
-          path: { id: parentSessionID },
-          throwOnError: true,
-        });
-        const session = isObjectRecord(sessionResponse?.data)
-          ? sessionResponse.data
-          : undefined;
-        currentModelSelection = parseContinuationModelSelection(session?.model);
-      } catch {
-        // Model enrichment is fail-soft. Older OpenCode session payloads do
-        // not expose Session.model, so fall back to the filtered chat hook.
-      }
-    }
-    const modelSelection =
-      currentModelSelection ?? deps.getObservedModelSelection(parentSessionID);
-
-    if (
-      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
-    ) {
-      return;
-    }
-
-    // Commit immediately before promptAsync — no await between commit and call.
-    // Once invoked, never retry in this epoch even if promptAsync rejects.
-    if (
-      !deps.continuationTokens.commitAttempt(parentSessionID, reservationOwner)
-    ) {
-      return;
-    }
-    committed = true;
-    await deps.sessionSdk.promptAsync({
-      path: { id: parentSessionID },
-      body: {
-        agent: 'orchestrator',
-        ...(modelSelection ? { model: modelSelection.model } : {}),
-        ...(modelSelection?.variant ? { variant: modelSelection.variant } : {}),
-        parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
-      },
-      throwOnError: true,
-    });
-  } catch (error) {
-    log(
-      '[task-session-manager] continuation nudge suppressed after SDK error',
-      {
-        parentSessionID,
-        error: error instanceof Error ? error.message : String(error),
-      },
-    );
-  } finally {
-    if (!committed) {
-      deps.continuationTokens.releaseAttempt(parentSessionID, reservationOwner);
-    }
-    cleanupEvaluationToken(
-      parentSessionID,
-      evaluationToken,
-      deps.continuationTokens.evaluations,
-    );
-  }
-}

+ 0 - 141
src/hooks/task-session-manager/continuation-token-manager.ts

@@ -1,141 +0,0 @@
-import {
-  clearContinuationAttempt,
-  commitContinuationAttempt,
-  hasConsumedContinuationAttempt,
-  rearmContinuationForUserMessage,
-  releaseContinuationAttempt,
-  tryReserveContinuationAttempt,
-} from './continuation-attempt-gate';
-
-export function createContinuationTokenManager(options?: {
-  onInvalidateContinuation?: (sessionID: string) => void;
-}) {
-  const continuationSessionTokens = new Map<string, symbol>();
-  const activeContinuationEvaluations = new Map<string, Set<symbol>>();
-  /** Uncommitted reservation owners created by this token-manager instance. */
-  const localReservations = new Map<string, symbol>();
-
-  function getContinuationSessionToken(sessionID: string): symbol {
-    const existing = continuationSessionTokens.get(sessionID);
-    if (existing) return existing;
-
-    const token = Symbol(sessionID);
-    continuationSessionTokens.set(sessionID, token);
-    return token;
-  }
-
-  function isCurrentContinuation(
-    sessionID: string,
-    sessionToken: symbol,
-    evaluationToken?: symbol,
-  ): boolean {
-    return (
-      continuationSessionTokens.get(sessionID) === sessionToken &&
-      (evaluationToken === undefined ||
-        activeContinuationEvaluations.get(sessionID)?.has(evaluationToken) ===
-          true)
-    );
-  }
-
-  function releaseLocalReservation(sessionID: string): void {
-    const owner = localReservations.get(sessionID);
-    if (!owner) return;
-    releaseContinuationAttempt(sessionID, owner);
-    localReservations.delete(sessionID);
-  }
-
-  function tryReserveAttempt(sessionID: string): symbol | null {
-    const owner = tryReserveContinuationAttempt(sessionID);
-    if (owner) {
-      localReservations.set(sessionID, owner);
-    }
-    return owner;
-  }
-
-  function commitAttempt(sessionID: string, owner: symbol): boolean {
-    const committed = commitContinuationAttempt(sessionID, owner);
-    if (committed && localReservations.get(sessionID) === owner) {
-      localReservations.delete(sessionID);
-    }
-    return committed;
-  }
-
-  function releaseAttempt(sessionID: string, owner: symbol): void {
-    releaseContinuationAttempt(sessionID, owner);
-    if (localReservations.get(sessionID) === owner) {
-      localReservations.delete(sessionID);
-    }
-  }
-
-  function invalidateContinuation(sessionID: string): void {
-    options?.onInvalidateContinuation?.(sessionID);
-    continuationSessionTokens.delete(sessionID);
-    activeContinuationEvaluations.delete(sessionID);
-    // Release this instance's uncommitted reservation immediately so a hung
-    // SDK read cannot pin the process-global gate. Owner-safe: foreign or
-    // already-committed attempts are untouched. Stale evaluator finally
-    // cleanup remains a harmless no-op.
-    releaseLocalReservation(sessionID);
-  }
-
-  /**
-   * Real external user message: process-global attempt clear is idempotent per
-   * message identity (string ID or same-process message object). Always
-   * invalidate this instance's local timers/tokens/reservations so a
-   * pre-message idle timer on a second hook cannot fire SDK reads after the
-   * shared observe.
-   */
-  function rearmForUserMessage(
-    sessionID: string,
-    messageIdentity: string | object,
-  ): void {
-    rearmContinuationForUserMessage(sessionID, messageIdentity);
-    invalidateContinuation(sessionID);
-  }
-
-  /**
-   * Full session reset: local tokens + process-global attempt (including
-   * consumed) and rearm identity. Used for genuine session deletion only.
-   */
-  function clearContinuation(sessionID: string): void {
-    invalidateContinuation(sessionID);
-    clearContinuationAttempt(sessionID);
-  }
-
-  /**
-   * Instance disposal: drop local bookkeeping and release only this instance's
-   * uncommitted reservations. Process-global committed attempts stay so another
-   * hook instance in the same process cannot rearm a spent epoch.
-   */
-  function disposeLocalState(): void {
-    for (const sessionID of [...localReservations.keys()]) {
-      releaseLocalReservation(sessionID);
-    }
-    for (const sessionID of [...continuationSessionTokens.keys()]) {
-      options?.onInvalidateContinuation?.(sessionID);
-    }
-    continuationSessionTokens.clear();
-    activeContinuationEvaluations.clear();
-  }
-
-  const consumed = {
-    has(sessionID: string): boolean {
-      return hasConsumedContinuationAttempt(sessionID);
-    },
-  };
-
-  return {
-    getContinuationSessionToken,
-    isCurrentContinuation,
-    invalidateContinuation,
-    rearmForUserMessage,
-    clearContinuation,
-    disposeLocalState,
-    tryReserveAttempt,
-    commitAttempt,
-    releaseAttempt,
-    sessionTokens: continuationSessionTokens,
-    evaluations: activeContinuationEvaluations,
-    consumed,
-  };
-}

+ 15 - 21
src/hooks/task-session-manager/event-router.ts

@@ -46,14 +46,12 @@ export async function handleEvent(
       clearInputWaits(sessionID: string): void;
       waitsByParent: Map<string, Set<string | symbol>>;
     };
-    continuationTokens: {
-      clearContinuation(sessionID: string): void;
-      invalidateContinuation(sessionID: string): void;
-      /** Release local uncommitted reservations only; keep global consumed. */
+    idleSessionTokens: {
+      clearSession(sessionID: string): void;
+      invalidate(sessionID: string): void;
+      /** Drop local idle-token bookkeeping; keep process-global wait_for_user. */
       disposeLocalState(): void;
       sessionTokens: Map<string, symbol>;
-      evaluations: Map<string, Set<symbol>>;
-      consumed: { has(sessionID: string): boolean };
     };
     options: {
       shouldManageSession: (sessionID: string) => boolean;
@@ -163,16 +161,13 @@ export async function handleEvent(
     deps.backgroundJobSupervisor?.dispose();
     deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
-    // Local-only: release this instance's uncommitted reservations and drop
-    // local tokens/evaluations. Do not enumerate or clear process-global
-    // consumed attempts owned by the shared gate.
+    // Local-only: drop idle tokens. Process-global wait_for_user stays armed.
     const waitSessionIDs = new Set([
       ...idleSessionIds,
-      ...deps.continuationTokens.sessionTokens.keys(),
-      ...deps.continuationTokens.evaluations.keys(),
+      ...deps.idleSessionTokens.sessionTokens.keys(),
       ...deps.inputWaits.waitsByParent.keys(),
     ]);
-    deps.continuationTokens.disposeLocalState();
+    deps.idleSessionTokens.disposeLocalState();
     for (const sessionID of waitSessionIDs) {
       deps.inputWaits.clearInputWaits(sessionID);
     }
@@ -228,7 +223,7 @@ export async function handleEvent(
     const sessionId =
       input.event.properties?.info?.id || input.event.properties?.sessionID;
     if (sessionId) {
-      deps.continuationTokens.invalidateContinuation(sessionId);
+      deps.idleSessionTokens.invalidate(sessionId);
     }
     if (sessionId && deps.options.shouldManageSession(sessionId)) {
       const props = input.event.properties as { error?: unknown } | undefined;
@@ -299,14 +294,14 @@ export async function handleEvent(
     const statusType = (
       input.event.properties as { status?: { type?: string } } | undefined
     )?.status?.type;
-    if (sessionId) deps.continuationTokens.invalidateContinuation(sessionId);
+    if (sessionId) deps.idleSessionTokens.invalidate(sessionId);
     if (statusType !== 'busy') {
       return;
     }
     // Live busy cancels a pending child idle-reconcile — the session
     // recovered (FG re-prompt or continued work).
-    // Note: invalidateContinuation above already cleared the parent
-    // idle-reconcile timer; clearIdleTimers handles the child timer.
+    // Note: invalidate above already cleared the parent idle-reconcile
+    // timer; clearIdleTimers handles the child timer.
     if (sessionId) {
       deps.idleReconciler.clearIdleTimers(sessionId);
       // Live busy after a deferred 401/410 means the fallback re-prompt
@@ -349,13 +344,12 @@ export async function handleEvent(
     input.event.properties?.info?.id || input.event.properties?.sessionID;
   if (!sessionId) return;
 
-  // Foreground-fallback teardown recreates the session; preserve a committed
-  // continuation attempt so the epoch is not rearmed without a real user message.
-  // Genuine deletion clears process-global attempt state for the session.
+  // Foreground-fallback teardown recreates the session; keep process-global
+  // wait_for_user. Genuine deletion clears wait state for the session.
   if (deps.options.isFallbackInProgress?.(sessionId)) {
-    deps.continuationTokens.invalidateContinuation(sessionId);
+    deps.idleSessionTokens.invalidate(sessionId);
   } else {
-    deps.continuationTokens.clearContinuation(sessionId);
+    deps.idleSessionTokens.clearSession(sessionId);
   }
   deps.inputWaits.clearInputWaits(sessionId);
   deps.retainedBoardSnapshots.delete(sessionId);

+ 6 - 12
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -3,21 +3,16 @@ import { log } from '../../utils/logger';
 
 export function createIdleReconciler(options: {
   backgroundJobBoard: BackgroundJobStore;
-  evaluateContinuation: (
-    parentSessionID: string,
-    sessionToken: symbol,
-  ) => Promise<void>;
   reconcileInjectedTerminalJobs: (parentSessionID: string) => void;
   /** Called when a deferred inline error is terminalized at idle. */
   onErrorTerminalize?: (sessionID: string) => void;
   idleReconcileDelayMs: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   hasInputWait: (sessionID: string) => boolean;
-  getContinuationSessionToken: (sessionID: string) => symbol;
-  isCurrentContinuation: (
+  getIdleSessionToken: (sessionID: string) => symbol;
+  isCurrentIdleSessionToken: (
     sessionID: string,
     sessionToken: symbol,
-    evaluationToken?: symbol,
   ) => boolean;
   taskContextTracker: {
     pendingManagedTaskIds: Set<string>;
@@ -43,14 +38,13 @@ export function createIdleReconciler(options: {
     ) {
       return;
     }
-    const sessionToken = options.getContinuationSessionToken(parentSessionID);
+    const sessionToken = options.getIdleSessionToken(parentSessionID);
     const timer = setTimeout(() => {
       idleReconcileTimers.delete(parentSessionID);
-      if (!options.isCurrentContinuation(parentSessionID, sessionToken)) {
+      if (!options.isCurrentIdleSessionToken(parentSessionID, sessionToken)) {
         return;
       }
       options.reconcileInjectedTerminalJobs(parentSessionID);
-      void options.evaluateContinuation(parentSessionID, sessionToken);
     }, options.idleReconcileDelayMs).unref?.();
     idleReconcileTimers.set(parentSessionID, timer);
   }
@@ -211,8 +205,8 @@ export function createIdleReconciler(options: {
     scheduleErrorTerminalize,
     clearIdleTimers,
     clearAllTimers,
-    /** Callback for continuation-token-manager's onInvalidateContinuation. */
-    onInvalidateContinuation: (sessionID: string) => {
+    /** Callback for idle-session-tokens invalidate. */
+    onInvalidateIdle: (sessionID: string) => {
       const timer = idleReconcileTimers.get(sessionID);
       if (timer) {
         clearTimeout(timer);

+ 70 - 0
src/hooks/task-session-manager/idle-session-tokens.ts

@@ -0,0 +1,70 @@
+import { clearUserWait, clearUserWaitForMessage } from './user-wait-gate';
+
+/**
+ * Per-instance session tokens used to invalidate delayed idle-reconciliation
+ * timers when the parent becomes busy, errors, waits, or is deleted.
+ */
+export function createIdleSessionTokens(options?: {
+  onInvalidate?: (sessionID: string) => void;
+}) {
+  const sessionTokens = new Map<string, symbol>();
+
+  function getSessionToken(sessionID: string): symbol {
+    const existing = sessionTokens.get(sessionID);
+    if (existing) return existing;
+    const token = Symbol(sessionID);
+    sessionTokens.set(sessionID, token);
+    return token;
+  }
+
+  function isCurrentSessionToken(
+    sessionID: string,
+    sessionToken: symbol,
+  ): boolean {
+    return sessionTokens.get(sessionID) === sessionToken;
+  }
+
+  function invalidate(sessionID: string): void {
+    options?.onInvalidate?.(sessionID);
+    sessionTokens.delete(sessionID);
+  }
+
+  /**
+   * Real external user message: clear process-global wait_for_user (idempotent
+   * per message identity) and invalidate local idle timers.
+   */
+  function onExternalUserMessage(
+    sessionID: string,
+    messageIdentity: string | object,
+  ): void {
+    clearUserWaitForMessage(sessionID, messageIdentity);
+    invalidate(sessionID);
+  }
+
+  /** Genuine session deletion: local tokens + process-global wait state. */
+  function clearSession(sessionID: string): void {
+    invalidate(sessionID);
+    clearUserWait(sessionID);
+  }
+
+  /**
+   * Instance disposal: drop local tokens only. Process-global wait_for_user
+   * state stays so another hook instance in the same process keeps the latch.
+   */
+  function disposeLocalState(): void {
+    for (const sessionID of [...sessionTokens.keys()]) {
+      options?.onInvalidate?.(sessionID);
+    }
+    sessionTokens.clear();
+  }
+
+  return {
+    getSessionToken,
+    isCurrentSessionToken,
+    invalidate,
+    onExternalUserMessage,
+    clearSession,
+    disposeLocalState,
+    sessionTokens,
+  };
+}

+ 116 - 2498
src/hooks/task-session-manager/index.test.ts

@@ -12,14 +12,11 @@ import {
   PHASE_REMINDER_METADATA_KEY,
 } from '../phase-reminder';
 import { createPostFileToolNudgeHook } from '../post-file-tool-nudge';
-import {
-  hasConsumedContinuationAttempt,
-  resetContinuationAttemptGateForTests,
-} from './continuation-attempt-gate';
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
   createTaskSessionManagerHook,
 } from './index';
+import { resetUserWaitGateForTests } from './user-wait-gate';
 
 // Route getClient back to _ctx.client so existing _ctx.client.session
 // mocks continue to work through the new v2 lookup path.
@@ -87,7 +84,6 @@ type HookOptions = {
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
   maxRetainedSnapshots?: number;
-  continueOnIdle?: boolean;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
@@ -117,7 +113,6 @@ function createHook(options?: HookOptions) {
       strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
-      continueOnIdle: options?.continueOnIdle ?? false,
       backgroundJobBoard: options?.backgroundJobBoard,
       backgroundJobSupervisor: options?.backgroundJobSupervisor,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
@@ -132,62 +127,6 @@ function createHook(options?: HookOptions) {
   return { hook };
 }
 
-function createContinuationHook(options?: HookOptions) {
-  return createHook({
-    ...options,
-    continueOnIdle: options?.continueOnIdle ?? true,
-  });
-}
-
-function createContinuationSessionClient(
-  promptAsync: unknown,
-  overrides?: Record<string, unknown>,
-): Record<string, unknown> {
-  return {
-    todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
-    children: mock(async () => ({ data: [] })),
-    status: mock(async () => ({ data: {} })),
-    promptAsync,
-    ...overrides,
-  };
-}
-
-function createRuntimeUserTurn(options: {
-  sessionID?: string;
-  messageID: string;
-  providerID: string;
-  modelID: string;
-  variant?: string;
-}) {
-  const sessionID = options.sessionID ?? 'parent-1';
-  const model = {
-    providerID: options.providerID,
-    modelID: options.modelID,
-  };
-  const parts = [{ type: 'text', text: 'continue with this model' }];
-  return {
-    input: {
-      sessionID,
-      messageID: options.messageID,
-      model,
-      ...(options.variant ? { variant: options.variant } : {}),
-      parts,
-    },
-    output: {
-      message: {
-        id: options.messageID,
-        sessionID,
-        role: 'user' as const,
-        model: {
-          ...model,
-          ...(options.variant ? { variant: options.variant } : {}),
-        },
-      },
-      parts,
-    },
-  };
-}
-
 function createMessages(sessionID: string, text = 'user message') {
   return {
     messages: [
@@ -271,7 +210,7 @@ function setupCompletedJob(
 describe('task-session-manager hook', () => {
   beforeEach(() => {
     // Process-global gate only — never reset inside createHook/production paths.
-    resetContinuationAttemptGateForTests();
+    resetUserWaitGateForTests();
   });
 
   test('ignores messages without OpenCode info or parts', async () => {
@@ -4752,80 +4691,77 @@ describe('task-session-manager hook', () => {
   test.each([
     ['foreground-created-first', ['foreground-child', 'background-child']],
     ['background-created-first', ['background-child', 'foreground-child']],
-  ])(
-    'ambiguous early created events never supervise the foreground child (%s)',
-    async (_, createdOrder) => {
-      const board = new BackgroundJobBoard();
-      const clock = createSupervisorClock();
-      const abort = mock(async () => undefined);
-      const supervisor = new BackgroundJobSupervisor({
-        backgroundJobStore: board,
-        wallClockTimeoutMs: 100,
-        abortGraceMs: 10,
-        abort,
-        now: clock.now,
-        setTimeout: clock.setTimeout,
-        clearTimeout: clock.clearTimeout,
-      });
-      const { hook } = createHook({
-        backgroundJobBoard: board,
-        backgroundJobSupervisor: supervisor,
-      });
+  ])('ambiguous early created events never supervise the foreground child (%s)', async (_, createdOrder) => {
+    const board = new BackgroundJobBoard();
+    const clock = createSupervisorClock();
+    const abort = mock(async () => undefined);
+    const supervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: board,
+      wallClockTimeoutMs: 100,
+      abortGraceMs: 10,
+      abort,
+      now: clock.now,
+      setTimeout: clock.setTimeout,
+      clearTimeout: clock.clearTimeout,
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      backgroundJobSupervisor: supervisor,
+    });
 
-      await hook['tool.execute.before'](
-        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
-        {
-          args: {
-            subagent_type: 'explorer',
-            background: true,
-            description: 'background child',
-          },
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          background: true,
+          description: 'background child',
         },
-      );
-      await hook['tool.execute.before'](
-        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
-        {
-          args: {
-            subagent_type: 'explorer',
-            background: false,
-            description: 'foreground child',
-          },
+      },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          background: false,
+          description: 'foreground child',
         },
-      );
+      },
+    );
 
-      for (const taskID of createdOrder) {
-        await hook.event({
-          event: {
-            type: 'session.created',
-            properties: { info: { id: taskID, parentID: 'parent-1' } },
-          },
-        });
-      }
+    for (const taskID of createdOrder) {
+      await hook.event({
+        event: {
+          type: 'session.created',
+          properties: { info: { id: taskID, parentID: 'parent-1' } },
+        },
+      });
+    }
 
-      expect(board.get('background-child')?.background).toBe(false);
-      expect(board.get('foreground-child')?.background).toBe(false);
-      expect(abort).not.toHaveBeenCalled();
+    expect(board.get('background-child')?.background).toBe(false);
+    expect(board.get('foreground-child')?.background).toBe(false);
+    expect(abort).not.toHaveBeenCalled();
 
-      await hook['tool.execute.after'](
-        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
-        { output: taskLaunchOutput('foreground-child') },
-      );
-      await hook['tool.execute.after'](
-        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
-        { output: taskLaunchOutput('background-child') },
-      );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+      { output: taskLaunchOutput('foreground-child') },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+      { output: taskLaunchOutput('background-child') },
+    );
 
-      expect(board.get('foreground-child')?.background).toBe(false);
-      expect(board.get('background-child')?.background).toBe(true);
-      const backgroundJob = board.get('background-child');
-      expect(backgroundJob).toBeDefined();
-      const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
-      await clock.advanceTo(deadline);
+    expect(board.get('foreground-child')?.background).toBe(false);
+    expect(board.get('background-child')?.background).toBe(true);
+    const backgroundJob = board.get('background-child');
+    expect(backgroundJob).toBeDefined();
+    const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
+    await clock.advanceTo(deadline);
 
-      expect(abort).toHaveBeenCalledTimes(1);
-      expect(abort).toHaveBeenCalledWith('background-child');
-    },
-  );
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('background-child');
+  });
 
   test('missing after-hook callID fails closed while an exact background call remains', async () => {
     const board = new BackgroundJobBoard();
@@ -5224,40 +5160,7 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
   });
 
-  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(
-      {
-        client: {
-          session: {
-            todo,
-            children: mock(async () => ({ data: [] })),
-            status: mock(async () => ({ data: {} })),
-            promptAsync,
-          },
-        },
-        directory: '/tmp',
-        worktree: '/tmp',
-      } as never,
-      {
-        maxSessionsPerAgent: 2,
-        maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-        idleReconcileDelayMs: 0,
-        shouldManageSession: () => true,
-      },
-    );
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('explicit continueOnIdle false reconciles parent terminal job without continuation', async () => {
+  test('idle reconciliation still runs without orchestrator wake SDK calls', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'child-1',
@@ -5273,7 +5176,6 @@ describe('task-session-manager hook', () => {
     const promptAsync = mock(async () => ({}));
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const { hook } = createHook({
-      continueOnIdle: false,
       backgroundJobBoard: board,
       idleReconcileDelayMs: 0,
       sessionClient: {
@@ -5303,2365 +5205,81 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).not.toHaveBeenCalled();
   });
 
-  test('continues after reconciling an injected parent terminal job', async () => {
-    const board = new BackgroundJobBoard();
-    setupCompletedJob(board);
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      backgroundJobBoard: board,
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
-    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(board.get('child-1')).toMatchObject({
-      state: 'reconciled',
-      terminalUnreconciled: false,
-    });
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(promptAsync).toHaveBeenCalledWith(
-      expect.objectContaining({
-        body: expect.objectContaining({
-          parts: [expect.objectContaining({ synthetic: true })],
-        }),
-      }),
-    );
-  });
-
-  test('nudges once for incomplete todos when parent and children are inactive', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      continueOnIdle: true,
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(promptAsync).toHaveBeenCalledWith(
-      expect.objectContaining({
-        path: { id: 'parent-1' },
-        body: expect.objectContaining({
-          agent: 'orchestrator',
-          parts: [expect.objectContaining({ synthetic: true })],
-        }),
-      }),
-    );
-  });
-
-  test('preserves the current session model and variant on continuation nudges', async () => {
-    const promptAsync = mock(async () => ({}));
-    const get = mock(async () => ({
-      data: {
-        model: {
-          providerID: 'runtime-provider',
-          id: 'selected-model',
-          variant: 'selected-variant',
-        },
-      },
-    }));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: createContinuationSessionClient(promptAsync, {
-        get,
-      }),
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(get).toHaveBeenCalledWith({
-      path: { id: 'parent-1' },
-      throwOnError: true,
-    });
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(promptAsync).toHaveBeenCalledWith(
-      expect.objectContaining({
-        body: expect.objectContaining({
-          model: {
-            providerID: 'runtime-provider',
-            modelID: 'selected-model',
-          },
-          variant: 'selected-variant',
-        }),
-      }),
-    );
-  });
-
-  test('falls back to the latest external user model when session lookup fails', async () => {
-    const promptAsync = mock(async () => ({}));
-    const userTurn = createRuntimeUserTurn({
-      messageID: 'user-1',
-      providerID: 'runtime-provider',
-      modelID: 'selected-model',
-      variant: 'selected-variant',
-    });
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: createContinuationSessionClient(promptAsync, {
-        get: mock(async () => {
-          throw new Error('session lookup unavailable');
-        }),
-      }),
-    });
+  test('hasInputWait is true after wait_for_user and clears on distinct external message', async () => {
+    const { hook } = createHook();
+    hook.beginUserWait('parent-1');
+    expect(hook.hasInputWait('parent-1')).toBe(true);
 
     hook.observeChatMessage(
+      { sessionID: 'parent-1', messageID: 'msg-user-resumes' },
       {
-        sessionID: userTurn.input.sessionID,
-        messageID: userTurn.input.messageID,
-        parts: userTurn.input.parts,
+        message: {
+          id: 'msg-user-resumes',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
+        parts: [{ type: 'text', text: 'The manual step is complete.' }],
       },
-      userTurn.output,
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(promptAsync).toHaveBeenCalledWith(
-      expect.objectContaining({
-        body: expect.objectContaining({
-          model: {
-            providerID: 'runtime-provider',
-            modelID: 'selected-model',
-          },
-          variant: 'selected-variant',
-        }),
-      }),
     );
+    expect(hook.hasInputWait('parent-1')).toBe(false);
   });
 
-  test('treats a current session model without variant as authoritative', async () => {
-    const promptAsync = mock(async (_input: unknown) => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: createContinuationSessionClient(promptAsync, {
-        get: mock(async () => ({
-          data: {
-            model: {
-              providerID: 'current-provider',
-              id: 'current-model',
-            },
-          },
-        })),
-      }),
-    });
-    const previousTurn = createRuntimeUserTurn({
-      messageID: 'user-1',
-      providerID: 'previous-provider',
-      modelID: 'previous-model',
-      variant: 'previous-variant',
-    });
-    hook.observeChatMessage(previousTurn.input, previousTurn.output);
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    const request = promptAsync.mock.calls[0]?.[0] as {
-      body?: {
-        model?: Record<string, unknown>;
-        variant?: string;
-      };
-    };
-    expect(request?.body?.model).toEqual({
-      providerID: 'current-provider',
-      modelID: 'current-model',
-    });
-    expect(request?.body).not.toHaveProperty('variant');
-  });
-
-  test('only external messages replace the model fallback and clear its variant', async () => {
-    const promptAsync = mock(async (_input: unknown) => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: createContinuationSessionClient(promptAsync),
-    });
-    const selectedTurn = createRuntimeUserTurn({
-      messageID: 'user-1',
-      providerID: 'selected-provider',
-      modelID: 'selected-model',
-      variant: 'selected-variant',
-    });
-    hook.observeChatMessage(selectedTurn.input, selectedTurn.output);
-    const newTurn = createRuntimeUserTurn({
-      messageID: 'user-2',
-      providerID: 'new-provider',
-      modelID: 'new-model',
-    });
-    hook.observeChatMessage(newTurn.input, newTurn.output);
+  test('synthetic and internal messages do not clear wait_for_user', async () => {
+    const { hook } = createHook();
+    hook.beginUserWait('parent-1');
     hook.observeChatMessage(
-      {
-        sessionID: 'parent-1',
-        messageID: 'synthetic-1',
-        model: { providerID: 'static-provider', modelID: 'static-model' },
-        variant: 'static-variant',
-      },
+      { sessionID: 'parent-1', messageID: 'msg-internal' },
       {
         message: {
-          id: 'synthetic-1',
-          sessionID: 'parent-1',
+          id: 'msg-internal',
           role: 'user',
+          sessionID: 'parent-1',
         },
         parts: [
-          {
-            type: 'text',
-            text: 'synthetic continuation',
-            synthetic: true,
-          },
+          { type: 'text', synthetic: true, text: 'synthetic continuation' },
+          createInternalAgentTextPart('internal continuation'),
         ],
       },
     );
+    expect(hook.hasInputWait('parent-1')).toBe(true);
+  });
 
+  test('question/permission asks arm hasInputWait until resolved', async () => {
+    const { hook } = createHook();
     await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'q-1' },
+      },
     });
-    await flushContinuation();
-
-    const request = promptAsync.mock.calls[0]?.[0] as {
-      body?: {
-        model?: Record<string, unknown>;
-        variant?: string;
-      };
-    };
-    expect(request?.body?.model).toEqual({
-      providerID: 'new-provider',
-      modelID: 'new-model',
+    expect(hook.hasInputWait('parent-1')).toBe(true);
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'q-1' },
+      },
     });
-    expect(request?.body).not.toHaveProperty('variant');
+    expect(hook.hasInputWait('parent-1')).toBe(false);
   });
 
-  test('a user message invalidates continuation while current model lookup is pending', async () => {
-    let resolveGet!: (value: {
-      data: {
-        model: { providerID: string; id: string; variant: string };
-      };
-    }) => void;
-    const get = mock(
-      () =>
-        new Promise<{
-          data: {
-            model: { providerID: string; id: string; variant: string };
-          };
-        }>((resolve) => {
-          resolveGet = resolve;
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: createContinuationSessionClient(promptAsync, {
-        get,
-      }),
-    });
+  test('user waits survive hook disposal and clear on genuine deletion', async () => {
+    const owner = createHook().hook;
+    owner.beginUserWait('parent-1');
+    expect(owner.hasInputWait('parent-1')).toBe(true);
 
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    await owner.event({
+      event: { type: 'server.instance.disposed' },
     });
-    await flushContinuation();
-    expect(get).toHaveBeenCalledTimes(1);
-
-    const newTurn = createRuntimeUserTurn({
-      messageID: 'user-2',
-      providerID: 'new-provider',
-      modelID: 'new-model',
-    });
-    hook.observeChatMessage(newTurn.input, newTurn.output);
-    resolveGet({
-      data: {
-        model: {
-          providerID: 'stale-provider',
-          id: 'stale-model',
-          variant: 'stale-variant',
-        },
+    // Process-local wait survives disposal of one hook instance.
+    const next = createHook().hook;
+    expect(next.hasInputWait('parent-1')).toBe(true);
+
+    await next.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'parent-1' },
       },
     });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('paired idle events submit at most one continuation', async () => {
-    const board = new BackgroundJobBoard();
-    setupCompletedJob(board);
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      backgroundJobBoard: board,
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
-
-    await Promise.all([
-      hook.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-      }),
-      hook.event({
-        event: {
-          type: 'session.status',
-          properties: { sessionID: 'parent-1', status: { type: 'idle' } },
-        },
-      }),
-    ]);
-    await flushContinuation();
-
-    expect(board.get('child-1')?.terminalUnreconciled).toBe(false);
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('only one of two hook instances enters deferred pre-read SDK calls', async () => {
-    let releaseTodo!: () => void;
-    const todo = mock(
-      () =>
-        new Promise<{ data: Array<{ status: string }> }>((resolve) => {
-          releaseTodo = () => resolve({ data: [{ status: 'pending' }] });
-        }),
-    );
-    const children = mock(async () => ({ data: [] }));
-    const status = mock(async () => ({ data: {} }));
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = { todo, children, status, promptAsync };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-          continueOnIdle: true,
-          idleReconcileDelayMs: 0,
-          shouldManageSession: () => true,
-        },
-      );
-    const hookA = makeHook();
-    const hookB = makeHook();
-
-    await Promise.all([
-      hookA.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-      }),
-      hookB.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-      }),
-    ]);
-    await flushContinuation();
-
-    // Reservation is taken before any SDK liveness read; loser never enters.
-    expect(todo).toHaveBeenCalledTimes(1);
-    expect(children).toHaveBeenCalledTimes(1);
-    expect(status).toHaveBeenCalledTimes(1);
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    releaseTodo();
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('non-owner disposal cannot rearm a committed continuation epoch', async () => {
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-          continueOnIdle: true,
-          idleReconcileDelayMs: 0,
-          shouldManageSession: () => true,
-        },
-      );
-    const owner = makeHook();
-    const other = makeHook();
-
-    await owner.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    // Disposing a different hook instance must not clear process-global consumed.
-    await other.event({ event: { type: 'server.instance.disposed' } });
-    await other.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    // Owner disposal after commit also leaves consumed intact.
-    await owner.event({ event: { type: 'server.instance.disposed' } });
-    const replacement = makeHook();
-    await replacement.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('committed non-settling promptAsync is not retried even through disposal', async () => {
-    let resolvePrompt!: (value: unknown) => void;
-    const promptAsync = mock(
-      () =>
-        new Promise((resolve) => {
-          resolvePrompt = resolve;
-        }),
-    );
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    await hook.event({ event: { type: 'server.instance.disposed' } });
-    const { hook: nextHook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-    await nextHook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    resolvePrompt({});
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('rejected promptAsync is not retried in the same epoch', async () => {
-    const promptAsync = mock(async () => {
-      throw new Error('prompt rejected');
-    });
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('pending read invalidated then valid idle can try again', async () => {
-    // First SDK read stays permanently unresolved — release must not depend on
-    // finally after the hung promise settles (old finally-only design fails).
-    let todoCalls = 0;
-    const todo = mock(
-      () =>
-        new Promise<{ data: Array<{ status: string }> }>((resolve) => {
-          todoCalls++;
-          if (todoCalls === 1) {
-            // Intentionally never resolve the first read.
-            return;
-          }
-          resolve({ data: [{ status: 'pending' }] });
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    // Invalidate while SDK read is still pending — releases uncommitted reservation.
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(false);
-
-    await hook.event({
-      event: {
-        type: 'question.replied',
-        properties: { sessionID: 'parent-1', requestID: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(2);
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('owner disposal while read pending lets another hook attempt', async () => {
-    let todoCalls = 0;
-    const todo = mock(
-      () =>
-        new Promise<{ data: Array<{ status: string }> }>((resolve) => {
-          todoCalls++;
-          if (todoCalls === 1) {
-            // Permanently unresolved — disposal must release without settle.
-            return;
-          }
-          resolve({ data: [{ status: 'pending' }] });
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo,
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-          continueOnIdle: true,
-          idleReconcileDelayMs: 0,
-          shouldManageSession: () => true,
-        },
-      );
-    const owner = makeHook();
-    const other = makeHook();
-
-    await owner.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await owner.event({ event: { type: 'server.instance.disposed' } });
-    await flushContinuation();
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(false);
-
-    await other.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(2);
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('fallback session deletion after commit does not resubmit', async () => {
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    let fallbackInProgress = false;
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      isFallbackInProgress: () => fallbackInProgress,
-      sessionClient,
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    fallbackInProgress = true;
-    await hook.event({
-      event: {
-        type: 'session.deleted',
-        properties: { sessionID: 'parent-1' },
-      },
-    });
-    expect(hasConsumedContinuationAttempt('parent-1')).toBe(true);
-
-    fallbackInProgress = false;
-    // After fallback teardown/recreation, idle must not rearm without a real user message.
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('malformed SDK data releases reservation so a later valid attempt can run', async () => {
-    const promptAsync = mock(async () => ({}));
-    let todoCalls = 0;
-    const todo = mock(async () => {
-      todoCalls++;
-      if (todoCalls === 1) return { data: undefined };
-      return { data: [{ status: 'pending' }] };
-    });
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('active parent releases reservation so a later idle can continue', async () => {
-    const promptAsync = mock(async () => ({}));
-    let statusCalls = 0;
-    const status = mock(async () => {
-      statusCalls++;
-      // First evaluation sees busy on the initial status read and returns.
-      if (statusCalls === 1) {
-        return { data: { 'parent-1': { type: 'busy' } } };
-      }
-      return { data: {} };
-    });
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status,
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'permission.asked',
-        properties: { sessionID: 'parent-1', id: 'permission-1' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('a distinct external user message releases wait_for_user', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-user-resumes' },
-      {
-        message: {
-          id: 'msg-user-resumes',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'The manual step is complete.' }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-    const previousUserMessage = {
-      input: { sessionID: 'parent-1', messageID: 'msg-before-wait' },
-      output: {
-        message: {
-          id: 'msg-before-wait',
-          role: 'user' as const,
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'Start the long task.' }],
-      },
-    };
-
-    hook.observeChatMessage(
-      previousUserMessage.input,
-      previousUserMessage.output,
-    );
-    hook.beginUserWait('parent-1');
-    hook.observeChatMessage(
-      previousUserMessage.input,
-      previousUserMessage.output,
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-internal' },
-      {
-        message: {
-          id: 'msg-internal',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [
-          { type: 'text', synthetic: true, text: 'synthetic continuation' },
-          createInternalAgentTextPart('internal continuation'),
-        ],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-fallback-replay' },
-      {
-        message: {
-          id: 'msg-fallback-replay',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [
-          { type: 'text', text: 'Start the long task.' },
-          createInternalAgentTextPart('foreground fallback replay'),
-        ],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('wait_for_user cancels a scheduled continuation before SDK reads', async () => {
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const children = mock(async () => ({ data: [] }));
-    const status = mock(async () => ({ data: {} }));
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: { todo, children, status, promptAsync },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    hook.beginUserWait('parent-1');
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(children).not.toHaveBeenCalled();
-    expect(status).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('wait_for_user invalidates an in-flight continuation evaluation', async () => {
-    let resolveTodo!: (value: { data: { status: string }[] }) => void;
-    const todo = mock(
-      () =>
-        new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
-          resolveTodo = resolveTodoRequest;
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-
-    hook.beginUserWait('parent-1');
-    resolveTodo({ data: [{ status: 'pending' }] });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('a user wait from another hook revokes an in-flight shared reservation', async () => {
-    let resolveTodo!: (value: { data: { status: string }[] }) => void;
-    const todo = mock(
-      () =>
-        new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
-          resolveTodo = resolveTodoRequest;
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo,
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const owner = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient,
-    }).hook;
-    const waiter = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient,
-    }).hook;
-
-    await owner.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-
-    waiter.beginUserWait('parent-1');
-    resolveTodo({ data: [{ status: 'pending' }] });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('external user input clears only the explicit wait while a question remains', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-user-replied' },
-      {
-        message: {
-          id: 'msg-user-replied',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'Manual work is done.' }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: {
-        type: 'question.replied',
-        properties: { sessionID: 'parent-1', requestID: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('user waits survive hook disposal and clear on genuine deletion', async () => {
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo,
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const makeHook = () =>
-      createContinuationHook({ idleReconcileDelayMs: 0, sessionClient }).hook;
-    const owner = makeHook();
-
-    owner.beginUserWait('parent-1');
-    await owner.event({ event: { type: 'server.instance.disposed' } });
-    const replacement = makeHook();
-    await replacement.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await replacement.event({
-      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
-    });
-    const afterDeletion = makeHook();
-    await afterDeletion.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('fallback session deletion preserves wait_for_user', async () => {
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const promptAsync = mock(async () => ({}));
-    let fallbackInProgress = true;
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      isFallbackInProgress: () => fallbackInProgress,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    hook.beginUserWait('parent-1');
-    await hook.event({
-      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
-    });
-    fallbackInProgress = false;
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('cancels a scheduled continuation when an input wait arrives before its timer fires', async () => {
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const children = mock(async () => ({ data: [] }));
-    const status = mock(async () => ({ data: {} }));
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: { todo, children, status, promptAsync },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await hook.event({
-      event: {
-        type: 'permission.asked',
-        properties: { sessionID: 'parent-1', id: 'permission-1' },
-      },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(children).not.toHaveBeenCalled();
-    expect(status).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1' },
-      },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: {
-        type: 'question.replied',
-        properties: { sessionID: 'parent-1', requestID: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('clears only the resolved input wait and resumes on a later idle', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'permission.asked',
-        properties: { sessionID: 'parent-1', id: 'permission-1' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-2' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'question.replied',
-        properties: { sessionID: 'parent-1', requestID: 'unknown-question' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: {
-        type: 'question.replied',
-        properties: { sessionID: 'parent-1', requestID: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'permission.replied',
-        properties: { sessionID: 'parent-1', requestID: 'permission-1' },
-      },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: {
-        type: 'question.rejected',
-        properties: { sessionID: 'parent-1', requestID: 'question-2' },
-      },
-    });
-    await flushContinuation();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('resumes on a later idle after a question rejection', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: {
-        type: 'question.rejected',
-        properties: { sessionID: 'parent-1', requestID: 'question-1' },
-      },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('invalidates an in-flight continuation when an input wait arrives', async () => {
-    let resolveTodo!: (value: { data: { status: string }[] }) => void;
-    const todo = mock(
-      () =>
-        new Promise<{ data: { status: string }[] }>((resolve) => {
-          resolveTodo = resolve;
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    resolveTodo({ data: [{ status: 'pending' }] });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  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 } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-synthetic-wait' },
-      {
-        message: {
-          id: 'msg-synthetic-wait',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [
-          { type: 'text', synthetic: true, text: 'synthetic response' },
-          createInternalAgentTextPart('internal response'),
-        ],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('retains input waits across a session error', async () => {
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: {
-        type: 'question.asked',
-        properties: { sessionID: 'parent-1', id: 'question-1' },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.error', properties: { sessionID: 'parent-1' } },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(todo).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('clears stale input waits on session and server cleanup', async () => {
-    // Distinct session IDs: disposed must not clear process-global consumed from
-    // a prior deleted+idle iteration on the same id.
-    for (const { sessionID, lifecycleEvent } of [
-      {
-        sessionID: 'parent-deleted',
-        lifecycleEvent: {
-          type: 'session.deleted',
-          properties: { sessionID: 'parent-deleted' },
-        },
-      },
-      {
-        sessionID: 'parent-disposed',
-        lifecycleEvent: { type: 'server.instance.disposed' },
-      },
-    ] as const) {
-      const promptAsync = mock(async () => ({}));
-      const { hook } = createContinuationHook({
-        idleReconcileDelayMs: 0,
-        sessionClient: {
-          todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-          children: mock(async () => ({ data: [] })),
-          status: mock(async () => ({ data: {} })),
-          promptAsync,
-        },
-      });
-
-      await hook.event({
-        event: {
-          type: 'question.asked',
-          properties: { sessionID, id: 'question-1' },
-        },
-      });
-      await hook.event({ event: lifecycleEvent });
-      await hook.event({
-        event: { type: 'session.idle', properties: { sessionID } },
-      });
-      await flushContinuation();
-
-      expect(promptAsync).toHaveBeenCalledTimes(1);
-    }
-  });
-
-  test('coalesces paired idle events and suppresses active children', async () => {
-    const promptAsync = mock(async () => ({}));
-    const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children,
-        status: mock(async () => ({ data: { 'child-1': { type: 'busy' } } })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await hook.event({
-      event: {
-        type: 'session.status',
-        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
-      },
-    });
-    await flushContinuation();
-
-    expect(children).toHaveBeenCalledTimes(1);
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('runtime-shaped external messages rearm a consumed nudge', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-continue-1' },
-      {
-        message: {
-          id: 'msg-continue-1',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'continue' }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('output.message.id rearms when input.messageID is missing', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    hook.observeChatMessage(
-      { sessionID: 'parent-1' },
-      {
-        message: {
-          id: 'msg-output-id-only',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'continue' }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('ID-less output.message object identity rearms once', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-    const message = { role: 'user', sessionID: 'parent-1' };
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    hook.observeChatMessage(
-      { sessionID: 'parent-1' },
-      { message, parts: [{ type: 'text', text: 'continue' }] },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-
-    // Same object again must not open another epoch.
-    hook.observeChatMessage(
-      { sessionID: 'parent-1' },
-      { message, parts: [{ type: 'text', text: 'continue' }] },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('two hooks share ID-less output.message object identity', async () => {
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          continueOnIdle: true,
-          idleReconcileDelayMs: 0,
-          shouldManageSession: () => true,
-        },
-      );
-    const hookA = makeHook();
-    const hookB = makeHook();
-    const message = { role: 'user', sessionID: 'parent-1' };
-    const output = {
-      message,
-      parts: [{ type: 'text', text: 'continue' }],
-    };
-
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    hookA.observeChatMessage({ sessionID: 'parent-1' }, output);
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-
-    hookB.observeChatMessage({ sessionID: 'parent-1' }, output);
-    await hookB.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('distinct ID-less message objects each open a new epoch', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    const text = 'identical text must not dedupe distinct objects';
-    hook.observeChatMessage(
-      { sessionID: 'parent-1' },
-      {
-        message: { role: 'user', sessionID: 'parent-1' },
-        parts: [{ type: 'text', text }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-
-    hook.observeChatMessage(
-      { sessionID: 'parent-1' },
-      {
-        message: { role: 'user', sessionID: 'parent-1' },
-        parts: [{ type: 'text', text }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(3);
-  });
-
-  test('missing id and output.message fails closed without rearm', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    // sessionID only on input; no messageID and no output.message object.
-    hook.observeChatMessage(
-      {
-        sessionID: 'parent-1',
-        parts: [{ type: 'text', text: 'continue' }],
-      },
-      { parts: [{ type: 'text', text: 'continue' }] },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('same user message observed by two hooks rearms only one new epoch', async () => {
-    const promptAsync = mock(async () => ({}));
-    const sessionClient = {
-      todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-      children: mock(async () => ({ data: [] })),
-      status: mock(async () => ({ data: {} })),
-      promptAsync,
-    };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-          continueOnIdle: true,
-          idleReconcileDelayMs: 0,
-          shouldManageSession: () => true,
-        },
-      );
-    const hookA = makeHook();
-    const hookB = makeHook();
-    const userMessage = {
-      input: { sessionID: 'parent-1', messageID: 'msg-shared-1' },
-      output: {
-        message: {
-          id: 'msg-shared-1',
-          role: 'user' as const,
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'continue' }],
-      },
-    };
-
-    // Initial epoch dispatch.
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-
-    // Interleave: A observes → new-epoch idle on A → B observes same message.
-    hookA.observeChatMessage(userMessage.input, userMessage.output);
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-
-    hookB.observeChatMessage(userMessage.input, userMessage.output);
-    await hookB.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    // Same message must not open a third epoch.
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('shared observe always cancels each hook local pre-message idle timer', async () => {
-    const promptAsync = mock(async () => ({}));
-    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
-    const children = mock(async () => ({ data: [] }));
-    const status = mock(async () => ({ data: {} }));
-    const sessionClient = { todo, children, status, promptAsync };
-    const makeHook = () =>
-      createTaskSessionManagerHook(
-        {
-          client: { session: sessionClient },
-          directory: '/tmp',
-          worktree: '/tmp',
-        } as never,
-        {
-          maxSessionsPerAgent: 2,
-          maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-          continueOnIdle: true,
-          // Non-zero so B can hold a pending timer across the observe.
-          idleReconcileDelayMs: 40,
-          shouldManageSession: () => true,
-        },
-      );
-    const hookA = makeHook();
-    const hookB = makeHook();
-    const userMessage = {
-      input: { sessionID: 'parent-1', messageID: 'msg-shared-timer' },
-      output: {
-        message: {
-          id: 'msg-shared-timer',
-          role: 'user' as const,
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'text', text: 'continue' }],
-      },
-    };
-
-    // B arms a pre-message idle timer (must not fire after shared observe).
-    await hookB.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-
-    hookA.observeChatMessage(userMessage.input, userMessage.output);
-    // Global rearm already recorded; B must still invalidate local timer/token.
-    hookB.observeChatMessage(userMessage.input, userMessage.output);
-
-    await new Promise((resolve) => setTimeout(resolve, 80));
-    expect(todo).not.toHaveBeenCalled();
-    expect(children).not.toHaveBeenCalled();
-    expect(status).not.toHaveBeenCalled();
-    expect(promptAsync).not.toHaveBeenCalled();
-
-    // Only a fresh post-message idle may enter SDK / promptAsync.
-    await hookA.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await new Promise((resolve) => setTimeout(resolve, 80));
-    await flushContinuation();
-    expect(todo).toHaveBeenCalled();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('file-only external messages rearm a consumed nudge', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-file-1' },
-      {
-        message: {
-          id: 'msg-file-1',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [{ type: 'file', filename: 'command-output.txt' }],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(2);
-  });
-
-  test('synthetic completion messages do not rearm a consumed nudge', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-synthetic-1' },
-      {
-        message: {
-          id: 'msg-synthetic-1',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [
-          {
-            type: 'text',
-            synthetic: true,
-            text: 'Background task completed: child-1',
-          },
-        ],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('nudge busy-to-idle cycle does not send a second unchanged nudge', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    await hook.event({
-      event: {
-        type: 'session.status',
-        properties: { sessionID: 'parent-1', status: { type: 'busy' } },
-      },
-    });
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('retry status invalidates a pending continuation evaluation', async () => {
-    let resolveTodo!: (value: { data: { status: string }[] }) => void;
-    const todo = mock(
-      () =>
-        new Promise<{ data: { status: string }[] }>((resolve) => {
-          resolveTodo = resolve;
-        }),
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo,
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    expect(todo).toHaveBeenCalledTimes(1);
-
-    await hook.event({
-      event: {
-        type: 'session.status',
-        properties: { sessionID: 'parent-1', status: { type: 'retry' } },
-      },
-    });
-    resolveTodo({ data: [{ status: 'pending' }] });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('terminal-unreconciled jobs suppress continuation nudges', async () => {
-    const board = new BackgroundJobBoard();
-    setupCompletedJob(board);
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      backgroundJobBoard: board,
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook['experimental.chat.messages.transform'](
-      {},
-      createMessages('parent-1'),
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('missing SDK response data fails closed without nudging', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: undefined })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('does not nudge when todos are completed or cancelled only', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({
-          data: [{ status: 'completed' }, { status: 'cancelled' }],
-        })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('does not nudge while the parent is active', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: { 'parent-1': { type: 'busy' } } })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('does not nudge while a child is retrying', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [{ id: 'child-1' }] })),
-        status: mock(async () => ({
-          data: { 'child-1': { type: 'retrying' } },
-        })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('does not rearm a consumed nudge for its actual internal part', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    hook.observeChatMessage(
-      { sessionID: 'parent-1', messageID: 'msg-internal-nudge' },
-      {
-        message: {
-          id: 'msg-internal-nudge',
-          role: 'user',
-          sessionID: 'parent-1',
-        },
-        parts: [createInternalAgentTextPart('Continue coordinating')],
-      },
-    );
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('keeps a rejected prompt consumed', async () => {
-    const promptAsync = mock(async () => {
-      throw new Error('prompt failed');
-    });
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('keeps a failed prompt response consumed', async () => {
-    const promptAsync = mock(async () => ({ error: 'prompt failed' }));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).toHaveBeenCalledTimes(1);
-  });
-
-  test('fails closed for missing or throwing SDK endpoints', async () => {
-    const missingPrompt = mock(async () => ({}));
-    const { hook: missingHook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: { promptAsync: missingPrompt },
-    });
-    const throwingPrompt = mock(async () => ({}));
-    const { hook: throwingHook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => {
-          throw new Error('todo unavailable');
-        }),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync: throwingPrompt,
-      },
-    });
-
-    for (const hook of [missingHook, throwingHook]) {
-      await hook.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-      });
-    }
-    await flushContinuation();
-
-    expect(missingPrompt).not.toHaveBeenCalled();
-    expect(throwingPrompt).not.toHaveBeenCalled();
-  });
-
-  test('does not nudge when fallback is already in progress', async () => {
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      isFallbackInProgress: () => true,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('does not nudge when fallback starts during evaluation', async () => {
-    let fallbackInProgress = false;
-    let releaseTodos: (() => void) | undefined;
-    const todos = new Promise<{ data: Array<{ status: string }> }>(
-      (resolve) => {
-        releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
-      },
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      isFallbackInProgress: () => fallbackInProgress,
-      sessionClient: {
-        todo: mock(async () => todos),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    fallbackInProgress = true;
-    releaseTodos?.();
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('final gate blocks a terminal result that arrives during SDK queries', async () => {
-    const board = new BackgroundJobBoard();
-    let childrenCalls = 0;
-    let releaseLatestChildren: (() => void) | undefined;
-    const latestChildren = new Promise<{ data: Array<unknown> }>((resolve) => {
-      releaseLatestChildren = () => resolve({ data: [] });
-    });
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      backgroundJobBoard: board,
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
-        children: mock(async () => {
-          childrenCalls++;
-          return childrenCalls === 1 ? { data: [] } : latestChildren;
-        }),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    setupCompletedJob(board);
-    releaseLatestChildren?.();
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
-  });
-
-  test('instance disposal invalidates an evaluation whose timer already fired', async () => {
-    let releaseTodos: (() => void) | undefined;
-    const todos = new Promise<{ data: Array<{ status: string }> }>(
-      (resolve) => {
-        releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
-      },
-    );
-    const promptAsync = mock(async () => ({}));
-    const { hook } = createContinuationHook({
-      idleReconcileDelayMs: 0,
-      sessionClient: {
-        todo: mock(async () => todos),
-        children: mock(async () => ({ data: [] })),
-        status: mock(async () => ({ data: {} })),
-        promptAsync,
-      },
-    });
-
-    await hook.event({
-      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
-    });
-    await flushContinuation();
-    await hook.event({ event: { type: 'server.instance.disposed' } });
-    releaseTodos?.();
-    await flushContinuation();
-
-    expect(promptAsync).not.toHaveBeenCalled();
+    expect(next.hasInputWait('parent-1')).toBe(false);
   });
 });

+ 25 - 84
src/hooks/task-session-manager/index.ts

@@ -19,14 +19,9 @@ import {
   stabilizeRunningTaskParts,
   updateFromInjectedCompletion,
 } from './board-injection';
-import { evaluateContinuation as evaluateContinuationFn } from './continuation-evaluator';
-import {
-  type ContinuationModelSelection,
-  parseContinuationModelSelection,
-} from './continuation-model-selection';
-import { createContinuationTokenManager } from './continuation-token-manager';
 import { handleEvent } from './event-router';
 import { createIdleReconciler } from './idle-reconciliation';
+import { createIdleSessionTokens } from './idle-session-tokens';
 import { createInputWaitTracker } from './input-wait-tracker';
 import { createPendingCallTracker } from './pending-call-tracker';
 import { createTaskContextTracker } from './task-context-tracker';
@@ -54,12 +49,6 @@ export function createTaskSessionManagerHook(
     maxRetainedSnapshots: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
-    /**
-     * 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;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     shouldManageSession: (sessionID: string) => boolean;
@@ -82,7 +71,6 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
   },
 ) {
-  const continueOnIdle = options.continueOnIdle === true;
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     new BackgroundJobBoard({
@@ -101,33 +89,23 @@ export function createTaskSessionManagerHook(
     string,
     Map<string, BackgroundJobExecution>
   >();
-  const observedContinuationModels = new Map<
-    string,
-    ContinuationModelSelection
-  >();
   /** Managed sessions with a deferred inline 401/410 awaiting fallback outcome. */
   const deferredInlineErrors = new Set<string>();
 
   // Forward refs for circular deps — set after corresponding managers exist.
   // These are captured by closure in createIdleReconciler and only called
   // at runtime (event handlers), well after initialization completes.
-  let evaluateContinuation: (
-    parentSessionID: string,
-    sessionToken: symbol,
-  ) => Promise<void>;
-  let getContinuationSessionToken: (sessionID: string) => symbol = () => {
-    throw new Error('unreachable: getContinuationSessionToken not initialized');
+  let getIdleSessionToken: (sessionID: string) => symbol = () => {
+    throw new Error('unreachable: getIdleSessionToken not initialized');
   };
-  let isCurrentContinuation: (
+  let isCurrentIdleSessionToken: (
     sessionID: string,
     sessionToken: symbol,
-    evaluationToken?: symbol,
   ) => boolean = () => false;
   let hasInputWait: (sessionID: string) => boolean = () => false;
 
   const idleReconciler = createIdleReconciler({
     backgroundJobBoard,
-    evaluateContinuation: (s, t) => evaluateContinuation(s, t),
     reconcileInjectedTerminalJobs: (parentSessionID: string) =>
       reconcileInjectedTerminalJobs(injectionState, parentSessionID),
     // Fallback could not recover a deferred 401/410; drop the deferred
@@ -142,59 +120,34 @@ export function createTaskSessionManagerHook(
       options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS,
     isFallbackInProgress: options.isFallbackInProgress,
     hasInputWait: (s) => hasInputWait(s),
-    getContinuationSessionToken: (s) => getContinuationSessionToken(s),
-    isCurrentContinuation: (s, t, e) => isCurrentContinuation(s, t, e),
+    getIdleSessionToken: (s) => getIdleSessionToken(s),
+    isCurrentIdleSessionToken: (s, t) => isCurrentIdleSessionToken(s, t),
     taskContextTracker,
   });
 
-  const continuationTokens = createContinuationTokenManager({
-    onInvalidateContinuation: idleReconciler.onInvalidateContinuation,
+  const idleSessionTokens = createIdleSessionTokens({
+    onInvalidate: idleReconciler.onInvalidateIdle,
   });
-  getContinuationSessionToken = (s) =>
-    continuationTokens.getContinuationSessionToken(s);
-  isCurrentContinuation = (s, t, e) =>
-    continuationTokens.isCurrentContinuation(s, t, e);
+  getIdleSessionToken = (s) => idleSessionTokens.getSessionToken(s);
+  isCurrentIdleSessionToken = (s, t) =>
+    idleSessionTokens.isCurrentSessionToken(s, t);
 
   const inputWaits = createInputWaitTracker({
     shouldManageSession: options.shouldManageSession,
-    invalidateContinuation: (sessionID) =>
-      continuationTokens.invalidateContinuation(sessionID),
+    invalidateIdle: (sessionID) => idleSessionTokens.invalidate(sessionID),
   });
   hasInputWait = (s) => inputWaits.hasInputWait(s);
 
-  type SdkResponse = { data?: unknown };
-  type SessionSdk = {
-    todo?: (input: unknown) => Promise<SdkResponse>;
-    children?: (input: unknown) => Promise<SdkResponse>;
-    status?: (input: unknown) => Promise<SdkResponse>;
-    get?: (input: unknown) => Promise<SdkResponse>;
-    promptAsync?: (input: unknown) => Promise<unknown>;
-  };
-  const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
-    .session;
-
-  evaluateContinuation = (parentSessionID, sessionToken) =>
-    evaluateContinuationFn(parentSessionID, sessionToken, {
-      continueOnIdle,
-      backgroundJobBoard,
-      continuationTokens,
-      inputWaits,
-      options,
-      sessionSdk,
-      getObservedModelSelection: (sessionID) =>
-        observedContinuationModels.get(sessionID),
-    });
-
   if (options.coordinator) {
     options.coordinator.onSessionDeleted((sessionId) => {
-      // Fallback teardown must not rearm a committed continuation epoch.
+      // Fallback teardown keeps process-global wait_for_user; genuine delete
+      // clears it via clearSession.
       if (options.isFallbackInProgress?.(sessionId)) {
-        continuationTokens.invalidateContinuation(sessionId);
+        idleSessionTokens.invalidate(sessionId);
       } else {
-        continuationTokens.clearContinuation(sessionId);
+        idleSessionTokens.clearSession(sessionId);
       }
       inputWaits.clearInputWaits(sessionId);
-      observedContinuationModels.delete(sessionId);
       idleReconciler.clearIdleTimers(sessionId);
       // During a foreground fallback abort/re-prompt cycle, the session
       // is being torn down and immediately recreated with a fallback model.
@@ -242,6 +195,12 @@ export function createTaskSessionManagerHook(
       inputWaits.beginUserWait(sessionID);
     },
 
+    /**
+     * Narrow exposure for the orchestrator-wake scheduler: true while a
+     * question/permission is open or wait_for_user is latched.
+     */
+    hasInputWait: (sessionID: string): boolean => hasInputWait(sessionID),
+
     observeChatMessage: (input: unknown, output: unknown): void => {
       const inputMessage = isObjectRecord(input) ? input : undefined;
       const outputRecord = isObjectRecord(output) ? output : undefined;
@@ -286,22 +245,7 @@ export function createTaskSessionManagerHook(
       ) {
         return;
       }
-      const outputModel = isObjectRecord(outputMessage?.model)
-        ? outputMessage.model
-        : undefined;
-      const variant =
-        typeof inputMessage?.variant === 'string'
-          ? inputMessage.variant
-          : outputModel?.variant;
-      const modelSelection =
-        parseContinuationModelSelection(inputMessage?.model, variant) ??
-        parseContinuationModelSelection(outputModel, variant);
-      if (modelSelection) {
-        observedContinuationModels.set(sessionID, modelSelection);
-      } else {
-        observedContinuationModels.delete(sessionID);
-      }
-      continuationTokens.rearmForUserMessage(sessionID, messageIdentity);
+      idleSessionTokens.onExternalUserMessage(sessionID, messageIdentity);
     },
 
     'tool.execute.before': (
@@ -387,20 +331,17 @@ export function createTaskSessionManagerHook(
         };
       };
     }): Promise<void> => {
-      if (input.event.type === 'server.instance.disposed') {
-        observedContinuationModels.clear();
-      } else if (input.event.type === 'session.deleted') {
+      if (input.event.type === 'session.deleted') {
         const sessionID =
           input.event.properties?.info?.id ?? input.event.properties?.sessionID;
         if (sessionID) {
-          observedContinuationModels.delete(sessionID);
           deferredInlineErrors.delete(sessionID);
         }
       }
 
       return handleEvent(input, {
         inputWaits,
-        continuationTokens,
+        idleSessionTokens,
         options,
         idleReconciler,
         deferredInlineErrors,

+ 6 - 5
src/hooks/task-session-manager/input-wait-tracker.ts

@@ -1,7 +1,7 @@
 import {
   beginUserWait as beginSharedUserWait,
   hasUserWait,
-} from './continuation-attempt-gate';
+} from './user-wait-gate';
 
 const IDLESS_INPUT_WAIT = Symbol('idless-input-wait');
 const INPUT_WAIT_ASK_EVENTS = {
@@ -32,7 +32,8 @@ function inputWaitKey(kind: 'permission' | 'question', requestID: string) {
 
 export function createInputWaitTracker(options: {
   shouldManageSession: (sessionID: string) => boolean;
-  invalidateContinuation: (sessionID: string) => void;
+  /** Cancel delayed idle work when an input wait arms. */
+  invalidateIdle: (sessionID: string) => void;
 }) {
   const inputWaitsByParent = new Map<string, Set<string | symbol>>();
 
@@ -50,7 +51,7 @@ export function createInputWaitTracker(options: {
       );
     }
     beginSharedUserWait(sessionID);
-    options.invalidateContinuation(sessionID);
+    options.invalidateIdle(sessionID);
   }
 
   function clearInputWaits(sessionID: string): void {
@@ -73,13 +74,13 @@ export function createInputWaitTracker(options: {
       if (!requestID) {
         waits.add(IDLESS_INPUT_WAIT);
         inputWaitsByParent.set(sessionID, waits);
-        options.invalidateContinuation(sessionID);
+        options.invalidateIdle(sessionID);
         return;
       }
       const key = inputWaitKey(INPUT_WAIT_ASK_EVENTS[event.type], requestID);
       waits.add(key);
       inputWaitsByParent.set(sessionID, waits);
-      options.invalidateContinuation(sessionID);
+      options.invalidateIdle(sessionID);
       return;
     }
 

+ 45 - 0
src/hooks/task-session-manager/user-wait-gate.test.ts

@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+import {
+  beginUserWait,
+  clearUserWait,
+  clearUserWaitForMessage,
+  hasUserWait,
+  resetUserWaitGateForTests,
+} from './user-wait-gate';
+
+describe('user wait gate', () => {
+  beforeEach(() => {
+    resetUserWaitGateForTests();
+  });
+
+  test('beginUserWait arms hasUserWait until a distinct external message', () => {
+    beginUserWait('parent-1');
+    expect(hasUserWait('parent-1')).toBe(true);
+
+    expect(clearUserWaitForMessage('parent-1', 'msg-1')).toBe(true);
+    expect(hasUserWait('parent-1')).toBe(false);
+
+    beginUserWait('parent-1');
+    expect(clearUserWaitForMessage('parent-1', 'msg-1')).toBe(false);
+    expect(hasUserWait('parent-1')).toBe(true);
+
+    expect(clearUserWaitForMessage('parent-1', 'msg-2')).toBe(true);
+    expect(hasUserWait('parent-1')).toBe(false);
+  });
+
+  test('clearUserWait drops wait and rearm identity', () => {
+    beginUserWait('parent-1');
+    clearUserWaitForMessage('parent-1', 'msg-1');
+    beginUserWait('parent-1');
+    clearUserWait('parent-1');
+    expect(hasUserWait('parent-1')).toBe(false);
+    // After full clear, the same message identity can clear a new wait.
+    beginUserWait('parent-1');
+    expect(clearUserWaitForMessage('parent-1', 'msg-1')).toBe(true);
+  });
+
+  test('wait state is shared across gate consumers in-process', () => {
+    beginUserWait('parent-1');
+    expect(hasUserWait('parent-1')).toBe(true);
+  });
+});

+ 94 - 0
src/hooks/task-session-manager/user-wait-gate.ts

@@ -0,0 +1,94 @@
+/**
+ * Process-local gate for explicit wait_for_user HITL latches.
+ *
+ * Scoped via globalThis + Symbol.for so independently created hook instances
+ * in the same JS process share wait state. Does not claim cross-process or
+ * restart durability.
+ */
+
+type WaitState = { status: 'waiting-for-user' };
+
+type RearmIdentity = string | symbol;
+
+type UserWaitStore = {
+  waits: Map<string, WaitState>;
+  /**
+   * Last external user-message identity that cleared each session wait.
+   * string = chat.message ID; symbol = same-process object identity fallback.
+   */
+  lastRearmIdentity: Map<string, RearmIdentity>;
+  /** Stable symbols for ID-less output.message object identity (same process). */
+  messageObjectIdentity: WeakMap<object, symbol>;
+};
+
+const STORE_KEY = Symbol.for('oh-my-opencode-slim.user-wait-gate');
+
+function getStore(): UserWaitStore {
+  const globalWithStore = globalThis as typeof globalThis & {
+    [STORE_KEY]?: UserWaitStore;
+  };
+  globalWithStore[STORE_KEY] ??= {
+    waits: new Map(),
+    lastRearmIdentity: new Map(),
+    messageObjectIdentity: new WeakMap(),
+  };
+  return globalWithStore[STORE_KEY];
+}
+
+/**
+ * Block automatic orchestrator wakes for a text-only HITL boundary until a
+ * distinct real external user message opens the next epoch.
+ */
+export function beginUserWait(sessionID: string): void {
+  getStore().waits.set(sessionID, { status: 'waiting-for-user' });
+}
+
+export function hasUserWait(sessionID: string): boolean {
+  return getStore().waits.get(sessionID)?.status === 'waiting-for-user';
+}
+
+function resolveRearmIdentity(identity: string | object): RearmIdentity {
+  if (typeof identity === 'string') return identity;
+  const store = getStore();
+  const existing = store.messageObjectIdentity.get(identity);
+  if (existing) return existing;
+  const token = Symbol('user-wait-rearm-message');
+  store.messageObjectIdentity.set(identity, token);
+  return token;
+}
+
+/**
+ * Clear wait_for_user for a real external user message.
+ * Idempotent per (sessionID, identity). Returns true when this call cleared
+ * wait state.
+ */
+export function clearUserWaitForMessage(
+  sessionID: string,
+  identity: string | object,
+): boolean {
+  const store = getStore();
+  const resolved = resolveRearmIdentity(identity);
+  if (store.lastRearmIdentity.get(sessionID) === resolved) {
+    return false;
+  }
+  store.lastRearmIdentity.set(sessionID, resolved);
+  store.waits.delete(sessionID);
+  return true;
+}
+
+/**
+ * Full session cleanup (genuine deletion). Clears wait state and rearm
+ * identity so a later session id reuse is not pinned to a prior message.
+ */
+export function clearUserWait(sessionID: string): void {
+  const store = getStore();
+  store.waits.delete(sessionID);
+  store.lastRearmIdentity.delete(sessionID);
+}
+
+/** Test seam: wipe process-local gate state between cases. */
+export function resetUserWaitGateForTests(): void {
+  const store = getStore();
+  store.waits.clear();
+  store.lastRearmIdentity.clear();
+}

+ 36 - 3
src/index.ts

@@ -22,6 +22,7 @@ import {
   createFilterAvailableSkillsHook,
   createJsonErrorRecoveryHook,
   createLoopCommandHook,
+  createOrchestratorWakeScheduler,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
   createReflectCommandHook,
@@ -149,6 +150,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
   let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
+  let orchestratorWakeScheduler: ReturnType<
+    typeof createOrchestratorWakeScheduler
+  >;
   let phaseReminder: ReturnType<typeof createPhaseReminderHook>;
   let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
   let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
@@ -326,7 +330,6 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxRetainedSnapshots: runtime.backgroundJobs.maxRetainedSnapshots,
       readContextMinLines: runtime.backgroundJobs.readContextMinLines,
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
-      continueOnIdle: runtime.backgroundJobs.continueOnIdle === true,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobSupervisor,
       shouldManageSession: (sessionID) =>
@@ -341,6 +344,17 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       coordinator: sessionLifecycle,
     });
 
+    orchestratorWakeScheduler = createOrchestratorWakeScheduler(ctx, {
+      config: runtime.backgroundJobs.orchestratorWake,
+      shouldManageSession: (sessionID) =>
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+      hasInputWait: (sessionID) =>
+        taskSessionManagerHook.hasInputWait(sessionID),
+      isFallbackInProgress: (sessionID) =>
+        foregroundFallback.isFallbackInProgress(sessionID),
+      coordinator: sessionLifecycle,
+    });
+
     // Initialize hooks and wrapPostToolHook helper for error isolation
 
     // Wrap tool.execute.after handlers with per-hook error isolation.
@@ -418,8 +432,10 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       registerSessionAsOrchestrator: (sessionID) => {
         sessionMetadata.setAgent(sessionID, 'orchestrator');
       },
-      beginUserWait: (sessionID) =>
-        taskSessionManagerHook.beginUserWait(sessionID),
+      beginUserWait: (sessionID) => {
+        taskSessionManagerHook.beginUserWait(sessionID);
+        orchestratorWakeScheduler.suppress(sessionID);
+      },
     });
 
     const shouldRegisterWebfetch = runtime.webfetch.enabled !== false;
@@ -937,6 +953,19 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
+      await orchestratorWakeScheduler.event(
+        input as {
+          event: {
+            type: string;
+            properties?: {
+              info?: { id?: string };
+              sessionID?: string;
+              status?: { type?: string };
+            };
+          };
+        },
+      );
+
       // Runtime model fallback for foreground agents (rate-limit detection)
       await foregroundFallback.handleEvent(input.event);
 
@@ -990,6 +1019,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await taskSessionManagerHook.event({
         event: { type: 'server.instance.disposed' },
       });
+      await orchestratorWakeScheduler.event({
+        event: { type: 'server.instance.disposed' },
+      });
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
     },
 
@@ -1088,6 +1120,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         });
       }
       taskSessionManagerHook.observeChatMessage(input, output);
+      orchestratorWakeScheduler.observeChatMessage(input, output);
     },
 
     // Inject orchestrator system prompt for serve-mode sessions. In serve

+ 37 - 3
src/utils/session-calls.contract.ts

@@ -31,6 +31,8 @@ const client = {
     message: noop,
     get: noop,
     status: noop,
+    todo: noop,
+    children: noop,
     delete: noop,
     create: noop,
     prompt: noop,
@@ -56,10 +58,10 @@ client.session.messages({
 // message (chat-headers)
 client.session.message({ path: { id: 'ses_x', messageID: 'msg_1' } });
 
-// get (cancel-task getSessionParentID, continuation-evaluator)
+// get (cancel-task getSessionParentID, orchestrator-wake)
 client.session.get({ path: { id: 'ses_x' }, query: { directory: '/d' } });
 
-// status (cancel-task getSessionStatus)
+// status (cancel-task getSessionStatus, orchestrator-wake)
 client.session.status({ query: { directory: '/d' } });
 
 // delete (cancel-task, secondary-model)
@@ -86,7 +88,7 @@ client.session.prompt({
   },
 });
 
-// promptAsync (foreground-fallback, continuation-evaluator)
+// promptAsync (foreground-fallback)
 client.session.promptAsync({
   path: { id: 'ses_x' },
   body: {
@@ -95,6 +97,27 @@ client.session.promptAsync({
   },
 });
 
+// promptAsync with directory query (orchestrator-wake)
+client.session.promptAsync({
+  path: { id: 'ses_x' },
+  query: { directory: '/d' },
+  body: {
+    agent: 'orchestrator',
+    model: { providerID: 'p', modelID: 'm' },
+    parts: [{ type: 'text', text: 'wake' }],
+  },
+});
+
+// todo / children (orchestrator-wake)
+client.session.todo({
+  path: { id: 'ses_x' },
+  query: { directory: '/d' },
+});
+client.session.children({
+  path: { id: 'ses_x' },
+  query: { directory: '/d' },
+});
+
 // tool.ids (secondary-model)
 client.tool.ids({ query: { directory: '/d' } });
 
@@ -141,3 +164,14 @@ client.session.prompt({
   // @ts-expect-error top-level variant is not part of the v1 prompt body
   variant: 'high',
 });
+
+// v1 promptAsync body does not declare variant
+client.session.promptAsync({
+  path: { id: 'ses_x' },
+  query: { directory: '/d' },
+  body: {
+    parts: [{ type: 'text', text: 'x' }],
+    // @ts-expect-error variant is not part of the v1 promptAsync body
+    variant: 'high',
+  },
+});