Pārlūkot izejas kodu

refactor(task-session-manager): extract tool hooks, evaluator, event router

Final extraction phase. index.ts thinned from 881 to 299 lines by moving:
- tool.execute.before/after -> tool-execute-hooks.ts
- evaluateContinuation god-function -> continuation-evaluator.ts
  (deduped the repeated 5-condition guard into isEvaluationAborted)
- event handler switch -> event-router.ts

All handlers take explicit deps; no behavior change. Cache-safety
allowlist updated for the Date.now() call now located in event-router.ts.
adikpb 2 nedēļas atpakaļ
vecāks
revīzija
4b40bd3d7c

+ 1 - 1
src/cache-safety-tripwire.test.ts

@@ -63,7 +63,7 @@ const ALLOWLIST = new Map<string, string>([
     'Date.now() records lastReadAt for internal recency ordering; formatted prompt output (background job board) is confined to the volatile trailing message.',
   ],
   [
-    'hooks/task-session-manager/index.ts',
+    'hooks/task-session-manager/event-router.ts',
     'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
   ],
   [

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

@@ -0,0 +1,227 @@
+/**
+ * Continuation evaluator subsystem for task session manager.
+ *
+ * Evaluates whether a parent session needs a continuation nudge
+ * when its children complete but todos remain unfinished.
+ *
+ * Exported as a pure function taking explicit dependency objects
+ * to avoid circular dependency issues with the other subsystems.
+ */
+import { createInternalAgentTextPart } from '../../utils';
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import { isActiveStatus } from './status-utils';
+
+const CONTINUATION_NUDGE =
+  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
+
+/**
+ * Shared 5-condition guard that appears (in identical form) after
+ * each async liveness re-check inside the main evaluation loop.
+ * Deduplicated here to avoid repeating the same short-circuit chain.
+ *
+ * Does NOT include the SDK-availability checks (first guard only);
+ * those remain inline since they only run once before any I/O.
+ */
+function isEvaluationAborted(
+  parentSessionID: string,
+  sessionToken: symbol,
+  evaluationToken: symbol,
+  deps: {
+    continuationTokens: {
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    backgroundJobBoard: BackgroundJobStore;
+  },
+): boolean {
+  return (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
+  );
+}
+
+export async function evaluateContinuation(
+  parentSessionID: string,
+  sessionToken: symbol,
+  deps: {
+    backgroundJobBoard: BackgroundJobStore;
+    continuationTokens: {
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    sessionSdk?: {
+      todo?: (input: unknown) => Promise<{ data?: unknown }>;
+      children?: (input: unknown) => Promise<{ data?: unknown }>;
+      status?: (input: unknown) => Promise<{ data?: unknown }>;
+      promptAsync?: (input: unknown) => Promise<unknown>;
+    };
+  },
+): Promise<void> {
+  const evaluationToken = Symbol(parentSessionID);
+  const activeEvaluations =
+    deps.continuationTokens.evaluations.get(parentSessionID) ??
+    new Set<symbol>();
+  activeEvaluations.add(evaluationToken);
+  deps.continuationTokens.evaluations.set(parentSessionID, activeEvaluations);
+
+  // Guard 1: pre-flight checks (includes SDK availability — only once)
+  if (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID) ||
+    !deps.sessionSdk?.todo ||
+    !deps.sessionSdk.children ||
+    !deps.sessionSdk.status ||
+    !deps.sessionSdk.promptAsync
+  ) {
+    activeEvaluations.delete(evaluationToken);
+    if (activeEvaluations.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+    return;
+  }
+
+  try {
+    const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
+      deps.sessionSdk.todo({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(todoResponse.data) ||
+      !Array.isArray(childrenResponse.data) ||
+      !isObjectRecord(statusResponse.data)
+    ) {
+      return;
+    }
+    const todos = todoResponse.data;
+    const children = childrenResponse.data;
+    const status = statusResponse.data;
+    if (
+      !todos.every(
+        (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
+      ) ||
+      !children.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      )
+    ) {
+      return;
+    }
+    if (
+      !todos.some(
+        (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
+      )
+    ) {
+      return;
+    }
+    const childIDs = children.map((child) => child.id as string);
+    if (
+      isActiveStatus(status, parentSessionID) ||
+      childIDs.some((childID) => isActiveStatus(status, childID))
+    ) {
+      return;
+    }
+
+    // Re-read liveness immediately before queuing work; board state is only
+    // authoritative for terminal results observed by this plugin instance.
+    const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(latestChildrenResponse.data) ||
+      !isObjectRecord(latestStatusResponse.data) ||
+      !latestChildrenResponse.data.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      ) ||
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    const latestChildIDs = latestChildrenResponse.data.map(
+      (child) => child.id as string,
+    );
+    const latestStatus = latestStatusResponse.data;
+    if (
+      isActiveStatus(latestStatus, parentSessionID) ||
+      latestChildIDs.some((childID) => isActiveStatus(latestStatus, childID))
+    ) {
+      return;
+    }
+
+    if (
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    deps.continuationTokens.consumed.add(parentSessionID);
+    await deps.sessionSdk.promptAsync({
+      path: { id: parentSessionID },
+      body: {
+        agent: 'orchestrator',
+        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 =
+      deps.continuationTokens.evaluations.get(parentSessionID);
+    evaluations?.delete(evaluationToken);
+    if (evaluations?.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+  }
+}

+ 298 - 0
src/hooks/task-session-manager/event-router.ts

@@ -0,0 +1,298 @@
+/**
+ * Event router for task session manager.
+ *
+ * Routes lifecycle events (session.created, server.instance.disposed,
+ * session.idle, session.error, session.status, session.deleted) to
+ * the appropriate subsystems.
+ */
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import { log } from '../../utils/logger';
+import { isFailoverError } from '../foreground-fallback/index';
+import type { PendingTaskCall } from './pending-call-tracker';
+
+export async function handleEvent(
+  input: {
+    event: {
+      type: string;
+      properties?: {
+        info?: { id?: string; parentID?: string; agent?: string };
+        id?: string;
+        requestID?: string;
+        sessionID?: string;
+        status?: { type?: string };
+        error?: { name?: string };
+      };
+    };
+  },
+  deps: {
+    inputWaits: {
+      trackInputWait(event: {
+        type: string;
+        properties?: {
+          id?: string;
+          requestID?: string;
+          sessionID?: string;
+        };
+      }): void;
+      clearInputWaits(sessionID: string): void;
+      waitsByParent: Map<string, Set<string | symbol>>;
+    };
+    continuationTokens: {
+      clearContinuation(sessionID: string): void;
+      invalidateContinuation(sessionID: string): void;
+      sessionTokens: Map<string, symbol>;
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+    };
+    options: {
+      shouldManageSession: (sessionID: string) => boolean;
+      registerSessionAsOrchestrator?: (sessionID: string) => void;
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    idleReconciler: {
+      scheduleIdleReconciliation(sessionID: string): void;
+      scheduleChildIdleReconciliation(
+        sessionID: string,
+        idleObservedAt: number,
+      ): void;
+      clearIdleTimers(sessionID: string): void;
+      clearAllTimers(): string[];
+    };
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      peekByParentAndAgent(
+        parentSessionID: string,
+        agentHint?: string,
+      ): PendingTaskCall | undefined;
+      clearSession(sessionID: string): void;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      clearSession(sessionID: string): void;
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+    terminalJobsInjectedByParent: Map<string, Set<string>>;
+  },
+): Promise<void> {
+  deps.inputWaits.trackInputWait(input.event);
+
+  if (input.event.type === 'session.created') {
+    const info = input.event.properties?.info;
+    log('[task-session-manager] session.created observed', {
+      sessionID: info?.id,
+      parentSessionID: info?.parentID,
+      managesParent: info?.parentID
+        ? deps.options.shouldManageSession(info.parentID)
+        : false,
+    });
+    if (
+      info?.id &&
+      info.parentID &&
+      deps.options.shouldManageSession(info.parentID)
+    ) {
+      deps.taskContextTracker.pendingManagedTaskIds.add(info.id);
+      // Early board registration: if the parent tool call is cancelled
+      // before tool.execute.after (e.g. foreground fallback abort), the
+      // after-hook never fires and the job is never tracked — idle then
+      // reports runningJobForSession:false and the orchestrator sees
+      // "Task cancelled" while the child is still working (#765).
+      // Peek (don't take) so tool.execute.after can still re-register.
+      //
+      // When the parent has multiple task calls in flight at once (e.g.
+      // parallel council reviewers), `info.agent` on the child session
+      // identifies which subagent started it; prefer the matching
+      // pending call so we don't attribute the child to the wrong agent.
+      const pending = deps.pendingCallTracker.peekByParentAndAgent(
+        info.parentID,
+        info.agent,
+      );
+      if (
+        pending &&
+        !pending.resumedTaskId &&
+        !deps.backgroundJobBoard.get(info.id)
+      ) {
+        const record = deps.backgroundJobBoard.registerLaunch({
+          taskID: info.id,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          description: pending.label,
+          objective: pending.label,
+        });
+        log(
+          '[task-session-manager] early board registration from session.created',
+          {
+            taskID: record.taskID,
+            alias: record.alias,
+            parentSessionID: record.parentSessionID,
+            agent: record.agent,
+          },
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.event.type === 'server.instance.disposed') {
+    const idleSessionIds = deps.idleReconciler.clearAllTimers();
+    const continuationSessionIDs = new Set([
+      ...idleSessionIds,
+      ...deps.continuationTokens.sessionTokens.keys(),
+      ...deps.continuationTokens.evaluations.keys(),
+      ...deps.continuationTokens.consumed,
+      ...deps.inputWaits.waitsByParent.keys(),
+    ]);
+    for (const sessionID of continuationSessionIDs) {
+      deps.continuationTokens.clearContinuation(sessionID);
+      deps.inputWaits.clearInputWaits(sessionID);
+    }
+    return;
+  }
+
+  if (
+    input.event.type === 'session.idle' ||
+    (input.event.type === 'session.status' &&
+      (input.event.properties as { status?: { type?: string } } | undefined)
+        ?.status?.type === 'idle')
+  ) {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const job = sessionId ? deps.backgroundJobBoard.get(sessionId) : undefined;
+    log('[task-session-manager] idle/status idle observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      terminalJobsPending: sessionId
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+        : 0,
+      runningJobForSession: job?.state === 'running' || false,
+    });
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      deps.idleReconciler.scheduleIdleReconciliation(sessionId);
+    }
+
+    // Fallback: for background child sessions that go idle without
+    // an injected completion, reconcile the board entry since the
+    // session being idle is itself the completion signal.
+    // Delayed so FG can claim the session before we mark completed.
+    if (job && sessionId && job.state === 'running') {
+      deps.idleReconciler.scheduleChildIdleReconciliation(
+        sessionId,
+        Date.now(),
+      );
+    }
+    return;
+  }
+
+  if (input.event.type === 'session.error') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    if (sessionId) {
+      deps.continuationTokens.invalidateContinuation(sessionId);
+    }
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      // Only clear injected terminal jobs for fatal errors.
+      // Rate-limit errors are recovered by ForegroundFallbackManager
+      // (abort + reprompt with fallback model); clearing the injected
+      // job state here would make the orchestrator lose track of
+      // completed background tasks and unable to dispatch follow-ups.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (!props?.error || !isFailoverError(props.error)) {
+        deps.terminalJobsInjectedByParent.delete(sessionId);
+        // Record non-retryable errors on the job board so the
+        // orchestrator sees the failure instead of a false completion.
+        const job = deps.backgroundJobBoard.get(sessionId);
+        if (job && job.state === 'running') {
+          deps.backgroundJobBoard.updateStatus({
+            taskID: sessionId,
+            state: 'error',
+            resultSummary:
+              (props?.error as { message?: string } | undefined)?.message ??
+              'Session error',
+          });
+        }
+      }
+    } else if (sessionId) {
+      // Child subagent sessions are not orchestrators, so the block
+      // above never runs for them. Without this, a failed background
+      // subagent leaves its job in `running` and the idle-reconciliation
+      // path (which has no shouldManageSession guard) marks it
+      // `completed` — a false success. A child with no fallback chain has
+      // nothing to retry into, so surface the failure on the board.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (deps.options.isFallbackInProgress?.(sessionId)) return;
+      const job = deps.backgroundJobBoard.get(sessionId);
+      if (job && job.state === 'running') {
+        deps.backgroundJobBoard.updateStatus({
+          taskID: sessionId,
+          state: 'error',
+          resultSummary:
+            (props?.error as { message?: string } | undefined)?.message ??
+            'Session error',
+        });
+      }
+    }
+
+    return;
+  }
+
+  if (input.event.type === 'session.status') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const statusType = (
+      input.event.properties as { status?: { type?: string } } | undefined
+    )?.status?.type;
+    if (sessionId) deps.continuationTokens.invalidateContinuation(sessionId);
+    if (statusType !== 'busy') {
+      return;
+    }
+    // Live busy cancels a pending child idle-reconcile — the session
+    // recovered (FG re-prompt or continued work).
+    // Note: invalidateContinuation above already cleared the parent
+    // idle-reconcile timer; clearIdleTimers handles the child timer.
+    if (sessionId) {
+      deps.idleReconciler.clearIdleTimers(sessionId);
+    }
+    const before = sessionId
+      ? deps.backgroundJobBoard.get(sessionId)
+      : undefined;
+    const updated = sessionId
+      ? deps.backgroundJobBoard.markRunningFromLiveSession(sessionId)
+      : undefined;
+    if (before?.cancellationRequested) {
+      log('[task-session-manager] busy observed after cancel request', {
+        sessionID: sessionId,
+        previousState: before.state,
+        previousTerminalState: before.terminalState,
+        terminalUnreconciled: before.terminalUnreconciled,
+        resultSummary: before.resultSummary,
+      });
+    }
+    log('[task-session-manager] busy/status busy observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      previousState: before?.state,
+      previousTerminalState: before?.terminalState,
+      previousCancellationRequested: before?.cancellationRequested ?? false,
+      previousLastLiveBusyAt: before?.lastLiveBusyAt,
+      updatedState: updated?.state,
+      updatedCancellationRequested: updated?.cancellationRequested ?? false,
+      updatedLastLiveBusyAt: updated?.lastLiveBusyAt,
+    });
+    return;
+  }
+
+  if (input.event.type !== 'session.deleted') return;
+  const sessionId =
+    input.event.properties?.info?.id || input.event.properties?.sessionID;
+  if (!sessionId) return;
+
+  deps.continuationTokens.clearContinuation(sessionId);
+  deps.inputWaits.clearInputWaits(sessionId);
+
+  log('[task-session-manager] session.deleted observed', {
+    sessionID: sessionId,
+  });
+}

+ 44 - 626
src/hooks/task-session-manager/index.ts

@@ -2,52 +2,33 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
   type BackgroundJobStore,
-  createInternalAgentTextPart,
-  deriveTaskSessionLabel,
   isInternalInitiatorPart,
-  parseTaskIdFromTaskOutput,
-  parseTaskLaunchOutput,
-  parseTaskStatusOutput,
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
-import { log } from '../../utils/logger';
-import { isFailoverError } from '../foreground-fallback/index';
 import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
   type InjectionState,
   injectBackgroundJobBoard,
-  isMissingRememberedSessionError,
   MAX_PROCESSED_INJECTED_COMPLETIONS,
   reconcileInjectedTerminalJobs,
   updateFromInjectedCompletion,
 } from './board-injection';
+import { evaluateContinuation as evaluateContinuationFn } from './continuation-evaluator';
 import { createContinuationTokenManager } from './continuation-token-manager';
+import { handleEvent } from './event-router';
 import { createIdleReconciler } from './idle-reconciliation';
 import { createInputWaitTracker } from './input-wait-tracker';
-import type { PendingTaskCall } from './pending-call-tracker';
 import { createPendingCallTracker } from './pending-call-tracker';
+import { createTaskContextTracker } from './task-context-tracker';
 import {
-  isActiveStatus,
-  normalizeLateCancelledTaskOutput,
-} from './status-utils';
-import {
-  createTaskContextTracker,
-  extractReadFiles,
-} from './task-context-tracker';
-
-interface TaskArgs {
-  description?: unknown;
-  prompt?: unknown;
-  subagent_type?: unknown;
-  task_id?: unknown;
-}
+  handleToolExecuteAfter,
+  handleToolExecuteBefore,
+} from './tool-execute-hooks';
 
 export { BACKGROUND_JOB_BOARD_METADATA_KEY } from './board-injection';
 
-const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
-
 /**
  * Delay before reconciling idle sessions.
  * Gives late injected completions time to arrive within this window.
@@ -57,9 +38,6 @@ const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
  */
 const IDLE_RECONCILE_DELAY_MS = 2_000;
 
-const CONTINUATION_NUDGE =
-  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
-
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
@@ -153,162 +131,14 @@ export function createTaskSessionManagerHook(
   const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
     .session;
 
-  evaluateContinuation = async (
-    parentSessionID: string,
-    sessionToken: symbol,
-  ): Promise<void> => {
-    const evaluationToken = Symbol(parentSessionID);
-    const activeEvaluations =
-      continuationTokens.evaluations.get(parentSessionID) ?? new Set<symbol>();
-    activeEvaluations.add(evaluationToken);
-    continuationTokens.evaluations.set(parentSessionID, activeEvaluations);
-
-    if (
-      continuationTokens.consumed.has(parentSessionID) ||
-      inputWaits.hasInputWait(parentSessionID) ||
-      !continuationTokens.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) {
-        continuationTokens.evaluations.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',
-        ) ||
-        continuationTokens.consumed.has(parentSessionID) ||
-        inputWaits.hasInputWait(parentSessionID) ||
-        !continuationTokens.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 (
-        continuationTokens.consumed.has(parentSessionID) ||
-        inputWaits.hasInputWait(parentSessionID) ||
-        !continuationTokens.isCurrentContinuation(
-          parentSessionID,
-          sessionToken,
-          evaluationToken,
-        ) ||
-        options.isFallbackInProgress?.(parentSessionID) ||
-        backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
-      ) {
-        return;
-      }
-      continuationTokens.consumed.add(parentSessionID);
-      await sessionSdk.promptAsync({
-        path: { id: parentSessionID },
-        body: {
-          agent: 'orchestrator',
-          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 = continuationTokens.evaluations.get(parentSessionID);
-      evaluations?.delete(evaluationToken);
-      if (evaluations?.size === 0) {
-        continuationTokens.evaluations.delete(parentSessionID);
-      }
-    }
-  };
+  evaluateContinuation = (parentSessionID, sessionToken) =>
+    evaluateContinuationFn(parentSessionID, sessionToken, {
+      backgroundJobBoard,
+      continuationTokens,
+      inputWaits,
+      options,
+      sessionSdk,
+    });
 
   if (options.coordinator) {
     options.coordinator.onSessionDeleted((sessionId) => {
@@ -379,227 +209,28 @@ export function createTaskSessionManagerHook(
       continuationTokens.clearContinuation(sessionID);
     },
 
-    'tool.execute.before': async (
+    'tool.execute.before': (
       input: { tool: string; sessionID?: string; callID?: string },
       output: { args?: unknown },
-    ): Promise<void> => {
-      const toolName = input.tool.toLowerCase();
-      if (toolName !== 'task') return;
-      if (!input.sessionID) return;
-      if (!options.shouldManageSession(input.sessionID)) {
-        // ponytail: no agent-identity guard here — at tool.execute.before
-        // time there's no message to inspect. Only orchestrators call `task`
-        // in standard architecture; non-orchestrator false-positives are
-        // accepted because leaf agents don't use this tool.
-        options.registerSessionAsOrchestrator?.(input.sessionID);
-        if (!options.shouldManageSession(input.sessionID)) return;
-        log('[task-session-manager] recovered stale orchestrator mapping', {
-          sessionID: input.sessionID,
-        });
-      }
-      if (!isObjectRecord(output.args)) return;
-
-      const args = output.args as TaskArgs;
-      if (
-        typeof args.subagent_type !== 'string' ||
-        args.subagent_type.trim() === ''
-      ) {
-        if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
-          delete args.task_id;
-        }
-        return;
-      }
-
-      const agentType = args.subagent_type.trim();
-
-      const label = deriveTaskSessionLabel({
-        description:
-          typeof args.description === 'string' ? args.description : undefined,
-        prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
-        agentType,
-      });
-
-      const pendingCall: PendingTaskCall = {
-        callId: pendingCallTracker.pendingCallId(input.sessionID, input.callID),
-        parentSessionId: input.sessionID,
-        agentType,
-        label,
-      };
-      pendingCallTracker.add(pendingCall);
-      log(
-        '[task-session-manager] tool.execute.before task — pending call created',
-        {
-          callId: pendingCall.callId,
-          parentSessionId: pendingCall.parentSessionId,
-          agentType: pendingCall.agentType,
-          label: pendingCall.label,
-          inputCallID: input.callID,
-          inputSessionID: input.sessionID,
-        },
-      );
-
-      if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
-        return;
-      }
-
-      const requested = args.task_id.trim();
-      const remembered =
-        backgroundJobBoard.resolveReusable(
-          input.sessionID,
-          requested,
-          agentType,
-        ) ??
-        backgroundJobBoard.resolveRecoverable(
-          input.sessionID,
-          requested,
-          agentType,
-        );
-
-      if (!remembered) {
-        const knownManagedTask = backgroundJobBoard.resolve(
-          input.sessionID,
-          requested,
-        );
-        if (knownManagedTask) {
-          delete args.task_id;
-          return;
-        }
-
-        if (RAW_SESSION_ID_PATTERN.test(requested)) {
-          pendingCall.resumedTaskId = requested;
-          pendingCallTracker.add(pendingCall);
-          return;
-        }
-        delete args.task_id;
-        return;
-      }
-
-      args.task_id = remembered.taskID;
-      taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
-      backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
-      pendingCall.resumedTaskId = remembered.taskID;
-      pendingCallTracker.add(pendingCall);
-    },
-
-    'tool.execute.after': async (
+    ): Promise<void> =>
+      handleToolExecuteBefore(input, output, {
+        shouldManageSession: options.shouldManageSession,
+        registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
+        backgroundJobBoard,
+        pendingCallTracker,
+        taskContextTracker,
+      }),
+
+    'tool.execute.after': (
       input: { tool: string; sessionID?: string; callID?: string },
       output: { output: unknown; metadata?: unknown },
-    ): Promise<void> => {
-      if (input.tool.toLowerCase() === 'read') {
-        if (input.sessionID) {
-          const canTrack =
-            taskContextTracker.pendingManagedTaskIds.has(input.sessionID) ||
-            backgroundJobBoard.taskIDs().has(input.sessionID);
-          if (canTrack) {
-            taskContextTracker.addContext(
-              input.sessionID,
-              extractReadFiles(_ctx.directory, output),
-            );
-          }
-        }
-        return;
-      }
-
-      if (input.tool.toLowerCase() !== 'task') return;
-
-      const pending = pendingCallTracker.take(input.callID, input.sessionID);
-      log('[task-session-manager] tool.execute.after task', {
-        callID: input.callID,
-        sessionID: input.sessionID,
-        hasPending: !!pending,
-        outputType: typeof output.output,
-        outputPreview:
-          typeof output.output === 'string'
-            ? output.output.slice(0, 120)
-            : undefined,
-      });
-
-      if (!pending || typeof output.output !== 'string') return;
-      const launch = parseTaskLaunchOutput(output.output);
-      if (launch && !launch.result?.match(/Timed out after \d+ms/i)) {
-        const record = backgroundJobBoard.registerLaunch({
-          taskID: launch.taskID,
-          parentSessionID: pending.parentSessionId,
-          agent: pending.agentType,
-          description: pending.label,
-          objective: pending.label,
-        });
-        log('[task-session-manager] background task launch registered', {
-          taskID: record.taskID,
-          alias: record.alias,
-          parentSessionID: record.parentSessionID,
-          agent: record.agent,
-          description: record.description,
-          state: record.state,
-        });
-        taskContextTracker.pendingManagedTaskIds.add(launch.taskID);
-        backgroundJobBoard.addContext(
-          launch.taskID,
-          taskContextTracker.contextFilesForPrompt(launch.taskID),
-        );
-        return;
-      }
-
-      normalizeLateCancelledTaskOutput(output, backgroundJobBoard);
-      const status = parseTaskStatusOutput(output.output);
-      if (status) {
-        const existing = backgroundJobBoard.get(status.taskID);
-        const record =
-          existing ??
-          backgroundJobBoard.registerLaunch({
-            taskID: status.taskID,
-            parentSessionID: pending.parentSessionId,
-            agent: pending.agentType,
-            description: pending.label,
-            objective: pending.label,
-          });
-        const updated = backgroundJobBoard.updateStatus({
-          taskID: status.taskID,
-          state: status.state,
-          timedOut: status.timedOut,
-          resultSummary: status.result,
-        });
-        log('[task-session-manager] foreground task status registered', {
-          taskID: status.taskID,
-          alias: updated?.alias ?? record.alias,
-          parentSessionID: pending.parentSessionId,
-          agent: pending.agentType,
-          state: updated?.state ?? record.state,
-        });
-        if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
-          backgroundJobBoard.drop(pending.resumedTaskId);
-        }
-        taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
-        backgroundJobBoard.addContext(
-          status.taskID,
-          taskContextTracker.contextFilesForPrompt(status.taskID),
-        );
-        taskContextTracker.prune(backgroundJobBoard);
-        return;
-      }
-
-      const taskId = parseTaskIdFromTaskOutput(output.output);
-      if (!taskId) {
-        if (
-          pending.resumedTaskId &&
-          isMissingRememberedSessionError(output.output)
-        ) {
-          backgroundJobBoard.drop(pending.resumedTaskId);
-        }
-        return;
-      }
-
-      if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
-        backgroundJobBoard.drop(pending.resumedTaskId);
-      }
-
-      taskContextTracker.pendingManagedTaskIds.delete(taskId);
-      backgroundJobBoard.addContext(
-        taskId,
-        taskContextTracker.contextFilesForPrompt(taskId),
-      );
-      taskContextTracker.prune(backgroundJobBoard);
-    },
+    ): Promise<void> =>
+      handleToolExecuteAfter(input, output, {
+        directory: _ctx.directory,
+        backgroundJobBoard,
+        pendingCallTracker,
+        taskContextTracker,
+      }),
 
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
@@ -641,7 +272,7 @@ export function createTaskSessionManagerHook(
       output: { messages?: unknown },
     ) => injectBackgroundJobBoard(injectionState, input, output),
 
-    event: async (input: {
+    event: (input: {
       event: {
         type: string;
         properties?: {
@@ -653,229 +284,16 @@ export function createTaskSessionManagerHook(
           error?: { name?: string };
         };
       };
-    }): Promise<void> => {
-      inputWaits.trackInputWait(input.event);
-
-      if (input.event.type === 'session.created') {
-        const info = input.event.properties?.info;
-        log('[task-session-manager] session.created observed', {
-          sessionID: info?.id,
-          parentSessionID: info?.parentID,
-          managesParent: info?.parentID
-            ? options.shouldManageSession(info.parentID)
-            : false,
-        });
-        if (
-          info?.id &&
-          info.parentID &&
-          options.shouldManageSession(info.parentID)
-        ) {
-          taskContextTracker.pendingManagedTaskIds.add(info.id);
-          // Early board registration: if the parent tool call is cancelled
-          // before tool.execute.after (e.g. foreground fallback abort), the
-          // after-hook never fires and the job is never tracked — idle then
-          // reports runningJobForSession:false and the orchestrator sees
-          // "Task cancelled" while the child is still working (#765).
-          // Peek (don't take) so tool.execute.after can still re-register.
-          //
-          // When the parent has multiple task calls in flight at once (e.g.
-          // parallel council reviewers), `info.agent` on the child session
-          // identifies which subagent started it; prefer the matching
-          // pending call so we don't attribute the child to the wrong agent.
-          const pending = pendingCallTracker.peekByParentAndAgent(
-            info.parentID,
-            info.agent,
-          );
-          if (
-            pending &&
-            !pending.resumedTaskId &&
-            !backgroundJobBoard.get(info.id)
-          ) {
-            const record = backgroundJobBoard.registerLaunch({
-              taskID: info.id,
-              parentSessionID: pending.parentSessionId,
-              agent: pending.agentType,
-              description: pending.label,
-              objective: pending.label,
-            });
-            log(
-              '[task-session-manager] early board registration from session.created',
-              {
-                taskID: record.taskID,
-                alias: record.alias,
-                parentSessionID: record.parentSessionID,
-                agent: record.agent,
-              },
-            );
-          }
-        }
-        return;
-      }
-
-      if (input.event.type === 'server.instance.disposed') {
-        const idleSessionIds = idleReconciler.clearAllTimers();
-        const continuationSessionIDs = new Set([
-          ...idleSessionIds,
-          ...continuationTokens.sessionTokens.keys(),
-          ...continuationTokens.evaluations.keys(),
-          ...continuationTokens.consumed,
-          ...inputWaits.waitsByParent.keys(),
-        ]);
-        for (const sessionID of continuationSessionIDs) {
-          continuationTokens.clearContinuation(sessionID);
-          inputWaits.clearInputWaits(sessionID);
-        }
-        return;
-      }
-
-      if (
-        input.event.type === 'session.idle' ||
-        (input.event.type === 'session.status' &&
-          (input.event.properties as { status?: { type?: string } } | undefined)
-            ?.status?.type === 'idle')
-      ) {
-        const sessionId =
-          input.event.properties?.info?.id || input.event.properties?.sessionID;
-        const job = sessionId ? backgroundJobBoard.get(sessionId) : undefined;
-        log('[task-session-manager] idle/status idle observed', {
-          sessionID: sessionId,
-          managesSession: sessionId
-            ? options.shouldManageSession(sessionId)
-            : false,
-          terminalJobsPending: sessionId
-            ? (terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
-            : 0,
-          runningJobForSession: job?.state === 'running' || false,
-        });
-        if (sessionId && options.shouldManageSession(sessionId)) {
-          idleReconciler.scheduleIdleReconciliation(sessionId);
-        }
-
-        // Fallback: for background child sessions that go idle without
-        // an injected completion, reconcile the board entry since the
-        // session being idle is itself the completion signal.
-        // Delayed so FG can claim the session before we mark completed.
-        if (job && sessionId && job.state === 'running') {
-          idleReconciler.scheduleChildIdleReconciliation(sessionId, Date.now());
-        }
-        return;
-      }
-
-      if (input.event.type === 'session.error') {
-        const sessionId =
-          input.event.properties?.info?.id || input.event.properties?.sessionID;
-        if (sessionId) {
-          continuationTokens.invalidateContinuation(sessionId);
-        }
-        if (sessionId && options.shouldManageSession(sessionId)) {
-          // Only clear injected terminal jobs for fatal errors.
-          // Rate-limit errors are recovered by ForegroundFallbackManager
-          // (abort + reprompt with fallback model); clearing the injected
-          // job state here would make the orchestrator lose track of
-          // completed background tasks and unable to dispatch follow-ups.
-          const props = input.event.properties as
-            | { error?: unknown }
-            | undefined;
-          if (!props?.error || !isFailoverError(props.error)) {
-            terminalJobsInjectedByParent.delete(sessionId);
-            // Record non-retryable errors on the job board so the
-            // orchestrator sees the failure instead of a false completion.
-            const job = backgroundJobBoard.get(sessionId);
-            if (job && job.state === 'running') {
-              backgroundJobBoard.updateStatus({
-                taskID: sessionId,
-                state: 'error',
-                resultSummary:
-                  (props?.error as { message?: string } | undefined)?.message ??
-                  'Session error',
-              });
-            }
-          }
-        } else if (sessionId) {
-          // Child subagent sessions are not orchestrators, so the block
-          // above never runs for them. Without this, a failed background
-          // subagent leaves its job in `running` and the idle-reconciliation
-          // path (which has no shouldManageSession guard) marks it
-          // `completed` — a false success. A child with no fallback chain has
-          // nothing to retry into, so surface the failure on the board.
-          const props = input.event.properties as
-            | { error?: unknown }
-            | undefined;
-          if (options.isFallbackInProgress?.(sessionId)) return;
-          const job = backgroundJobBoard.get(sessionId);
-          if (job && job.state === 'running') {
-            backgroundJobBoard.updateStatus({
-              taskID: sessionId,
-              state: 'error',
-              resultSummary:
-                (props?.error as { message?: string } | undefined)?.message ??
-                'Session error',
-            });
-          }
-        }
-
-        return;
-      }
-
-      if (input.event.type === 'session.status') {
-        const sessionId =
-          input.event.properties?.info?.id || input.event.properties?.sessionID;
-        const statusType = (
-          input.event.properties as { status?: { type?: string } } | undefined
-        )?.status?.type;
-        if (sessionId) continuationTokens.invalidateContinuation(sessionId);
-        if (statusType !== 'busy') {
-          return;
-        }
-        // Live busy cancels a pending child idle-reconcile — the session
-        // recovered (FG re-prompt or continued work).
-        // Note: invalidateContinuation above already cleared the parent
-        // idle-reconcile timer; clearIdleTimers handles the child timer.
-        if (sessionId) {
-          idleReconciler.clearIdleTimers(sessionId);
-        }
-        const before = sessionId
-          ? backgroundJobBoard.get(sessionId)
-          : undefined;
-        const updated = sessionId
-          ? backgroundJobBoard.markRunningFromLiveSession(sessionId)
-          : undefined;
-        if (before?.cancellationRequested) {
-          log('[task-session-manager] busy observed after cancel request', {
-            sessionID: sessionId,
-            previousState: before.state,
-            previousTerminalState: before.terminalState,
-            terminalUnreconciled: before.terminalUnreconciled,
-            resultSummary: before.resultSummary,
-          });
-        }
-        log('[task-session-manager] busy/status busy observed', {
-          sessionID: sessionId,
-          managesSession: sessionId
-            ? options.shouldManageSession(sessionId)
-            : false,
-          previousState: before?.state,
-          previousTerminalState: before?.terminalState,
-          previousCancellationRequested: before?.cancellationRequested ?? false,
-          previousLastLiveBusyAt: before?.lastLiveBusyAt,
-          updatedState: updated?.state,
-          updatedCancellationRequested: updated?.cancellationRequested ?? false,
-          updatedLastLiveBusyAt: updated?.lastLiveBusyAt,
-        });
-        return;
-      }
-
-      if (input.event.type !== 'session.deleted') return;
-      const sessionId =
-        input.event.properties?.info?.id || input.event.properties?.sessionID;
-      if (!sessionId) return;
-
-      continuationTokens.clearContinuation(sessionId);
-      inputWaits.clearInputWaits(sessionId);
-
-      log('[task-session-manager] session.deleted observed', {
-        sessionID: sessionId,
-      });
-    },
+    }): Promise<void> =>
+      handleEvent(input, {
+        inputWaits,
+        continuationTokens,
+        options,
+        idleReconciler,
+        backgroundJobBoard,
+        pendingCallTracker,
+        taskContextTracker,
+        terminalJobsInjectedByParent,
+      }),
   };
 }

+ 277 - 0
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -0,0 +1,277 @@
+/**
+ * Tool execute hooks for task session manager.
+ *
+ * Handles `tool.execute.before` (task tool: pending call creation,
+ * reusable/recoverable task_id resolution) and `tool.execute.after`
+ * (read context tracking, task launch registration/update from output).
+ */
+import type { BackgroundJobStore, ContextFile } from '../../utils';
+import {
+  deriveTaskSessionLabel,
+  parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
+} from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import { isMissingRememberedSessionError } from './board-injection';
+import type { PendingTaskCall } from './pending-call-tracker';
+import { normalizeLateCancelledTaskOutput } from './status-utils';
+import { extractReadFiles } from './task-context-tracker';
+
+const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
+
+interface TaskArgs {
+  description?: unknown;
+  prompt?: unknown;
+  subagent_type?: unknown;
+  task_id?: unknown;
+}
+
+export async function handleToolExecuteBefore(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { args?: unknown },
+  deps: {
+    shouldManageSession: (sessionID: string) => boolean;
+    registerSessionAsOrchestrator?: (sessionID: string) => void;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      add(call: PendingTaskCall): void;
+      pendingCallId(sessionID?: string, callID?: string): string;
+    };
+    taskContextTracker: { pendingManagedTaskIds: Set<string> };
+  },
+): Promise<void> {
+  const toolName = input.tool.toLowerCase();
+  if (toolName !== 'task') return;
+  if (!input.sessionID) return;
+  if (!deps.shouldManageSession(input.sessionID)) {
+    // ponytail: no agent-identity guard here — at tool.execute.before
+    // time there's no message to inspect. Only orchestrators call `task`
+    // in standard architecture; non-orchestrator false-positives are
+    // accepted because leaf agents don't use this tool.
+    deps.registerSessionAsOrchestrator?.(input.sessionID);
+    if (!deps.shouldManageSession(input.sessionID)) return;
+    log('[task-session-manager] recovered stale orchestrator mapping', {
+      sessionID: input.sessionID,
+    });
+  }
+  if (!isObjectRecord(output.args)) return;
+
+  const args = output.args as TaskArgs;
+  if (
+    typeof args.subagent_type !== 'string' ||
+    args.subagent_type.trim() === ''
+  ) {
+    if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+      delete args.task_id;
+    }
+    return;
+  }
+
+  const agentType = args.subagent_type.trim();
+
+  const label = deriveTaskSessionLabel({
+    description:
+      typeof args.description === 'string' ? args.description : undefined,
+    prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
+    agentType,
+  });
+
+  const pendingCall: PendingTaskCall = {
+    callId: deps.pendingCallTracker.pendingCallId(
+      input.sessionID,
+      input.callID,
+    ),
+    parentSessionId: input.sessionID,
+    agentType,
+    label,
+  };
+  deps.pendingCallTracker.add(pendingCall);
+  log(
+    '[task-session-manager] tool.execute.before task — pending call created',
+    {
+      callId: pendingCall.callId,
+      parentSessionId: pendingCall.parentSessionId,
+      agentType: pendingCall.agentType,
+      label: pendingCall.label,
+      inputCallID: input.callID,
+      inputSessionID: input.sessionID,
+    },
+  );
+
+  if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
+    return;
+  }
+
+  const requested = args.task_id.trim();
+  const remembered =
+    deps.backgroundJobBoard.resolveReusable(
+      input.sessionID,
+      requested,
+      agentType,
+    ) ??
+    deps.backgroundJobBoard.resolveRecoverable(
+      input.sessionID,
+      requested,
+      agentType,
+    );
+
+  if (!remembered) {
+    const knownManagedTask = deps.backgroundJobBoard.resolve(
+      input.sessionID,
+      requested,
+    );
+    if (knownManagedTask) {
+      delete args.task_id;
+      return;
+    }
+
+    if (RAW_SESSION_ID_PATTERN.test(requested)) {
+      pendingCall.resumedTaskId = requested;
+      deps.pendingCallTracker.add(pendingCall);
+      return;
+    }
+    delete args.task_id;
+    return;
+  }
+
+  args.task_id = remembered.taskID;
+  deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
+  deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+  pendingCall.resumedTaskId = remembered.taskID;
+  deps.pendingCallTracker.add(pendingCall);
+}
+
+export async function handleToolExecuteAfter(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { output: unknown; metadata?: unknown },
+  deps: {
+    directory: string;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      addContext(taskId: string, files: ContextFile[]): void;
+      contextFilesForPrompt(taskId: string): ContextFile[];
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+  },
+): Promise<void> {
+  if (input.tool.toLowerCase() === 'read') {
+    if (input.sessionID) {
+      const canTrack =
+        deps.taskContextTracker.pendingManagedTaskIds.has(input.sessionID) ||
+        deps.backgroundJobBoard.taskIDs().has(input.sessionID);
+      if (canTrack) {
+        deps.taskContextTracker.addContext(
+          input.sessionID,
+          extractReadFiles(deps.directory, output),
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.tool.toLowerCase() !== 'task') return;
+
+  const pending = deps.pendingCallTracker.take(input.callID, input.sessionID);
+  log('[task-session-manager] tool.execute.after task', {
+    callID: input.callID,
+    sessionID: input.sessionID,
+    hasPending: !!pending,
+    outputType: typeof output.output,
+    outputPreview:
+      typeof output.output === 'string'
+        ? output.output.slice(0, 120)
+        : undefined,
+  });
+
+  if (!pending || typeof output.output !== 'string') return;
+  const launch = parseTaskLaunchOutput(output.output);
+  if (launch && !launch.result?.match(/Timed out after \d+ms/i)) {
+    const record = deps.backgroundJobBoard.registerLaunch({
+      taskID: launch.taskID,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      description: pending.label,
+      objective: pending.label,
+    });
+    log('[task-session-manager] background task launch registered', {
+      taskID: record.taskID,
+      alias: record.alias,
+      parentSessionID: record.parentSessionID,
+      agent: record.agent,
+      description: record.description,
+      state: record.state,
+    });
+    deps.taskContextTracker.pendingManagedTaskIds.add(launch.taskID);
+    deps.backgroundJobBoard.addContext(
+      launch.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(launch.taskID),
+    );
+    return;
+  }
+
+  normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
+  const status = parseTaskStatusOutput(output.output);
+  if (status) {
+    const existing = deps.backgroundJobBoard.get(status.taskID);
+    const record =
+      existing ??
+      deps.backgroundJobBoard.registerLaunch({
+        taskID: status.taskID,
+        parentSessionID: pending.parentSessionId,
+        agent: pending.agentType,
+        description: pending.label,
+        objective: pending.label,
+      });
+    const updated = deps.backgroundJobBoard.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+    log('[task-session-manager] foreground task status registered', {
+      taskID: status.taskID,
+      alias: updated?.alias ?? record.alias,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      state: updated?.state ?? record.state,
+    });
+    if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    deps.taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
+    deps.backgroundJobBoard.addContext(
+      status.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(status.taskID),
+    );
+    deps.taskContextTracker.prune(deps.backgroundJobBoard);
+    return;
+  }
+
+  const taskId = parseTaskIdFromTaskOutput(output.output);
+  if (!taskId) {
+    if (
+      pending.resumedTaskId &&
+      isMissingRememberedSessionError(output.output)
+    ) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    return;
+  }
+
+  if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
+    deps.backgroundJobBoard.drop(pending.resumedTaskId);
+  }
+
+  deps.taskContextTracker.pendingManagedTaskIds.delete(taskId);
+  deps.backgroundJobBoard.addContext(
+    taskId,
+    deps.taskContextTracker.contextFilesForPrompt(taskId),
+  );
+  deps.taskContextTracker.prune(deps.backgroundJobBoard);
+}