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
sidebutton install agents 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. Open a terminal
- title
- Agent: Resume Stopped Job Session
- cwd
- {{entry_path}}
terminal.open - 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
)")"