A

Autonomous Agents Agentic Workflow

Resume Stopped Job Session — Autonomous Agents Agentic Workflow

Resume a stopped job step's session on this agent from its session bundle: verify it, restore the transcript and the checkout, then continue the job

Available free v1.39.0 Browser
$ sidebutton install agents
Download ZIP
ops

The receiving half of session handoff (DEV-50). When an operator resumes a stopped job step on another agent — or on the same one, under another agent app — the portal closes the source step, stores the source session's bundle beside its transcript, and dispatches this workflow as a new job. The agent pulls that bundle with its own token, verifies it before anything is extracted (the size ceiling, every digest it offers, a path allow-list scoped to the source session, regular files only), restores the transcript and its sub-agent, tool-result and file-history companions under ~/.claude, and puts each checkout back at the recorded branch and head with the previous agent's uncommitted changes applied.

Claude Code then resumes the transcript as a fork under the new step's own session id, with a short continuation prompt that says where the session came from, what was restored and what to re-verify, so the work goes on with its whole context instead of starting over. The continuation reports like any job step, and its ticket comment is gated with the source workflow's verdict vocabulary.

Steps

  1. 1.
    Open a terminal
    title
    Agent: Resume Stopped Job Session
    cwd
    {{entry_path}}
    terminal.open
  2. 2.
    Run a terminal command
    cmd
    |
    terminal.run

Workflow definition

schema_version: 1
# Id is a CONTRACT: the portal's continueJob workflow (the-assistant, DEV-53 · SH-3) enqueues exactly this
# string (CONTINUE_SESSION_WORKFLOW) and seeds its `continue-session` pipeline from this file — it answers
# 409 workflow_not_seeded until the pack has synced. Treat it as frozen.
id: agent_continue_session
title: "Resume Stopped Job Session"
description: "Resume a stopped job step's session on this agent from its session bundle: verify it, restore the transcript and the checkout, then continue the job"
overview: |
  The receiving half of session handoff (DEV-50). When an operator resumes a stopped job step on another agent — or on the same one, under another agent app — the portal closes the source step, stores the source session's bundle beside its transcript, and dispatches this workflow as a new job. The agent pulls that bundle with its own token, verifies it before anything is extracted (the size ceiling, every digest it offers, a path allow-list scoped to the source session, regular files only), restores the transcript and its sub-agent, tool-result and file-history companions under ~/.claude, and puts each checkout back at the recorded branch and head with the previous agent's uncommitted changes applied.

  Claude Code then resumes the transcript as a fork under the new step's own session id, with a short continuation prompt that says where the session came from, what was restored and what to re-verify, so the work goes on with its whole context instead of starting over. The continuation reports like any job step, and its ticket comment is gated with the source workflow's verdict vocabulary.

category:
  level: pipeline
  domain: engineering

metadata:
  agent: true
  role: ops
  # No `verdicts`, deliberately: the portal gates a continuation with the SOURCE workflow's vocabulary
  # (source_workflow_id), and the enqueue passes the source row's role explicitly. The plan's sketch had
  # `role: any` / `verdicts: inherit`, but neither works as a literal — a role is checked against the
  # agent's roles at dispatch, and `inherit` sanitises to "declared nothing" — so this declares the
  # universal `ops` role and no vocabulary of its own.

# Every param the portal sends is declared here: the agent's interpolation leaves an undeclared {{key}}
# literal. continueJob (the-assistant website/temporal/src/workflows/continueJob.ts) resolves the prompt
# placeholders at enqueue; the fleet claim adds agentic_app and executePipeline adds entry_path.
params:
  agentic_app:
    type: string
    default: "cc"
    description: "Agent-app slug selecting the per-run env file ~/.agent-env.d/<slug> (AAP-C); may differ from the source step's app — the transcript is provider-neutral"
  entry_path:
    type: string
    default: "~/workspace"
    description: "Working directory for the agent; the checkouts named by the bundle are restored under it"
  ticket_url:
    type: string
    default: ""
    description: "The source dispatch's ticket — a Jira / Linear / Notion issue, or a SideButton portal issue (`/api/issues/<key>`)"
  hint:
    type: string
    default: ""
    description: "The operator's hint for the continuation (at most 800 characters)"
  source_job_id:
    type: string
    default: ""
    description: "The job whose step is resumed; the bundle is GET /api/jobs/<source_job_id>/session-bundle?step=<source_step_index>"
  source_step_index:
    type: string
    default: ""
    description: "The resumed step's index in that job"
  source_session_id:
    type: string
    default: ""
    description: "The resumed step's Claude session id — the bundle's allow-list is scoped to it"
  source_workflow_id:
    type: string
    default: ""
    description: "The source step's workflow (e.g. agent_se_review_merge) — its verdict vocabulary gates this continuation"
  reason:
    type: string
    default: "operator"
    description: "Why the session moved: operator | provider_quota | agent_offline | window_closed | restart"
  stale_transcript:
    type: string
    default: "false"
    description: "'true' when the move fell back to the transcript the portal held (a snapshot bundle: the main transcript only)"
  source_agent:
    type: string
    default: ""
    description: "Prompt placeholder: the agent the session ran on"
  moved_at:
    type: string
    default: ""
    description: "Prompt placeholder: when the session moved (UTC)"
  branch:
    type: string
    default: ""
    description: "Prompt placeholder: the export's first repo branch; empty on the stale path"
  head:
    type: string
    default: ""
    description: "Prompt placeholder: that repo's head, 12 characters; empty on the stale path"
  delta_note:
    type: string
    default: ""
    description: "Prompt placeholder: what happened to the uncommitted changes, as the portal knew it (the agent's own apply result wins)"
  stale_note:
    type: string
    default: ""
    description: "Prompt placeholder: empty after a live export; otherwise why nothing was carried over and how old the transcript is"

steps:
  - type: terminal.open
    title: "Agent: Resume Stopped Job Session"
    cwd: "{{entry_path}}"
  - type: terminal.run
    cmd: |
      source ~/.agent-env
      # AAP-C (SCRUM-1506) + AAP-17 (SCRUM-1653): clear EVERY provider var an agent-app can deliver so
      # none hijacks/poisons a subscription run. A stray global ANTHROPIC_MODEL / ANTHROPIC_SMALL_FAST_MODEL
      # needs no CLAUDE_CODE_USE_* flag, so the old ${!CLAUDE_CODE_USE_@} glob never caught it — it survived
      # into the run and 404-ed aux/small-fast calls against api.anthropic.com. This explicit list mirrors
      # AGENT_APP_ENV_KEYS 1:1 (the-assistant website/src/lib/cloud/agent-app-env.ts — the single source of
      # truth; a parity test in each repo guards the two from drifting). Explicit over a glob: the union has
      # non-ANTHROPIC_ members (AWS_REGION, AWS_PROFILE, CLOUD_ML_REGION, CLAUDE_CODE_MAX_OUTPUT_TOKENS) and
      # a ${!AWS_@} glob would over-clear unrelated creds. Then source the per-run app env by slug when it
      # exists; no file => subscription/default. base/19-secrets stages ~/.agent-env.d/<slug>.
      unset \
        ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN CCR_CONFIG_B64 \
        CLAUDE_CODE_USE_BEDROCK AWS_REGION AWS_PROFILE ANTHROPIC_MODEL \
        ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION ANTHROPIC_SMALL_FAST_MODEL CLAUDE_CODE_MAX_OUTPUT_TOKENS \
        CLAUDE_CODE_USE_VERTEX CLOUD_ML_REGION ANTHROPIC_VERTEX_PROJECT_ID ANTHROPIC_VERTEX_BASE_URL \
        CLAUDE_CODE_USE_FOUNDRY ANTHROPIC_FOUNDRY_RESOURCE ANTHROPIC_FOUNDRY_BASE_URL \
        ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL
      if [ -f "$HOME/.agent-env.d/{{agentic_app}}" ]; then
        source "$HOME/.agent-env.d/{{agentic_app}}"
      fi
      # SESSION HANDOFF (DEV-51 · SH-1; plan PLAN-job-continue-on-another-agent §4.5–§4.7, bundle §4.3).
      # Fetch the source step's bundle with this agent's own token, verify ALL of it before a single file is
      # written, restore the session files (under ~/.claude/) and the checkouts, then resume the transcript as a fork. Every step prints
      # one `handoff:` line. A failure prints why, launches nothing and holds the window open so the reason
      # can be read (SB_HANDOFF_FAIL_HOLD_SEC, default 300 s).
      # EDITING RULE: the daemon injects `--session-id <new uuid>` at the FIRST literal occurrence of the CLI's
      # lowercase name followed by a space in this block — after interpolation — so no line above the launch
      # may contain it (comments and messages included), and every free-text param is read BELOW the launch
      # token: a hint that names the CLI would otherwise take the injection and the fork would run under an
      # id no hook recognises. The params read up here are validated to digits / a UUID before any use.
      sb_say() { printf 'handoff: %s\n' "$*"; }
      sb_fail() {
        printf '\nhandoff: FAILED — %s\n' "$*"
        printf 'handoff: nothing was resumed. This window stays open %ss so the reason can be read.\n' "${SB_HANDOFF_FAIL_HOLD_SEC:-300}"
        sleep "${SB_HANDOFF_FAIL_HOLD_SEC:-300}" 2>/dev/null
        exit 1
      }
      { read -r SB_SRC_JOB; read -r SB_SRC_STEP; read -r SB_SRC_SID; read -r SB_STALE; } <<'SB_HANDOFF_PARAMS'
      {{source_job_id}}
      {{source_step_index}}
      {{source_session_id}}
      {{stale_transcript}}
      SB_HANDOFF_PARAMS
      case "$SB_SRC_JOB" in ''|*[!0-9]*) sb_fail "source_job_id '$SB_SRC_JOB' is not a job id" ;; esac
      case "$SB_SRC_STEP" in ''|*[!0-9]*) sb_fail "source_step_index '$SB_SRC_STEP' is not a step index" ;; esac
      [[ "$SB_SRC_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] \
        || sb_fail "source_session_id '$SB_SRC_SID' is not a session UUID"
      case "$SB_STALE" in true|false|'') ;; *) sb_fail "stale_transcript '$SB_STALE' is neither true nor false" ;; esac
      # The entry path is this terminal's cwd — the daemon opened the terminal at the entry_path param with ~
      # expanded and refused a directory that does not exist — so it is read from $PWD, never templated in
      # up here, where a path containing the CLI's name would take the --session-id injection.
      SB_ENTRY="$PWD"
      for sb_tool in curl jq python3 git sha256sum gzip; do
        command -v "$sb_tool" >/dev/null 2>&1 || sb_fail "$sb_tool is not installed on this agent"
      done
      # The fork's transcript directory, per plan §4.6 step 0. Claude Code 2.1.283 honours this variable only
      # when CLAUDE_CONFIG_DIR is also set — and setting that one, even to ~/.claude, moves the CLI's global
      # config from ~/.claude.json to ~/.claude/.claude.json: onboarding, folder trust and user MCP servers
      # would be gone and the unattended TUI would stop on first-run dialogs. So CLAUDE_CONFIG_DIR stays
      # unset; the fork lands under its cwd slug, which nothing depends on (every hook reads transcript_path
      # from its own input, and the daemon's export finds a session by <id>.jsonl across ~/.claude/projects).
      # The export below is therefore INTENTIONALLY inert today (DEV-51's definition of done asks for it): if a
      # later CLI honours it on its own, the fork lands in sbjob-<new id>/ — the plan's intent, harmless.
      # This step's own session id. The daemon runs the step in tmux session sbjob-<id>; job-context.json is one
      # file per agent that a neighbouring dispatch may already have rewritten, so it is only the fallback.
      SB_NEW_SID=""
      if [ -n "${TMUX:-}" ]; then
        sb_tmux="$(tmux display-message -p '#S' 2>/dev/null)"
        case "$sb_tmux" in sbjob-?*) SB_NEW_SID="${sb_tmux#sbjob-}" ;; esac
      fi
      [ -n "$SB_NEW_SID" ] || SB_NEW_SID="$(jq -r '.session_id // empty' "$HOME/.sidebutton/job-context.json" 2>/dev/null)"
      [[ "$SB_NEW_SID" =~ ^[A-Za-z0-9_-]{1,58}$ ]] || SB_NEW_SID=""   # it names a directory below: never raw
      if [ -n "$SB_NEW_SID" ]; then export CLAUDE_CODE_PROJECT_DIR_NAME="sbjob-$SB_NEW_SID"; fi
      SB_TOKEN="${SIDEBUTTON_AGENT_TOKEN:-${AGENT_TOKEN:-}}"
      SB_AGENT="${SIDEBUTTON_AGENT_NAME:-${AGENT_NAME:-}}"
      SB_PORTAL="${PORTAL_URL:-https://sidebutton.com}"
      [ -n "$SB_TOKEN" ] && [ -n "$SB_AGENT" ] || sb_fail "no agent token / agent name in ~/.agent-env"

      # 1. fetch — only the agent that holds this continuation may read the bundle (the route 404s anyone else).
      #    Every run gets a fresh handoff dir of its own (mktemp), so no run can ever wipe the patch another
      #    continuation's prompt points at. Week-old ones are pruned here.
      find "$HOME/.sidebutton/handoff" -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null
      mkdir -p "$HOME/.sidebutton/handoff" \
        && SB_H="$(mktemp -d "$HOME/.sidebutton/handoff/${SB_NEW_SID:-$SB_SRC_SID}.XXXXXX")" \
        || sb_fail "cannot create a handoff dir under ~/.sidebutton/handoff"
      sb_say "fetching the session bundle of job $SB_SRC_JOB step $SB_SRC_STEP (session $SB_SRC_SID) from $SB_PORTAL"
      SB_CODE="$(curl -sS -o "$SB_H/bundle.tgz" -D "$SB_H/headers" -w '%{http_code}' \
        -H "Authorization: Bearer $SB_TOKEN" -H "X-Agent-Name: $SB_AGENT" \
        --connect-timeout 10 --max-time 600 --retry 2 --retry-delay 3 --max-filesize 67108864 \
        "$SB_PORTAL/api/jobs/$SB_SRC_JOB/session-bundle?step=$SB_SRC_STEP")"
      [ "$?" != 63 ] || sb_fail "the bundle is over the 64 MiB ceiling — the download was stopped"
      if [ "${SB_CODE:-000}" != 200 ]; then
        sb_fail "GET /api/jobs/$SB_SRC_JOB/session-bundle?step=$SB_SRC_STEP answered HTTP ${SB_CODE:-000} $(head -c 300 "$SB_H/bundle.tgz" 2>/dev/null | tr -d '\n')"
      fi

      # 2. verify — the ceiling, then every digest the bundle offers, then python3 reads the archive once to
      #    check every entry and the manifest and only then writes it: one parser checks what it writes.
      SB_SIZE="$(wc -c < "$SB_H/bundle.tgz" | tr -d ' ')"
      [ "$SB_SIZE" -le 67108864 ] || sb_fail "the bundle is $SB_SIZE bytes, over the 64 MiB ceiling"
      SB_SOURCE="$(tr -d '\r' < "$SB_H/headers" | awk -F': *' 'tolower($1) == "x-session-bundle-source" {v = $2} END {print v}')"
      SB_DIGEST="$(tr -d '\r' < "$SB_H/headers" | awk -F': *' 'tolower($1) == "x-session-bundle-sha256" || tolower($1) == "x-bundle-sha256" {v = tolower($2)} END {print v}')"
      if [ -n "$SB_DIGEST" ]; then
        [ "$(sha256sum "$SB_H/bundle.tgz" | cut -d' ' -f1)" = "$SB_DIGEST" ] \
          || sb_fail "the bundle's sha256 does not match the digest the portal sent — nothing was extracted"
        sb_say "bundle digest verified (sha256 ${SB_DIGEST:0:16}…)"
      else
        sb_say "the portal sent no bundle digest — relying on the gzip trailer's CRC and the manifest's own checks"
      fi
      # The one checksum every bundle carries: the gzip trailer's CRC32 and length over the whole archive. tar
      # stops reading at its end-of-archive blocks, before the trailer, so python never checks it — gzip -t
      # does (exit 1 = corrupt; 2 is only a warning, such as trailing zero padding).
      gzip -t "$SB_H/bundle.tgz" >/dev/null 2>&1
      [ "$?" != 1 ] || sb_fail "the bundle fails its gzip integrity check (CRC or length) — it is corrupt, and nothing was extracted"
      sb_say "verifying the ${SB_SOURCE:-unlabelled} bundle ($SB_SIZE bytes) before anything is extracted"
      python3 - "$SB_H/bundle.tgz" "$SB_H/stage" "$SB_SRC_SID" "$HOME" <<'SB_VERIFY_PY'
      import hashlib, json, os, re, shutil, sys, tarfile
      bundle, stage, sid, home = sys.argv[1:5]
      MAX_UNPACKED = 512 * 1024 * 1024  # the source daemon's own staging cap
      def fail(msg):
          print('handoff: refused — ' + msg)
          sys.exit(1)
      def normal(name):
          return (0 < len(name) <= 4096 and '\\' not in name and '\0' not in name and not name.startswith('/')
                  and all(p not in ('', '.', '..') for p in name.split('/')))
      def allowed(rel):
          # Mirror of the exporter's isAllowedBundlePath (the-assistant packages/server/src/session-bundle.ts):
          # manifest.json, workspace/<name>.patch, and — scoped to THIS session id — the transcript, its
          # subagents/ and tool-results/, and its file history. Nothing else, never credentials or job identity.
          p = rel.split('/')
          if any(x in ('.agent-env', '.agent-env.d', 'job-context.json')
                 or x.startswith(('session-heads-', 'session-branches-')) for x in p):
              return False
          if rel == 'manifest.json':
              return True
          if p[0] == 'workspace':
              return len(p) == 2 and re.fullmatch(r'[A-Za-z0-9._-]+\.patch', p[1]) is not None
          if p[0] != '.claude' or len(p) < 3:
              return False
          if p[1] == 'file-history':
              return len(p) >= 4 and p[2] == sid
          if p[1] == 'projects':
              if len(p) == 4:
                  return p[3] == sid + '.jsonl'
              return len(p) >= 6 and p[3] == sid and p[4] in ('subagents', 'tool-results')
          return False
      MAX_ENTRIES = 50000
      try:
          tf = tarfile.open(bundle, 'r:gz')
      except Exception as exc:
          fail('the bundle is not a readable tar.gz (%s)' % exc)
      files, total, count = {}, 0, 0
      def entries():  # lazily, so a hostile archive is refused at its first bad header, not after inflating it all
          try:
              for member in tf:
                  yield member
          except (tarfile.TarError, OSError, EOFError) as exc:
              fail('the bundle is not a readable tar.gz (%s)' % exc)
      for m in entries():
          count += 1
          if count > MAX_ENTRIES:
              fail('the bundle holds more than %d entries' % MAX_ENTRIES)
          if not normal(m.name):
              fail('entry %r is not a plain relative path' % m.name)
          if m.isdir():
              if m.name.split('/')[0] not in ('.claude', 'workspace'):
                  fail('directory entry %r is outside the session allow-list' % m.name)
              continue
          if not m.isreg():
              kind = 'link' if (m.issym() or m.islnk()) else 'device or special file'
              fail('entry %r is a %s — a bundle holds regular files only' % (m.name, kind))
          if not allowed(m.name):
              fail('entry %r is outside the allow-list of session %s' % (m.name, sid))
          if m.name in files:
              fail('entry %r appears twice' % m.name)
          files[m.name] = m
          total += m.size
          if total > MAX_UNPACKED:
              fail('the bundle unpacks to more than %d MiB' % (MAX_UNPACKED // 1048576))
      if 'manifest.json' not in files:
          fail('the bundle has no manifest.json')
      try:
          manifest = json.loads(tf.extractfile(files['manifest.json']).read().decode('utf-8'))
      except Exception as exc:
          fail('manifest.json is not valid JSON (%s)' % exc)
      if not isinstance(manifest, dict) or manifest.get('version') != 1:
          fail('manifest version %r is not 1' % (manifest.get('version') if isinstance(manifest, dict) else None))
      named = (manifest.get('source') or {}).get('session_id')
      if named != sid:
          fail('the manifest describes session %r, not %s' % (named, sid))
      tr = manifest.get('transcript') or {}
      tpath = tr.get('path')
      if not isinstance(tpath, str) or tpath not in files or not tpath.startswith('.claude/projects/') \
              or not tpath.endswith('/' + sid + '.jsonl'):
          fail('manifest transcript.path %r is not this session\'s transcript in the bundle' % tpath)
      if isinstance(tr.get('bytes'), int) and tr['bytes'] != files[tpath].size:
          fail('the transcript holds %d bytes, the manifest says %d' % (files[tpath].size, tr['bytes']))
      want = tr.get('sha256')
      if want:
          digest, src = hashlib.sha256(), tf.extractfile(files[tpath])
          for chunk in iter(lambda: src.read(1 << 20), b''):
              digest.update(chunk)
          if digest.hexdigest() != str(want).lower():
              fail('the transcript sha256 does not match the manifest')
      for c in manifest.get('companions') or []:
          cp = (c or {}).get('path')
          if not isinstance(cp, str):
              continue
          under = [n for n in files if n.startswith(cp.rstrip('/') + '/')]
          if isinstance(c.get('files'), int) and c['files'] != len(under):
              fail('companion %s holds %d files, the manifest says %d' % (cp, len(under), c['files']))
          if isinstance(c.get('bytes'), int) and c['bytes'] != sum(files[n].size for n in under):
              fail('companion %s does not hold the %d bytes the manifest states' % (cp, c['bytes']))
      for w in manifest.get('workspace') or []:
          dp = (w or {}).get('delta_patch')
          if dp and dp not in files:
              fail('the manifest names %s, which the bundle does not hold' % dp)
      print('handoff: bundle verified — %d files, %d bytes unpacked; transcript %s' % (
          len(files), total, 'sha256 matches the manifest' if want else 'size matches (the export offers no in-manifest digest)'))
      # Verified: stage every file (never through a link), then move .claude/** into $HOME. What this agent
      # already holds is kept — a same-agent continuation keeps its own, fuller transcript — except a shorter
      # copy of the session transcript, which the bundle's replaces. A failure part-way (a full disk, a path
      # that is a file) removes every file this run created under $HOME and exits 2, so "nothing restored"
      # stays true and a retry does not mistake a half-written copy for this agent's own.
      def write_new(path, src):
          os.makedirs(os.path.dirname(path), exist_ok=True)
          fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
          with os.fdopen(fd, 'wb') as out:
              shutil.copyfileobj(src, out)
      def place(staged, dest):  # a move on one filesystem, a copy across two
          os.makedirs(os.path.dirname(dest), exist_ok=True)
          try:
              os.rename(staged, dest)
          except OSError:
              with open(staged, 'rb') as src:
                  write_new(dest, src)
      created, restored, kept, swap = [], 0, 0, False
      try:
          for name, m in files.items():
              write_new(os.path.join(stage, name), tf.extractfile(m))
          for name, m in files.items():
              if not name.startswith('.claude/'):
                  continue
              dest = os.path.join(home, name)
              if os.path.lexists(dest):
                  if name == tpath and os.path.isfile(dest) and not os.path.islink(dest) and os.path.getsize(dest) < m.size:
                      swap = True  # replaced last, below, once everything else is in place
                  else:
                      kept += 1
                  continue
              created.append(dest)  # before the write: a file left half-written is taken back too
              place(os.path.join(stage, name), dest)
              restored += 1
          if swap:
              dest = os.path.join(home, tpath)
              tmp = dest + '.handoff-tmp'
              if os.path.lexists(tmp):
                  os.unlink(tmp)
              created.append(tmp)
              place(os.path.join(stage, tpath), tmp)
              os.replace(tmp, dest)  # atomic: this agent's shorter copy stays whole until this very moment
              created.remove(tmp)
              restored += 1
      except Exception as exc:
          for path in created:
              try:
                  os.unlink(path)
              except OSError:
                  pass
          print('handoff: the restore failed part-way (%s) — every file it had begun to place under ~/.claude/ was removed again (%d)' % (exc, len(created)))
          sys.exit(2)
      print('handoff: restored %d session files under ~/.claude%s' % (
          restored, ' (%d already on this agent kept)' % kept if kept else ''))
      SB_VERIFY_PY
      case "$?" in
        0) ;;
        2) sb_fail "the session files could not be restored (the line above says why) — nothing was left behind" ;;
        *) sb_fail "the bundle did not pass verification (the line above says why) — nothing was restored" ;;
      esac
      SB_MANIFEST="$SB_H/stage/manifest.json"
      SB_TRANSCRIPT="$HOME/$(jq -r '.transcript.path' "$SB_MANIFEST")"
      [ -f "$SB_TRANSCRIPT" ] || sb_fail "the restored transcript is missing at $SB_TRANSCRIPT"
      # The session files are in place under ~/.claude/ by now: the archive and the staged copies of them go, and
      # only the manifest and the workspace patches stay beside this continuation (a prompt may name a patch).
      rm -rf "$SB_H/bundle.tgz" "$SB_H/stage/.claude"
      # A same-agent continuation from a snapshot restores the portal's copy under sbjob-<sid>/ while this agent
      # may still hold the session's own transcript under its cwd slug. The JSONL only grows, so the larger file
      # with this session id is the fuller history: resume from that one.
      SB_LOCAL=""
      for sb_f in "$HOME"/.claude/projects/*/"$SB_SRC_SID.jsonl"; do
        [ -f "$sb_f" ] && [ ! -L "$sb_f" ] || continue
        if [ "$(wc -c < "$sb_f")" -gt "$(wc -c < "$SB_TRANSCRIPT")" ]; then SB_TRANSCRIPT="$sb_f"; SB_LOCAL=1; fi
      done
      [ -z "$SB_LOCAL" ] || sb_say "this agent holds a fuller copy of the session than the bundle — resuming from $SB_TRANSCRIPT"

      # 3. restore the checkouts (plan §4.7): per repo under the entry path — fetch, stash a dirty tree (never
      #    discard uncommitted work that is on this agent), branch + head, then the previous agent's delta with
      #    `git apply --3way`. A problem never aborts here: it is told to the resumed session instead.
      SB_PRIMARY=""; SB_LINE=""; SB_OTHERS=""; SB_HEADLESS=""
      # The first repo with a head is the one the portal's branch/head/delta_note describe (the same rule as
      # continueJob's composePromptPlaceholders); any further repo is summed up after it.
      sb_note_repo() {  # $1 = repo, $2 = branch, $3 = head, $4 = the clause after it, $5 = 1 restored | kept | 0 not restored
        local item
        # A repo this agent did not restore still has its uncommitted changes in the bundle: $sb_kept says where.
        case "$5" in 1|kept) item="${2:-a detached HEAD} @ ${3:0:12}$4" ;; *) item="$4$sb_kept" ;; esac
        if [ -z "$SB_PRIMARY" ]; then
          SB_PRIMARY="$1"
          case "$5" in
            1)    SB_LINE="The checkout was restored to $item." ;;
            kept) SB_LINE="The checkout is still at $item." ;;
            *)    SB_LINE="The checkout of $1 was NOT restored: $item." ;;
          esac
        else
          case "$5" in
            1)    SB_OTHERS="${SB_OTHERS}${SB_OTHERS:+; }$1 restored to $item" ;;
            kept) SB_OTHERS="${SB_OTHERS}${SB_OTHERS:+; }$1 still at $item" ;;
            *)    SB_OTHERS="${SB_OTHERS}${SB_OTHERS:+; }$1 NOT restored — $item" ;;
          esac
        fi
      }
      # `checkout -B` moves a local branch wherever it pointed; a tip holding commits the target does not
      # contain (a neighbour's unpushed work) is kept under a hidden ref first, so nothing becomes unreachable.
      sb_keep_branch() {  # $1 = checkout, $2 = branch, $3 = the commit it is about to point at
        local tip ref
        tip="$(git -C "$1" rev-parse -q --verify "refs/heads/$2" 2>/dev/null)" || return 0
        git -C "$1" merge-base --is-ancestor "$tip" "$3" 2>/dev/null && return 0
        # Keyed by the tip itself: a later handoff of the same session saving another tip adds a ref, never
        # replaces this one (refs outside heads/ keep no reflog to fall back on).
        ref="refs/sb-handoff/$SB_SRC_SID/$2@${tip:0:12}"
        git -C "$1" update-ref "$ref" "$tip" 2>/dev/null \
          && sb_say "$sb_repo: local $2 held commits the restore does not — kept at $ref"
      }
      # A checkout is a repository's own top level — not a directory that merely sits inside another one (a
      # workspace that is itself a repo, a dotfiles $HOME), where git would answer for the enclosing repo.
      sb_is_top() { [ "$(git -C "$1" rev-parse --show-toplevel 2>/dev/null)" = "$(cd "$1" 2>/dev/null && pwd -P)" ]; }
      SB_SAME_AGENT=""
      [ "$(jq -r '.source.agent_name // empty' "$SB_MANIFEST")" = "$SB_AGENT" ] && SB_SAME_AGENT=1
      SB_N="$(jq '.workspace | if type == "array" then length else 0 end' "$SB_MANIFEST")"
      for ((sb_i = 0; sb_i < SB_N; sb_i++)); do
        { IFS= read -r sb_repo; IFS= read -r sb_branch; IFS= read -r sb_head; IFS= read -r sb_pushed
          IFS= read -r sb_patch; IFS= read -r sb_omitted; IFS= read -r sb_err; IFS= read -r sb_skipped; } < <(jq -r --argjson i "$sb_i" '.workspace[$i]
          | (.repo // ""), (.branch // ""), (.head // ""), (if .pushed == false then "false" else "true" end),
            (.delta_patch // ""), (.delta_omitted // false | tostring),
            ((.error // "") | tostring | gsub("[\r\n\t]+"; " ") | .[0:200]),
            ((.untracked_skipped // []) | [length, ([.[:3][] | (.path // "?")] | join(", "))]
              | if .[0] == 0 then "" else "\(.[0]) untracked file(s) were left out of the export (\(.[1])\(if .[0] > 3 then ", …" else "" end)) — recreate them if the job needs them" end
              | gsub("[\r\n\t]+"; " "))' "$SB_MANIFEST")
        # Where the uncommitted changes are whenever this agent does not apply them itself (every "NOT restored"
        # note carries it): the patch stays in this handoff's stage, so the session can apply it once the
        # checkout exists — an SE job's linked worktree (<entry>/.worktrees/<ticket>) is missing on any other agent.
        sb_kept=""
        if [ "$sb_omitted" = true ]; then
          sb_kept="; its uncommitted changes were over the export size cap and did not come with it — re-derive them from this transcript"
        elif [ -n "$sb_patch" ] && [ -f "$SB_H/stage/$sb_patch" ]; then
          sb_kept="; its uncommitted changes came with the bundle and are kept at $SB_H/stage/$sb_patch — apply them with git apply --3way once it is checked out"
        fi
        if [ -z "$sb_head" ]; then
          # No commit to restore (a fresh `git init` on the previous agent), or a repo the export could not read.
          [ -z "$sb_repo" ] || SB_HEADLESS="${SB_HEADLESS}${SB_HEADLESS:+; }$sb_repo — ${sb_err:-it had no commit yet on the previous agent, so its files exist only in this transcript}"
          continue
        fi
        case "$sb_repo" in
          ''|/*|*..*|*[!A-Za-z0-9._/-]*)
            sb_say "skipping a repo with an unusable name: $sb_repo"
            sb_note_repo "$sb_repo" "" "" "its recorded name is not a usable path, so it was not touched" 0
            continue ;;
        esac
        [[ "$sb_head" =~ ^[0-9a-f]{7,64}$ ]] || { sb_note_repo "$sb_repo" "" "$sb_head" "its recorded head is not a commit id" 0; continue; }
        if [ -n "$sb_branch" ] && ! git check-ref-format --branch "$sb_branch" >/dev/null 2>&1; then
          sb_note_repo "$sb_repo" "" "$sb_head" "its recorded branch name is not a valid branch" 0; continue
        fi
        sb_dir=""
        if [ -d "$SB_ENTRY/$sb_repo" ] && sb_is_top "$SB_ENTRY/$sb_repo"; then sb_dir="$SB_ENTRY/$sb_repo"
        elif [ "$(basename "$SB_ENTRY")" = "$sb_repo" ] && sb_is_top "$SB_ENTRY"; then sb_dir="$SB_ENTRY"; fi
        if [ -z "$sb_dir" ]; then
          sb_say "$sb_repo is not checked out under $SB_ENTRY on this agent — not restored"
          sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "it is not checked out under $SB_ENTRY on this agent — clone it (or, for a linked worktree, git worktree add it from its repository), then check out ${sb_branch:-$sb_head}" 0
          continue
        fi
        # Back on the agent the session ran on, with the checkout still where it stopped AND the delta still in
        # the tree (the patch reverse-applies): the tree already IS the session's state — including what an
        # export leaves out (ignored files, binaries) — so leave it be. Anything less takes the full restore.
        if [ -n "$SB_SAME_AGENT" ] && [ "$(git -C "$sb_dir" rev-parse HEAD 2>/dev/null)" = "$sb_head" ] \
           && { [ -z "$sb_branch" ] || [ "$(git -C "$sb_dir" symbolic-ref -q --short HEAD 2>/dev/null)" = "$sb_branch" ]; } \
           && { [ -z "$sb_patch" ] || [ ! -f "$SB_H/stage/$sb_patch" ] \
                || git -C "$sb_dir" apply --check -R "$SB_H/stage/$sb_patch" >/dev/null 2>&1; }; then
          sb_say "$sb_repo: still at ${sb_branch:-a detached HEAD} @ ${sb_head:0:12} with its changes, on the agent the session ran on — left as it is"
          sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" " on the agent the session ran on — the checkout never moved and still holds its uncommitted work, so it was left untouched" kept
          continue
        fi
        sb_say "restoring $sb_repo to ${sb_branch:-a detached HEAD} @ ${sb_head:0:12}"
        # Unattended: a credential prompt would hold this window forever, and a dead network for as long.
        if GIT_TERMINAL_PROMPT=0 timeout 120 git -C "$sb_dir" fetch --prune origin </dev/null >/dev/null 2>&1; then sb_fetched=1
        else sb_fetched=0; sb_say "$sb_repo: git fetch failed — restoring from what this checkout already has"; fi
        # What the checkout can be restored to is decided BEFORE anything in it is touched: the recorded head when
        # this clone has it, else origin's tip of the branch. With neither, the tree stays exactly as it is — a
        # neighbour's uncommitted work is never stashed for a restore that cannot happen.
        if git -C "$sb_dir" cat-file -e "${sb_head}^{commit}" 2>/dev/null; then sb_target=head
        elif [ -n "$sb_branch" ] && git -C "$sb_dir" rev-parse -q --verify "refs/remotes/origin/$sb_branch" >/dev/null 2>&1; then sb_target=origin
        else
          sb_say "$sb_repo: its head is not in this clone and there is no origin/${sb_branch:-branch} — left as it is"
          sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "its head is not in this clone and there is no origin/${sb_branch:-branch}$([ "$sb_fetched" = 1 ] || printf ' (git fetch failed)') — the checkout stays on the branch it had" 0
          continue
        fi
        sb_stash=""
        if [ -n "$(git -C "$sb_dir" status --porcelain 2>/dev/null)" ]; then
          if git -C "$sb_dir" stash push --include-untracked -m "sb-handoff $SB_SRC_SID" >/dev/null 2>&1; then
            sb_say "$sb_repo: the uncommitted work in this checkout was stashed as 'sb-handoff $SB_SRC_SID'"
            if [ -n "$SB_SAME_AGENT" ]; then
              sb_stash="; the uncommitted work that was in this checkout was stashed first as 'sb-handoff $SB_SRC_SID' — on the agent the session ran on that is most likely its own (files the export leaves out included), so take what is missing from it with git stash"
            else
              sb_stash="; uncommitted work found in this checkout was stashed first as 'sb-handoff $SB_SRC_SID' — it belongs to another job on this agent, leave it alone"
            fi
          else
            sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "it has uncommitted work that could not be stashed, so it was not touched" 0
            continue
          fi
        fi
        sb_extra=""; sb_rec="$sb_head"
        if [ "$sb_target" = head ]; then
          [ -z "$sb_branch" ] || sb_keep_branch "$sb_dir" "$sb_branch" "$sb_head"
          if [ -n "$sb_branch" ]; then git -C "$sb_dir" checkout -q -B "$sb_branch" "$sb_head" 2>/dev/null
          else git -C "$sb_dir" checkout -q --detach "$sb_head" 2>/dev/null; fi \
            && git -C "$sb_dir" reset -q --hard "$sb_head" 2>/dev/null \
            || { sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "git could not check out $sb_head$sb_stash" 0; continue; }
        else
          sb_keep_branch "$sb_dir" "$sb_branch" "refs/remotes/origin/$sb_branch"
          git -C "$sb_dir" checkout -q -B "$sb_branch" "origin/$sb_branch" 2>/dev/null \
            || { sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "git could not check out origin/$sb_branch$sb_stash" 0; continue; }
          sb_head="$(git -C "$sb_dir" rev-parse HEAD)"
          if [ "$sb_fetched" != 1 ]; then
            sb_extra="; git fetch failed and the recorded head ${sb_rec:0:12} is not in this clone, so this is the last-known origin/$sb_branch — fetch and move to ${sb_rec:0:12} before you continue"
          elif [ "$sb_pushed" = false ]; then
            sb_extra="; its recorded head ${sb_rec:0:12} was never pushed, so this is origin/$sb_branch and the commits after it exist only in this transcript"
          else
            sb_extra="; its recorded head ${sb_rec:0:12} is no longer on origin (was the branch rewritten?), so this is origin/$sb_branch — find out why before you push"
          fi
        fi
        # A branch created here from a commit has no upstream; give it origin's, so a plain `git push` works.
        if [ -n "$sb_branch" ] && git -C "$sb_dir" rev-parse -q --verify "refs/remotes/origin/$sb_branch" >/dev/null 2>&1; then
          git -C "$sb_dir" branch -q --set-upstream-to="origin/$sb_branch" "$sb_branch" >/dev/null 2>&1
        fi
        if [ "$sb_omitted" = true ]; then
          sb_delta=", without its uncommitted changes (over the export size cap — re-derive them from this transcript)"
        elif [ -n "$sb_patch" ] && [ -f "$SB_H/stage/$sb_patch" ]; then
          if git -C "$sb_dir" apply --3way --whitespace=nowarn "$SB_H/stage/$sb_patch" >/dev/null 2>&1; then
            git -C "$sb_dir" reset -q 2>/dev/null   # --3way stages what it applies; they were working-tree changes
            sb_delta=", with the previous agent's uncommitted changes applied"
          else
            # --3way either merges what it can and leaves conflict markers (the unmerged paths say where), or
            # refuses the whole patch and writes nothing: say which, never "re-derive" over a half-applied tree.
            sb_conf="$(git -C "$sb_dir" diff --name-only --diff-filter=U 2>/dev/null | head -n 10 | paste -sd ' ' -)"
            if [ -n "$sb_conf" ]; then
              sb_delta=", with the previous agent's uncommitted changes applied BUT in conflict in: $sb_conf — resolve those conflict markers before anything else (git status lists them)"
            else
              sb_delta=", but the previous agent's uncommitted changes did NOT apply and none of them is in the tree (the patch is kept at $SB_H/stage/$sb_patch) — re-derive them from this transcript"
            fi
          fi
        elif [ -n "$sb_err" ]; then
          sb_delta=", but the previous agent's uncommitted changes could not be exported ($sb_err) — re-derive them from this transcript"
        elif [ -n "$sb_skipped" ]; then
          sb_delta=" (no patch came with it)"
        else
          sb_delta=" (the previous agent left no uncommitted changes)"
        fi
        # What the export itself says it could not carry, beside whatever was applied.
        [ -z "$sb_err" ] || [ -z "$sb_patch" ] || sb_delta="$sb_delta; the export could not inspect everything ($sb_err)"
        [ -z "$sb_skipped" ] || sb_delta="$sb_delta; $sb_skipped"
        sb_say "$sb_repo: at $(git -C "$sb_dir" rev-parse --short=12 HEAD)$sb_delta"
        sb_note_repo "$sb_repo" "$sb_branch" "$sb_head" "$sb_delta$sb_extra$sb_stash" 1
      done
      [ -z "$SB_OTHERS" ] || SB_LINE="$SB_LINE Other repositories: $SB_OTHERS."
      [ -z "$SB_HEADLESS" ] || SB_LINE="${SB_LINE:+$SB_LINE }Not carried over: $SB_HEADLESS."
      [ "$SB_N" -gt 0 ] || sb_say "the bundle carries no checkout state — nothing to restore"

      # 4. the continuation prompt (plan §4.6). The portal resolved its placeholders at enqueue; this agent's
      #    own restore result wins over the portal's delta_note, and a portal value left empty falls back to
      #    the manifest. Under 2000 characters, built so the cut never lands on what matters: the opening line
      #    and the closing rules (verdict vocabulary, the ticket) stay whole, the restore / stale middle is
      #    clipped next, and the hint gets what is left.
      sb_prompt() {  # one argument per placeholder, so a value holding a newline can never shift the others
        # Lengths and cuts in characters, not bytes: the daemon's script sets no locale, and a byte cut through
        # an em dash would hand the CLI invalid UTF-8.
        local LC_ALL=C.UTF-8
        local agent="$1" moved="$2" reason="$3" branch="$4" head="$5" dnote="$6" snote="$7" ticket="$8" hint="$9"
        local why restore stale open mid rules out room NL=$'\n'
        [ -n "$agent" ] || agent="$(jq -r '.source.agent_name // "another agent"' "$SB_MANIFEST" 2>/dev/null)"
        [ -n "$moved" ] || moved="$(date -u '+%Y-%m-%d %H:%M UTC')"
        case "$reason" in
          operator)       why="the operator moved it" ;;
          provider_quota) why="the provider's usage quota stopped it" ;;
          agent_offline)  why="the previous agent went offline" ;;
          window_closed)  why="its terminal window was closed" ;;
          restart)        why="the previous agent restarted" ;;
          '')             why="it was moved by the operator" ;;
          *)              why="$reason" ;;
        esac
        if [ -n "$SB_LINE" ]; then restore="$SB_LINE"
        elif [ -n "$branch" ]; then restore="The previous session worked on $branch @ $head, but no checkout was restored on this agent — check it out yourself."
        else restore=""; fi
        if [ -n "$SB_LOCAL" ]; then
          stale="This agent still held the session's own transcript, fuller than the copy the portal sent, and it was resumed from that — check the repository state yourself before you continue."
        else
          stale="$snote"
          if [ -z "$stale" ] && { [ "$SB_STALE" = true ] || [ "$(jq -r '.transcript.stale // false' "$SB_MANIFEST" 2>/dev/null)" = true ]; }; then
            stale="This transcript is a stored copy (last entry $(jq -r '.transcript.last_entry_at // "of unknown time"' "$SB_MANIFEST" 2>/dev/null)) — the previous session's last steps may be missing from it, so re-verify them first."
          fi
        fi
        if [ -z "$restore" ] && [ -z "$stale" ]; then
          restore="No checkout state came with this session — fetch origin and check out the job branch yourself."
        fi
        open="CONTINUATION — this session was moved from agent $agent to this agent at $moved because: $why."
        mid="Every background process and everything under /tmp of the previous agent is gone.${restore:+ $restore}${stale:+$NL$stale}"
        rules="Re-verify the repository state first, then continue the job from where it stopped. Keep the original prompt's execution rules, verdict vocabulary and report format; do not repeat work that is already done and pushed.${ticket:+${NL}The ticket: $ticket}"
        room=$(( 1990 - ${#open} - ${#rules} - 2 ))
        # A negative length would make bash cut from the END — or fail outright and launch an empty prompt —
        # so an opening and rules that alone overrun the budget (an oversized ticket URL) drop the middle, and
        # the whole is cut at the budget.
        if [ "$room" -lt 2 ]; then mid=""
        elif [ "${#mid}" -gt "$room" ]; then mid="${mid:0:$(( room - 1 ))}…"; fi
        out="$open$NL$mid$NL$rules"
        [ "${#out}" -le 1990 ] || out="${out:0:1989}…"
        if [ -n "$hint" ]; then
          room=$(( 1990 - ${#out} - 1 ))
          [ "$room" -gt 0 ] && out="$out$NL${hint:0:$room}"
        fi
        printf '%s' "$out"
      }
      sb_say "resuming $SB_TRANSCRIPT as a fork under this step's own session id"
      claude --dangerously-skip-permissions --resume "$SB_TRANSCRIPT" --fork-session "$(sb_prompt \
        "$(cat <<'SB_HANDOFF_SOURCE_AGENT_7D3F'
      {{source_agent}}
      SB_HANDOFF_SOURCE_AGENT_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_MOVED_AT_7D3F'
      {{moved_at}}
      SB_HANDOFF_MOVED_AT_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_REASON_7D3F'
      {{reason}}
      SB_HANDOFF_REASON_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_BRANCH_7D3F'
      {{branch}}
      SB_HANDOFF_BRANCH_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_HEAD_7D3F'
      {{head}}
      SB_HANDOFF_HEAD_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_DELTA_NOTE_7D3F'
      {{delta_note}}
      SB_HANDOFF_DELTA_NOTE_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_STALE_NOTE_7D3F'
      {{stale_note}}
      SB_HANDOFF_STALE_NOTE_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_TICKET_URL_7D3F'
      {{ticket_url}}
      SB_HANDOFF_TICKET_URL_7D3F
      )" \
        "$(cat <<'SB_HANDOFF_HINT_7D3F'
      {{hint}}
      SB_HANDOFF_HINT_7D3F
      )")"