vercel/ai · error

claude-code session is already stopped; cannot detach.

Error message

claude-code session ${sessionId} is already stopped; cannot detach.

What it means

The claude-code harness session exposes a doDetach operation on each created session. The library tracks a `stopped` flag per session and throws this error when detach is called on a session that has already been stopped (by doStop or doSuspendTurn, or after a prior detach tore things down), because detaching a stopped session has no channel left to suspend and would corrupt state. It is a guard against double teardown of the same session.

Solutions

  1. Check the session's stopped state before calling doDetach and skip the call if it is already stopped
  2. Wrap doDetach in try-catch and treat this error as a no-op idempotent outcome rather than a failure
  3. Restructure code so teardown happens in exactly one place (single owner / AbortSignal-driven cleanup) instead of multiple call sites
  4. If you need to keep working after a stop, create a new session via createClaudeCode instead of reusing the stopped one

Example fix

// before
await session.doDetach();
await session.doDetach(); // throws: already stopped; cannot detach

// after
let detached = false;
async function detachOnce() {
  if (detached) return;
  detached = true;
  await session.doDetach();
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Track lifecycle locally before calling
if (sessionStopped.has(sessionId)) {
  throw new Error(`skip detach: session ${sessionId} already stopped`);
}

Type guard

function canDetach(session: { stopped: boolean } | unknown): session is { stopped: false } {
  return typeof session === 'object' && session !== null &&
    (session as { stopped?: boolean }).stopped !== true;
}

Try / catch

try {
  await session.doDetach();
} catch (err) {
  if (err instanceof Error && err.message.includes('already stopped; cannot detach')) {
    return; // idempotent: already torn down
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling session.doDetach() after the same session was already stopped: a second doDetach, a doDetach after doStop, or a doDetach after doSuspendTurn (which also sets stopped = true).

Common situations: Race conditions where two parts of an app both call detach (e.g. an abort handler plus a cleanup path in a finally block); calling detach on a cached session object that a previous request already stopped; resuming work with an old session handle that was torn down earlier.

Related errors


AI-assisted analysis of vercel/ai@69428b1f8b (2026-08-30). Data as JSON: /api/errors/87936606e8aa20d9. Report an issue: GitHub.

Appendix: source

Thrown at packages/harness-claude-code/src/claude-code-harness.ts:1895

      return control;
    },
    doCompact: async (customInstructions?: string) => {
      /*
       * Claude Code has no SDK/control method for compaction — the supported
       * trigger is the `/compact` slash command submitted as user input. Ride
       * the existing user-message rail; the bridge feeds it into the streaming
       * query input and Claude's native compaction handles the rest, emitting a
       * `compact_boundary` + `PostCompact` we observe as a `compaction` event.
       */
      const text =
        customInstructions && customInstructions.trim()
          ? `/compact ${customInstructions.trim()}`
          : '/compact';
      channel.send({ type: 'user-message', text });
    },
    doDetach: async () => {
      if (stopped) {
        throw new Error(
          `claude-code session ${sessionId} is already stopped; cannot detach.`,
        );
      }
      stopped = true;
      const lastSeenEventId = await channel.suspend();
      const payload: HarnessV1ResumeSessionState = {
        type: 'resume-session',
        harnessId: 'claude-code',
        specificationVersion: 'harness-v1',
        data: {
          ...(sandboxCredentialEnvironment == null
            ? {}
            : { sandboxCredentialEnvironment }),
          bridge: {
            port: bridgePort,
            token: bridgeToken,
            lastSeenEventId,
            ...(sandboxId == null ? {} : { sandboxId }),

View on GitHub (pinned to 69428b1f8b)