From 49c8c4f60b2ef9efd335268a0ee002a7e5d0925f Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:16:53 -0800 Subject: [PATCH] feat: add VM reconnect functionality to spawn list (#1175) * feat: add VM reconnect functionality to spawn list (#1144) Implements ability to reconnect to previously spawned VMs instead of always creating new instances. Changes include: - Add VMConnection interface to track IP, user, and server metadata - Add save_vm_connection() bash function for scripts to persist connection info - Modify spawn list to show connection status and offer reconnect option - Support both SSH (cloud providers) and sprite console reconnection - Update digitalocean/claude.sh and sprite/claude.sh as reference implementations Fixes #1144 Co-Authored-By: Claude Sonnet 4.5 * improve: add helpful error message when VM reconnect fails Show user-friendly message suggesting to spawn a new VM if reconnection fails, rather than just showing raw SSH error. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: spawn-refactor-bot Co-authored-by: Claude Sonnet 4.5 --- cli/src/commands.ts | 117 ++++++++++++++++++++++++++++++++++++++--- cli/src/history.ts | 44 ++++++++++++++++ digitalocean/claude.sh | 3 ++ shared/common.sh | 32 +++++++++++ sprite/claude.sh | 3 ++ 5 files changed, 192 insertions(+), 7 deletions(-) diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 19980c00..cc7af3a4 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -16,7 +16,7 @@ import { import pkg from "../package.json" with { type: "json" }; const VERSION = pkg.version; import { validateIdentifier, validateScriptContent, validatePrompt } from "./security.js"; -import { saveSpawnRecord, filterHistory, clearHistory, type SpawnRecord } from "./history.js"; +import { saveSpawnRecord, filterHistory, clearHistory, type SpawnRecord, type VMConnection } from "./history.js"; // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -1441,6 +1441,13 @@ function renderListTable(records: SpawnRecord[], manifest: Manifest | null): voi pc.green(agentDisplay.padEnd(20)) + cloudDisplay.padEnd(20) + pc.dim(relative); + if (r.connection) { + if (r.connection.ip === "sprite-console" && r.connection.server_name) { + line += pc.green(` sprite console -s ${r.connection.server_name}`); + } else { + line += pc.green(` ssh ${r.connection.user}@${r.connection.ip}`); + } + } if (r.prompt) { const preview = r.prompt.length > 40 ? r.prompt.slice(0, 40) + "..." : r.prompt; line += pc.dim(` --prompt "${preview}"`); @@ -1461,14 +1468,25 @@ export function buildRecordLabel(r: SpawnRecord, manifest: Manifest | null): str return `${agentDisplay} on ${cloudDisplay}`; } -/** Build a hint string (relative timestamp + optional prompt preview) for the interactive picker */ +/** Build a hint string (relative timestamp + connection status + optional prompt preview) for the interactive picker */ export function buildRecordHint(r: SpawnRecord): string { const relative = formatRelativeTime(r.timestamp); + const parts: string[] = [relative]; + + if (r.connection) { + if (r.connection.ip === "sprite-console" && r.connection.server_name) { + parts.push(pc.green(`sprite console -s ${r.connection.server_name}`)); + } else { + parts.push(pc.green(`ssh ${r.connection.user}@${r.connection.ip}`)); + } + } + if (r.prompt) { const preview = r.prompt.length > 30 ? r.prompt.slice(0, 30) + "..." : r.prompt; - return `${relative} --prompt "${preview}"`; + parts.push(`--prompt "${preview}"`); } - return relative; + + return parts.join(" "); } /** Try to load manifest and resolve filter display names to keys. @@ -1505,7 +1523,7 @@ async function resolveListFilters( return { manifest, agentFilter, cloudFilter }; } -/** Show interactive picker to select and rerun a previous spawn */ +/** Show interactive picker to select and reconnect/rerun a previous spawn */ async function interactiveListPicker(records: SpawnRecord[], manifest: Manifest | null): Promise { p.log.info(pc.dim(`Filter: ${pc.cyan("spawn list -a ")} or ${pc.cyan("spawn list -c ")} | Clear: ${pc.cyan("spawn list --clear")}`)); @@ -1516,7 +1534,7 @@ async function interactiveListPicker(records: SpawnRecord[], manifest: Manifest })); const choice = await p.select({ - message: `Select a spawn to rerun (${records.length} recorded)`, + message: `Select a spawn (${records.length} recorded)`, options, }); if (p.isCancel(choice)) { @@ -1524,7 +1542,34 @@ async function interactiveListPicker(records: SpawnRecord[], manifest: Manifest } const selected = records[choice]; - p.log.step(`Rerunning ${pc.bold(buildRecordLabel(selected, manifest))}`); + + // If there's connection info, offer to reconnect or rerun + if (selected.connection) { + const action = await p.select({ + message: "What would you like to do?", + options: [ + { value: "reconnect", label: "Reconnect to existing VM", hint: `ssh ${selected.connection.user}@${selected.connection.ip}` }, + { value: "rerun", label: "Spawn a new VM", hint: "Create a fresh instance" }, + ], + }); + + if (p.isCancel(action)) { + handleCancel(); + } + + if (action === "reconnect") { + try { + await cmdConnect(selected.connection); + } catch (err) { + p.log.error(`Connection failed: ${getErrorMessage(err)}`); + p.log.info(`VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent}/${selected.cloud}`)} to start a new one.`); + } + return; + } + } + + // Rerun (create new spawn) + p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`); await cmdRun(selected.agent, selected.cloud, selected.prompt); } @@ -1594,6 +1639,64 @@ export async function cmdLast(): Promise { await cmdRun(latest.agent, latest.cloud, latest.prompt); } +// ── Connect ──────────────────────────────────────────────────────────────────── + +/** Connect to an existing VM via SSH */ +async function cmdConnect(connection: VMConnection): Promise { + // Handle Sprite console connections + if (connection.ip === "sprite-console" && connection.server_name) { + p.log.step(`Connecting to sprite ${pc.bold(connection.server_name)}...`); + + return new Promise((resolve, reject) => { + const child = spawn("sprite", ["console", "-s", connection.server_name], { + stdio: "inherit", + }); + + child.on("close", (code: number | null) => { + if (code === 0 || code === null) { + resolve(); + } else { + reject(new Error(`Sprite console connection failed with exit code ${code}`)); + } + }); + + child.on("error", (err) => { + p.log.error(`Failed to connect: ${getErrorMessage(err)}`); + p.log.info(`Try manually: ${pc.cyan(`sprite console -s ${connection.server_name}`)}`); + reject(err); + }); + }); + } + + // Handle SSH connections + p.log.step(`Connecting to ${pc.bold(connection.ip)}...`); + + const sshCmd = `ssh -o StrictHostKeyChecking=accept-new ${connection.user}@${connection.ip}`; + + return new Promise((resolve, reject) => { + const child = spawn("ssh", [ + "-o", "StrictHostKeyChecking=accept-new", + `${connection.user}@${connection.ip}` + ], { + stdio: "inherit", + }); + + child.on("close", (code: number | null) => { + if (code === 0 || code === null) { + resolve(); + } else { + reject(new Error(`SSH connection failed with exit code ${code}`)); + } + }); + + child.on("error", (err) => { + p.log.error(`Failed to connect: ${getErrorMessage(err)}`); + p.log.info(`Try manually: ${pc.cyan(sshCmd)}`); + reject(err); + }); + }); +} + // ── Agents ───────────────────────────────────────────────────────────────────── export function getImplementedAgents(manifest: Manifest, cloud: string): string[] { diff --git a/cli/src/history.ts b/cli/src/history.ts index 825af62e..e81c76b4 100644 --- a/cli/src/history.ts +++ b/cli/src/history.ts @@ -2,11 +2,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from " import { join, resolve, isAbsolute } from "path"; import { homedir } from "os"; +export interface VMConnection { + ip: string; + user: string; + server_id?: string; + server_name?: string; +} + export interface SpawnRecord { agent: string; cloud: string; timestamp: string; prompt?: string; + connection?: VMConnection; } /** Returns the directory for spawn data, respecting SPAWN_HOME env var. @@ -30,6 +38,10 @@ export function getHistoryPath(): string { return join(getSpawnDir(), "history.json"); } +export function getConnectionPath(): string { + return join(getSpawnDir(), "last-connection.json"); +} + export function loadHistory(): SpawnRecord[] { const path = getHistoryPath(); if (!existsSync(path)) return []; @@ -68,10 +80,41 @@ export function clearHistory(): number { return count; } +/** Check for pending connection data and merge it into the last history entry. + * Bash scripts write connection info to last-connection.json after successful spawn. + * This function merges that data into the history and persists it. */ +export function mergeLastConnection(): void { + const connPath = getConnectionPath(); + if (!existsSync(connPath)) return; + + try { + const connData = JSON.parse(readFileSync(connPath, "utf-8")) as VMConnection; + const history = loadHistory(); + + if (history.length > 0) { + // Update the most recent entry with connection info + const latest = history[history.length - 1]; + if (!latest.connection) { + latest.connection = connData; + // Save updated history + writeFileSync(getHistoryPath(), JSON.stringify(history, null, 2) + "\n"); + } + } + + // Clean up the connection file after merging + unlinkSync(connPath); + } catch { + // Ignore errors - connection data is optional + } +} + export function filterHistory( agentFilter?: string, cloudFilter?: string ): SpawnRecord[] { + // Merge any pending connection data before filtering + mergeLastConnection(); + let records = loadHistory(); if (agentFilter) { const lower = agentFilter.toLowerCase(); @@ -83,5 +126,6 @@ export function filterHistory( } // Show newest first (reverse chronological order) records.reverse(); + return records; } diff --git a/digitalocean/claude.sh b/digitalocean/claude.sh index 7ac45bf4d..68f5c8c8 100755 --- a/digitalocean/claude.sh +++ b/digitalocean/claude.sh @@ -72,6 +72,9 @@ log_info "DigitalOcean droplet setup completed successfully!" log_info "Droplet: ${DROPLET_NAME} (ID: ${DO_DROPLET_ID}, IP: ${DO_SERVER_IP})" echo "" +# Save connection info for spawn list +save_vm_connection "${DO_SERVER_IP}" "root" "${DO_DROPLET_ID}" "${DROPLET_NAME}" + # 9. Start Claude Code interactively log_step "Starting Claude Code..." sleep 1 diff --git a/shared/common.sh b/shared/common.sh index c979a93a..c3cf56fe 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -2866,6 +2866,38 @@ opencode_install_cmd() { printf '%s' 'OC_ARCH=$(uname -m); if [ "$OC_ARCH" = "aarch64" ]; then OC_ARCH=arm64; fi; OC_OS=$(uname -s | tr A-Z a-z); if [ "$OC_OS" = "darwin" ]; then OC_OS=mac; fi; mkdir -p /tmp/opencode-install "$HOME/.opencode/bin" && curl -fsSL -o /tmp/opencode-install/oc.tar.gz "https://github.com/opencode-ai/opencode/releases/latest/download/opencode-${OC_OS}-${OC_ARCH}.tar.gz" && tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install && mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/" && rm -rf /tmp/opencode-install && grep -q ".opencode/bin" "$HOME/.bashrc" 2>/dev/null || echo '"'"'export PATH="$HOME/.opencode/bin:$PATH"'"'"' >> "$HOME/.bashrc"; grep -q ".opencode/bin" "$HOME/.zshrc" 2>/dev/null || echo '"'"'export PATH="$HOME/.opencode/bin:$PATH"'"'"' >> "$HOME/.zshrc" 2>/dev/null; export PATH="$HOME/.opencode/bin:$PATH"' } +# ============================================================ +# VM Connection Tracking +# ============================================================ + +# Save VM connection info for spawn list reconnect functionality. +# This allows users to reconnect to previously spawned VMs via `spawn list`. +# Usage: save_vm_connection IP USER [SERVER_ID] [SERVER_NAME] +# Example: save_vm_connection "$DO_SERVER_IP" "root" "$DO_DROPLET_ID" "$DROPLET_NAME" +save_vm_connection() { + local ip="${1}" + local user="${2}" + local server_id="${3:-}" + local server_name="${4:-}" + + local spawn_dir="${HOME}/.spawn" + mkdir -p "${spawn_dir}" + + local conn_file="${spawn_dir}/last-connection.json" + + # Build JSON (handle optional fields) + local json="{\"ip\":\"${ip}\",\"user\":\"${user}\"" + if [[ -n "${server_id}" ]]; then + json="${json},\"server_id\":\"${server_id}\"" + fi + if [[ -n "${server_name}" ]]; then + json="${json},\"server_name\":\"${server_name}\"" + fi + json="${json}}" + + printf '%s\n' "${json}" > "${conn_file}" +} + # ============================================================ # Auto-initialization # ============================================================ diff --git a/sprite/claude.sh b/sprite/claude.sh index 1c634e37..c16caafd 100755 --- a/sprite/claude.sh +++ b/sprite/claude.sh @@ -62,6 +62,9 @@ echo "" log_info "Sprite setup completed successfully!" echo "" +# Save sprite connection info for spawn list +save_vm_connection "sprite-console" "${USER:-root}" "" "${SPRITE_NAME}" + # Check if running in non-interactive mode if [[ -n "${SPAWN_PROMPT:-}" ]]; then # Non-interactive mode: execute prompt and exit