Explorar o código

fix: prevent duplicate idle continuation prompts

Alvin Unreal hai 1 mes
pai
achega
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."
 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)
 ## 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.
 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").
    - `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.
 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 |
 | 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: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:871` | User submits interview chat | **Yes** | same |
 | `src/interview/service.ts:933` | User submits interview answer | **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
 ## 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
 ## 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)
 ## Fix directions (not implemented — awaiting decision)
 
 
 ### Minimal fix
 ### 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)
 ### 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.
 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
 ## Evidence index
 
 
 ### omos source
 ### 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`
 - **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`
 - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
 - **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21`
 - **`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`
 - **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600`
 
 
 ### opencode source (`anomalyco/opencode` @ `dev`)
 ### 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
 ### 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
 ### 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.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.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.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 |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
 | `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
 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
 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
 ### Agent Display Names
 
 

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

@@ -1053,6 +1053,11 @@
           "type": "integer",
           "type": "integer",
           "minimum": 1,
           "minimum": 1,
           "maximum": 100
           "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', () => {
   it('accepts checkpoint-compatible board injection', () => {
     const result = PluginConfigSchema.safeParse({
     const result = PluginConfigSchema.safeParse({
       backgroundJobs: { strategy: 'checkpoint-compatible' },
       backgroundJobs: { strategy: 'checkpoint-compatible' },

+ 6 - 0
src/config/schema.ts

@@ -216,6 +216,12 @@ export const BackgroundJobsConfigSchema = z.object({
     .describe(
     .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.',
       '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>;
 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,
   evaluationToken: symbol,
   deps: {
   deps: {
     continuationTokens: {
     continuationTokens: {
-      consumed: Set<string>;
+      consumed: { has: (sessionID: string) => boolean };
       isCurrentContinuation: (
       isCurrentContinuation: (
         sessionID: string,
         sessionID: string,
         sessionToken: symbol,
         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(
 export async function evaluateContinuation(
   parentSessionID: string,
   parentSessionID: string,
   sessionToken: symbol,
   sessionToken: symbol,
   deps: {
   deps: {
+    continueOnIdle: boolean;
     backgroundJobBoard: BackgroundJobStore;
     backgroundJobBoard: BackgroundJobStore;
     continuationTokens: {
     continuationTokens: {
       evaluations: Map<string, Set<symbol>>;
       evaluations: Map<string, Set<symbol>>;
-      consumed: Set<string>;
+      consumed: { has: (sessionID: string) => boolean };
       isCurrentContinuation: (
       isCurrentContinuation: (
         sessionID: string,
         sessionID: string,
         sessionToken: symbol,
         sessionToken: symbol,
         evaluationToken?: symbol,
         evaluationToken?: symbol,
       ) => boolean;
       ) => boolean;
+      tryReserveAttempt: (sessionID: string) => symbol | null;
+      commitAttempt: (sessionID: string, owner: symbol) => boolean;
+      releaseAttempt: (sessionID: string, owner: symbol) => void;
     };
     };
     inputWaits: {
     inputWaits: {
       hasInputWait: (sessionID: string) => boolean;
       hasInputWait: (sessionID: string) => boolean;
@@ -87,6 +103,11 @@ export async function evaluateContinuation(
     };
     };
   },
   },
 ): Promise<void> {
 ): Promise<void> {
+  // Opt-in only: idle reconciliation still runs; continuation SDK calls do not.
+  if (!deps.continueOnIdle) {
+    return;
+  }
+
   const evaluationToken = Symbol(parentSessionID);
   const evaluationToken = Symbol(parentSessionID);
   const activeEvaluations =
   const activeEvaluations =
     deps.continuationTokens.evaluations.get(parentSessionID) ??
     deps.continuationTokens.evaluations.get(parentSessionID) ??
@@ -110,13 +131,28 @@ export async function evaluateContinuation(
     !deps.sessionSdk.status ||
     !deps.sessionSdk.status ||
     !deps.sessionSdk.promptAsync
     !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;
     return;
   }
   }
 
 
+  let committed = false;
   try {
   try {
     const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
     const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
       deps.sessionSdk.todo({
       deps.sessionSdk.todo({
@@ -199,7 +235,15 @@ export async function evaluateContinuation(
     ) {
     ) {
       return;
       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({
     await deps.sessionSdk.promptAsync({
       path: { id: parentSessionID },
       path: { id: parentSessionID },
       body: {
       body: {
@@ -217,11 +261,13 @@ export async function evaluateContinuation(
       },
       },
     );
     );
   } finally {
   } 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?: {
 export function createContinuationTokenManager(options?: {
   onInvalidateContinuation?: (sessionID: string) => void;
   onInvalidateContinuation?: (sessionID: string) => void;
 }) {
 }) {
   const continuationSessionTokens = new Map<string, symbol>();
   const continuationSessionTokens = new Map<string, symbol>();
   const activeContinuationEvaluations = new Map<string, Set<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 {
   function getContinuationSessionToken(sessionID: string): symbol {
     const existing = continuationSessionTokens.get(sessionID);
     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 {
   function invalidateContinuation(sessionID: string): void {
     options?.onInvalidateContinuation?.(sessionID);
     options?.onInvalidateContinuation?.(sessionID);
     continuationSessionTokens.delete(sessionID);
     continuationSessionTokens.delete(sessionID);
     activeContinuationEvaluations.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 {
   function clearContinuation(sessionID: string): void {
     invalidateContinuation(sessionID);
     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 {
   return {
     getContinuationSessionToken,
     getContinuationSessionToken,
     isCurrentContinuation,
     isCurrentContinuation,
     invalidateContinuation,
     invalidateContinuation,
+    rearmForUserMessage,
     clearContinuation,
     clearContinuation,
-    // Exposed internal state for consumers not yet migrated (evaluateContinuation, etc.)
+    disposeLocalState,
+    tryReserveAttempt,
+    commitAttempt,
+    releaseAttempt,
     sessionTokens: continuationSessionTokens,
     sessionTokens: continuationSessionTokens,
     evaluations: activeContinuationEvaluations,
     evaluations: activeContinuationEvaluations,
-    consumed: continuationConsumed,
+    consumed,
   };
   };
 }
 }

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

@@ -41,9 +41,11 @@ export async function handleEvent(
     continuationTokens: {
     continuationTokens: {
       clearContinuation(sessionID: string): void;
       clearContinuation(sessionID: string): void;
       invalidateContinuation(sessionID: string): void;
       invalidateContinuation(sessionID: string): void;
+      /** Release local uncommitted reservations only; keep global consumed. */
+      disposeLocalState(): void;
       sessionTokens: Map<string, symbol>;
       sessionTokens: Map<string, symbol>;
       evaluations: Map<string, Set<symbol>>;
       evaluations: Map<string, Set<symbol>>;
-      consumed: Set<string>;
+      consumed: { has(sessionID: string): boolean };
     };
     };
     options: {
     options: {
       shouldManageSession: (sessionID: string) => boolean;
       shouldManageSession: (sessionID: string) => boolean;
@@ -138,15 +140,17 @@ export async function handleEvent(
   if (input.event.type === 'server.instance.disposed') {
   if (input.event.type === 'server.instance.disposed') {
     deps.retainedBoardSnapshots.clear();
     deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     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,
       ...idleSessionIds,
       ...deps.continuationTokens.sessionTokens.keys(),
       ...deps.continuationTokens.sessionTokens.keys(),
       ...deps.continuationTokens.evaluations.keys(),
       ...deps.continuationTokens.evaluations.keys(),
-      ...deps.continuationTokens.consumed,
       ...deps.inputWaits.waitsByParent.keys(),
       ...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);
       deps.inputWaits.clearInputWaits(sessionID);
     }
     }
     return;
     return;
@@ -293,7 +297,14 @@ export async function handleEvent(
     input.event.properties?.info?.id || input.event.properties?.sessionID;
     input.event.properties?.info?.id || input.event.properties?.sessionID;
   if (!sessionId) return;
   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.inputWaits.clearInputWaits(sessionId);
   deps.retainedBoardSnapshots.delete(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 { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
 import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
 import {
@@ -11,6 +11,10 @@ import {
   PHASE_REMINDER_METADATA_KEY,
   PHASE_REMINDER_METADATA_KEY,
 } from '../phase-reminder';
 } from '../phase-reminder';
 import { createPostFileToolNudgeHook } from '../post-file-tool-nudge';
 import { createPostFileToolNudgeHook } from '../post-file-tool-nudge';
+import {
+  hasConsumedContinuationAttempt,
+  resetContinuationAttemptGateForTests,
+} from './continuation-attempt-gate';
 import {
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
   BACKGROUND_JOB_BOARD_METADATA_KEY,
   createTaskSessionManagerHook,
   createTaskSessionManagerHook,
@@ -38,6 +42,8 @@ function createHook(options?: {
   readContextMaxFiles?: number;
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
   strategy?: 'latest' | 'checkpoint-compatible';
   maxRetainedSnapshots?: number;
   maxRetainedSnapshots?: number;
+  /** Matches production default true; set false to exercise opt-out. */
+  continueOnIdle?: boolean;
   backgroundJobBoard?: BackgroundJobBoard;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   sessionStatus?: unknown;
   sessionClient?: Record<string, unknown>;
   sessionClient?: Record<string, unknown>;
@@ -63,6 +69,7 @@ function createHook(options?: {
       strategy: options?.strategy,
       strategy: options?.strategy,
       readContextMinLines: options?.readContextMinLines,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       readContextMaxFiles: options?.readContextMaxFiles,
+      continueOnIdle: options?.continueOnIdle ?? true,
       backgroundJobBoard: options?.backgroundJobBoard,
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
@@ -153,6 +160,11 @@ function setupCompletedJob(
 }
 }
 
 
 describe('task-session-manager hook', () => {
 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 () => {
   test('ignores messages without OpenCode info or parts', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({
@@ -3459,6 +3471,85 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
     ).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 () => {
   test('nudges once for incomplete todos when parent and children are inactive', async () => {
     const promptAsync = mock(async () => ({}));
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({
     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 () => {
   test('does not evaluate or nudge while a question or permission waits', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
     const promptAsync = mock(async () => ({}));
@@ -3757,9 +4279,13 @@ describe('task-session-manager hook', () => {
       },
       },
     });
     });
     hook.observeChatMessage(
     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: [
         parts: [
           { type: 'text', synthetic: true, text: 'synthetic response' },
           { type: 'text', synthetic: true, text: 'synthetic response' },
           createInternalAgentTextPart('internal response'),
           createInternalAgentTextPart('internal response'),
@@ -3807,10 +4333,21 @@ describe('task-session-manager hook', () => {
   });
   });
 
 
   test('clears stale input waits on session and server cleanup', async () => {
   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 promptAsync = mock(async () => ({}));
       const { hook } = createHook({
       const { hook } = createHook({
         idleReconcileDelayMs: 0,
         idleReconcileDelayMs: 0,
@@ -3825,12 +4362,12 @@ describe('task-session-manager hook', () => {
       await hook.event({
       await hook.event({
         event: {
         event: {
           type: 'question.asked',
           type: 'question.asked',
-          properties: { sessionID: 'parent-1', id: 'question-1' },
+          properties: { sessionID, id: 'question-1' },
         },
         },
       });
       });
       await hook.event({ event: lifecycleEvent });
       await hook.event({ event: lifecycleEvent });
       await hook.event({
       await hook.event({
-        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+        event: { type: 'session.idle', properties: { sessionID } },
       });
       });
       await flushContinuation();
       await flushContinuation();
 
 
@@ -3883,9 +4420,13 @@ describe('task-session-manager hook', () => {
     });
     });
     await flushContinuation();
     await flushContinuation();
     hook.observeChatMessage(
     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' }],
         parts: [{ type: 'text', text: 'continue' }],
       },
       },
     );
     );
@@ -3897,6 +4438,134 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(2);
     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 () => {
   test('file-only external messages rearm a consumed nudge', async () => {
     const promptAsync = mock(async () => ({}));
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({
     const { hook } = createHook({
@@ -3914,9 +4583,13 @@ describe('task-session-manager hook', () => {
     });
     });
     await flushContinuation();
     await flushContinuation();
     hook.observeChatMessage(
     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' }],
         parts: [{ type: 'file', filename: 'command-output.txt' }],
       },
       },
     );
     );
@@ -3945,9 +4618,13 @@ describe('task-session-manager hook', () => {
     });
     });
     await flushContinuation();
     await flushContinuation();
     hook.observeChatMessage(
     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: [
         parts: [
           {
           {
             type: 'text',
             type: 'text',
@@ -4160,9 +4837,13 @@ describe('task-session-manager hook', () => {
     });
     });
     await flushContinuation();
     await flushContinuation();
     hook.observeChatMessage(
     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')],
         parts: [createInternalAgentTextPart('Continue coordinating')],
       },
       },
     );
     );

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

@@ -46,6 +46,12 @@ export function createTaskSessionManagerHook(
     maxRetainedSnapshots: number;
     maxRetainedSnapshots: number;
     readContextMinLines?: number;
     readContextMinLines?: number;
     readContextMaxFiles?: 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;
     backgroundJobBoard?: BackgroundJobStore;
     shouldManageSession: (sessionID: string) => boolean;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
     /** Register a session as orchestrator when the transform hook detects
@@ -62,6 +68,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
     idleReconcileDelayMs?: number;
   },
   },
 ) {
 ) {
+  const continueOnIdle = options.continueOnIdle !== false;
   const backgroundJobBoard =
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     options.backgroundJobBoard ??
     new BackgroundJobBoard({
     new BackgroundJobBoard({
@@ -135,6 +142,7 @@ export function createTaskSessionManagerHook(
 
 
   evaluateContinuation = (parentSessionID, sessionToken) =>
   evaluateContinuation = (parentSessionID, sessionToken) =>
     evaluateContinuationFn(parentSessionID, sessionToken, {
     evaluateContinuationFn(parentSessionID, sessionToken, {
+      continueOnIdle,
       backgroundJobBoard,
       backgroundJobBoard,
       continuationTokens,
       continuationTokens,
       inputWaits,
       inputWaits,
@@ -144,7 +152,12 @@ export function createTaskSessionManagerHook(
 
 
   if (options.coordinator) {
   if (options.coordinator) {
     options.coordinator.onSessionDeleted((sessionId) => {
     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);
       inputWaits.clearInputWaits(sessionId);
       idleReconciler.clearIdleTimers(sessionId);
       idleReconciler.clearIdleTimers(sessionId);
       // During a foreground fallback abort/re-prompt cycle, the session
       // During a foreground fallback abort/re-prompt cycle, the session
@@ -194,8 +207,18 @@ export function createTaskSessionManagerHook(
       const parts = Array.isArray(outputRecord?.parts)
       const parts = Array.isArray(outputRecord?.parts)
         ? outputRecord.parts
         ? outputRecord.parts
         : inputMessage?.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 (
       if (
         !sessionID ||
         !sessionID ||
+        !messageID ||
         (typeof outputMessage?.role === 'string' &&
         (typeof outputMessage?.role === 'string' &&
           outputMessage.role !== 'user') ||
           outputMessage.role !== 'user') ||
         !options.shouldManageSession(sessionID) ||
         !options.shouldManageSession(sessionID) ||
@@ -212,7 +235,7 @@ export function createTaskSessionManagerHook(
       ) {
       ) {
         return;
         return;
       }
       }
-      continuationTokens.clearContinuation(sessionID);
+      continuationTokens.rearmForUserMessage(sessionID, messageID);
     },
     },
 
 
     'tool.execute.before': (
     'tool.execute.before': (

+ 1 - 0
src/index.ts

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