diff --git a/packages/cli/package.json b/packages/cli/package.json index edbd7e4d..87a08a6a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.25.14", + "version": "0.25.15", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/orchestrate-cov.test.ts b/packages/cli/src/__tests__/orchestrate-cov.test.ts index 57fc84f1..3f5737ad 100644 --- a/packages/cli/src/__tests__/orchestrate-cov.test.ts +++ b/packages/cli/src/__tests__/orchestrate-cov.test.ts @@ -89,6 +89,7 @@ beforeEach(() => { delete process.env.SPAWN_ENABLED_STEPS; delete process.env.SPAWN_BETA; delete process.env.MODEL_ID; + delete process.env.SPAWN_HEADLESS; stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); exitSpy = spyOn(process, "exit").mockImplementation((code) => { throw new Error(`__EXIT_${isNumber(code) ? code : 0}__`); diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 6110cff4..f192cc54 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -114,6 +114,7 @@ describe("runOrchestration", () => { // Ensure no stale env leaks between tests delete process.env.SPAWN_ENABLED_STEPS; delete process.env.SPAWN_BETA; + delete process.env.SPAWN_HEADLESS; stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); exitSpy = spyOn(process, "exit").mockImplementation((code) => { capturedExitCode = isNumber(code) ? code : 0; @@ -131,6 +132,7 @@ describe("runOrchestration", () => { } else { delete process.env.SPAWN_HOME; } + delete process.env.SPAWN_HEADLESS; tryCatch(() => rmSync(testDir, { recursive: true, @@ -873,4 +875,45 @@ describe("runOrchestration", () => { exitSpy.mockRestore(); }); }); + + describe("headless mode", () => { + it("skips interactive session and exits 0 when SPAWN_HEADLESS=1", async () => { + process.env.SPAWN_HEADLESS = "1"; + const cloud = createMockCloud(); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + // Provisioning steps should still run + expect(cloud.authenticate).toHaveBeenCalledTimes(1); + expect(cloud.createServer).toHaveBeenCalledTimes(1); + expect(cloud.waitForReady).toHaveBeenCalledTimes(1); + expect(agent.install).toHaveBeenCalledTimes(1); + + // Interactive session should NOT be called + expect(cloud.interactiveSession).toHaveBeenCalledTimes(0); + + // Should exit with code 0 + expect(capturedExitCode).toBe(0); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("still saves launch command in headless mode", async () => { + process.env.SPAWN_HEADLESS = "1"; + const cloud = createMockCloud(); + const agent = createMockAgent({ + launchCmd: mock(() => "claude --print"), + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + // launchCmd should be called (to save it for later `spawn connect`) + expect(agent.launchCmd).toHaveBeenCalledTimes(1); + expect(cloud.interactiveSession).toHaveBeenCalledTimes(0); + expect(capturedExitCode).toBe(0); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); }); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 91f2dd66..0fe2fc93 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -531,18 +531,30 @@ async function postInstall( logInfo(`Tip: ${agent.preLaunchMsg}`); } - // Launch interactive session + // Launch agent logInfo(`${agent.name} is ready`); process.stderr.write("\n"); logInfo(`${cloud.cloudLabel} setup completed successfully!`); process.stderr.write("\n"); - logStep("Starting agent..."); - - prepareStdinForHandoff(); const launchCmd = agent.launchCmd(); saveLaunchCmd(launchCmd, spawnId); + // In headless mode, provisioning is done — skip the interactive session. + // The VM is healthy and the agent is installed; callers can SSH in or use `spawn connect`. + const isHeadless = process.env.SPAWN_HEADLESS === "1"; + if (isHeadless) { + logInfo("Headless mode — provisioning complete. Skipping interactive session."); + if (tunnelHandle) { + tunnelHandle.stop(); + } + process.exit(0); + } + + logStep("Starting agent..."); + + prepareStdinForHandoff(); + const sessionCmd = cloud.cloudName === "local" ? launchCmd : wrapWithRestartLoop(launchCmd); // Auto-reconnect on connection drops. Ctrl+C (exit 0 or 130) exits immediately.