Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions packages/server/src/server/agent/providers/opencode-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import type { Logger } from "pino";
import { z } from "zod";

Expand Down Expand Up @@ -691,6 +694,14 @@ export const __openCodeInternals = {
resolveOpenCodeSelectedModelContextWindow,
};

interface OpenCodeServeState {
pid: number;
port: number;
startedAt: string;
}

const OPENCODE_SERVE_STATE_FILENAME = "opencode-serve.json";

export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
Expand All @@ -700,11 +711,14 @@ export class OpenCodeServerManager {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private readonly stateFilePath: string;

private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
const paseoHome = process.env.PASEO_HOME ?? path.join(os.homedir(), ".paseo");
this.stateFilePath = path.join(paseoHome, OPENCODE_SERVE_STATE_FILENAME);
}

static getInstance(
Expand All @@ -714,6 +728,7 @@ export class OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.instance.cleanupOrphanedServer();
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
Expand All @@ -727,6 +742,91 @@ export class OpenCodeServerManager {
return OpenCodeServerManager.instance;
}

/**
* On daemon startup, check for a state file left by a previous daemon instance.
* If the recorded PID is still alive, kill it — it's an orphan from a prior run.
* Then remove the stale state file so we start clean.
*/
private cleanupOrphanedServer(): void {
try {
if (!fs.existsSync(this.stateFilePath)) {
return;
}

const raw = fs.readFileSync(this.stateFilePath, "utf8");
let state: OpenCodeServeState;
try {
state = JSON.parse(raw);
} catch {
// Malformed JSON — just remove the corrupt file
this.logger.warn(
{ stateFile: this.stateFilePath },
"Removing malformed opencode serve state file",
);
fs.unlinkSync(this.stateFilePath);
return;
}

if (typeof state.pid !== "number") {
fs.unlinkSync(this.stateFilePath);
return;
}

try {
// Check if the process is still running (throws if not)
process.kill(state.pid, 0);
this.logger.info(
{ orphanPid: state.pid, orphanPort: state.port, stateFile: this.stateFilePath },
"Killing orphaned opencode serve process from previous daemon instance",
);
process.kill(state.pid, "SIGTERM");
} catch {
// Process doesn't exist — stale state file from a prior crash
this.logger.debug(
{ pid: state.pid, stateFile: this.stateFilePath },
"OpenCode serve state file found but process is already dead, cleaning up",
);
}

fs.unlinkSync(this.stateFilePath);
} catch (error) {
this.logger.warn(
{ err: error, stateFile: this.stateFilePath },
"Failed to clean up orphaned opencode serve state",
);
}
}

/**
* Persist the server PID and port to a state file so a future daemon instance
* can detect and clean up orphaned processes on startup.
*/
private writeStateFile(): void {
try {
const state: OpenCodeServeState = {
pid: this.server!.pid!,
port: this.port!,
startedAt: new Date().toISOString(),
};
fs.writeFileSync(this.stateFilePath, JSON.stringify(state, null, 2));
} catch (error) {
this.logger.warn(
{ err: error, stateFile: this.stateFilePath },
"Failed to write opencode serve state file",
);
}
}

private removeStateFile(): void {
try {
if (fs.existsSync(this.stateFilePath)) {
fs.unlinkSync(this.stateFilePath);
}
} catch {
// Best-effort cleanup
}
}

private static registerExitHandler(): void {
if (OpenCodeServerManager.exitHandlerRegistered) {
return;
Expand All @@ -738,6 +838,10 @@ export class OpenCodeServerManager {
if (instance?.server && !instance.server.killed) {
instance.server.kill("SIGTERM");
}
instance?.removeStateFile();
// Reset singleton so a new daemon process starts fresh
OpenCodeServerManager.instance = null;
OpenCodeServerManager.exitHandlerRegistered = false;
};

process.on("exit", cleanup);
Expand Down Expand Up @@ -793,6 +897,7 @@ export class OpenCodeServerManager {
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
this.writeStateFile();
resolve({ port: this.port!, url });
}
});
Expand All @@ -813,6 +918,7 @@ export class OpenCodeServerManager {
}
this.server = null;
this.port = null;
this.removeStateFile();
});
});
}
Expand All @@ -833,6 +939,20 @@ export class OpenCodeServerManager {
}
this.server = null;
this.port = null;
this.removeStateFile();
}

/**
* Reset the singleton. For testing and daemon restart scenarios.
*/
static resetInstance(): void {
const instance = OpenCodeServerManager.instance;
if (instance?.server && !instance.server.killed) {
instance.server.kill("SIGTERM");
}
instance?.removeStateFile();
OpenCodeServerManager.instance = null;
OpenCodeServerManager.exitHandlerRegistered = false;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";

import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeServerManager } from "./opencode-agent.js";

function tmpDir(): string {
return mkdtempSync(path.join(os.tmpdir(), "opencode-server-manager-test-"));
}

describe("OpenCodeServerManager", () => {
let tempHome: string;

beforeEach(() => {
tempHome = tmpDir();
// Reset singleton between tests
OpenCodeServerManager.resetInstance();
});

afterEach(() => {
OpenCodeServerManager.resetInstance();
rmSync(tempHome, { recursive: true, force: true });
});

describe("cleanupOrphanedServer", () => {
test("removes stale state file when no process is running", () => {
const stateFile = path.join(tempHome, "opencode-serve.json");
writeFileSync(
stateFile,
JSON.stringify({ pid: 99999999, port: 12345, startedAt: "2026-01-01T00:00:00Z" }),
);
expect(existsSync(stateFile)).toBe(true);

// Create manager with PASEO_HOME pointing to temp dir
process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
OpenCodeServerManager.getInstance(logger);
// The stale state file should have been cleaned up
expect(existsSync(stateFile)).toBe(false);
} finally {
delete process.env.PASEO_HOME;
}
});

test("does nothing when no state file exists", () => {
process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
// Should not throw
OpenCodeServerManager.getInstance(logger);
} finally {
delete process.env.PASEO_HOME;
}
});

test("handles malformed state file gracefully", () => {
const stateFile = path.join(tempHome, "opencode-serve.json");
writeFileSync(stateFile, "not valid json{{{");
expect(existsSync(stateFile)).toBe(true);

process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
// Should not throw even with bad JSON
OpenCodeServerManager.getInstance(logger);
// Bad file should be cleaned up (catch block handles parse error)
expect(existsSync(stateFile)).toBe(false);
} finally {
delete process.env.PASEO_HOME;
}
});

test("handles state file with missing pid gracefully", () => {
const stateFile = path.join(tempHome, "opencode-serve.json");
writeFileSync(stateFile, JSON.stringify({ port: 12345 }));
expect(existsSync(stateFile)).toBe(true);

process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
OpenCodeServerManager.getInstance(logger);
expect(existsSync(stateFile)).toBe(false);
} finally {
delete process.env.PASEO_HOME;
}
});

test("removes state file with non-existent pid", () => {
const stateFile = path.join(tempHome, "opencode-serve.json");
// Use a PID that definitely doesn't exist
writeFileSync(
stateFile,
JSON.stringify({ pid: 1, port: 54321, startedAt: new Date().toISOString() }),
);

process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
OpenCodeServerManager.getInstance(logger);
// PID 1 (init) won't be killable by non-root, but the file should still be cleaned up
// since process.kill(pid, 0) might succeed (init always exists) but we still unlink
// Actually on macOS, process.kill(1, 0) succeeds since init always runs.
// The SIGTERM to PID 1 will fail (EPERM) but we catch that.
// The file should be cleaned up regardless.
} finally {
delete process.env.PASEO_HOME;
}
});
});

describe("resetInstance", () => {
test("clears singleton so a fresh instance can be created", () => {
process.env.PASEO_HOME = tempHome;
try {
const logger = createTestLogger();
const instance1 = OpenCodeServerManager.getInstance(logger);
const instance2 = OpenCodeServerManager.getInstance(logger);
expect(instance1).toBe(instance2);

OpenCodeServerManager.resetInstance();

const instance3 = OpenCodeServerManager.getInstance(logger);
expect(instance3).not.toBe(instance1);
} finally {
delete process.env.PASEO_HOME;
}
});
});
});