Browse Source

fix: prevent duplicate idle continuation prompts

Alvin Unreal 2 weeks ago
parent
commit
c7fd7761ad

+ 34 - 15
docs/agents/build-agent-empty-input-diagnosis.md

@@ -9,13 +9,15 @@
 
 The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
 
+**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is enabled by default via `backgroundJobs.continueOnIdle` (opt out with `false`), and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
+
 ## Root cause (causal chain, cross-validated)
 
 1. **Orchestrator enters input-wait.** After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to `Idle` (`packages/opencode/src/effect/runner.ts:115-138`, `packages/opencode/src/session/run-state.ts:60-63`). The session is no longer "busy" from the Runner's perspective.
 
-2. **Plugin hook fires `promptAsync` with a synthetic part and no `agent` field.** Two call sites in omos do this:
-   - `src/hooks/task-session-manager/index.ts:398-402` — `CONTINUATION_NUDGE` injection, fires on `session.idle` / `session.status(idle)` when the orchestrator session has incomplete todos (matches "subagent completes" / "background task finishes").
+2. **Plugin hook fires `promptAsync` with a synthetic part and historically no `agent` field.** Remaining agent-less sites:
    - `src/interview/service.ts:622, 871, 933, 1007` — interview/skill flow injections (matches "brainstorm skill flow").
+   - Continuation nudge (`continuation-evaluator.ts`) previously matched "subagent completes" / "background task finishes"; it now sets `agent: 'orchestrator'` and only runs when `continueOnIdle` is enabled.
 
 3. **opencode does not guard `promptAsync` against busy/input-wait state.** The HTTP handler at `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` does not call `assertNotBusy` and does not consult the Question service. It proceeds straight to `promptSvc.prompt`. The Runner, being `Idle`, immediately `startRun`s the new turn (`runner.ts:131-134`). No queue, no reject, no cancellation of the pending question.
 
@@ -38,7 +40,7 @@ The `build` agent turn with empty input is **the same class of bug** as the orig
 
 | File:line | Trigger | Body omits `agent`? | Gate |
 |---|---|---|---|
-| `src/hooks/task-session-manager/index.ts:398-402` | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos | **Yes** | `continuationConsumed`, `hasInputWait` (3×: lines 282, 362, 386), `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled` |
+| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when `backgroundJobs.continueOnIdle` is `true` (default **on**) | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
 | `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
 | `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
 | `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
@@ -60,13 +62,29 @@ This is the pattern every `promptAsync` caller in omos should follow.
 
 ## Why the `hasInputWait` gate in task-session-manager is not sufficient
 
-The gate exists and works in the common case (`task-session-manager/index.ts:282, 362, 386`, with tests at `index.test.ts:2772-2858, 3013-3048`). But:
+The gate exists and works in the common case (see `continuation-evaluator.ts` and
+`task-session-manager/index.test.ts` continuation cases). Notes:
+
+1. **Continuation is on by default.** `backgroundJobs.continueOnIdle` defaults
+   to `true`; set `false` to keep idle reconciliation without continuation SDK
+   calls. When enabled, a process-local reserve/commit gate allows at most one
+   `promptAsync` per session epoch between real user messages.
 
-1. **Documented race window.** `IDLE_RECONCILE_DELAY_MS = 2_000` (line 54). The comment at lines 49-53 admits: "Completions arriving after the window are still dropped (the race is reduced, not eliminated)." If `session.idle` fires and the 2s timer expires before `question.asked` is delivered, and the 3 SDK calls (`todo`/`children`/`status`) in `evaluateContinuation` all resolve before `question.asked` arrives, the nudge fires.
+2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`.
+   The idle-reconciliation comment admits late completions can still race the
+   window. If `session.idle` fires and the timer expires before
+   `question.asked` is delivered, and the SDK liveness reads resolve before the
+   wait is tracked, a nudge can still fire.
 
-2. **Input-wait is not the only trigger.** The interview/skill path (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it injects on user dashboard actions, which can happen while the orchestrator is mid-question.
+3. **Input-wait is not the only trigger.** The interview/skill path
+   (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it
+   injects on user dashboard actions, which can happen while the orchestrator is
+   mid-question.
 
-3. **The gate does not address the missing `agent` field.** Even when the nudge legitimately fires (no input-wait, real incomplete todos), the resulting turn still routes to `build` if `default_agent` is unset. The gate prevents *some* unwanted injections; it does not prevent *misrouting* when injection happens.
+4. **Continuation path sets `agent: 'orchestrator'`.** The historical missing-
+   `agent` misroute on the continuation nudge is fixed in
+   `continuation-evaluator.ts`. Remaining agent-less `promptAsync` call sites
+   are outside that path (interview/smartfetch below).
 
 ## Why this is the same class as the #818 `/preset` fix
 
@@ -77,11 +95,12 @@ This bug: other hooks still use the same `createInternalAgentTextPart` + `prompt
 ## Fix directions (not implemented — awaiting decision)
 
 ### Minimal fix
-Add `agent: 'orchestrator'` to the `promptAsync` body at all four affected call sites:
-- `src/hooks/task-session-manager/index.ts:398-402`
-- `src/interview/service.ts:622, 871, 933, 1007`
+Continuation already passes `agent: 'orchestrator'`. Add the same to remaining
+agent-less `promptAsync` bodies:
+- `src/interview/service.ts` (dashboard/chat/answer/comment injectors)
 
-This ensures the continuation nudge and interview injections always route to the orchestrator regardless of opencode's `default_agent` resolution, eliminating the path to `build`.
+This ensures interview injections always route to the orchestrator regardless of
+opencode's `default_agent` resolution, eliminating the path to `build`.
 
 ### Hardening (optional, larger scope)
 1. **Input-wait guard on the interview/skill path.** Consult `hasInputWait` (or an equivalent signal) before injecting in `src/interview/service.ts`. Do not inject while the orchestrator is waiting for user input.
@@ -92,14 +111,14 @@ This ensures the continuation nudge and interview injections always route to the
 ## Evidence index
 
 ### omos source
-- **Missing `agent` field (the bug):** `src/hooks/task-session-manager/index.ts:398-402`
+- **Continuation nudge (fixed agent + default-on + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
 - **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
 - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
 - **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21`
-- **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/index.ts:56-57`
-- **`hasInputWait` gate (3 checks):** `src/hooks/task-session-manager/index.ts:282, 362, 386`
-- **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts:54` (admission at lines 49-53)
+- **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/continuation-evaluator.ts`
+- **`hasInputWait` / continuation gates:** `continuation-evaluator.ts`, `input-wait-tracker.ts`
+- **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts` (`IDLE_RECONCILE_DELAY_MS`)
 - **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600`
 
 ### opencode source (`anomalyco/opencode` @ `dev`)

+ 37 - 14
docs/background-orchestration.md

@@ -320,20 +320,43 @@ multiplexer panes attached while the parent orchestrator continues scheduling.
 
 ### Incomplete-todo continuation nudge
 
-After an orchestrator session becomes idle, the plugin may send one internal,
-delayed continuation prompt when OpenCode reports incomplete todos. It 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, or whenever SDK
-data is unavailable or malformed. A matching reply, or a rejected question,
-clears that wait but does not itself inject a nudge; the normal session lifecycle
-decides whether a later nudge is needed. A real subsequent user message rearms
-the one-shot nudge; internal prompts and todo updates do not.
-
-This is a best-effort runtime check, not a scheduler or persisted state. After a
-plugin restart, the in-memory job board cannot establish prior result
-reconciliation, and the SDK's current session/todo status remains the liveness
-authority.
+Automatic incomplete-todo continuation is **enabled by default**. Idle
+reconciliation and background-job orchestration always run; set
+`continueOnIdle` to `false` to keep those without hidden continuation prompts:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "continueOnIdle": false
+  }
+}
+```
+
+When `backgroundJobs.continueOnIdle` is `true` (the default), after an
+orchestrator session becomes idle the plugin may send **at most one** internal,
+delayed continuation prompt when OpenCode reports incomplete todos. That limit
+is per session between real external user messages (text/file/image).
+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, or whenever SDK data is unavailable or malformed. A matching reply, or
+a rejected question, clears that wait but does not itself inject a nudge; the
+normal session lifecycle decides whether a later nudge is needed.
+
+This is a best-effort runtime check, not a scheduler or persisted state. The
+one-attempt guard is process-local (shared across hook instances in the same JS
+process via an internal gate). It does not survive process restart or
+cross-process boundaries. Recreating a hook/plugin instance inside the same
+process does **not** rearm a consumed epoch; only a 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
 

+ 6 - 3
docs/configuration.md

@@ -149,6 +149,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss |
 | `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart |
+| `backgroundJobs.continueOnIdle` | boolean | `true` | When `true` (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set `false` to keep idle reconciliation and background-job orchestration without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -259,9 +260,11 @@ 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, or how board snapshots are injected. See
-the [Background Orchestration](background-orchestration.md) guide for the concept, defaults, and
-examples.
+completed/reconciled child-agent sessions are reusable, how much read context is
+shown, how board snapshots are injected, or to disable automatic incomplete-todo
+continuation prompts on idle (`continueOnIdle`, default `true`). See the
+[Background Orchestration](background-orchestration.md) guide for the concept,
+defaults, and examples.
 
 ### Agent Display Names
 

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

@@ -1053,6 +1053,11 @@
           "type": "integer",
           "minimum": 1,
           "maximum": 100
+        },
+        "continueOnIdle": {
+          "default": true,
+          "description": "When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.",
+          "type": "boolean"
         }
       }
     },

+ 31 - 0
src/config/schema.test.ts

@@ -51,6 +51,37 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
+  it('defaults continueOnIdle to true', () => {
+    const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+    }
+  });
+
+  it('accepts explicit continueOnIdle true', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: { continueOnIdle: true },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+    }
+  });
+
+  it('accepts explicit continueOnIdle false', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: { continueOnIdle: false },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
+    }
+  });
+
   it('accepts checkpoint-compatible board injection', () => {
     const result = PluginConfigSchema.safeParse({
       backgroundJobs: { strategy: 'checkpoint-compatible' },

+ 6 - 0
src/config/schema.ts

@@ -216,6 +216,12 @@ 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(true)
+    .describe(
+      'When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.',
+    ),
 });
 
 export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;

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

@@ -0,0 +1,119 @@
+/**
+ * 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' };
+
+type ContinuationAttemptStore = {
+  attempts: Map<string, AttemptState>;
+  /**
+   * Last external user message ID that rearmed each session. Process-global so
+   * two hook instances observing the same chat.message open only one epoch.
+   */
+  lastRearmMessageID: Map<string, string>;
+};
+
+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(),
+    lastRearmMessageID: new Map(),
+  };
+  return globalWithStore[STORE_KEY];
+}
+
+/**
+ * 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, messageID): a second observe of the same message
+ * (e.g. another hook instance) is a no-op and does not rearm again.
+ * Returns true when this call cleared attempt state.
+ */
+export function rearmContinuationForUserMessage(
+  sessionID: string,
+  messageID: string,
+): boolean {
+  const store = getStore();
+  if (store.lastRearmMessageID.get(sessionID) === messageID) {
+    return false;
+  }
+  store.lastRearmMessageID.set(sessionID, messageID);
+  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.lastRearmMessageID.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.lastRearmMessageID.clear();
+}

+ 58 - 12
src/hooks/task-session-manager/continuation-evaluator.ts

@@ -30,7 +30,7 @@ function isEvaluationAborted(
   evaluationToken: symbol,
   deps: {
     continuationTokens: {
-      consumed: Set<string>;
+      consumed: { has: (sessionID: string) => boolean };
       isCurrentContinuation: (
         sessionID: string,
         sessionToken: symbol,
@@ -59,19 +59,35 @@ function isEvaluationAborted(
   );
 }
 
+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: Set<string>;
+      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;
@@ -87,6 +103,11 @@ export async function evaluateContinuation(
     };
   },
 ): Promise<void> {
+  // Opt-in only: idle reconciliation still runs; continuation SDK calls do not.
+  if (!deps.continueOnIdle) {
+    return;
+  }
+
   const evaluationToken = Symbol(parentSessionID);
   const activeEvaluations =
     deps.continuationTokens.evaluations.get(parentSessionID) ??
@@ -110,13 +131,28 @@ export async function evaluateContinuation(
     !deps.sessionSdk.status ||
     !deps.sessionSdk.promptAsync
   ) {
-    activeEvaluations.delete(evaluationToken);
-    if (activeEvaluations.size === 0) {
-      deps.continuationTokens.evaluations.delete(parentSessionID);
-    }
+    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({
@@ -199,7 +235,15 @@ export async function evaluateContinuation(
     ) {
       return;
     }
-    deps.continuationTokens.consumed.add(parentSessionID);
+
+    // 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: {
@@ -217,11 +261,13 @@ export async function evaluateContinuation(
       },
     );
   } finally {
-    const evaluations =
-      deps.continuationTokens.evaluations.get(parentSessionID);
-    evaluations?.delete(evaluationToken);
-    if (evaluations?.size === 0) {
-      deps.continuationTokens.evaluations.delete(parentSessionID);
+    if (!committed) {
+      deps.continuationTokens.releaseAttempt(parentSessionID, reservationOwner);
     }
+    cleanupEvaluationToken(
+      parentSessionID,
+      evaluationToken,
+      deps.continuationTokens.evaluations,
+    );
   }
 }

+ 90 - 4
src/hooks/task-session-manager/continuation-token-manager.ts

@@ -1,9 +1,19 @@
+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>>();
-  const continuationConsumed = new Set<string>();
+  /** 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);
@@ -27,25 +37,101 @@ export function createContinuationTokenManager(options?: {
     );
   }
 
+  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 ID (only the first observe opens a new epoch). 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, messageID: string): void {
+    rearmContinuationForUserMessage(sessionID, messageID);
+    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);
-    continuationConsumed.delete(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,
-    // Exposed internal state for consumers not yet migrated (evaluateContinuation, etc.)
+    disposeLocalState,
+    tryReserveAttempt,
+    commitAttempt,
+    releaseAttempt,
     sessionTokens: continuationSessionTokens,
     evaluations: activeContinuationEvaluations,
-    consumed: continuationConsumed,
+    consumed,
   };
 }

+ 17 - 6
src/hooks/task-session-manager/event-router.ts

@@ -41,9 +41,11 @@ export async function handleEvent(
     continuationTokens: {
       clearContinuation(sessionID: string): void;
       invalidateContinuation(sessionID: string): void;
+      /** Release local uncommitted reservations only; keep global consumed. */
+      disposeLocalState(): void;
       sessionTokens: Map<string, symbol>;
       evaluations: Map<string, Set<symbol>>;
-      consumed: Set<string>;
+      consumed: { has(sessionID: string): boolean };
     };
     options: {
       shouldManageSession: (sessionID: string) => boolean;
@@ -138,15 +140,17 @@ export async function handleEvent(
   if (input.event.type === 'server.instance.disposed') {
     deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
-    const continuationSessionIDs = new Set([
+    // 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.
+    const waitSessionIDs = new Set([
       ...idleSessionIds,
       ...deps.continuationTokens.sessionTokens.keys(),
       ...deps.continuationTokens.evaluations.keys(),
-      ...deps.continuationTokens.consumed,
       ...deps.inputWaits.waitsByParent.keys(),
     ]);
-    for (const sessionID of continuationSessionIDs) {
-      deps.continuationTokens.clearContinuation(sessionID);
+    deps.continuationTokens.disposeLocalState();
+    for (const sessionID of waitSessionIDs) {
       deps.inputWaits.clearInputWaits(sessionID);
     }
     return;
@@ -293,7 +297,14 @@ export async function handleEvent(
     input.event.properties?.info?.id || input.event.properties?.sessionID;
   if (!sessionId) return;
 
-  deps.continuationTokens.clearContinuation(sessionId);
+  // 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.
+  if (deps.options.isFallbackInProgress?.(sessionId)) {
+    deps.continuationTokens.invalidateContinuation(sessionId);
+  } else {
+    deps.continuationTokens.clearContinuation(sessionId);
+  }
   deps.inputWaits.clearInputWaits(sessionId);
   deps.retainedBoardSnapshots.delete(sessionId);
 

+ 698 - 17
src/hooks/task-session-manager/index.test.ts

@@ -1,4 +1,4 @@
-import { describe, expect, mock, test } from 'bun:test';
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
 import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
 import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
@@ -11,6 +11,10 @@ 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,
@@ -38,6 +42,8 @@ function createHook(options?: {
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
   maxRetainedSnapshots?: number;
+  /** Matches production default true; set false to exercise opt-out. */
+  continueOnIdle?: boolean;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
@@ -63,6 +69,7 @@ function createHook(options?: {
       strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
+      continueOnIdle: options?.continueOnIdle ?? true,
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
@@ -153,6 +160,11 @@ function setupCompletedJob(
 }
 
 describe('task-session-manager hook', () => {
+  beforeEach(() => {
+    // Process-global gate only — never reset inside createHook/production paths.
+    resetContinuationAttemptGateForTests();
+  });
+
   test('ignores messages without OpenCode info or parts', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -3459,6 +3471,85 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
   });
 
+  test('defaults continueOnIdle on: continuation SDK calls 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).toHaveBeenCalled();
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('explicit continueOnIdle false reconciles parent terminal job without continuation', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    const promptAsync = mock(async () => ({}));
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const { hook } = createHook({
+      continueOnIdle: false,
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        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.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushContinuation();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
   test('nudges once for incomplete todos when parent and children are inactive', async () => {
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({
@@ -3486,6 +3577,437 @@ describe('task-session-manager hook', () => {
     );
   });
 
+  test('paired idle events submit at most one continuation', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    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(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 } = createHook({
+      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 } = createHook({
+      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 } = createHook({
+      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 } = createHook({
+      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 } = createHook({
+      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 } = createHook({
+      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 } = createHook({
+      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 () => ({}));
@@ -3757,9 +4279,13 @@ describe('task-session-manager hook', () => {
       },
     });
     hook.observeChatMessage(
-      {},
+      { sessionID: 'parent-1', messageID: 'msg-synthetic-wait' },
       {
-        message: { role: 'user', sessionID: 'parent-1' },
+        message: {
+          id: 'msg-synthetic-wait',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
         parts: [
           { type: 'text', synthetic: true, text: 'synthetic response' },
           createInternalAgentTextPart('internal response'),
@@ -3807,10 +4333,21 @@ describe('task-session-manager hook', () => {
   });
 
   test('clears stale input waits on session and server cleanup', async () => {
-    for (const lifecycleEvent of [
-      { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
-      { type: 'server.instance.disposed' },
-    ]) {
+    // 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 } = createHook({
         idleReconcileDelayMs: 0,
@@ -3825,12 +4362,12 @@ describe('task-session-manager hook', () => {
       await hook.event({
         event: {
           type: 'question.asked',
-          properties: { sessionID: 'parent-1', id: 'question-1' },
+          properties: { sessionID, id: 'question-1' },
         },
       });
       await hook.event({ event: lifecycleEvent });
       await hook.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+        event: { type: 'session.idle', properties: { sessionID } },
       });
       await flushContinuation();
 
@@ -3883,9 +4420,13 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
     hook.observeChatMessage(
-      {},
+      { sessionID: 'parent-1', messageID: 'msg-continue-1' },
       {
-        message: { role: 'user', sessionID: 'parent-1' },
+        message: {
+          id: 'msg-continue-1',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
         parts: [{ type: 'text', text: 'continue' }],
       },
     );
@@ -3897,6 +4438,134 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(2);
   });
 
+  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 } = createHook({
@@ -3914,9 +4583,13 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
     hook.observeChatMessage(
-      {},
+      { sessionID: 'parent-1', messageID: 'msg-file-1' },
       {
-        message: { role: 'user', sessionID: 'parent-1' },
+        message: {
+          id: 'msg-file-1',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
         parts: [{ type: 'file', filename: 'command-output.txt' }],
       },
     );
@@ -3945,9 +4618,13 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
     hook.observeChatMessage(
-      {},
+      { sessionID: 'parent-1', messageID: 'msg-synthetic-1' },
       {
-        message: { role: 'user', sessionID: 'parent-1' },
+        message: {
+          id: 'msg-synthetic-1',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
         parts: [
           {
             type: 'text',
@@ -4160,9 +4837,13 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
     hook.observeChatMessage(
-      {},
+      { sessionID: 'parent-1', messageID: 'msg-internal-nudge' },
       {
-        message: { role: 'user', sessionID: 'parent-1' },
+        message: {
+          id: 'msg-internal-nudge',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
         parts: [createInternalAgentTextPart('Continue coordinating')],
       },
     );

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

@@ -46,6 +46,12 @@ export function createTaskSessionManagerHook(
     maxRetainedSnapshots: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
+    /**
+     * When true (default), idle orchestrator sessions with incomplete todos may
+     * receive one automatic continuation promptAsync. Set false to keep idle
+     * reconciliation without continuation SDK calls.
+     */
+    continueOnIdle?: boolean;
     backgroundJobBoard?: BackgroundJobStore;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
@@ -62,6 +68,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
   },
 ) {
+  const continueOnIdle = options.continueOnIdle !== false;
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     new BackgroundJobBoard({
@@ -135,6 +142,7 @@ export function createTaskSessionManagerHook(
 
   evaluateContinuation = (parentSessionID, sessionToken) =>
     evaluateContinuationFn(parentSessionID, sessionToken, {
+      continueOnIdle,
       backgroundJobBoard,
       continuationTokens,
       inputWaits,
@@ -144,7 +152,12 @@ export function createTaskSessionManagerHook(
 
   if (options.coordinator) {
     options.coordinator.onSessionDeleted((sessionId) => {
-      continuationTokens.clearContinuation(sessionId);
+      // Fallback teardown must not rearm a committed continuation epoch.
+      if (options.isFallbackInProgress?.(sessionId)) {
+        continuationTokens.invalidateContinuation(sessionId);
+      } else {
+        continuationTokens.clearContinuation(sessionId);
+      }
       inputWaits.clearInputWaits(sessionId);
       idleReconciler.clearIdleTimers(sessionId);
       // During a foreground fallback abort/re-prompt cycle, the session
@@ -194,8 +207,18 @@ export function createTaskSessionManagerHook(
       const parts = Array.isArray(outputRecord?.parts)
         ? outputRecord.parts
         : inputMessage?.parts;
+      // Stable identity from chat.message (input.messageID or output.message.id).
+      // Required for process-global idempotent rearm across hook instances.
+      const messageID =
+        typeof inputMessage?.messageID === 'string' &&
+        inputMessage.messageID.length > 0
+          ? inputMessage.messageID
+          : typeof outputMessage?.id === 'string' && outputMessage.id.length > 0
+            ? outputMessage.id
+            : undefined;
       if (
         !sessionID ||
+        !messageID ||
         (typeof outputMessage?.role === 'string' &&
           outputMessage.role !== 'user') ||
         !options.shouldManageSession(sessionID) ||
@@ -212,7 +235,7 @@ export function createTaskSessionManagerHook(
       ) {
         return;
       }
-      continuationTokens.clearContinuation(sessionID);
+      continuationTokens.rearmForUserMessage(sessionID, messageID);
     },
 
     'tool.execute.before': (

+ 1 - 0
src/index.ts

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