Studio Notebook

Claude Code Atlas

Remote Agent Task Lifecycle

Follow the remote agent lifecycle from eligibility to completion.

Why this matters

Remote agent tasks do not sit on top of a local process handle, so the app has to keep a durable breadcrumb trail and poll the remote session to know what happened. Local tasks can often ask the runtime directly whether the work is still alive; remote tasks have to reconstruct that story from metadata, session state, and the event stream.

Metadata is the resume key, polling is the live status feed, and ultraplan extraction is the step that turns the session log into the actual plan the user needs.

Start with the remote task state

Before the lifecycle code, it helps to look at the shape the app is trying to protect. This is the durable record for a remote session.

export type RemoteAgentTaskState = TaskStateBase & {
  type: 'remote_agent';
  remoteTaskType: RemoteTaskType;
  /** Task-specific metadata (PR number, repo, etc.). */
  remoteTaskMetadata?: RemoteTaskMetadata;
  sessionId: string; // Original session ID for API calls
  command: string;
  title: string;
  todoList: TodoList;
  log: SDKMessage[];
  /**
   * Long-running agent that will not be marked as complete after the first `result`.
   */
  isLongRunning?: boolean;
  /**
   * When the local poller started watching this task (at spawn or on restore).
   * Review timeout clocks from here so a restore doesn't immediately time out
   * a task spawned >30min ago.
   */
  pollStartedAt: number;
  /** True when this task was created by a teleported /ultrareview command. */
  isRemoteReview?: boolean;
  /** Parsed from the orchestrator's <remote-review-progress> heartbeat echoes. */
  reviewProgress?: {
    stage?: 'finding' | 'verifying' | 'synthesizing';
    bugsFound: number;
    bugsVerified: number;
    bugsRefuted: number;
  };
  isUltraplan?: boolean;
  /**
   * Scanner-derived pill state. Undefined = running. `needs_input` when the
   * remote asked a clarifying question and is idle; `plan_ready` when
   * ExitPlanMode is awaiting browser approval. Surfaced in the pill badge
   * and detail dialog status line.
   */
  ultraplanPhase?: Exclude<UltraplanPhase, 'running'>;
};
const REMOTE_TASK_TYPES = ['remote-agent', 'ultraplan', 'ultrareview', 'autofix-pr', 'background-pr'] as const;
export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number];

remoteTaskType decides which remote behavior gets layered on top of the session, and remoteTaskMetadata carries the extra facts that local tasks do not need to remember.

Eligibility and human-readable errors

Remote tasks cannot start unless the environment is actually able to host one. The source splits that into a machine-checkable eligibility function and a small formatter that turns the failure into something the user can act on.

export async function checkRemoteAgentEligibility({
  skipBundle = false
}: {
  skipBundle?: boolean;
} = {}): Promise<RemoteAgentPreconditionResult> {
  const errors = await checkBackgroundRemoteSessionEligibility({
    skipBundle
  });
  if (errors.length > 0) {
    return {
      eligible: false,
      errors
    };
  }
  return {
    eligible: true
  };
}

export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string {
  switch (error.type) {
    case 'not_logged_in':
      return 'Please run /login and sign in with your Claude.ai account (not Console).';
    case 'no_remote_environment':
      return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup';
    case 'not_in_git_repo':
      return 'Background tasks require a git repository. Initialize git or run from a git repository.';
    case 'no_git_remote':
      return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.';
    case 'github_app_not_installed':
      return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new';
    case 'policy_blocked':
      return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them.";
  }
}

This is the gate that keeps remote-agent work from starting in a context where there is nowhere to run it or no way to reconnect to it later.

Completion checkers and session metadata

Some remote task types need their own completion rule. The checker registry lets the poller ask a task-type-specific question on every tick without hardcoding every remote variant into one giant switch.

export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checker: RemoteTaskCompletionChecker): void {
  completionCheckers.set(remoteTaskType, checker);
}

async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise<void> {
  try {
    await writeRemoteAgentMetadata(meta.taskId, meta);
  } catch (e) {
    logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`);
  }
}

writeRemoteAgentMetadata is what makes --resume work for remote tasks. It stores enough identity to reconnect to the session later, even if the original foreground process is gone.

Polling, logs, and ultraplan extraction

The remote session is not local state; it is something the app has to ask for repeatedly. Polling pulls in new events, appends them to the task log, and then lets the completion logic decide whether the task is done, still running, or needs to be restored after a restart.

const response = await pollRemoteSessionEvents(task.sessionId, lastEventId);
lastEventId = response.lastEventId;
const logGrew = response.newEvents.length > 0;
if (logGrew) {
  accumulatedLog = [...accumulatedLog, ...response.newEvents];
  const deltaText = response.newEvents.map(msg => {
    if (msg.type === 'assistant') {
      return msg.message.content.filter(block => block.type === 'text').map(block => 'text' in block ? block.text : '').join('\n');
    }
    return jsonStringify(msg);
  }).join('\n');
  if (deltaText) {
    appendTaskOutput(taskId, deltaText + '\n');
  }
}
if (response.sessionStatus === 'archived') {
  updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t => t.status === 'running' ? {
    ...t,
    status: 'completed',
    endTime: Date.now()
  } : t);
  enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId);
  void evictTaskOutput(taskId);
  void removeRemoteAgentMetadata(taskId);
  return;
}
const checker = completionCheckers.get(task.remoteTaskType);
if (checker) {
  const completionResult = await checker(task.remoteTaskMetadata);
  if (completionResult !== null) {
    updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t => t.status === 'running' ? {
      ...t,
      status: 'completed',
      endTime: Date.now()
    } : t);
    enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId);
    void evictTaskOutput(taskId);
    void removeRemoteAgentMetadata(taskId);
    return;
  }
}

For ultraplan, the important result is not just that the session ended. It is the plan text hidden in the log. That is why the chapter needs the extraction helper too.

/**
 * Extract the plan content from the remote session log.
 * Searches all assistant messages for <ultraplan>...</ultraplan> tags.
 */
export function extractPlanFromLog(log: SDKMessage[]): string | null {
  // Walk backwards through assistant messages to find <ultraplan> content
  for (let i = log.length - 1; i >= 0; i--) {
    const msg = log[i];
    if (msg?.type !== 'assistant') continue;
    const fullText = extractTextContent(msg.message.content, '\n');
    const plan = extractTag(fullText, ULTRAPLAN_TAG);
    if (plan?.trim()) return plan.trim();
  }
  return null;
}

That flow is the full remote lifecycle in one sentence: eligibility decides whether the task may start, metadata makes the task restorable, polling keeps the UI current, and plan extraction turns a remote run into something the user can actually use.