Browse Source

feat: add SDK-backed continuation nudge

Alvin Unreal 2 weeks ago
parent
commit
931cd95114

+ 14 - 0
docs/background-orchestration.md

@@ -311,6 +311,20 @@ rather than "work complete". It tracks running task IDs, exposes recent work in
 the background job board, updates aliases from task results, and keeps
 multiplexer panes attached while the parent orchestrator continues scheduling.
 
+### Incomplete-todo continuation nudge
+
+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,
+or whenever SDK data is unavailable or malformed. 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.
+
 ---
 
 ## Startup Behavior

+ 535 - 0
src/hooks/task-session-manager/index.test.ts

@@ -20,6 +20,11 @@ function flushIdleReconcileDelay() {
   return new Promise((resolve) => setTimeout(resolve, 2100));
 }
 
+async function flushContinuation(): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, 0));
+  await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
 function createHook(options?: {
   shouldManageSession?: (sessionID: string) => boolean;
   registerSessionAsOrchestrator?: (sessionID: string) => void;
@@ -27,6 +32,8 @@ function createHook(options?: {
   readContextMaxFiles?: number;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
+  sessionClient?: Record<string, unknown>;
+  idleReconcileDelayMs?: number;
   isFallbackInProgress?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
 }) {
@@ -35,6 +42,7 @@ function createHook(options?: {
       client: {
         session: {
           status: mock(async () => ({ data: options?.sessionStatus ?? {} })),
+          ...options?.sessionClient,
         },
       },
       directory: '/tmp',
@@ -49,6 +57,7 @@ function createHook(options?: {
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       isFallbackInProgress: options?.isFallbackInProgress,
       coordinator: options?.coordinator,
+      idleReconcileDelayMs: options?.idleReconcileDelayMs,
     },
   );
 
@@ -2498,4 +2507,530 @@ describe('task-session-manager hook', () => {
       ),
     ).toHaveLength(1);
   });
+
+  test('nudges once for incomplete todos when parent and children are inactive', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(promptAsync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        body: expect.objectContaining({
+          parts: [expect.objectContaining({ synthetic: true })],
+        }),
+      }),
+    );
+  });
+
+  test('coalesces paired idle events and suppresses active children', async () => {
+    const promptAsync = mock(async () => ({}));
+    const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children,
+        status: mock(async () => ({ data: { 'child-1': { type: 'busy' } } })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    await flushContinuation();
+
+    expect(children).toHaveBeenCalledTimes(1);
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('runtime-shaped external messages rearm a consumed nudge', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = 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();
+    hook.observeChatMessage(
+      {},
+      {
+        message: { role: 'user', sessionID: 'parent-1' },
+        parts: [{ type: 'text', text: 'continue' }],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+  });
+
+  test('synthetic completion messages do not rearm a consumed nudge', 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 hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    hook.observeChatMessage(
+      {},
+      {
+        message: { role: 'user', sessionID: 'parent-1' },
+        parts: [
+          {
+            type: 'text',
+            synthetic: true,
+            text: 'Background task completed: child-1',
+          },
+        ],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('nudge busy-to-idle cycle does not send a second unchanged nudge', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = 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();
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'busy' } },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('retry status invalidates a pending continuation evaluation', async () => {
+    let resolveTodo!: (value: { data: { status: string }[] }) => void;
+    const todo = mock(
+      () =>
+        new Promise<{ data: { status: string }[] }>((resolve) => {
+          resolveTodo = resolve;
+        }),
+    );
+    const promptAsync = mock(async () => ({}));
+    const { hook } = 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);
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'retry' } },
+      },
+    });
+    resolveTodo({ data: [{ status: 'pending' }] });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('terminal-unreconciled jobs suppress continuation nudges', async () => {
+    const board = new BackgroundJobBoard();
+    setupCompletedJob(board);
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('parent-1'),
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('missing SDK response data fails closed without nudging', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: undefined })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not nudge when todos are completed or cancelled only', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({
+          data: [{ status: 'completed' }, { status: 'cancelled' }],
+        })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not nudge while the parent is active', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: { 'parent-1': { type: 'busy' } } })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not nudge while a child is retrying', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [{ id: 'child-1' }] })),
+        status: mock(async () => ({
+          data: { 'child-1': { type: 'retrying' } },
+        })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not rearm a consumed nudge for its actual internal part', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = 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();
+    hook.observeChatMessage(
+      {},
+      {
+        message: { role: 'user', sessionID: 'parent-1' },
+        parts: [createInternalAgentTextPart('Continue coordinating')],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('keeps a rejected prompt consumed', async () => {
+    const promptAsync = mock(async () => {
+      throw new Error('prompt failed');
+    });
+    const { hook } = 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();
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('keeps a failed prompt response consumed', async () => {
+    const promptAsync = mock(async () => ({ error: 'prompt failed' }));
+    const { hook } = 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();
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('fails closed for missing or throwing SDK endpoints', async () => {
+    const missingPrompt = mock(async () => ({}));
+    const { hook: missingHook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: { promptAsync: missingPrompt },
+    });
+    const throwingPrompt = mock(async () => ({}));
+    const { hook: throwingHook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => {
+          throw new Error('todo unavailable');
+        }),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync: throwingPrompt,
+      },
+    });
+
+    for (const hook of [missingHook, throwingHook]) {
+      await hook.event({
+        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+      });
+    }
+    await flushContinuation();
+
+    expect(missingPrompt).not.toHaveBeenCalled();
+    expect(throwingPrompt).not.toHaveBeenCalled();
+  });
+
+  test('does not nudge when fallback is already in progress', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      isFallbackInProgress: () => true,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('does not nudge when fallback starts during evaluation', async () => {
+    let fallbackInProgress = false;
+    let releaseTodos: (() => void) | undefined;
+    const todos = new Promise<{ data: Array<{ status: string }> }>(
+      (resolve) => {
+        releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
+      },
+    );
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      isFallbackInProgress: () => fallbackInProgress,
+      sessionClient: {
+        todo: mock(async () => todos),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    fallbackInProgress = true;
+    releaseTodos?.();
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('final gate blocks a terminal result that arrives during SDK queries', async () => {
+    const board = new BackgroundJobBoard();
+    let childrenCalls = 0;
+    let releaseLatestChildren: (() => void) | undefined;
+    const latestChildren = new Promise<{ data: Array<unknown> }>((resolve) => {
+      releaseLatestChildren = () => resolve({ data: [] });
+    });
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => {
+          childrenCalls++;
+          return childrenCalls === 1 ? { data: [] } : latestChildren;
+        }),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    setupCompletedJob(board);
+    releaseLatestChildren?.();
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('instance disposal invalidates an evaluation whose timer already fired', async () => {
+    let releaseTodos: (() => void) | undefined;
+    const todos = new Promise<{ data: Array<{ status: string }> }>(
+      (resolve) => {
+        releaseTodos = () => resolve({ data: [{ status: 'pending' }] });
+      },
+    );
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => todos),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    await hook.event({ event: { type: 'server.instance.disposed' } });
+    releaseTodos?.();
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
 });

+ 295 - 30
src/hooks/task-session-manager/index.ts

@@ -3,6 +3,7 @@ import {
   BackgroundJobBoard,
   type BackgroundJobRecord,
   type BackgroundJobStore,
+  createInternalAgentTextPart,
   deriveTaskSessionLabel,
   isInternalInitiatorPart,
   parseTaskIdFromTaskOutput,
@@ -48,8 +49,8 @@ const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
  */
 const IDLE_RECONCILE_DELAY_MS = 2_000;
 
-/** Track idle reconciliation timers to cancel on busy/error/deleted. */
-const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
+const CONTINUATION_NUDGE =
+  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
 
 function djb2Hash(str: string): string {
   let hash = 5381;
@@ -109,6 +110,8 @@ export function createTaskSessionManagerHook(
      *  model fallback rather than natural completion. */
     isFallbackInProgress?: (sessionID: string) => boolean;
     coordinator?: SessionLifecycle;
+    /** Test seam only; production always uses the reconciliation delay. */
+    idleReconcileDelayMs?: number;
   },
 ) {
   const backgroundJobBoard =
@@ -125,9 +128,240 @@ export function createTaskSessionManagerHook(
   const processedInjectedCompletions = new Set<string>();
   const processedInjectedCompletionOrder: string[] = [];
   const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
+  const continuationSessionTokens = new Map<string, symbol>();
+  const activeContinuationEvaluations = new Map<string, Set<symbol>>();
+  const continuationConsumed = new Set<string>();
+  const idleReconcileDelayMs =
+    options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS;
+
+  type SdkResponse = { data?: unknown };
+  type SessionSdk = {
+    todo?: (input: unknown) => Promise<SdkResponse>;
+    children?: (input: unknown) => Promise<SdkResponse>;
+    status?: (input: unknown) => Promise<SdkResponse>;
+    promptAsync?: (input: unknown) => Promise<unknown>;
+  };
+  const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
+    .session;
+
+  function getContinuationSessionToken(sessionID: string): symbol {
+    const existing = continuationSessionTokens.get(sessionID);
+    if (existing) return existing;
+
+    const token = Symbol(sessionID);
+    continuationSessionTokens.set(sessionID, token);
+    return token;
+  }
+
+  function isCurrentContinuation(
+    sessionID: string,
+    sessionToken: symbol,
+    evaluationToken?: symbol,
+  ): boolean {
+    return (
+      continuationSessionTokens.get(sessionID) === sessionToken &&
+      (evaluationToken === undefined ||
+        activeContinuationEvaluations.get(sessionID)?.has(evaluationToken) ===
+          true)
+    );
+  }
+
+  function invalidateContinuation(sessionID: string): void {
+    const timer = idleReconcileTimers.get(sessionID);
+    if (timer) {
+      clearTimeout(timer);
+      idleReconcileTimers.delete(sessionID);
+    }
+    continuationSessionTokens.delete(sessionID);
+    activeContinuationEvaluations.delete(sessionID);
+  }
+
+  function clearContinuation(sessionID: string): void {
+    invalidateContinuation(sessionID);
+    continuationConsumed.delete(sessionID);
+  }
+
+  function isActiveStatus(
+    status: Record<string, unknown>,
+    sessionID: string,
+  ): boolean {
+    return Object.hasOwn(status, sessionID);
+  }
+
+  async function evaluateContinuation(
+    parentSessionID: string,
+    sessionToken: symbol,
+  ): Promise<void> {
+    const evaluationToken = Symbol(parentSessionID);
+    const activeEvaluations =
+      activeContinuationEvaluations.get(parentSessionID) ?? new Set<symbol>();
+    activeEvaluations.add(evaluationToken);
+    activeContinuationEvaluations.set(parentSessionID, activeEvaluations);
+
+    if (
+      continuationConsumed.has(parentSessionID) ||
+      !isCurrentContinuation(parentSessionID, sessionToken, evaluationToken) ||
+      options.isFallbackInProgress?.(parentSessionID) ||
+      backgroundJobBoard.hasTerminalUnreconciled(parentSessionID) ||
+      !sessionSdk?.todo ||
+      !sessionSdk.children ||
+      !sessionSdk.status ||
+      !sessionSdk.promptAsync
+    ) {
+      activeEvaluations.delete(evaluationToken);
+      if (activeEvaluations.size === 0) {
+        activeContinuationEvaluations.delete(parentSessionID);
+      }
+      return;
+    }
+
+    try {
+      const [todoResponse, childrenResponse, statusResponse] =
+        await Promise.all([
+          sessionSdk.todo({
+            path: { id: parentSessionID },
+            throwOnError: true,
+          }),
+          sessionSdk.children({
+            path: { id: parentSessionID },
+            throwOnError: true,
+          }),
+          sessionSdk.status({ throwOnError: true }),
+        ]);
+      if (
+        !Array.isArray(todoResponse.data) ||
+        !Array.isArray(childrenResponse.data) ||
+        !isObjectRecord(statusResponse.data)
+      ) {
+        return;
+      }
+      const todos = todoResponse.data;
+      const children = childrenResponse.data;
+      const status = statusResponse.data;
+      if (
+        !todos.every(
+          (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
+        ) ||
+        !children.every(
+          (child) => isObjectRecord(child) && typeof child.id === 'string',
+        )
+      ) {
+        return;
+      }
+      if (
+        !todos.some(
+          (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
+        )
+      ) {
+        return;
+      }
+      const childIDs = children.map((child) => child.id as string);
+      if (
+        isActiveStatus(status, parentSessionID) ||
+        childIDs.some((childID) => isActiveStatus(status, childID))
+      ) {
+        return;
+      }
+
+      // Re-read liveness immediately before queuing work; board state is only
+      // authoritative for terminal results observed by this plugin instance.
+      const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
+        sessionSdk.children({
+          path: { id: parentSessionID },
+          throwOnError: true,
+        }),
+        sessionSdk.status({ throwOnError: true }),
+      ]);
+      if (
+        !Array.isArray(latestChildrenResponse.data) ||
+        !isObjectRecord(latestStatusResponse.data) ||
+        !latestChildrenResponse.data.every(
+          (child) => isObjectRecord(child) && typeof child.id === 'string',
+        ) ||
+        continuationConsumed.has(parentSessionID) ||
+        !isCurrentContinuation(
+          parentSessionID,
+          sessionToken,
+          evaluationToken,
+        ) ||
+        options.isFallbackInProgress?.(parentSessionID) ||
+        backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
+      ) {
+        return;
+      }
+      const latestChildIDs = latestChildrenResponse.data.map(
+        (child) => child.id as string,
+      );
+      const latestStatus = latestStatusResponse.data;
+      if (
+        isActiveStatus(latestStatus, parentSessionID) ||
+        latestChildIDs.some((childID) => isActiveStatus(latestStatus, childID))
+      ) {
+        return;
+      }
+
+      if (
+        continuationConsumed.has(parentSessionID) ||
+        !isCurrentContinuation(
+          parentSessionID,
+          sessionToken,
+          evaluationToken,
+        ) ||
+        options.isFallbackInProgress?.(parentSessionID) ||
+        backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
+      ) {
+        return;
+      }
+      continuationConsumed.add(parentSessionID);
+      await sessionSdk.promptAsync({
+        path: { id: parentSessionID },
+        body: { parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)] },
+        throwOnError: true,
+      });
+    } catch (error) {
+      log(
+        '[task-session-manager] continuation nudge suppressed after SDK error',
+        {
+          parentSessionID,
+          error: error instanceof Error ? error.message : String(error),
+        },
+      );
+    } finally {
+      const evaluations = activeContinuationEvaluations.get(parentSessionID);
+      evaluations?.delete(evaluationToken);
+      if (evaluations?.size === 0) {
+        activeContinuationEvaluations.delete(parentSessionID);
+      }
+    }
+  }
+
+  function scheduleIdleReconciliation(parentSessionID: string): void {
+    if (
+      idleReconcileTimers.has(parentSessionID) ||
+      options.isFallbackInProgress?.(parentSessionID)
+    ) {
+      return;
+    }
+    const sessionToken = getContinuationSessionToken(parentSessionID);
+    const timer = setTimeout(() => {
+      idleReconcileTimers.delete(parentSessionID);
+      if (!isCurrentContinuation(parentSessionID, sessionToken)) {
+        return;
+      }
+      const hadTerminalUnreconciled =
+        backgroundJobBoard.hasTerminalUnreconciled(parentSessionID);
+      reconcileInjectedTerminalJobs(parentSessionID);
+      if (!hadTerminalUnreconciled) {
+        void evaluateContinuation(parentSessionID, sessionToken);
+      }
+    }, idleReconcileDelayMs).unref?.();
+    idleReconcileTimers.set(parentSessionID, timer);
+  }
 
   if (options.coordinator) {
     options.coordinator.onSessionDeleted((sessionId) => {
+      clearContinuation(sessionId);
       // During a foreground fallback abort/re-prompt cycle, the session
       // is being torn down and immediately recreated with a fallback model.
       // Dropping the job from the board here would make the orchestrator
@@ -338,6 +572,42 @@ export function createTaskSessionManagerHook(
   }
 
   return {
+    observeChatMessage: (input: unknown, output: unknown): void => {
+      const inputMessage = isObjectRecord(input) ? input : undefined;
+      const outputRecord = isObjectRecord(output) ? output : undefined;
+      const outputMessage = isObjectRecord(outputRecord?.message)
+        ? outputRecord.message
+        : undefined;
+      const sessionID =
+        typeof outputMessage?.sessionID === 'string'
+          ? outputMessage.sessionID
+          : typeof inputMessage?.sessionID === 'string'
+            ? inputMessage.sessionID
+            : undefined;
+      const parts = Array.isArray(outputRecord?.parts)
+        ? outputRecord.parts
+        : inputMessage?.parts;
+      if (
+        !sessionID ||
+        (typeof outputMessage?.role === 'string' &&
+          outputMessage.role !== 'user') ||
+        !options.shouldManageSession(sessionID) ||
+        !Array.isArray(parts) ||
+        !parts.some(
+          (part) =>
+            isObjectRecord(part) &&
+            part.type === 'text' &&
+            typeof part.text === 'string' &&
+            part.synthetic !== true &&
+            !isInternalInitiatorPart(part),
+        )
+      ) {
+        return;
+      }
+      invalidateContinuation(sessionID);
+      continuationConsumed.delete(sessionID);
+    },
+
     'tool.execute.before': async (
       input: { tool: string; sessionID?: string; callID?: string },
       output: { args?: unknown },
@@ -664,6 +934,19 @@ export function createTaskSessionManagerHook(
         return;
       }
 
+      if (input.event.type === 'server.instance.disposed') {
+        const continuationSessionIDs = new Set([
+          ...idleReconcileTimers.keys(),
+          ...continuationSessionTokens.keys(),
+          ...activeContinuationEvaluations.keys(),
+          ...continuationConsumed,
+        ]);
+        for (const sessionID of continuationSessionIDs) {
+          clearContinuation(sessionID);
+        }
+        return;
+      }
+
       if (
         input.event.type === 'session.idle' ||
         (input.event.type === 'session.status' &&
@@ -684,11 +967,7 @@ export function createTaskSessionManagerHook(
           runningJobForSession: job?.state === 'running' || false,
         });
         if (sessionId && options.shouldManageSession(sessionId)) {
-          const timer = setTimeout(() => {
-            idleReconcileTimers.delete(sessionId);
-            reconcileInjectedTerminalJobs(sessionId);
-          }, IDLE_RECONCILE_DELAY_MS).unref?.();
-          idleReconcileTimers.set(sessionId, timer);
+          scheduleIdleReconciliation(sessionId);
         }
 
         // Fallback: for background child sessions that go idle without
@@ -728,13 +1007,7 @@ export function createTaskSessionManagerHook(
       if (input.event.type === 'session.error') {
         const sessionId =
           input.event.properties?.info?.id || input.event.properties?.sessionID;
-        if (sessionId) {
-          const timer = idleReconcileTimers.get(sessionId);
-          if (timer) {
-            clearTimeout(timer);
-            idleReconcileTimers.delete(sessionId);
-          }
-        }
+        if (sessionId) invalidateContinuation(sessionId);
         if (sessionId && options.shouldManageSession(sessionId)) {
           // Only clear injected terminal jobs for fatal errors.
           // Rate-limit errors are recovered by ForegroundFallbackManager
@@ -752,19 +1025,15 @@ export function createTaskSessionManagerHook(
         return;
       }
 
-      if (
-        input.event.type === 'session.status' &&
-        (input.event.properties as { status?: { type?: string } } | undefined)
-          ?.status?.type === 'busy'
-      ) {
+      if (input.event.type === 'session.status') {
         const sessionId =
           input.event.properties?.info?.id || input.event.properties?.sessionID;
-        if (sessionId) {
-          const timer = idleReconcileTimers.get(sessionId);
-          if (timer) {
-            clearTimeout(timer);
-            idleReconcileTimers.delete(sessionId);
-          }
+        const statusType = (
+          input.event.properties as { status?: { type?: string } } | undefined
+        )?.status?.type;
+        if (sessionId) invalidateContinuation(sessionId);
+        if (statusType !== 'busy') {
+          return;
         }
         const before = sessionId
           ? backgroundJobBoard.get(sessionId)
@@ -802,11 +1071,7 @@ export function createTaskSessionManagerHook(
         input.event.properties?.info?.id || input.event.properties?.sessionID;
       if (!sessionId) return;
 
-      const timer = idleReconcileTimers.get(sessionId);
-      if (timer) {
-        clearTimeout(timer);
-        idleReconcileTimers.delete(sessionId);
-      }
+      clearContinuation(sessionId);
 
       log('[task-session-manager] session.deleted observed', {
         sessionID: sessionId,

+ 107 - 0
src/index.event.test.ts

@@ -0,0 +1,107 @@
+import { afterAll, describe, expect, mock, spyOn, test } from 'bun:test';
+import { MultiplexerSessionManager } from './multiplexer';
+
+let taskSessionEventCalls = 0;
+let cleanupStarted = false;
+let nudgeWouldBePermitted = false;
+let notifyCleanupStarted = (): void => {};
+let cleanupStartedPromise = Promise.resolve();
+let releaseCleanup = (): void => {
+  throw new Error('disposal cleanup did not start');
+};
+
+function resetCleanupSignals(): void {
+  cleanupStarted = false;
+  nudgeWouldBePermitted = false;
+  cleanupStartedPromise = new Promise<void>((resolve) => {
+    notifyCleanupStarted = resolve;
+  });
+  releaseCleanup = () => {
+    throw new Error('disposal cleanup did not start');
+  };
+}
+
+const asyncNoop = async (): Promise<void> => {};
+const passiveHook = {
+  'experimental.chat.messages.transform': asyncNoop,
+  'tool.execute.after': asyncNoop,
+};
+
+mock.module('./hooks', () => ({
+  createApplyPatchHook: () => ({ 'tool.execute.before': asyncNoop }),
+  createAutoUpdateCheckerHook: () => ({ event: asyncNoop }),
+  createChatHeadersHook: () => ({ 'chat.headers': asyncNoop }),
+  createDeepworkCommandHook: () => ({ registerCommand: () => {} }),
+  createDelegateTaskRetryHook: () => passiveHook,
+  createFilterAvailableSkillsHook: () => passiveHook,
+  createJsonErrorRecoveryHook: () => passiveHook,
+  createLoopCommandHook: () => ({ registerCommand: () => {} }),
+  createPhaseReminderHook: () => passiveHook,
+  createPostFileToolNudgeHook: () => passiveHook,
+  createReflectCommandHook: () => ({ registerCommand: () => {} }),
+  createTaskSessionManagerHook: () => ({
+    event: async () => {
+      taskSessionEventCalls++;
+    },
+    observeChatMessage: () => {},
+    'experimental.chat.messages.transform': asyncNoop,
+    'tool.execute.after': asyncNoop,
+    'tool.execute.before': asyncNoop,
+  }),
+  ForegroundFallbackManager: class {
+    disableChain() {}
+    handleEvent = asyncNoop;
+    isFallbackInProgress() {
+      return false;
+    }
+    registerSessionAgent() {}
+  },
+  SessionLifecycle: class {
+    dispatchSessionDeleted() {}
+  },
+}));
+
+const cleanupOnInstanceDisposed = spyOn(
+  MultiplexerSessionManager.prototype,
+  'cleanupOnInstanceDisposed',
+).mockImplementation(async () => {
+  cleanupStarted = true;
+  nudgeWouldBePermitted = taskSessionEventCalls === 0;
+  notifyCleanupStarted();
+  await new Promise<void>((resolve) => {
+    releaseCleanup = resolve;
+  });
+});
+
+afterAll(() => {
+  cleanupOnInstanceDisposed.mockRestore();
+});
+
+const { default: plugin } = await import('./index');
+
+describe('plugin disposal event ordering', () => {
+  test('invalidates task continuations before awaited disposal cleanup', async () => {
+    taskSessionEventCalls = 0;
+    resetCleanupSignals();
+
+    const hooks = await plugin({
+      directory: process.cwd(),
+      client: { app: { log: asyncNoop } },
+    } as unknown as Parameters<typeof plugin>[0]);
+
+    const eventPromise = hooks.event?.({
+      event: { type: 'server.instance.disposed' },
+    } as never);
+
+    await cleanupStartedPromise;
+
+    expect(taskSessionEventCalls).toBe(1);
+    expect(cleanupStarted).toBeTrue();
+    expect(nudgeWouldBePermitted).toBeFalse();
+
+    releaseCleanup();
+    await eventPromise;
+
+    expect(taskSessionEventCalls).toBe(1);
+  });
+});

+ 22 - 11
src/index.ts

@@ -930,6 +930,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
+      // Instance disposal must reach the task-session manager before any
+      // awaited cleanup. This invalidates active continuation evaluations so
+      // they cannot nudge while teardown is in progress.
+      await taskSessionManagerHook.event(
+        input as {
+          event: {
+            type: string;
+            properties?: { info?: { id?: string }; sessionID?: string };
+          };
+        },
+      );
+
       // Handle multiplexer pane spawning for OpenCode's Task tool sessions
       await multiplexerSessionManager.onSessionCreated(event);
 
@@ -956,15 +968,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
-      await taskSessionManagerHook.event(
-        input as {
-          event: {
-            type: string;
-            properties?: { info?: { id?: string }; sessionID?: string };
-          };
-        },
-      );
-
       if (
         event.type === 'permission.asked' ||
         event.type === 'question.asked'
@@ -1069,8 +1072,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Track which agent each session uses (needed for serve-mode prompt
     // injection)
     'chat.message': async (
-      input: { sessionID: string; agent?: string },
-      output?: { message?: { agent?: string } },
+      input: { sessionID: string; agent?: string; parts?: unknown[] },
+      output?: {
+        message?: {
+          agent?: string;
+          role?: string;
+          sessionID?: string;
+        };
+        parts?: unknown[];
+      },
     ) => {
       const rawAgent = input.agent ?? output?.message?.agent;
       const agent = rawAgent
@@ -1097,6 +1107,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           status: 'busy',
         });
       }
+      taskSessionManagerHook.observeChatMessage(input, output);
     },
 
     // Inject orchestrator system prompt for serve-mode sessions. In serve