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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: spawn-refactor-bot <refactor@openrouter.ai>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-14 21:16:53 -08:00 committed by GitHub
parent 1826fceee3
commit 49c8c4f60b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 192 additions and 7 deletions

View file

@ -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<void> {
p.log.info(pc.dim(`Filter: ${pc.cyan("spawn list -a <agent>")} or ${pc.cyan("spawn list -c <cloud>")} | 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<void> {
await cmdRun(latest.agent, latest.cloud, latest.prompt);
}
// ── Connect ────────────────────────────────────────────────────────────────────
/** Connect to an existing VM via SSH */
async function cmdConnect(connection: VMConnection): Promise<void> {
// 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<void>((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<void>((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[] {

View file

@ -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;
}

View file

@ -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

View file

@ -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
# ============================================================

View file

@ -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