feat: add "Enter agent" option to spawn ls (#1662)

When selecting a previous spawn from `spawn ls`, the first option is now
"Enter <agent>" which SSHes into the VM and launches the agent directly,
instead of just opening a plain SSH shell.

The exact launch command is captured at spawn time and stored in the
connection record, so dynamic state (PATH setup, env sourcing) is
preserved for reconnection.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-21 19:40:05 -08:00 committed by GitHub
parent ef7b67752e
commit 8eedcd8553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 150 additions and 2 deletions

View file

@ -1956,10 +1956,23 @@ async function handleRecordAction(
const options: { value: string; label: string; hint?: string }[] = [];
// Prefer stored launch command (captured at spawn time), fall back to manifest
const agentDef = manifest?.agents?.[selected.agent];
const launchCmd = conn.launch_cmd || agentDef?.launch;
if (!conn.deleted && launchCmd) {
const agentName = agentDef?.name || selected.agent;
options.push({
value: "enter",
label: `Enter ${agentName}`,
hint: agentDef?.launch || launchCmd,
});
}
if (!conn.deleted) {
options.push({
value: "reconnect",
label: "Reconnect to existing VM",
label: "SSH into VM",
hint: conn.ip === "sprite-console"
? `sprite console -s ${conn.server_name}`
: conn.ip === "fly-ssh"
@ -1993,6 +2006,16 @@ async function handleRecordAction(
handleCancel();
}
if (action === "enter") {
try {
await cmdEnterAgent(selected.connection, selected.agent, manifest);
} 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;
}
if (action === "reconnect") {
try {
await cmdConnect(selected.connection);
@ -2234,6 +2257,90 @@ async function cmdConnect(connection: VMConnection): Promise<void> {
);
}
/** SSH into a VM and launch the agent directly */
async function cmdEnterAgent(
connection: VMConnection,
agentKey: string,
manifest: Manifest | null
): Promise<void> {
// SECURITY: Validate all connection parameters before use
try {
validateConnectionIP(connection.ip);
validateUsername(connection.user);
if (connection.server_name) {
validateServerIdentifier(connection.server_name);
}
} catch (err) {
p.log.error(`Security validation failed: ${getErrorMessage(err)}`);
p.log.info(`Your spawn history file may be corrupted or tampered with.`);
p.log.info(`Location: ${getHistoryPath()}`);
p.log.info(`To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'`);
process.exit(1);
}
const agentDef = manifest?.agents?.[agentKey];
// Prefer the launch command stored at spawn time (captures dynamic state),
// fall back to manifest definition, then to agent key as last resort
const storedCmd = connection.launch_cmd;
let remoteCmd: string;
if (storedCmd) {
// Stored command already includes source ~/.spawnrc, PATH setup, etc.
remoteCmd = storedCmd;
} else {
const launchCmd = agentDef?.launch ?? agentKey;
const preLaunch = agentDef?.pre_launch;
const parts = ["source ~/.spawnrc 2>/dev/null"];
if (preLaunch) parts.push(preLaunch);
parts.push(launchCmd);
remoteCmd = parts.join("; ");
}
const agentName = agentDef?.name || agentKey;
// Handle Sprite console connections
if (connection.ip === "sprite-console" && connection.server_name) {
p.log.step(`Entering ${pc.bold(agentName)} on sprite ${pc.bold(connection.server_name)}...`);
return runInteractiveCommand(
"sprite",
["console", "-s", connection.server_name, "--", "bash", "-lc", remoteCmd],
`Failed to enter ${agentName}`,
`sprite console -s ${connection.server_name} -- bash -lc '${remoteCmd}'`
);
}
// Handle Fly.io SSH connections
if (connection.ip === "fly-ssh" && connection.server_name) {
p.log.step(`Entering ${pc.bold(agentName)} on Fly.io app ${pc.bold(connection.server_name)}...`);
return runInteractiveCommand(
"fly",
["ssh", "console", "-a", connection.server_name, "--pty", "-C", remoteCmd],
`Failed to enter ${agentName}`,
`fly ssh console -a ${connection.server_name} --pty -C '${remoteCmd}'`
);
}
// Handle Daytona sandbox connections
if (connection.ip === "daytona-sandbox" && connection.server_id) {
p.log.step(`Entering ${pc.bold(agentName)} on Daytona sandbox ${pc.bold(connection.server_id)}...`);
return runInteractiveCommand(
"daytona",
["ssh", connection.server_id, "--", "bash", "-lc", remoteCmd],
`Failed to enter ${agentName}`,
`daytona ssh ${connection.server_id} -- bash -lc '${remoteCmd}'`
);
}
// Standard SSH connection with agent launch
p.log.step(`Entering ${pc.bold(agentName)} on ${pc.bold(connection.ip)}...`);
return runInteractiveCommand(
"ssh",
["-t", "-o", "StrictHostKeyChecking=accept-new", `${connection.user}@${connection.ip}`, "--", `bash -lc '${remoteCmd}'`],
`Failed to enter ${agentName}`,
`ssh -t ${connection.user}@${connection.ip} -- bash -lc '${remoteCmd}'`
);
}
// ── Agents ─────────────────────────────────────────────────────────────────────
export function getImplementedAgents(manifest: Manifest, cloud: string): string[] {

View file

@ -233,6 +233,7 @@ export function saveVmConnection(
serverId: string,
serverName: string,
cloud: string,
launchCmd?: string,
): void {
const dir = `${process.env.HOME}/.spawn`;
mkdirSync(dir, { recursive: true });
@ -240,9 +241,22 @@ export function saveVmConnection(
if (serverId) json.server_id = serverId;
if (serverName) json.server_name = serverName;
if (cloud) json.cloud = cloud;
if (launchCmd) json.launch_cmd = launchCmd;
writeFileSync(`${dir}/last-connection.json`, JSON.stringify(json) + "\n");
}
/** Append launch_cmd to the last-connection.json file */
export function saveLaunchCmd(launchCmd: string): void {
const connFile = `${process.env.HOME}/.spawn/last-connection.json`;
try {
const data = JSON.parse(readFileSync(connFile, "utf-8"));
data.launch_cmd = launchCmd;
writeFileSync(connFile, JSON.stringify(data) + "\n");
} catch {
// Connection file may not exist — non-fatal
}
}
// ─── Authentication ──────────────────────────────────────────────────────────
export async function ensureFlyCli(): Promise<void> {

View file

@ -11,6 +11,7 @@ import {
waitForCloudInit,
runServer,
interactiveSession,
saveLaunchCmd,
listVolumes,
FLY_VM_TIERS,
DEFAULT_VM_TIER,
@ -185,7 +186,9 @@ async function main() {
logStep("Starting agent...");
await new Promise((r) => setTimeout(r, 1000));
const exitCode = await interactiveSession(agent.launchCmd());
const launchCmd = agent.launchCmd();
saveLaunchCmd(launchCmd);
const exitCode = await interactiveSession(launchCmd);
process.exit(exitCode);
}

View file

@ -11,6 +11,7 @@ export interface VMConnection {
cloud?: string;
deleted?: boolean;
deleted_at?: string;
launch_cmd?: string;
metadata?: Record<string, string>;
}

View file

@ -1830,6 +1830,10 @@ spawn_agent() {
log_info "${agent_name} is ready"
local launch_cmd
launch_cmd=$(agent_launch_cmd)
# Save the launch command to connection file for `spawn list` → "Enter agent"
_save_launch_cmd "${launch_cmd}"
launch_session "$(cloud_label)" cloud_interactive "${launch_cmd}"
}
@ -3765,6 +3769,25 @@ save_vm_connection() {
printf '%s\n' "${json}" > "${conn_file}"
}
# Append launch_cmd to an existing last-connection.json file.
# Called by spawn_agent after computing the agent's launch command.
# Usage: _save_launch_cmd LAUNCH_CMD
_save_launch_cmd() {
local cmd="${1:-}"
if [[ -z "${cmd}" ]]; then return 0; fi
local conn_file="${HOME}/.spawn/last-connection.json"
if [[ ! -f "${conn_file}" ]]; then return 0; fi
# Read existing JSON content and inject launch_cmd before the closing brace
local existing
existing=$(cat "${conn_file}")
# Strip trailing } and add launch_cmd field
existing="${existing%\}}"
existing="${existing},\"launch_cmd\":$(json_escape "${cmd}")}"
printf '%s\n' "${existing}" > "${conn_file}"
}
# ============================================================
# Auto-initialization
# ============================================================