Skip to content
8 changes: 8 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ export const Info = Schema.Struct({
}),
}),
),
session: Schema.optional(
Schema.Struct({
auto_resume: Schema.optional(Schema.Boolean).annotate({
description:
"Automatically resume sessions that were active when opencode crashed (default: false)",
}),
}),
).annotate({ description: "Session crash recovery configuration" }),
}).annotate({ identifier: "Config" })

export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
25 changes: 25 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { open } from "node:fs/promises"
import { Effect } from "effect"
import { ActiveManifest } from "@/session/active-manifest"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { EOL } from "os"
Expand Down Expand Up @@ -491,6 +492,30 @@ export const RunCommand = effectCmd({

const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined

if (!args.continue && !args.session) {
const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()).catch(() => false)
if (crashed) {
const cfg = await sdk.config.get().catch(() => undefined)
if (cfg?.data?.session?.auto_resume) {
const active = await Effect.runPromise(ActiveManifest.read()).catch(() => [])
if (active.length > 0) {
const candidate = active[active.length - 1]
const existing = await sdk.session.get({ sessionID: candidate.id }).catch(() => undefined)
if (existing?.data) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!" + UI.Style.TEXT_NORMAL + ` crash detected — resuming last active session`)
await Effect.runPromise(ActiveManifest.clear()).catch(() => {})
return {
id: candidate.id,
title: existing.data.title,
directory: existing.data.directory,
}
}
}
}
await Effect.runPromise(ActiveManifest.clear()).catch(() => {})
}
}

if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { DbCommand } from "./cli/cmd/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { ActiveManifest } from "./session/active-manifest"
import { Effect } from "effect"

const args = hideBin(process.argv)

Expand Down Expand Up @@ -134,6 +136,9 @@ try {
}
process.exitCode = 1
} finally {
// Clean shutdown: clear the active-sessions manifest so the next startup
// knows the previous process exited cleanly (not a crash).
await Effect.runPromise(ActiveManifest.clear()).catch(() => {})
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
Expand Down
91 changes: 91 additions & 0 deletions packages/opencode/src/session/active-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import path from "node:path"
import fs from "node:fs/promises"
import fsSync from "node:fs"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"

export type ActiveSession = {
id: string
timestamp: number
}

type Manifest = { sessions: ActiveSession[] }

let manifestDir = Global.Path.data

export function setManifestDir(dir: string) {
manifestDir = dir
}

export function manifestPath() {
return path.join(manifestDir, "active-sessions.json")
}

// Best-effort: manifest errors must never crash the session lifecycle
async function readRaw(): Promise<Manifest> {
const file = Bun.file(manifestPath())
if (!(await file.exists())) return { sessions: [] }
const parsed = await file.json().catch(() => null)
if (!parsed || !Array.isArray(parsed.sessions)) return { sessions: [] }
return { sessions: parsed.sessions }
}

async function writeRaw(manifest: Manifest): Promise<void> {
const tmp = manifestPath() + ".tmp"
await Bun.write(tmp, JSON.stringify(manifest))
await fs.rename(tmp, manifestPath())
}

const write = Effect.fn("ActiveManifest.write")(function* (entry: ActiveSession) {
yield* Effect.promise(async () => {
const manifest = await readRaw()
const without = manifest.sessions.filter((s) => s.id !== entry.id)
without.push(entry)
await writeRaw({ sessions: without })
})
})

const remove = Effect.fn("ActiveManifest.remove")(function* (sessionID: string) {
yield* Effect.promise(async () => {
const manifest = await readRaw()
const without = manifest.sessions.filter((s) => s.id !== sessionID)
if (without.length === 0) {
const file = Bun.file(manifestPath())
if (await file.exists()) await file.unlink()
return
}
await writeRaw({ sessions: without })
})
})

const read = Effect.fn("ActiveManifest.read")(function* () {
const manifest = yield* Effect.promise(() => readRaw())
return manifest.sessions
})

const clear = Effect.fn("ActiveManifest.clear")(function* () {
yield* Effect.promise(async () => {
const file = Bun.file(manifestPath())
if (await file.exists()) await file.unlink()
})
})

const hasCrashed = Effect.fn("ActiveManifest.hasCrashed")(function* () {
return yield* Effect.promise(() => Bun.file(manifestPath()).exists())
})

export const ActiveManifest = {
write: (entry: ActiveSession) => write(entry).pipe(Effect.catch(() => Effect.void)),
remove: (sessionID: string) => remove(sessionID).pipe(Effect.catch(() => Effect.void)),
read: () => read().pipe(Effect.catch(() => Effect.succeed([] as ActiveSession[]))),
clear: () => clear().pipe(Effect.catch(() => Effect.void)),
hasCrashed: () => hasCrashed().pipe(Effect.catch(() => Effect.succeed(false))),
}

// Synchronous cleanup on process.exit() — runs for clean exits but not for
// SIGKILL/crashes, which is exactly the desired crash-detection behavior.
process.on("exit", () => {
try {
fsSync.unlinkSync(manifestPath())
} catch {}
})
3 changes: 3 additions & 0 deletions packages/opencode/src/session/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { ActiveManifest } from "./active-manifest"

export const Info = SessionStatusEvent.Info
export type Info = SessionStatusEvent.Info
Expand Down Expand Up @@ -42,9 +43,11 @@ const layer = Layer.effect(
if (status.type === "idle") {
yield* events.publish(Event.Idle, { sessionID })
data.delete(sessionID)
yield* ActiveManifest.remove(sessionID)
return
}
data.set(sessionID, status)
yield* ActiveManifest.write({ id: sessionID, timestamp: Date.now() })
})

return Service.of({ get, list, set })
Expand Down
156 changes: 156 additions & 0 deletions packages/opencode/test/session/active-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { ActiveManifest, setManifestDir } from "@/session/active-manifest"

const sampleEntry = {
id: "session-001",
timestamp: Date.now(),
}

async function withTmpDir(fn: (dir: string) => Promise<void>) {
const dir = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
await fs.mkdir(dir, { recursive: true })
try {
await fn(dir)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
}

test("writeActiveSession creates manifest with session", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe("session-001")
})
})

test("writeActiveSession adds to existing manifest", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
await Effect.runPromise(
ActiveManifest.write({
id: "session-002",
timestamp: Date.now(),
}),
)
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(2)
})
})

test("writeActiveSession updates existing session", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
await Effect.runPromise(ActiveManifest.write({ ...sampleEntry, timestamp: 999 }))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(1)
expect(sessions[0].timestamp).toBe(999)
})
})

test("removeActiveSession removes from manifest", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
await Effect.runPromise(ActiveManifest.remove("session-001"))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(0)
})
})

test("removeActiveSession deletes manifest file when last session removed", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
await Effect.runPromise(ActiveManifest.remove("session-001"))
const crashed = await Effect.runPromise(ActiveManifest.hasCrashed())
expect(crashed).toBe(false)
})
})

test("clearActiveSessions deletes the manifest file", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
await Effect.runPromise(ActiveManifest.clear())
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(0)
})
})

test("hasCrashed returns false when no manifest exists", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
const crashed = await Effect.runPromise(ActiveManifest.hasCrashed())
expect(crashed).toBe(false)
})
})

test("hasCrashed returns true when manifest exists", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write(sampleEntry))
const crashed = await Effect.runPromise(ActiveManifest.hasCrashed())
expect(crashed).toBe(true)
})
})

test("read returns empty array when manifest does not exist", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(0)
})
})

test("read returns empty array for valid-but-wrong-shape JSON", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
const manifestPath = path.join(dir, "active-sessions.json")
await fs.writeFile(manifestPath, JSON.stringify({}))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(0)
})
})

test("read returns empty array for valid array JSON", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
const manifestPath = path.join(dir, "active-sessions.json")
await fs.writeFile(manifestPath, JSON.stringify([]))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(0)
})
})

test("write after corrupt-shape manifest recovers gracefully", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
const manifestPath = path.join(dir, "active-sessions.json")
await fs.writeFile(manifestPath, JSON.stringify({ sessions: "not-an-array" }))
await Effect.runPromise(ActiveManifest.write(sampleEntry))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe("session-001")
})
})

test("multi-session manifest preserves write order", async () => {
await withTmpDir(async (dir) => {
setManifestDir(dir)
await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: 1000 }))
await Effect.runPromise(ActiveManifest.write({ id: "s2", timestamp: 2000 }))
const sessions = await Effect.runPromise(ActiveManifest.read())
expect(sessions).toHaveLength(2)
expect(sessions[0].id).toBe("s1")
expect(sessions[sessions.length - 1].id).toBe("s2")
})
})
Loading
Loading