mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-08 18:39:50 +00:00
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:
parent
1826fceee3
commit
49c8c4f60b
5 changed files with 192 additions and 7 deletions
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue