Skip to content
Merged
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
3 changes: 2 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ No public inbound ports are required for normal usage.

- Works with OpenCode V1 and OpenCode V2 servers; the API version is set in configuration (`OPENCODE_SERVER_VERSION`, default V1) and a server of the other version is reported in the log
- Check OpenCode server status (running / not running)
- Start OpenCode server from the app: `opencode serve` on V1, the registered background server (`opencode serve --service`) on V2; no start while the configured address answers with the wrong password or as the other version, while the local `opencode` executable is the other version, or on V2 while a registered V2 server runs on another port — the reason goes to the log
- Start OpenCode server from the app: `opencode serve` on V1, the registered background server (`opencode serve --service`) on V2; no start while the configured address answers with the wrong password or as the other version, or while the local `opencode` executable is the other version — the reason goes to the log
- On V2, when the configured address does not answer and OpenCode's registered V2 server runs on another port, `/opencode_start` and auto-restart connect the bot to that server instead of starting one; the connection lasts until the bot restarts, and `/opencode_stop` then stops that server
- Stop OpenCode server from the app
- Optionally monitor and auto-restart a local OpenCode server, with the same start rules

Expand Down
19 changes: 17 additions & 2 deletions src/bot/commands/opencode-start-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CommandContext, Context } from "grammy";
import type { AppContainer } from "../../app/bootstrap/app-container.js";
import { config } from "../../config.js";
import { opencodeClient, opencodeServerVersion } from "../../opencode/client.js";
import { canStartLocalOpencodeServer } from "../../opencode/local-start.js";
import { resolveUnavailableOpencodeServer } from "../../opencode/local-start.js";
import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js";
import { explainFailedHealthCheck } from "../../opencode/server-health.js";
import { logger } from "../../utils/logger.js";
Expand Down Expand Up @@ -116,11 +116,26 @@ export async function opencodeStartCommand(
// would then skip the ready refresh (model catalog, selection check, session restore).
deps.opencodeReadyLifecycle.notifyUnavailable("opencode_start_not_running");

if (!(await canStartLocalOpencodeServer(localTarget, "always"))) {
const resolution = await resolveUnavailableOpencodeServer(localTarget, "always");
if (resolution.kind === "refuse") {
await ctx.reply(t("opencode_start.error"));
return;
}

if (resolution.kind === "answers") {
await ctx.reply(t("opencode_start.already_running", { version: resolution.version }));
await deps.opencodeReadyLifecycle.notifyReady("opencode_start_already_running");
return;
}

if (resolution.kind === "connected") {
await ctx.reply(
t("opencode_start.connected", { url: resolution.url, version: resolution.version }),
);
await deps.opencodeReadyLifecycle.notifyReady("opencode_start_connected");
return;
}

const statusMessage = await ctx.reply(t("opencode_start.starting"));

const childProcess = startLocalOpencodeServer(localTarget, opencodeServerVersion);
Expand Down
5 changes: 3 additions & 2 deletions src/bot/commands/opencode-stop-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommandContext, Context } from "grammy";
import type { AppContainer } from "../../app/bootstrap/app-container.js";
import { config } from "../../config.js";
import { getActiveOpencodeServer } from "../../opencode/server-target.js";
import {
findServerPid,
killServerProcess,
Expand Down Expand Up @@ -72,7 +72,8 @@ export async function opencodeStopCommand(
return;
}

const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
// After a switch to another server, that server is the one stopped.
const localTarget = resolveLocalOpencodeTarget(getActiveOpencodeServer().url);
if (!localTarget) {
await ctx.reply(t("opencode_stop.remote_configured"));
return;
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export const ar: I18nDictionary = {
"opencode_start.already_running_external":
"✅ خادم OpenCode يعمل بالفعل كعملية خارجية\n\nالإصدار: {version}\n\nلم يتم تشغيل هذا الخادم بواسطة البوت، لذلك لا يمكن إيقافه باستخدام /opencode-stop.",
"opencode_start.already_running": "✅ خادم OpenCode يعمل بالفعل\n\nالإصدار: {version}",
"opencode_start.connected": "✅ تم الاتصال بخادم OpenCode قيد التشغيل\n{url} · v{version}",
"opencode_start.remote_configured": "⚠️ يعمل /opencode_start فقط مع خادم OpenCode محلي.",
"opencode_start.starting": "🔄 جارٍ تشغيل خادم OpenCode...",
"opencode_start.start_error":
Expand Down
1 change: 1 addition & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export const de: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode-Server läuft bereits als externer Prozess\n\nVersion: {version}\n\nDieser Server wurde nicht vom Bot gestartet, daher kann /opencode-stop ihn nicht stoppen.",
"opencode_start.already_running": "✅ OpenCode-Server läuft bereits\n\nVersion: {version}",
"opencode_start.connected": "✅ Mit einem laufenden OpenCode-Server verbunden\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start funktioniert nur mit einem lokalen OpenCode-Server.",
"opencode_start.starting": "🔄 Starte OpenCode-Server...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export const en = {
"opencode_start.already_running_external":
"✅ OpenCode Server is already running as an external process\n\nVersion: {version}\n\nThis server was not started by bot, so /opencode-stop cannot stop it.",
"opencode_start.already_running": "✅ OpenCode Server is already running\n\nVersion: {version}",
"opencode_start.connected": "✅ Connected to a running OpenCode Server\n{url} · v{version}",
"opencode_start.remote_configured": "⚠️ /opencode_start works only with a local OpenCode Server.",
"opencode_start.starting": "🔄 Starting OpenCode Server...",
"opencode_start.start_error":
Expand Down
1 change: 1 addition & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export const es: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode Server ya está en ejecución como un proceso externo\n\nVersión: {version}\n\nEste servidor no fue iniciado por el bot, por lo que /opencode-stop no puede detenerlo.",
"opencode_start.already_running": "✅ OpenCode Server ya está en ejecución\n\nVersión: {version}",
"opencode_start.connected": "✅ Conectado a un OpenCode Server en ejecución\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start solo funciona con un OpenCode Server local.",
"opencode_start.starting": "🔄 Iniciando OpenCode Server...",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ export const fr: I18nDictionary = {
"✅ Le serveur OpenCode est déjà en cours d'exécution en tant que processus externe\n\nVersion : {version}\n\nCe serveur n'a pas été démarré par le bot, donc /opencode-stop ne peut pas l'arrêter.",
"opencode_start.already_running":
"✅ Le serveur OpenCode est déjà en cours d'exécution\n\nVersion : {version}",
"opencode_start.connected":
"✅ Connecté à un serveur OpenCode en cours d'exécution\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start fonctionne uniquement avec un serveur OpenCode local.",
"opencode_start.starting": "🔄 Démarrage du serveur OpenCode...",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export const id: I18nDictionary = {
"opencode_start.already_running_external":
"✅ Server OpenCode sudah berjalan sebagai proses eksternal\n\nVersi: {version}\n\nServer ini tidak dimulai oleh bot, jadi /opencode-stop tidak bisa menghentikannya.",
"opencode_start.already_running": "✅ Server OpenCode sudah berjalan\n\nVersi: {version}",
"opencode_start.connected":
"✅ Terhubung ke Server OpenCode yang sedang berjalan\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start hanya berfungsi dengan Server OpenCode lokal.",
"opencode_start.starting": "🔄 Memulai Server OpenCode...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export const it: I18nDictionary = {
"opencode_start.already_running_external":
"✅ Il server OpenCode è già in esecuzione come processo esterno\n\nVersione: {version}\n\nQuesto server non è stato avviato dal bot, quindi /opencode-stop non può fermarlo.",
"opencode_start.already_running": "✅ Il server OpenCode è già in esecuzione\n\nVersione: {version}",
"opencode_start.connected": "✅ Connesso a un server OpenCode in esecuzione\n{url} · v{version}",
"opencode_start.remote_configured": "⚠️ /opencode_start funziona solo con un server OpenCode locale.",
"opencode_start.starting": "🔄 Avvio del server OpenCode...",
"opencode_start.start_error":
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export const ko: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode 서버가 외부 프로세스로 이미 실행 중입니다\n\n버전: {version}\n\n이 서버는 봇이 시작한 것이 아니므로 /opencode-stop으로 중지할 수 없습니다.",
"opencode_start.already_running": "✅ OpenCode 서버가 이미 실행 중입니다\n\n버전: {version}",
"opencode_start.connected": "✅ 실행 중인 OpenCode 서버에 연결했습니다\n{url} · v{version}",
"opencode_start.remote_configured": "⚠️ /opencode_start는 로컬 OpenCode 서버에서만 동작합니다.",
"opencode_start.starting": "🔄 OpenCode 서버를 시작하는 중...",
"opencode_start.start_error":
Expand Down
1 change: 1 addition & 0 deletions src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export const pt: I18nDictionary = {
"opencode_start.already_running_external":
"✅ O OpenCode Server já está em execução como um processo externo\n\nVersão: {version}\n\nEste servidor não foi iniciado pelo bot, então /opencode-stop não pode encerrá-lo.",
"opencode_start.already_running": "✅ O OpenCode Server já está em execução\n\nVersão: {version}",
"opencode_start.connected": "✅ Conectado a um OpenCode Server em execução\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ O /opencode_start funciona apenas com um OpenCode Server local.",
"opencode_start.starting": "🔄 Iniciando o OpenCode Server...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export const ru: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode Server уже запущен внешним процессом\n\nВерсия: {version}\n\nЭтот сервер не был запущен через бота, поэтому команда /opencode-stop не сможет его остановить.",
"opencode_start.already_running": "✅ OpenCode Server уже запущен\n\nВерсия: {version}",
"opencode_start.connected": "✅ Подключено к запущенному OpenCode Server\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start работает только с локальным OpenCode Server.",
"opencode_start.starting": "🔄 Запускаю OpenCode Server...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const tr: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode Sunucusu zaten harici bir süreç olarak çalışıyor\n\nSürüm: {version}\n\nBu sunucu bot tarafından başlatılmadı, bu yüzden /opencode-stop onu durduramaz.",
"opencode_start.already_running": "✅ OpenCode Sunucusu zaten çalışıyor\n\nSürüm: {version}",
"opencode_start.connected": "✅ Çalışan bir OpenCode Sunucusuna bağlanıldı\n{url} · v{version}",
"opencode_start.remote_configured":
"⚠️ /opencode_start yalnızca yerel bir OpenCode Sunucusu ile çalışır.",
"opencode_start.starting": "🔄 OpenCode Sunucusu başlatılıyor...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export const zh: I18nDictionary = {
"opencode_start.already_running_external":
"✅ OpenCode 服务器正作为外部进程运行\n\n版本:{version}\n\n该服务器不是由机器人启动,因此 /opencode-stop 无法停止它。",
"opencode_start.already_running": "✅ OpenCode 服务器已在运行\n\n版本:{version}",
"opencode_start.connected": "✅ 已连接到正在运行的 OpenCode 服务器\n{url} · v{version}",
"opencode_start.remote_configured": "⚠️ /opencode_start 仅适用于本地 OpenCode 服务器。",
"opencode_start.starting": "🔄 正在启动 OpenCode 服务器...",
"opencode_start.start_error":
Expand Down
14 changes: 12 additions & 2 deletions src/opencode/auto-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { config } from "../config.js";
import { isContainerRuntime } from "../runtime/container.js";
import { logger } from "../utils/logger.js";
import { opencodeClient, opencodeServerVersion } from "./client.js";
import { canStartLocalOpencodeServer } from "./local-start.js";
import { resolveUnavailableOpencodeServer } from "./local-start.js";
import type { OpencodeReadyLifecycle } from "./ready-lifecycle.js";
import { explainFailedHealthCheck } from "./server-health.js";
import {
Expand Down Expand Up @@ -154,13 +154,23 @@ export class OpencodeAutoRestartService {
return;
}

if (!(await canStartLocalOpencodeServer(this.localTarget, "once"))) {
const resolution = await resolveUnavailableOpencodeServer(this.localTarget, "once");
if (resolution.kind === "refuse") {
logger.debug(
`[OpenCodeAutoRestart] OpenCode server is unavailable; not starting a local server: reason=${reason}`,
);
return;
}

if (resolution.kind === "answers" || resolution.kind === "connected") {
logger.info(
`[OpenCodeAutoRestart] OpenCode server answers: version=${resolution.version}, reason=${reason}`,
);
this.serverWasHealthy = true;
await this.opencodeReadyLifecycle.notifyReady(`auto_restart_${reason}`);
return;
}

logger.warn(
`[OpenCodeAutoRestart] OpenCode server is unavailable, starting local server: reason=${reason}, port=${this.localTarget.port}`,
);
Expand Down
45 changes: 26 additions & 19 deletions src/opencode/client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import { config, type OpencodeServerVersion } from "../config.js";
import { getActiveOpencodeServer, type OpencodeServerTarget } from "./server-target.js";
import {
createV2OpencodeClient,
findRegisteredV2ServerUrl,
findRegisteredV2Server,
type RegisteredV2Server,
type V2ClientExtension,
} from "./v2/client.js";

const PROBE_TIMEOUT_MS = 5000;

const getAuth = () => {
if (!config.opencode.password) {
const getAuth = (server: OpencodeServerTarget) => {
if (!server.password) {
return undefined;
}
const credentials = `${config.opencode.username}:${config.opencode.password}`;
const credentials = `${server.username ?? ""}:${server.password}`;
return `Basic ${Buffer.from(credentials).toString("base64")}`;
};

const authHeaders = (): Record<string, string> | undefined => {
const auth = getAuth();
const authHeaders = (
server: OpencodeServerTarget = getActiveOpencodeServer(),
): Record<string, string> | undefined => {
const auth = getAuth(server);
return auth ? { Authorization: auth } : undefined;
};

Expand All @@ -26,16 +30,20 @@ export const opencodeServerVersion: OpencodeServerVersion =
config.opencode.serverVersion === "v2" ? "v2" : "v1";

function createClient() {
const headers = authHeaders();
if (opencodeServerVersion === "v2") {
// Read on every request, so the client follows a switch to another server.
return createV2OpencodeClient({
baseUrl: config.opencode.apiUrl,
...(headers ? { headers } : {}),
get baseUrl() {
return getActiveOpencodeServer().url;
},
get headers() {
return authHeaders() ?? {};
},
});
}
return createOpencodeClient({
baseUrl: config.opencode.apiUrl,
headers,
headers: authHeaders(),
});
}

Expand Down Expand Up @@ -69,17 +77,16 @@ function readServerVersion(version: OpencodeServerVersion, body: unknown): strin
}

/**
* Asks the configured URL whether it answers the given API version, with the configured
* credentials. Used to explain a failed health check, never to switch clients.
* Asks a server (by default the active one) whether it answers the given API version, with
* that server's credentials.
*/
export async function probeOpencodeServer(
version: OpencodeServerVersion,
server: OpencodeServerTarget = getActiveOpencodeServer(),
): Promise<OpencodeServerProbe> {
const baseUrl = config.opencode.apiUrl.endsWith("/")
? config.opencode.apiUrl
: `${config.opencode.apiUrl}/`;
const baseUrl = server.url.endsWith("/") ? server.url : `${server.url}/`;
try {
const headers = authHeaders();
const headers = authHeaders(server);
const response = await fetch(new URL(PROBE_ROUTES[version], baseUrl), {
...(headers ? { headers } : {}),
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
Expand All @@ -98,7 +105,7 @@ export async function probeOpencodeServer(
}
}

/** URL of the registered OpenCode V2 background server, or null when none answers. */
export async function findRegisteredOpencodeServerUrl(): Promise<string | null> {
return findRegisteredV2ServerUrl();
/** The registered OpenCode V2 background server and its credentials, or null when none answers. */
export async function findRegisteredOpencodeServer(): Promise<RegisteredV2Server | null> {
return findRegisteredV2Server();
}
Loading
Loading