
Swain
- 122 installs
- 2 repo stars
- Updated July 24, 2026
- cristoslc/swain
Operate the Swain agent workspace for managing skills, running workflows, and coordinating Claude Code sessions with consistent conventions across design, keys, search, and updates.
About
Core Swain skill that orients Claude to the Swain agent workspace: how to initialize sessions, route between specialized Swain skills, follow project conventions, and coordinate design, keys, search, and update capabilities as one toolchain.
- Swain workspace orchestration
- Skill routing and session setup
- Workflow hook conventions
- Cross-skill coordination patterns
- Agent context management
Swain by the numbers
- 122 all-time installs (skills.sh)
- Ranked #3,777 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/swain --skill swainAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 122 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 24, 2026 |
| Repository | cristoslc/swain ↗ |
What it does
Operate the Swain agent workspace for managing skills, running workflows, and coordinating Claude Code sessions with consistent conventions across design, keys, search, and updates.
Files
<!-- swain-model-hint: haiku, effort: low --> Invoke the Skill tool for exactly one match. Pass the user's full prompt as args.
Disambiguation: When the user's intent includes an artifact type name (spec, epic, ADR, spike, vision, initiative, journey, persona, runbook, design) alongside a question word (how, what, why), prefer swain-design over swain-help. swain-help is for meta-questions about swain itself, not for artifact operations.
| swain-design | vision, initiative, epic, story, spec, ADR, spike, bug, persona, runbook, journey, design | | swain-search | research, evidence, gather sources, search for, evidence pool, what do we know about | | swain-do | tasks, implementation, tracking, tk, ticket, bookmark, remember where I am | | swain-sync | commit, push, stage, sync, fetch | | swain-release | release, version, changelog, tag | | swain-update | update/upgrade swain | | swain-doctor | governance, doctor, health check, gitignore | | swain-roadmap | roadmap, priority matrix, show roadmap, refresh roadmap, status, dashboard, what's next, overview, where are we, what should I work on, show me priorities | | swain-help | help, how do I, what is, reference, cheat sheet, commands | | swain-init | init, onboard, setup, bootstrap, session, session info, tab name, preferences, focus on | | swain-keys | SSH keys, signing, provision keys, configure signing, key setup | | swain-retro | retro, retrospective, reflect, what did we learn, learnings | | swain-teardown | teardown, clean up, wrap up, session end, end session, close session, done, log off, sign off |
#!/usr/bin/env bash
# swain — pre-runtime crash recovery and session launcher (SPEC-180)
#
# Phases:
# 1. Pre-runtime structural checks (crash debris detection + cleanup)
# 2. Session selection (resume crashed session or start fresh)
# 3. Runtime invocation (resolve + launch preferred agentic CLI)
#
# Usage: swain [--fresh] [--runtime <name>] [session purpose text...]
#
# Per ADR-018: all logic is structural (bash), not prosaic (LLM).
# Per ADR-015: never auto-discard worktree state.
# Per ADR-019: canonical location is the installed skill tree's
# swain/scripts/swain; operator access via bin/swain symlink.
set -euo pipefail
_src="${BASH_SOURCE[0]}"
while [[ -L "$_src" ]]; do
_dir="$(cd "$(dirname "$_src")" && pwd)"
_src="$(readlink "$_src")"
[[ "$_src" != /* ]] && _src="$_dir/$_src"
done
SCRIPT_DIR="$(cd "$(dirname "$_src")" && pwd)"
SKILLS_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || true)}"
if [ -z "$REPO_ROOT" ]; then
echo "swain: not in a git repository" >&2
exit 1
fi
CURRENT_ROOT="$REPO_ROOT"
COMMON_ROOT="$REPO_ROOT"
LAUNCH_ROOT="$REPO_ROOT"
IN_LINKED_WORKTREE=false
VALID_GIT_REPO=false
# --- Argument parsing ---
FLAG_FRESH=false
FLAG_RUNTIME=""
FLAG_DRY_RUN=false
FLAG_PHASE1_ONLY=false
FLAG_PHASE2_ONLY=false
FLAG_NON_INTERACTIVE=false
FLAG_FORMAT="" # "ndjson" for structured I/O (DESIGN-025 / SPEC-291)
FLAG_RESUME=""
FLAG_TRUNK=false
PURPOSE_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--fresh)
FLAG_FRESH=true
shift
;;
--runtime)
FLAG_RUNTIME="$2"
shift 2
;;
--_dry_run)
FLAG_DRY_RUN=true
shift
;;
--_phase1_only)
FLAG_PHASE1_ONLY=true
shift
;;
--_phase2_only)
FLAG_PHASE2_ONLY=true
shift
;;
--_non_interactive)
FLAG_NON_INTERACTIVE=true
shift
;;
--format)
FLAG_FORMAT="$2"
shift 2
;;
--resume)
FLAG_RESUME="$2"
shift 2
;;
--trunk)
FLAG_TRUNK=true
shift
;;
--help|-h)
echo "Usage: swain [--fresh] [--runtime <name>] [--resume <name>] [--trunk] [purpose...]"
echo ""
echo "Options:"
echo " --fresh Skip crash recovery, start a clean session"
echo " --runtime NAME Force a specific runtime (claude, codex, copilot, opencode)"
echo " --resume NAME Resume an existing worktree by name"
echo " --trunk Launch on trunk (no worktree isolation)"
echo ""
echo "Arguments after flags become the session purpose text."
exit 0
;;
*)
PURPOSE_ARGS+=("$1")
shift
;;
esac
done
# --- NDJSON mode helpers (DESIGN-025 / SPEC-291) ---
# When --format ndjson is set, all I/O is structured JSON on stdout/stdin.
# The project bridge reads stdout and writes stdin.
ndjson_mode() {
[[ "$FLAG_FORMAT" == "ndjson" ]]
}
ndjson_emit() {
# Emit a single NDJSON line to stdout.
# Usage: ndjson_emit '{"type":"info","text":"hello"}'
printf '%s\n' "$1"
}
ndjson_info() {
# Emit an info message.
local text="$1"
local escaped
escaped=$(printf '%s' "$text" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/$//')
ndjson_emit "{\"type\":\"info\",\"text\":\"$escaped\"}"
}
ndjson_question() {
# Emit a question and read the answer from stdin.
# Usage: ndjson_question "Resume or fresh?" "r" "f"
# The question is written to fd 3 (real stdout) so $() doesn't capture it.
# Only the extracted answer goes to stdout (captured by $()).
local text="$1"
shift
local options_json="["
local first=true
for opt in "$@"; do
if [[ "$first" == "true" ]]; then
first=false
else
options_json+=","
fi
options_json+="\"$opt\""
done
options_json+="]"
local escaped
escaped=$(printf '%s' "$text" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/$//')
# Write question to fd 3 (real stdout), not to $() capture.
printf '%s\n' "{\"type\":\"question\",\"text\":\"$escaped\",\"options\":$options_json}" >&3
# Read answer from stdin (NDJSON line).
local line=""
IFS= read -r line
# Extract "text" field from {"type":"answer","text":"..."}
printf '%s' "$line" | sed 's/.*"text"[[:space:]]*:[[:space:]]*"//;s/".*//'
}
ndjson_ready() {
# Emit the ready signal — setup is complete.
local purpose="$1"
local worktree="$2"
local runtime="$3"
local prompt="$4"
local purpose_escaped worktree_escaped
purpose_escaped=$(printf '%s' "$purpose" | sed 's/\\/\\\\/g; s/"/\\"/g')
worktree_escaped=$(printf '%s' "$worktree" | sed 's/\\/\\\\/g; s/"/\\"/g')
ndjson_emit "{\"type\":\"ready\",\"purpose\":\"$purpose_escaped\",\"worktree\":\"$worktree_escaped\",\"runtime\":\"$runtime\",\"prompt\":\"$prompt\"}"
}
resolve_abs_dir() {
local base="$1"
local path="$2"
if [[ -z "$path" ]]; then
return 1
fi
if [[ "$path" = /* ]]; then
cd "$path" 2>/dev/null && pwd -P
else
cd "$base/$path" 2>/dev/null && pwd -P
fi
}
detect_checkout_state() {
CURRENT_ROOT="$REPO_ROOT"
COMMON_ROOT="$REPO_ROOT"
LAUNCH_ROOT="$REPO_ROOT"
IN_LINKED_WORKTREE=false
VALID_GIT_REPO=false
local top
top="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$top" ]]; then
return
fi
VALID_GIT_REPO=true
CURRENT_ROOT="$top"
COMMON_ROOT="$top"
LAUNCH_ROOT="$top"
local git_common git_dir git_common_abs git_dir_abs
git_common="$(git -C "$CURRENT_ROOT" rev-parse --git-common-dir 2>/dev/null || true)"
git_dir="$(git -C "$CURRENT_ROOT" rev-parse --git-dir 2>/dev/null || true)"
git_common_abs="$(resolve_abs_dir "$CURRENT_ROOT" "$git_common" || true)"
git_dir_abs="$(resolve_abs_dir "$CURRENT_ROOT" "$git_dir" || true)"
if [[ -n "$git_common_abs" && -n "$git_dir_abs" && "$git_common_abs" != "$git_dir_abs" ]]; then
IN_LINKED_WORKTREE=true
COMMON_ROOT="$(cd "$git_common_abs/.." 2>/dev/null && pwd -P)"
fi
}
read_session_bookmark() {
local root="$1"
local session_file="$root/.agents/session.json"
if [[ ! -f "$session_file" ]]; then
return
fi
grep -o '"note"[[:space:]]*:[[:space:]]*"[^"]*"' "$session_file" 2>/dev/null \
| head -1 \
| sed 's/.*"note"[[:space:]]*:[[:space:]]*"//;s/"$//'
}
derive_worktree_context() {
local raw slug
raw="${PURPOSE_ARGS[*]:-session}"
slug=$(printf '%s\n' "$raw" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9][^a-z0-9]*/-/g; s/^-//; s/-$//' \
| cut -c1-24)
if [[ -z "$slug" ]]; then
slug="session"
fi
echo "$slug"
}
choose_worktree_parent() {
local base_root="$1"
# ADR-034: .worktrees/ is the single canonical location. No fallback.
echo "$base_root/.worktrees"
}
next_worktree_name() {
local context="$1"
local name_script=""
if [[ -x "$COMMON_ROOT/.agents/bin/swain-worktree-name.sh" ]]; then
name_script="$COMMON_ROOT/.agents/bin/swain-worktree-name.sh"
elif [[ -x "$CURRENT_ROOT/.agents/bin/swain-worktree-name.sh" ]]; then
name_script="$CURRENT_ROOT/.agents/bin/swain-worktree-name.sh"
elif [[ -x "$SCRIPT_DIR/../../swain-session/scripts/swain-worktree-name.sh" ]]; then
name_script="$SCRIPT_DIR/../../swain-session/scripts/swain-worktree-name.sh"
fi
if [[ -n "$name_script" ]]; then
bash "$name_script" "$context"
else
printf '%s-%s\n' "$context" "$(date +%Y%m%d-%H%M%S)"
fi
}
create_session_worktree() {
local base_root="$1"
local context="$2"
local parent name path
parent="$(choose_worktree_parent "$base_root")"
mkdir -p "$parent"
name="$(next_worktree_name "$context")"
path="$parent/$name"
git -C "$base_root" worktree add "$path" -b "$name" >/dev/null 2>&1 || return 1
# Symlink gitignored agentic structure from main tree into worktree.
# Without these, runtimes launched in worktrees can't find skills,
# re-run onboarding, or lose runtime-specific config.
# 1. Skill directories (.claude/skills, .agents/skills)
for skill_dir in .claude/skills .agents/skills; do
if [[ -d "$base_root/$skill_dir" ]]; then
mkdir -p "$path/$(dirname "$skill_dir")"
ln -s "$base_root/$skill_dir" "$path/$skill_dir"
fi
done
# 2. .swain/init.json marker — prevents re-onboarding in worktrees
if [[ -f "$base_root/.swain/init.json" ]] && [[ ! -e "$path/.swain/init.json" ]]; then
mkdir -p "$path/.swain" 2>/dev/null || true
ln -s "$base_root/.swain/init.json" "$path/.swain/init.json"
fi
# 3. Runtime-specific dot-folders (gitignored agent config/skills dirs).
# Matches the list in .gitignore — symlink any that exist on trunk.
local agent_dirs=(
.adal .agent .augment .codebuddy .commandcode .continue .codex
.cortex .crush .factory .goose .iflow .junie .kilocode .kiro
.kode .mcpjam .mux .neovate .openhands .pi .pochi .qoder .qwen
.roo .trae .vibe .windsurf .zencoder .superpowers
)
for agent_dir in "${agent_dirs[@]}"; do
if [[ -d "$base_root/$agent_dir" ]] && [[ ! -e "$path/$agent_dir" ]]; then
ln -s "$base_root/$agent_dir" "$path/$agent_dir"
fi
done
echo "$path"
}
prepare_session_workspace() {
detect_checkout_state
# SPEC-245: --trunk flag — launch on trunk with warning
if [[ "$FLAG_TRUNK" == "true" ]]; then
if [[ -t 0 ]] && [[ "$FLAG_NON_INTERACTIVE" != "true" ]]; then
echo "WARNING: Working on trunk violates worktree discipline."
echo ""
echo "This should only be used for:"
echo " - Quick typo fixes (3 files or fewer)"
echo " - Merge/rebase operations"
echo " - Emergency hotfixes"
echo ""
read -r -p "Proceed on trunk? (y/N): " confirm </dev/tty
if [[ "$confirm" != "y" ]] && [[ "$confirm" != "Y" ]]; then
echo "Cancelled."
exit 0
fi
fi
LAUNCH_ROOT="$REPO_ROOT"
return
fi
# SPEC-245: --resume flag — find and resume existing worktree
if [[ -n "$FLAG_RESUME" ]]; then
local found_path=""
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
local wt_path="${line#worktree }"
if [[ "$wt_path" == *"$FLAG_RESUME"* ]]; then
found_path="$wt_path"
break
fi
fi
done < <(git worktree list --porcelain 2>/dev/null)
if [[ -n "$found_path" ]]; then
LAUNCH_ROOT="$found_path"
RESUME_PROMPT="/swain-session Session purpose: resume — $FLAG_RESUME"
PURPOSE_ARGS=()
echo "Resuming worktree: $LAUNCH_ROOT"
else
echo "error: no worktree matching '$FLAG_RESUME' found" >&2
exit 1
fi
return
fi
if [[ ${#PURPOSE_ARGS[@]} -eq 0 ]] || [[ -n "$RESUME_PROMPT" ]] || [[ "$VALID_GIT_REPO" != "true" ]]; then
return
fi
local current_bookmark=""
current_bookmark="$(read_session_bookmark "$CURRENT_ROOT")"
if [[ "$IN_LINKED_WORKTREE" != "true" ]]; then
if [[ "$FLAG_DRY_RUN" == "true" ]]; then
return
fi
local new_root=""
new_root="$(create_session_worktree "$COMMON_ROOT" "$(derive_worktree_context)")" || {
echo "error: failed to create a worktree for the new session" >&2
exit 1
}
LAUNCH_ROOT="$new_root"
echo "Starting new session in worktree: $LAUNCH_ROOT"
return
fi
if [[ -z "$current_bookmark" ]]; then
return
fi
if ndjson_mode; then
ndjson_info "Active worktree session detected. Bookmark: $current_bookmark"
local choice
choice=$(ndjson_question "Resume this worktree or start a new one?" "resume" "new")
case "$choice" in
new|n|N)
local new_root=""
new_root="$(create_session_worktree "$COMMON_ROOT" "$(derive_worktree_context)")" || {
ndjson_emit '{"type":"error","text":"Failed to create worktree"}'
exit 1
}
LAUNCH_ROOT="$new_root"
ndjson_info "New worktree: $LAUNCH_ROOT"
;;
*)
RESUME_PROMPT="/swain-session Session purpose: resume — $current_bookmark"
PURPOSE_ARGS=()
;;
esac
return
fi
if [[ "$FLAG_NON_INTERACTIVE" == "true" ]] || [[ "$FLAG_DRY_RUN" == "true" ]] || ! [[ -t 0 ]]; then
RESUME_PROMPT="/swain-session Session purpose: resume — $current_bookmark"
PURPOSE_ARGS=()
echo "note: active worktree bookmark detected; leaving current worktree session in place" >&2
return
fi
echo "=== Active worktree session detected ==="
echo " Current bookmark: $current_bookmark"
echo ""
echo "Options:"
echo " [r] Resume or finish this worktree"
echo " [n] Leave it in place and open a new worktree for the new session"
echo ""
read -r -p "Choice (r/n): " choice </dev/tty
case "$choice" in
n|N)
local new_root=""
new_root="$(create_session_worktree "$COMMON_ROOT" "$(derive_worktree_context)")" || {
echo "error: failed to create a worktree for the new session" >&2
exit 1
}
LAUNCH_ROOT="$new_root"
echo "Starting new session in worktree: $LAUNCH_ROOT"
;;
*)
RESUME_PROMPT="/swain-session Session purpose: resume — $current_bookmark"
PURPOSE_ARGS=()
echo "Resuming current worktree session."
;;
esac
}
# --- Phase 1: Pre-runtime structural checks ---
phase1_crash_detection() {
# Source the crash debris detection library (SPEC-182)
local lib="$SKILLS_ROOT/swain-doctor/scripts/crash-debris-lib.sh"
if [[ ! -f "$lib" ]]; then
echo "warning: crash-debris-lib.sh not found; skipping crash detection" >&2
return
fi
source "$lib"
# Run all crash debris checks
local findings
findings=$(check_all_crash_debris "$REPO_ROOT" 2>/dev/null)
# AC5 silent fast path: no findings → return immediately
if [[ -z "$findings" ]]; then
return
fi
# Display findings
echo "=== Crash debris detected ==="
echo ""
local count=0
while IFS=$'\t' read -r type status detail; do
[[ "$status" != "found" ]] && continue
count=$((count + 1))
case "$type" in
git_index_lock)
echo " [$count] Git index lock: $detail"
;;
interrupted_git_ops)
echo " [$count] $detail"
;;
stale_tk_locks)
echo " [$count] Stale task lock: $detail"
;;
dangling_worktrees)
echo " [$count] Dangling worktree: $detail"
;;
orphaned_mcp)
echo " [$count] Orphaned MCP server: $detail"
;;
*)
echo " [$count] $type: $detail"
;;
esac
done <<< "$findings"
echo ""
if [[ "$FLAG_NON_INTERACTIVE" == "true" ]] || [[ "$FLAG_DRY_RUN" == "true" ]] || ! [[ -t 0 ]]; then
# Store findings for Phase 2
PHASE1_FINDINGS="$findings"
return
fi
# Offer cleanup with confirmation (per ADR-015: never auto-discard)
echo "Clean up crash debris? (y/n/q — q skips remaining)"
local item_num=0
while IFS=$'\t' read -r type status detail; do
[[ "$status" != "found" ]] && continue
item_num=$((item_num + 1))
local action=""
case "$type" in
git_index_lock)
local lock_path
lock_path=$(echo "$detail" | awk '{print $1}')
action="rm -f \"$lock_path\""
;;
interrupted_git_ops)
if echo "$detail" | grep -q "merge"; then
action="git -C \"$REPO_ROOT\" merge --abort"
elif echo "$detail" | grep -q "rebase"; then
action="git -C \"$REPO_ROOT\" rebase --abort"
elif echo "$detail" | grep -q "cherry-pick"; then
action="git -C \"$REPO_ROOT\" cherry-pick --abort"
fi
;;
stale_tk_locks)
local task_id
task_id=$(echo "$detail" | awk '{print $2}')
action="rm -rf \"$REPO_ROOT/.tickets/.locks/$task_id\""
;;
dangling_worktrees)
# Don't auto-cleanup worktrees — surface for Phase 2
action=""
;;
orphaned_mcp)
local mcp_pid
mcp_pid=$(echo "$detail" | grep -oE 'PID [0-9]+' | awk '{print $2}')
action="kill $mcp_pid"
;;
esac
if [[ -z "$action" ]]; then
continue
fi
read -r -p " [$item_num] Clean up? (y/n/q) " choice </dev/tty
case "$choice" in
y|Y)
eval "$action" 2>/dev/null && echo " Cleaned." || echo " Failed."
;;
q|Q)
echo " Skipping remaining."
break
;;
*)
echo " Skipped."
;;
esac
done <<< "$findings"
echo ""
# Store findings for Phase 2 (session selection needs to know about dangling worktrees)
PHASE1_FINDINGS="$findings"
}
# --- Phase 2: Session selection ---
phase2_session_selection() {
local has_crash_indicators=false
if [[ -n "$PHASE1_FINDINGS" ]]; then
has_crash_indicators=true
fi
local session_file="$REPO_ROOT/.agents/session.json"
local bookmark="" focus_lane=""
if [[ -f "$session_file" ]]; then
bookmark=$(grep -o '"note"[[:space:]]*:[[:space:]]*"[^"]*"' "$session_file" 2>/dev/null \
| head -1 | sed 's/.*"note"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
focus_lane=$(grep -o '"focus_lane"[[:space:]]*:[[:space:]]*"[^"]*"' "$session_file" 2>/dev/null \
| head -1 | sed 's/.*"focus_lane"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
fi
# Fast path: no crash indicators and no bookmark → skip Phase 2
if [[ "$has_crash_indicators" != "true" ]] && [[ -z "$bookmark" ]]; then
return
fi
local context_msg="Previous session detected."
if [[ -n "$bookmark" ]]; then
context_msg="$context_msg Last activity: $bookmark."
fi
if [[ -n "$focus_lane" ]]; then
context_msg="$context_msg Focus: $focus_lane."
fi
if ndjson_mode; then
ndjson_info "$context_msg"
local choice
choice=$(ndjson_question "Resume previous session or start fresh?" "resume" "fresh")
case "$choice" in
resume|r|R)
RESUME_PROMPT="/swain-session"
if [[ -n "$bookmark" ]]; then
RESUME_PROMPT="/swain-session Session purpose: resume — $bookmark"
fi
;;
*)
RESUME_PROMPT=""
;;
esac
return
fi
echo "=== Previous session detected ==="
if [[ -n "$bookmark" ]]; then
echo " Last activity: $bookmark"
fi
if [[ -n "$focus_lane" ]]; then
echo " Focus: $focus_lane"
fi
# Show dangling worktrees with uncommitted changes (AC5)
if echo "$PHASE1_FINDINGS" | grep -q 'dangling_worktrees.*found' 2>/dev/null; then
echo ""
echo " Worktrees with unmerged work:"
echo "$PHASE1_FINDINGS" | grep 'dangling_worktrees.*found' | while IFS=$'\t' read -r _ _ detail; do
echo " - $detail"
done
fi
echo ""
if [[ "$FLAG_NON_INTERACTIVE" == "true" ]] || [[ "$FLAG_DRY_RUN" == "true" ]] || ! [[ -t 0 ]]; then
RESUME_PROMPT="/swain-session Session purpose: resume after crash"
if [[ -n "$bookmark" ]]; then
RESUME_PROMPT="/swain-session Session purpose: resume — $bookmark"
fi
return
fi
echo "Options:"
echo " [r] Resume previous session"
echo " [f] Start fresh"
echo ""
read -r -p "Choice (r/f): " choice </dev/tty
case "$choice" in
r|R)
RESUME_PROMPT="/swain-session"
if [[ -n "$bookmark" ]]; then
RESUME_PROMPT="/swain-session Session purpose: resume — $bookmark"
fi
echo " Resuming with context."
;;
*)
RESUME_PROMPT=""
echo " Starting fresh."
;;
esac
echo ""
}
# --- Runtime resolution ---
# Priority: --runtime flag > per-project setting > global setting > auto-detect
resolve_runtime() {
# 1. CLI flag
if [[ -n "$FLAG_RUNTIME" ]]; then
echo "$FLAG_RUNTIME"
return
fi
# 2. Per-project setting (swain.settings.json)
local project_settings="$REPO_ROOT/swain.settings.json"
if [[ -f "$project_settings" ]]; then
local rt
rt=$(grep -o '"runtime"[[:space:]]*:[[:space:]]*"[^"]*"' "$project_settings" 2>/dev/null \
| head -1 | sed 's/.*"runtime"[[:space:]]*:[[:space:]]*"//;s/"$//')
if [[ -n "$rt" ]]; then
echo "$rt"
return
fi
fi
# 3. Global setting (~/.config/swain/settings.json)
local global_settings="$HOME/.config/swain/settings.json"
if [[ -f "$global_settings" ]]; then
local rt
rt=$(grep -o '"runtime"[[:space:]]*:[[:space:]]*"[^"]*"' "$global_settings" 2>/dev/null \
| head -1 | sed 's/.*"runtime"[[:space:]]*:[[:space:]]*"//;s/"$//')
if [[ -n "$rt" ]]; then
echo "$rt"
return
fi
fi
# 4. Auto-detect installed runtimes
local runtimes=(claude codex copilot opencode)
local available=()
for rt in "${runtimes[@]}"; do
if command -v "$rt" &>/dev/null; then
available+=("$rt")
fi
done
if [[ ${#available[@]} -eq 0 ]]; then
echo ""
return
fi
# Single runtime — use it directly.
if [[ ${#available[@]} -eq 1 ]]; then
echo "${available[0]}"
return
fi
# Multiple runtimes — prompt if interactive, otherwise first match.
if [[ "$FLAG_NON_INTERACTIVE" == "true" ]] || ! [[ -t 0 ]]; then
echo "${available[0]}"
return
fi
echo "Multiple runtimes detected:" >&2
local i=1
for rt in "${available[@]}"; do
echo " [$i] $rt" >&2
i=$((i + 1))
done
echo "" >&2
read -r -p "Choose runtime (1-${#available[@]}): " choice </dev/tty
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le ${#available[@]} ]]; then
echo "${available[$((choice - 1))]}"
else
echo "${available[0]}"
fi
}
# Build launch command for a given runtime
build_launch_cmd() {
local runtime="$1"
local prompt="$2"
case "$runtime" in
claude)
echo "claude --dangerously-skip-permissions \"$prompt\""
;;
codex)
echo "codex --full-auto \"$prompt\""
;;
copilot)
echo "copilot --yolo -i \"$prompt\""
;;
opencode)
# cmd is resolved in phase3_launch_runtime before this point; fallback only.
echo "opencode"
;;
*)
echo ""
;;
esac
}
PHASE1_FINDINGS=""
RESUME_PROMPT=""
# --- SPEC-276: Tmux session name helper ---
tmux_session_name() {
local context="${1:-swain}"
printf '%s\n' "swain-$context" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-//; s/-$//' \
| cut -c1-32
}
# --- Phase 3: Runtime invocation ---
phase3_launch_runtime() {
prepare_session_workspace
local runtime
runtime=$(resolve_runtime)
if [[ -z "$runtime" ]]; then
echo "error: no supported runtime found. Install one of: claude, codex, copilot, opencode" >&2
exit 1
fi
# Compose initial prompt
local prompt="/swain-init"
if [[ -n "$RESUME_PROMPT" ]]; then
prompt="$RESUME_PROMPT"
elif [[ ${#PURPOSE_ARGS[@]} -gt 0 ]]; then
prompt="/swain-session Session purpose: ${PURPOSE_ARGS[*]}"
fi
# Always export SWAIN_PURPOSE so the greeting script can capture it
# deterministically (SPEC-297). Crush (Partial tier) depends on this
# exclusively per ADR-017; other runtimes also get the inline prompt.
if [[ -n "$RESUME_PROMPT" ]]; then
export SWAIN_PURPOSE="resume — previous session"
elif [[ ${#PURPOSE_ARGS[@]} -gt 0 ]]; then
export SWAIN_PURPOSE="${PURPOSE_ARGS[*]}"
fi
# --- Opencode: resolve session ID before tmux branching ---
# Run the init prompt headlessly in the current process so we can read the
# session ID from the first JSON event without any race condition.
# The TUI resume command is then a simple `opencode --session <id>` string
# that works safely in tmux new-session or eval.
local opencode_session_id=""
if [[ "$runtime" == "opencode" ]]; then
opencode_session_id=$(
opencode run --format json "$prompt" 2>/dev/null \
| head -1 \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('sessionID',''))" 2>/dev/null
) || true
fi
local cmd
cmd=$(build_launch_cmd "$runtime" "$prompt")
# For opencode, override cmd with the TUI resume command now that we have the session ID.
if [[ "$runtime" == "opencode" ]]; then
if [[ -n "$opencode_session_id" ]]; then
cmd="opencode --session \"$opencode_session_id\""
else
cmd="opencode"
fi
fi
# --- SPEC-276: Determine tmux action ---
local tmux_action="none"
local session_name=""
session_name="$(tmux_session_name "$(derive_worktree_context)")"
if [[ -z "${TMUX:-}" ]]; then
if command -v tmux &>/dev/null; then
tmux_action="new-session"
else
echo "warning: tmux not found; launching without session wrapping" >&2
fi
else
tmux_action="rename-window"
fi
if [[ "$FLAG_DRY_RUN" == "true" ]]; then
echo "runtime: $runtime"
echo "prompt: $prompt"
echo "launch_root: $LAUNCH_ROOT"
echo "command: $cmd"
echo "tmux_action: $tmux_action"
echo "tmux_session: $session_name"
return
fi
cd "$LAUNCH_ROOT"
# --- SPEC-245: Lockfile claiming + env var export ---
local lockfile_script="$REPO_ROOT/.agents/bin/swain-lockfile.sh"
local branch=""
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
if [[ -n "$branch" ]] && [[ "$branch" != "trunk" ]] && [[ -f "$lockfile_script" ]]; then
local purpose_text="${PURPOSE_ARGS[*]:-}"
export SWAIN_RUNTIME="$runtime"
bash "$lockfile_script" claim "$branch" "$LAUNCH_ROOT" "$purpose_text" 2>/dev/null || true
export SWAIN_WORKTREE_PATH="$LAUNCH_ROOT"
export SWAIN_LOCKFILE_PATH="$REPO_ROOT/.agents/worktrees/${branch}.lock"
fi
# --- SPEC-276: Rename tmux window if inside tmux ---
if [[ "$tmux_action" == "rename-window" ]]; then
tmux rename-window "$session_name" 2>/dev/null || true
fi
# --- SPEC-245: Launch runtime as child process (not exec) ---
# Running as child allows post-runtime cleanup (lockfile verification, worktree pruning).
# Forward signals to the child so Ctrl-C propagates cleanly.
local child_pid=""
trap '[[ -n "$child_pid" ]] && kill -TERM "$child_pid" 2>/dev/null' TERM INT HUP
# --- SPEC-276: Wrap in tmux if outside tmux ---
if [[ "$tmux_action" == "new-session" ]]; then
tmux new-session -s "$session_name" -d "cd '$LAUNCH_ROOT' && $cmd" 2>/dev/null
tmux attach-session -t "$session_name" &
child_pid=$!
else
eval "$cmd" &
child_pid=$!
fi
wait "$child_pid" 2>/dev/null
local exit_code=$?
child_pid=""
# --- SPEC-245: Post-runtime cleanup ---
if [[ -n "$branch" ]] && [[ "$branch" != "trunk" ]] && [[ -f "$lockfile_script" ]]; then
# Check if worktree is ready for cleanup
local verify_exit=0
bash "$lockfile_script" verify-ready "$branch" >/dev/null 2>&1 || verify_exit=$?
if [[ $verify_exit -eq 0 ]]; then
# Commit hash matches — safe to prune
local archive_script="$REPO_ROOT/.agents/bin/swain-session-archive.sh"
if [[ -f "$archive_script" ]]; then
bash "$archive_script" save "$LAUNCH_ROOT" 2>/dev/null || true
fi
cd "$REPO_ROOT" || cd "$HOME"
git worktree remove "$LAUNCH_ROOT" 2>/dev/null || true
bash "$lockfile_script" release "$branch" 2>/dev/null || true
git worktree prune 2>/dev/null || true
echo "Worktree cleaned up: $LAUNCH_ROOT"
elif [[ $verify_exit -eq 1 ]]; then
# Commit mismatch — new work since ready mark
echo "Note: worktree has new commits since ready_for_cleanup — leaving in place."
else
# Not marked ready — leave lockfile for teardown
:
fi
fi
exit "$exit_code"
}
# --- Main ---
cd "$REPO_ROOT"
# NDJSON mode implies non-interactive (DESIGN-025 / SPEC-291).
# fd 3 = real stdout, used by ndjson_question so $() doesn't capture questions.
if ndjson_mode; then
FLAG_NON_INTERACTIVE=true
exec 3>&1
fi
if [[ "$FLAG_PHASE1_ONLY" == "true" ]]; then
phase1_crash_detection
exit 0
fi
if [[ "$FLAG_PHASE2_ONLY" == "true" ]]; then
phase1_crash_detection
phase2_session_selection
exit 0
fi
if [[ "$FLAG_FRESH" != "true" ]]; then
# Skip Phase 1 in NDJSON mode — crash cleanup needs tty confirmations.
if ! ndjson_mode; then
phase1_crash_detection
fi
phase2_session_selection
fi
# NDJSON mode: prepare workspace and emit ready signal, don't launch runtime.
# The project bridge spawns the runtime adapter after receiving "ready".
if ndjson_mode; then
prepare_session_workspace
local_runtime=$(resolve_runtime)
if [[ -z "$local_runtime" ]]; then
ndjson_emit '{"type":"error","text":"No supported runtime found"}'
exit 1
fi
# Compose initial prompt (same logic as phase3_launch_runtime)
local_prompt="/swain-init"
if [[ -n "$RESUME_PROMPT" ]]; then
local_prompt="$RESUME_PROMPT"
elif [[ ${#PURPOSE_ARGS[@]} -gt 0 ]]; then
local_prompt="/swain-session Session purpose: ${PURPOSE_ARGS[*]}"
fi
local_purpose="${PURPOSE_ARGS[*]:-}"
ndjson_ready "$local_purpose" "$LAUNCH_ROOT" "$local_runtime" "$local_prompt"
exit 0
fi
phase3_launch_runtime
#!/bin/sh
# swain-box — Unified sandbox launcher for AI coding agents (SPEC-092)
# Usage: ./swain-box [--runtime=NAME] [--isolation=MODE] [--prompt=TEXT] [--cleanup=NAME] [path]
#
# Two-step interactive launcher:
# 1. Select agent runtime (claude, copilot, codex, gemini, kiro, opencode)
# 2. Select isolation mode (microVM via Docker Sandboxes, or container via docker run)
#
# Flags:
# --runtime=NAME Skip runtime menu (claude|copilot|codex|gemini|kiro|opencode)
# --isolation=MODE Skip isolation menu (microvm|container)
# --prompt=TEXT Initial prompt for the agent session (default: /swain-session)
# --cleanup=NAME Remove container/sandbox for the named sandbox, then exit
# [path] Project directory (default: $PWD)
#
# Requires Docker Desktop 4.58+ (for docker sandbox subcommand).
set -eu
# ============================================================
# Known issues table
# Empty = no known issues; non-empty = warning text for microVM mode
# ============================================================
_known_issue_microvm() {
case "$1" in
claude) echo "OAuth/Max broken — requires ANTHROPIC_API_KEY" ;;
*) echo "" ;;
esac
}
# Image tag mapping (docker/sandbox-templates:<tag>)
_image_tag() {
case "$1" in
claude) echo "claude-code" ;;
*) echo "$1" ;;
esac
}
# Login command per runtime
_login_cmd() {
case "$1" in
claude) echo "claude /login" ;;
codex) echo "codex login --device-auth" ;;
copilot) echo "/login" ;;
gemini) echo "(login prompt appears on first run)" ;;
kiro) echo "kiro-cli login" ;;
opencode) echo "/connect" ;;
*) echo "$1 login" ;;
esac
}
# API key env var name per runtime
_api_key_var() {
case "$1" in
claude) echo "ANTHROPIC_API_KEY" ;;
codex) echo "OPENAI_API_KEY" ;;
copilot) echo "GITHUB_TOKEN" ;;
gemini) echo "GOOGLE_API_KEY" ;;
*) echo "" ;;
esac
}
# ============================================================
# Apply auth (uses SELECTED_AUTH and COLLECTED_API_KEY from step 2)
# Called after sandbox/container is created
# ============================================================
_apply_auth() {
_target="$1" # "container:<name>" or "microvm:<name>"
_login_command=$(_login_cmd "$SELECTED_RUNTIME")
if [ "$SELECTED_AUTH" = "subscription" ]; then
if ! [ -t 0 ]; then
echo "swain-box: NOTE — subscription login requires interactive mode." >&2
return 1
fi
echo "" >&2
echo "swain-box: Opening a shell. Run:" >&2
echo " $_login_command" >&2
echo "When done, type 'exit' to return here." >&2
echo "" >&2
case "$_target" in
container:*)
docker exec -it "${_target#container:}" /bin/bash ;;
microvm:*)
docker sandbox exec -it "${_target#microvm:}" /bin/bash ;;
esac
echo "" >&2
printf "swain-box: Did login complete successfully? [y/N]: "
read -r _login_ok
case "$_login_ok" in
y|Y)
case "$_target" in
container:*)
docker exec "${_target#container:}" touch /home/agent/.swain-box-auth-done 2>/dev/null ;;
microvm:*)
docker sandbox exec "${_target#microvm:}" touch /home/agent/.swain-box-auth-done 2>/dev/null ;;
esac
echo "swain-box: Login saved. Starting session..." >&2
;;
*)
echo "swain-box: Login not confirmed. You can retry on next launch." >&2
return 1
;;
esac
elif [ "$SELECTED_AUTH" = "apikey" ]; then
_api_var=$(_api_key_var "$SELECTED_RUNTIME")
if [ -n "$COLLECTED_API_KEY" ] && [ -n "$_api_var" ]; then
case "$_target" in
container:*)
docker exec "${_target#container:}" sh -c "echo 'export $_api_var=$COLLECTED_API_KEY' >> /home/agent/.bashrc"
docker exec "${_target#container:}" touch /home/agent/.swain-box-auth-done 2>/dev/null
;;
microvm:*)
docker sandbox exec "${_target#microvm:}" sh -c "echo 'export $_api_var=$COLLECTED_API_KEY' >> ~/.bashrc"
docker sandbox exec "${_target#microvm:}" touch /home/agent/.swain-box-auth-done 2>/dev/null
;;
esac
echo "swain-box: API key saved." >&2
fi
fi
return 0
}
# Check if auth has been completed in a sandbox/container
_auth_done() {
case "$1" in
container:*)
_cname="${1#container:}"
docker exec "$_cname" test -f /home/agent/.swain-box-auth-done 2>/dev/null
;;
microvm:*)
_sname="${1#microvm:}"
docker sandbox exec "$_sname" test -f /home/agent/.swain-box-auth-done 2>/dev/null
;;
esac
}
# ============================================================
# Sandbox management screen (DESIGN-005)
# ============================================================
_manage_sandboxes() {
while true; do
# Collect sandboxes from both Docker Sandboxes and swain-box containers
_sandbox_list=""
_sandbox_count=0
# Docker Sandboxes (microVM)
_sb_lines=$(docker sandbox ls 2>/dev/null | tail -n +2) || true
if [ -n "$_sb_lines" ]; then
echo "$_sb_lines" | while IFS= read -r _line; do
_name=$(echo "$_line" | awk '{print $1}')
_agent=$(echo "$_line" | awk '{print $2}')
_status=$(echo "$_line" | awk '{print $3}')
_ws=$(echo "$_line" | awk '{print $4}')
_sandbox_count=$((_sandbox_count + 1))
echo "$_sandbox_count) $_name|microvm|$_status|$_ws"
done
fi
# Docker containers created by swain-box (named <runtime>-<workdir>)
_ct_lines=$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null | grep -E "^(claude|copilot|codex|gemini|kiro|opencode)-") || true
# Build display
echo ""
echo "swain-box: Active sandboxes:"
_items=""
_count=0
# Collect microVM sandboxes
while IFS= read -r _line; do
[ -z "$_line" ] && continue
_name=$(echo "$_line" | awk '{print $1}')
_status=$(echo "$_line" | awk '{print $3}')
_ws=$(echo "$_line" | awk '{$1=$2=$3=""; print}' | sed 's/^ *//')
_count=$((_count + 1))
_items="${_items}${_count}|${_name}|microvm|${_status}|${_ws}
"
printf " %d) %-20s %-10s %-8s %s\n" "$_count" "$_name" "microvm" "$_status" "$_ws"
done <<EOF
$(docker sandbox ls 2>/dev/null | tail -n +2)
EOF
# Collect containers
while IFS=' ' read -r _name _status; do
[ -z "$_name" ] && continue
_short_status=$(echo "$_status" | awk '{print $1}')
_count=$((_count + 1))
_items="${_items}${_count}|${_name}|container|${_short_status}|
"
printf " %d) %-20s %-10s %-8s\n" "$_count" "$_name" "container" "$_short_status"
done <<EOF
$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null | grep -E "^(claude|copilot|codex|gemini|kiro|opencode)-")
EOF
if [ "$_count" = "0" ]; then
echo " (no sandboxes found)"
echo ""
return
fi
echo ""
echo " Actions: [r]estart [s]top [d]elete [b]ack [q]uit"
printf "Select sandbox [1]: "
read -r _sel
_sel="${_sel:-1}"
[ "$_sel" = "q" ] || [ "$_sel" = "Q" ] && { echo "swain-box: bye." >&2; exit 0; }
[ "$_sel" = "b" ] || [ "$_sel" = "B" ] && return
# Find selected sandbox
_selected=$(echo "$_items" | sed -n "${_sel}p")
if [ -z "$_selected" ]; then
echo "Invalid selection." >&2
continue
fi
_sel_name=$(echo "$_selected" | cut -d'|' -f2)
_sel_type=$(echo "$_selected" | cut -d'|' -f3)
echo ""
echo " $_sel_name ($_sel_type)"
printf " Action: [r]estart [s]top [d]elete [b]ack [q]uit: "
read -r _action
case "$_action" in q|Q) echo "swain-box: bye." >&2; exit 0 ;; esac
case "$_action" in
r|R)
printf " restarting $_sel_name... " >&2
if [ "$_sel_type" = "microvm" ]; then
docker sandbox run "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
else
docker start "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
fi
;;
s|S)
printf " stopping $_sel_name... " >&2
if [ "$_sel_type" = "microvm" ]; then
docker sandbox stop "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
else
docker stop "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
fi
;;
d|D)
printf " Delete $_sel_name? This removes all data inside the sandbox. [y/N]: "
read -r _confirm
case "$_confirm" in
y|Y)
printf " deleting $_sel_name... " >&2
if [ "$_sel_type" = "microvm" ]; then
docker sandbox rm "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
else
docker rm -f "$_sel_name" 2>&1 | sed 's/^/ /' >&2 && echo "done." >&2 || echo "failed." >&2
fi
;;
*)
echo " cancelled." >&2
;;
esac
;;
b|B) ;;
*) echo "Unknown action." >&2 ;;
esac
done
}
# ============================================================
# Argument parsing
# ============================================================
TARGET_PATH=""
EXPLICIT_RUNTIME=""
EXPLICIT_ISOLATION=""
PROMPT="/swain-session"
CLEANUP_NAME=""
for arg in "$@"; do
case "$arg" in
--runtime=*) EXPLICIT_RUNTIME="${arg#--runtime=}" ;;
--isolation=*) EXPLICIT_ISOLATION="${arg#--isolation=}" ;;
--prompt=*) PROMPT="${arg#--prompt=}" ;;
--cleanup=*) CLEANUP_NAME="${arg#--cleanup=}" ;;
--cleanup) CLEANUP_NAME="__next__" ;;
*)
if [ "$CLEANUP_NAME" = "__next__" ]; then
CLEANUP_NAME="$arg"
else
TARGET_PATH="$arg"
fi
;;
esac
done
# ============================================================
# Cleanup subcommand
# ============================================================
if [ -n "$CLEANUP_NAME" ] && [ "$CLEANUP_NAME" != "__next__" ]; then
docker rm -f "$CLEANUP_NAME" 2>/dev/null && \
echo "swain-box: removed container $CLEANUP_NAME" >&2 || true
docker sandbox rm "$CLEANUP_NAME" 2>/dev/null && \
echo "swain-box: removed sandbox $CLEANUP_NAME" >&2 || true
exit 0
fi
# ============================================================
# Docker checks
# ============================================================
if ! command -v docker >/dev/null 2>&1; then
echo "swain-box: 'docker' not found on PATH." >&2
echo "Install Docker Desktop: https://www.docker.com/products/docker-desktop/" >&2
exit 1
fi
if ! docker sandbox --help >/dev/null 2>&1; then
echo "swain-box: 'docker sandbox' subcommand is not available." >&2
echo "Requires Docker Desktop 4.58 or later." >&2
exit 1
fi
# ============================================================
# Resolve project path
# ============================================================
TARGET_PATH="${TARGET_PATH:-$PWD}"
PROJECT_PATH="$(cd "$TARGET_PATH" && pwd)" || {
echo "swain-box: path does not exist: $TARGET_PATH" >&2
exit 1
}
# ============================================================
# Step 1 — Runtime selection
# ============================================================
KNOWN_RUNTIMES="claude copilot codex gemini kiro opencode"
# Detect available runtimes from docker sandbox create --help (instant)
_create_help=$(docker sandbox create --help 2>&1)
AVAILABLE_RUNTIMES=""
AVAILABLE_COUNT=0
for _rt in $KNOWN_RUNTIMES; do
if echo "$_create_help" | grep -qw "$_rt"; then
AVAILABLE_RUNTIMES="${AVAILABLE_RUNTIMES:+$AVAILABLE_RUNTIMES }$_rt"
AVAILABLE_COUNT=$((AVAILABLE_COUNT + 1))
fi
done
SELECTED_RUNTIME=""
if [ -n "$EXPLICIT_RUNTIME" ]; then
# Validate
if ! echo "$_create_help" | grep -qw "$EXPLICIT_RUNTIME"; then
echo "swain-box: runtime '$EXPLICIT_RUNTIME' is not available." >&2
[ "$AVAILABLE_COUNT" -gt 0 ] && echo " Available: $AVAILABLE_RUNTIMES" >&2
exit 1
fi
SELECTED_RUNTIME="$EXPLICIT_RUNTIME"
elif [ "$AVAILABLE_COUNT" = "0" ]; then
echo "swain-box: no supported agent runtimes found in Docker Sandboxes." >&2
echo " Expected one of: $KNOWN_RUNTIMES" >&2
exit 1
elif [ "$AVAILABLE_COUNT" = "1" ]; then
SELECTED_RUNTIME="$AVAILABLE_RUNTIMES"
echo "swain-box: using $SELECTED_RUNTIME." >&2
else
if [ -t 0 ]; then
_show_runtime_menu() {
echo "swain-box: Select a runtime:"
_i=1
for _rt in $AVAILABLE_RUNTIMES; do
echo " $_i) $_rt"
_i=$((_i + 1))
done
echo ""
echo " s) Manage sandboxes q) Quit"
}
_show_runtime_menu
_attempts=0
while [ "$_attempts" -lt 2 ]; do
printf "Choice [1]: "
read -r _choice
_choice="${_choice:-1}"
# Quit
case "$_choice" in q|Q) echo "swain-box: bye." >&2; exit 0 ;; esac
# Sandbox management
if [ "$_choice" = "s" ] || [ "$_choice" = "S" ]; then
_manage_sandboxes
echo ""
_show_runtime_menu
_attempts=0
continue
fi
case "$_choice" in
*[!0-9]*) ;;
*)
if [ "$_choice" -ge 1 ] 2>/dev/null && [ "$_choice" -le "$AVAILABLE_COUNT" ] 2>/dev/null; then
_i=1
for _rt in $AVAILABLE_RUNTIMES; do
[ "$_i" = "$_choice" ] && SELECTED_RUNTIME="$_rt" && break
_i=$((_i + 1))
done
break
fi
;;
esac
_attempts=$((_attempts + 1))
[ "$_attempts" -lt 2 ] && echo "Invalid selection. Try again:" || { echo "swain-box: invalid selection." >&2; exit 1; }
done
echo "swain-box: using $SELECTED_RUNTIME." >&2
else
SELECTED_RUNTIME="${AVAILABLE_RUNTIMES%% *}"
echo "swain-box: auto-selected $SELECTED_RUNTIME (non-interactive). Use --runtime=<name> to specify." >&2
fi
fi
# ============================================================
# Step 2 — Auth type selection (ADR-008: subscription default)
# ============================================================
SELECTED_AUTH="subscription" # default
_api_var=$(_api_key_var "$SELECTED_RUNTIME")
COLLECTED_API_KEY=""
if [ -t 0 ]; then
echo "swain-box: How do you authenticate?"
echo " 1) Subscription (login inside sandbox)"
if [ -n "$_api_var" ]; then
echo " 2) API key ($_api_var)"
fi
echo ""
echo " b) Back q) Quit"
_attempts=0
while [ "$_attempts" -lt 2 ]; do
printf "Choice [1]: "
read -r _choice
_choice="${_choice:-1}"
case "$_choice" in
q|Q) echo "swain-box: bye." >&2; exit 0 ;;
b|B) exec "$0" "$@" ;;
1) SELECTED_AUTH="subscription"; break ;;
2)
SELECTED_AUTH="apikey"
if [ -n "$_api_var" ]; then
printf "Enter %s: " "$_api_var" >&2
read -r COLLECTED_API_KEY
fi
break
;;
*)
_attempts=$((_attempts + 1))
[ "$_attempts" -lt 2 ] && echo "Invalid selection. Try again:" || { echo "swain-box: invalid selection." >&2; exit 1; }
;;
esac
done
fi
# ============================================================
# Step 3 — Isolation selection (informed by auth choice)
# ============================================================
SELECTED_ISOLATION=""
# Known issues depend on BOTH runtime and auth type
_issue=""
if [ "$SELECTED_AUTH" = "subscription" ]; then
_issue=$(_known_issue_microvm "$SELECTED_RUNTIME")
fi
# API key auth has no known microVM issues (MITM proxy handles api.anthropic.com fine)
if [ -n "$EXPLICIT_ISOLATION" ]; then
case "$EXPLICIT_ISOLATION" in
microvm|container) SELECTED_ISOLATION="$EXPLICIT_ISOLATION" ;;
*) echo "swain-box: invalid --isolation mode: $EXPLICIT_ISOLATION (microvm|container)" >&2; exit 1 ;;
esac
else
if [ -n "$_issue" ]; then
_default=2
else
_default=1
fi
if [ -t 0 ]; then
echo "swain-box: Select isolation for $SELECTED_RUNTIME ($SELECTED_AUTH):"
if [ -n "$_issue" ]; then
echo " 1) Docker Sandboxes (microVM) — WARNING: $_issue"
echo " 2) Docker Container — subscription login works"
else
echo " 1) Docker Sandboxes (microVM) — recommended, strongest isolation"
echo " 2) Docker Container"
fi
echo ""
echo " b) Back q) Quit"
_attempts=0
while [ "$_attempts" -lt 2 ]; do
printf "Choice [$_default]: "
read -r _choice
_choice="${_choice:-$_default}"
case "$_choice" in
q|Q) echo "swain-box: bye." >&2; exit 0 ;;
b|B) exec "$0" "$@" ;;
1) SELECTED_ISOLATION="microvm"; break ;;
2) SELECTED_ISOLATION="container"; break ;;
*)
_attempts=$((_attempts + 1))
[ "$_attempts" -lt 2 ] && echo "Invalid selection. Try again:" || { echo "swain-box: invalid selection." >&2; exit 1; }
;;
esac
done
else
if [ -n "$_issue" ]; then
SELECTED_ISOLATION="container"
else
SELECTED_ISOLATION="microvm"
fi
echo "swain-box: auto-selected $SELECTED_ISOLATION isolation (non-interactive)." >&2
fi
fi
echo "swain-box: $SELECTED_RUNTIME ($SELECTED_AUTH) in $SELECTED_ISOLATION mode" >&2
# ============================================================
# Derive names
# ============================================================
WORKDIR_NAME="$(basename "$PROJECT_PATH")"
SANDBOX_NAME="${SELECTED_RUNTIME}-${WORKDIR_NAME}"
IMAGE_TAG=$(_image_tag "$SELECTED_RUNTIME")
# ============================================================
# Build agent args with prompt injection
# ============================================================
# Build the full exec command (runtime + flags + prompt)
_build_exec_cmd() {
case "$SELECTED_RUNTIME" in
claude)
echo "claude --dangerously-skip-permissions \"$PROMPT\""
;;
*)
echo "swain-box: NOTE — run $PROMPT (or your runtime's equivalent) to start" >&2
echo "$SELECTED_RUNTIME"
;;
esac
}
# ============================================================
# Launch
# ============================================================
if [ "$SELECTED_ISOLATION" = "microvm" ]; then
# --- Docker Sandboxes (microVM) ---
# Check if this is a first run (no existing sandbox)
if ! docker sandbox ls 2>/dev/null | grep -q "$SANDBOX_NAME"; then
# New sandbox: create → auth → run
echo "swain-box: creating sandbox..." >&2
docker sandbox create "$SELECTED_RUNTIME" "$PROJECT_PATH" 2>&1 | grep -v "^$" >&2
SANDBOX_NAME=$(docker sandbox ls 2>/dev/null | grep "$SELECTED_RUNTIME" | awk '{print $1}' | head -1)
if [ -z "$SANDBOX_NAME" ]; then
echo "swain-box: failed to create sandbox." >&2
exit 1
fi
_apply_auth "microvm:$SANDBOX_NAME" || exec "$0" "$@"
elif ! _auth_done "microvm:$SANDBOX_NAME"; then
_apply_auth "microvm:$SANDBOX_NAME" || exec "$0" "$@"
fi
case "$SELECTED_RUNTIME" in
claude)
exec docker sandbox run "$SANDBOX_NAME" -- --dangerously-skip-permissions "$PROMPT"
;;
*)
echo "swain-box: NOTE — run $PROMPT (or your runtime's equivalent) to start" >&2
exec docker sandbox run "$SANDBOX_NAME"
;;
esac
else
# --- Docker Container ---
# Strategy: container runs `sleep infinity` as its CMD so it stays alive.
# Claude sessions are launched via `docker exec`. This means:
# - Container persists between sessions (credentials, packages survive)
# - Multiple exec sessions can attach concurrently
# - Exiting Claude doesn't kill the container
# Check if container already exists
if docker container inspect "$SANDBOX_NAME" >/dev/null 2>&1; then
_state=$(docker container inspect "$SANDBOX_NAME" --format '{{.State.Status}}' 2>/dev/null)
if [ "$_state" != "running" ]; then
echo "swain-box: starting container $SANDBOX_NAME" >&2
docker start "$SANDBOX_NAME" >/dev/null 2>&1
fi
# Auth check — if never completed, run it now
if ! _auth_done "container:$SANDBOX_NAME"; then
_apply_auth "container:$SANDBOX_NAME" || exec "$0" "$@"
fi
echo "swain-box: connecting to $SANDBOX_NAME" >&2
# shellcheck disable=SC2086
exec docker exec -it "$SANDBOX_NAME" $(_build_exec_cmd)
fi
echo "swain-box: creating container $SANDBOX_NAME" >&2
# Create with sleep infinity as CMD — container stays alive indefinitely.
docker run -d \
--name "$SANDBOX_NAME" \
-v "$PROJECT_PATH:/home/agent/workspace" \
"docker/sandbox-templates:$IMAGE_TAG" \
sleep infinity >/dev/null 2>&1
# --- First-run auth (using choice from step 2) ---
_apply_auth "container:$SANDBOX_NAME" || exec "$0" "$@"
# Launch the agent session
# shellcheck disable=SC2086
exec docker exec -it "$SANDBOX_NAME" $(_build_exec_cmd)
fi
#!/usr/bin/env bash
# swain-lockfile.sh — Lockfile claiming, releasing, and stale detection for worktrees
# SPEC-244 | EPIC-056
set -uo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
LOCKFILE_DIR="${SWAIN_LOCKFILE_DIR:-$REPO_ROOT/.agents/worktrees}"
# --- Helpers ---
_lockfile_path() {
echo "$LOCKFILE_DIR/$1.lock"
}
_ensure_dir() {
mkdir -p "$LOCKFILE_DIR"
}
_pid_alive() {
local p="$1"
kill -0 "$p" 2>/dev/null
}
_pane_alive() {
local pane="$1"
[ -z "$pane" ] && return 1
[ -z "${TMUX:-}" ] && return 1 # not in tmux, can't check
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qF "$pane"
}
_is_stale() {
local lockfile="$1"
[ ! -f "$lockfile" ] && return 0 # missing = stale
local pid user_field exe pane_id
# shellcheck disable=SC1090
source "$lockfile"
# PID alive?
if _pid_alive "${pid:-0}"; then
# Check for PID recycling: user and exe must match
local current_user
current_user="$(whoami)"
if [ "${user:-}" != "$current_user" ]; then
return 0 # stale: different user owns the PID now
fi
# PID alive and user matches -> not stale
return 1
fi
# PID dead. In tmux, also check pane.
if [ -n "${TMUX:-}" ] && [ -n "${pane_id:-}" ]; then
if _pane_alive "$pane_id"; then
return 1 # pane alive, might be restarting — not stale yet
fi
fi
# PID dead (and pane dead if applicable) -> stale
return 0
}
# --- Commands ---
cmd_claim() {
local branch="$1" worktree_path="$2" purpose="${3:-}"
_ensure_dir
local lockfile
lockfile="$(_lockfile_path "$branch")"
# Check existing claim
if [ -f "$lockfile" ]; then
if ! _is_stale "$lockfile"; then
echo "ERROR: Worktree '$branch' already claimed (lockfile exists and is active)" >&2
return 1
fi
# Stale — remove and reclaim
rm -f "$lockfile"
fi
# Atomic write: temp file + mv
local tmpfile
tmpfile="$(mktemp "$LOCKFILE_DIR/.claim-XXXXXX")"
cat > "$tmpfile" << EOF
version=1
pid=$$
user=$(whoami)
exe=${SWAIN_RUNTIME:-unknown}
pane_id=${TMUX_PANE:-}
claimed_at=$(date -Iseconds)
worktree_path=$worktree_path
purpose="$purpose"
status=active
EOF
mv "$tmpfile" "$lockfile"
echo "Claimed: $branch -> $worktree_path"
}
cmd_release() {
local branch="$1"
local lockfile
lockfile="$(_lockfile_path "$branch")"
if [ ! -f "$lockfile" ]; then
return 0 # no-op
fi
rm -f "$lockfile"
echo "Released: $branch"
}
cmd_is_stale() {
local branch="$1"
local lockfile
lockfile="$(_lockfile_path "$branch")"
if [ ! -f "$lockfile" ]; then
echo "No lockfile for '$branch'" >&2
return 0 # no lockfile = effectively stale
fi
if _is_stale "$lockfile"; then
echo "Stale: $branch"
return 0
else
echo "Active: $branch"
return 1
fi
}
cmd_list() {
_ensure_dir
local first=true
echo "["
for lockfile in "$LOCKFILE_DIR"/*.lock; do
[ -f "$lockfile" ] || continue
local branch
branch="$(basename "$lockfile" .lock)"
# Source to get fields
local version pid user exe pane_id claimed_at worktree_path purpose status ready_for_cleanup ready_commit
version="" pid="" user="" exe="" pane_id="" claimed_at="" worktree_path="" purpose="" status="" ready_for_cleanup="" ready_commit=""
# shellcheck disable=SC1090
source "$lockfile"
local lock_status="active"
if _is_stale "$lockfile"; then
lock_status="stale"
elif [ "${ready_for_cleanup:-}" = "true" ]; then
lock_status="ready"
fi
# Calculate age
local age_seconds=0
if [ -n "$claimed_at" ]; then
local claimed_epoch now_epoch
claimed_epoch="$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$claimed_at" "+%s" 2>/dev/null || echo 0)"
now_epoch="$(date +%s)"
age_seconds=$(( now_epoch - claimed_epoch ))
fi
if [ "$first" = true ]; then
first=false
else
echo ","
fi
# Strip quotes from purpose if present
purpose="${purpose#\"}"
purpose="${purpose%\"}"
cat << ENTRY_EOF
{
"branch": "$branch",
"status": "$lock_status",
"pid": ${pid:-0},
"user": "${user:-}",
"exe": "${exe:-}",
"pane_id": "${pane_id:-}",
"worktree_path": "${worktree_path:-}",
"purpose": "$purpose",
"claimed_at": "${claimed_at:-}",
"age_seconds": $age_seconds,
"ready_for_cleanup": ${ready_for_cleanup:-false},
"ready_commit": "${ready_commit:-}"
}
ENTRY_EOF
done
echo ""
echo "]"
}
cmd_mark_ready() {
local branch="$1"
local lockfile
lockfile="$(_lockfile_path "$branch")"
if [ ! -f "$lockfile" ]; then
echo "ERROR: No lockfile for '$branch'" >&2
return 1
fi
# Get current HEAD
local head_commit
head_commit="$(git rev-parse HEAD 2>/dev/null || echo "unknown")"
# Append ready fields (atomic rewrite)
local tmpfile
tmpfile="$(mktemp "$LOCKFILE_DIR/.ready-XXXXXX")"
# Copy existing content, filter out any previous ready fields
grep -v '^ready_for_cleanup=' "$lockfile" | grep -v '^ready_commit=' > "$tmpfile"
# Append ready fields
echo "ready_for_cleanup=true" >> "$tmpfile"
echo "ready_commit=$head_commit" >> "$tmpfile"
mv "$tmpfile" "$lockfile"
echo "Marked ready: $branch (commit: $head_commit)"
}
cmd_verify_ready() {
local branch="$1"
local lockfile
lockfile="$(_lockfile_path "$branch")"
if [ ! -f "$lockfile" ]; then
echo "ERROR: No lockfile for '$branch'" >&2
return 2
fi
# Source lockfile
local ready_for_cleanup ready_commit
ready_for_cleanup="" ready_commit=""
# shellcheck disable=SC1090
source "$lockfile"
if [ "${ready_for_cleanup:-}" != "true" ]; then
echo "Not marked ready: $branch"
return 2
fi
local current_head
current_head="$(git rev-parse HEAD 2>/dev/null || echo "unknown")"
if [ "$ready_commit" = "$current_head" ]; then
echo "Verified: $branch (commit matches)"
return 0
else
echo "Mismatch: $branch (ready=$ready_commit, current=$current_head)"
return 1
fi
}
# --- Main dispatch ---
cmd="${1:-help}"
shift || true
case "$cmd" in
claim) cmd_claim "$@" ;;
release) cmd_release "$@" ;;
is-stale) cmd_is_stale "$@" ;;
list) cmd_list ;;
mark-ready) cmd_mark_ready "$@" ;;
verify-ready) cmd_verify_ready "$@" ;;
help)
echo "Usage: swain-lockfile.sh <command> [args]"
echo ""
echo "Commands:"
echo " claim <branch> <worktree-path> [purpose] Claim a worktree"
echo " release <branch> Release a claim"
echo " is-stale <branch> Check if claim is stale (exit 0=stale, 1=active)"
echo " list List all lockfiles (JSON)"
echo " mark-ready <branch> Mark lockfile ready_for_cleanup"
echo " verify-ready <branch> Verify ready commit matches HEAD"
echo ""
;;
*)
echo "Unknown command: $cmd" >&2
exit 1
;;
esac
../../scripts/swain../../scripts/swain-box