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
15 changes: 9 additions & 6 deletions .specs/kagan-supervision-board/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,17 @@ commands are ignored.
`@latest` resolve npm `latest`; exact pins and file installs return before network access. A newer
clean release is classified only after its manifest supplies a valid `engines.opencode` range.
Compatible latest is prepared exactly through `api.plugins.add`, which independently performs the
host compatibility check and imports the package without activating a duplicate `kagan` plugin id.
The manager proves that the current and prepared targets are non-symlinked
host compatibility check and imports the package without activating a duplicate `kagan` plugin id
(the host dedupes by module id).
The manager verifies that the current and prepared targets are valid
`opencode/packages/@kagan-sh/kagan@…/node_modules/@kagan-sh/kagan` wrappers before writing one
sibling marker. Its disposal callback renames current to one backup, promotes prepared to
`kagan@latest`, and restores current if promotion fails. The next successful load of the marker's
version removes only that validated backup and marker. Ready/blocked status is a dedicated store
signal: home/session routes receive one host toast, while the board renders persistent footer text
and never consumes Notice capacity.
`kagan@latest`, and restores current if promotion fails in-process. If promotion was interrupted by
process exit, the host re-downloads `latest` on the next launch (requiring network) and Kagan's
cleanup then removes the leftover backup, marker, and prepared directory. The next successful load
of the marker's version removes only that validated backup and marker. Ready/blocked/broken status is a dedicated store signal: home/session routes
receive one host toast for ready/blocked, while the board renders persistent footer text and never
consumes Notice capacity.

## Configuration

Expand Down
7 changes: 4 additions & 3 deletions .specs/kagan-supervision-board/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,9 @@ me when OpenCode blocks one, so that updating requires only a restart and no com
6. IF Kagan was loaded from an exact npm pin or a local/file source THEN Kagan SHALL NOT query npm,
prepare a release, or mutate its wrapper.
7. IF a registry request, manifest validation, download, import, or cache-path validation fails THEN
Kagan SHALL remain quiet and SHALL leave the current wrapper unchanged.
8. WHEN promotion of the prepared wrapper fails after the current wrapper was moved to backup THEN
Kagan SHALL restore the current wrapper immediately.
Kagan SHALL leave the current wrapper unchanged; WHEN cleanup or preparation fails due to local cache
state THEN Kagan SHALL persistently show that automatic updates are unavailable on the board
footer.
8. WHEN promotion of the prepared wrapper fails in-process after the current wrapper was moved to backup THEN Kagan SHALL restore the current wrapper immediately; WHEN promotion was interrupted by process exit THEN the host SHALL re-download `latest` on the next launch (requiring network) and Kagan's cleanup SHALL then remove the leftover backup, marker, and prepared directory.
9. WHEN the prepared version loads successfully after restart THEN Kagan SHALL remove its validated
backup and marker without deleting any broader OpenCode cache path.
5 changes: 3 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ and shows `vX.Y.Z ready — restart OpenCode`; restart once to apply it. Ready a
appears once as a host toast on home/session routes and persists in the board footer.

If `latest` requires another OpenCode version, Kagan keeps the current release and names the
required OpenCode range. Update-check and preparation failures are silent and never disturb the
working plugin; the ready message appears only after preparation succeeds.
required OpenCode range. Registry and manifest failures stay silent and never disturb the working
plugin. Local cache cleanup or preparation failures show `updates unavailable` in the board footer;
the ready message appears only after preparation succeeds.

Exact version pins and local/file installs are advanced-user choices. Kagan never checks or changes
them automatically.
Expand Down
5 changes: 5 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ apply it.
That Kagan release does not support the running OpenCode version. The footer names the required
range; the current Kagan release remains active until OpenCode is compatible.

**The footer says updates unavailable.**
Automatic update cleanup or preparation failed — usually stale cache state after an interrupted
restart. Restart OpenCode once more; if the footer persists, remove the Kagan plugin cache under
OpenCode's cache directory and reinstall.

**My existing sessions don't appear on the board.**
By design — the board shows only tasks created through it. Chat sessions stay in OpenCode's
native session list.
Expand Down
41 changes: 8 additions & 33 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,24 @@ import { Board } from "./tui/board/board"
import { Settings } from "./tui/routes/settings"
import { showOnboarding } from "./tui/dialogs/onboarding"
import { createBoardStore, createSessionEventSubscription, createSessionStatusSubscription } from "./tui/board/store"
import { checkForUpdate, type UpdateStatus } from "./tui/updates"
import { cleanupPreparedUpdate, prepareUpdate } from "./tui/update-manager"
import { runAutomaticUpdateLaunch } from "./tui/update-launch"
import { ROUTE, SETTINGS_ROUTE } from "./tui/types"
import { version } from "../package.json"

export function showUpdateToast(api: TuiPluginApi, currentVersion: string, status: Exclude<UpdateStatus, undefined>) {
if (api.route.current.name !== "home" && api.route.current.name !== "session") return
api.ui.toast({
variant: status.kind === "ready" ? "success" : "warning",
title: "Kagan",
message:
status.kind === "ready"
? `Kagan v${status.version} is ready. Restart OpenCode to apply.`
: `Kagan v${currentVersion} remains active. Kagan v${status.version} requires OpenCode ${status.requiredOpenCode}.`,
})
}

const tui: TuiPlugin = async (api, options, meta) => {
const store = createRoot(() => createBoardStore(api, options))
const disposeEvents = createSessionEventSubscription(api, () => store.refresh())
const disposeStatusEvents = createSessionStatusSubscription(api, store.setSessionStatus)
api.lifecycle.onDispose(() => disposeEvents())
api.lifecycle.onDispose(() => disposeStatusEvents())

cleanupPreparedUpdate(meta, version)
.catch(() => {})
.then(() =>
checkForUpdate({
kv: api.kv,
currentVersion: version,
openCodeVersion: api.app.version,
source: meta.source,
spec: meta.spec,
now: Date.now(),
}),
)
.then(async (status) => {
if (!status || api.lifecycle.signal.aborted) return
if (status.kind === "ready" && !(await prepareUpdate({ api, meta, currentVersion: version, status }))) return
store.setUpdateStatus(status)
showUpdateToast(api, version, status)
})
.catch(() => {})
runAutomaticUpdateLaunch({
api,
meta,
currentVersion: version,
now: Date.now(),
setUpdateStatus: store.setUpdateStatus,
}).catch(() => {})

api.route.register([
{
Expand Down
8 changes: 5 additions & 3 deletions src/tui/board/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@ function Main(props: {
}

function updateFooter(status: Exclude<UpdateStatus, undefined>) {
return status.kind === "ready"
? ` · v${status.version} ready — restart OpenCode`
: ` · update OpenCode to ${status.requiredOpenCode} for Kagan v${status.version}`
if (status.kind === "ready") return ` · v${status.version} ready — restart OpenCode`
if (status.kind === "blocked") {
return ` · update OpenCode to ${status.requiredOpenCode} for Kagan v${status.version}`
}
return " · updates unavailable"
}

function Footer(props: { api: TuiPluginApi; store: BoardStore; hints: () => { key: string; label: string }[] }) {
Expand Down
82 changes: 82 additions & 0 deletions src/tui/update-cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { TuiPluginMeta } from "@opencode-ai/plugin/tui"
import { basename, dirname, join } from "node:path"
import { isAutomaticUpdateInstall, parseRelease } from "./updates"
import {
defaultFileSystem,
type FileSystem,
markerMatches,
stat,
type UpdateMarker,
type UpdatePaths,
updatePaths,
validateWrapper,
} from "./update-paths"

async function readMarker(fs: FileSystem, markerPath: string): Promise<UpdateMarker | undefined> {
const info = await stat(fs, markerPath)
if (!info?.isFile()) return
try {
return JSON.parse(await fs.readFile(markerPath, "utf8")) as UpdateMarker
} catch {
await fs.rm(markerPath, { force: true })
Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Corrupt Marker Blocks Recovery

If the process exits after current was renamed to backup and the marker is left malformed, this catch deletes the only recovery marker and returns undefined. The interrupted-promotion branch never runs, current stays missing, and each launch can remain stuck in updates unavailable instead of restoring from the validated backup.

Context Used: AGENTS.md (source)

Artifacts

Repro: focused Bun harness for corrupt marker crash-recovery state

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: command output showing marker deletion with current still missing and backup still present

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tui/update-cleanup.ts
Line: 18-21

Comment:
**Corrupt Marker Blocks Recovery**

If the process exits after `current` was renamed to `backup` and the marker is left malformed, this catch deletes the only recovery marker and returns `undefined`. The interrupted-promotion branch never runs, `current` stays missing, and each launch can remain stuck in `updates unavailable` instead of restoring from the validated backup.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/kagan/github/kagan-sh/kagan/-/custom-context?memory=88a14340-9a15-4297-925d-3656d144ad2a))

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

}
}

async function removeOrphanBackup(
fs: FileSystem,
paths: UpdatePaths,
target: string,
currentVersion: string,
marker: UpdateMarker | undefined,
matchesCurrent: boolean,
) {
if (!(await stat(fs, paths.backup))) return
if (marker && matchesCurrent) return
await validateWrapper(fs, paths.current, target, currentVersion)
await fs.rm(paths.backup, { recursive: true })
}

// Guard against a corrupted marker naming an arbitrary path: only sweep prepared dirs Kagan itself
// could have created — a `kagan@<x.y.z>` sibling of the current wrapper in the same scope cache.
function preparedInCache(preparedPath: string, paths: UpdatePaths): boolean {
if (dirname(preparedPath) !== dirname(paths.current)) return false
const name = basename(preparedPath)
if (!name.startsWith("kagan@")) return false
return parseRelease(name.slice("kagan@".length)) !== undefined
}

async function removeStaleMarker(fs: FileSystem, paths: UpdatePaths, marker: UpdateMarker) {
if (marker.prepared && preparedInCache(marker.prepared, paths)) {
await fs.rm(marker.prepared, { recursive: true, force: true }).catch(() => {})
}
await fs.rm(paths.marker, { force: true })
}

export async function cleanupPreparedUpdate(
meta: TuiPluginMeta,
currentVersion: string,
fs: FileSystem = defaultFileSystem,
): Promise<void> {
if (!isAutomaticUpdateInstall({ source: meta.source, spec: meta.spec, version: currentVersion })) return
const paths = updatePaths(meta.target, currentVersion)
const marker = await readMarker(fs, paths.marker)
const matches = Boolean(marker && markerMatches(marker, paths, currentVersion))

await removeOrphanBackup(fs, paths, meta.target, currentVersion, marker, matches)

if (marker && !matches) {
await removeStaleMarker(fs, paths, marker)
}

const markerForCleanup = await readMarker(fs, paths.marker)
if (!markerForCleanup || !markerMatches(markerForCleanup, paths, currentVersion)) return

await validateWrapper(fs, paths.current, meta.target, currentVersion)
if (await stat(fs, paths.backup)) {
await validateWrapper(fs, paths.backup, join(paths.backup, "node_modules", "@kagan-sh", "kagan"))
await fs.rm(paths.backup, { recursive: true })
}
// Interrupted promotion + host re-download leaves the prepared dir behind; it is self-computed, so sweep it.
if (await stat(fs, paths.prepared)) await fs.rm(paths.prepared, { recursive: true })
await fs.rm(paths.marker)
}
60 changes: 60 additions & 0 deletions src/tui/update-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
import { cleanupPreparedUpdate } from "./update-cleanup"
import { prepareUpdate } from "./update-manager"
import { checkForUpdate, type UpdateStatus } from "./updates"
import { defaultFileSystem, type FileSystem } from "./update-paths"

export function showUpdateToast(api: TuiPluginApi, currentVersion: string, status: Exclude<UpdateStatus, undefined>) {
if (status.kind === "broken") return
if (api.route.current.name !== "home" && api.route.current.name !== "session") return
api.ui.toast({
variant: status.kind === "ready" ? "success" : "warning",
title: "Kagan",
message:
status.kind === "ready"
? `Kagan v${status.version} is ready. Restart OpenCode to apply.`
: `Kagan v${currentVersion} remains active. Kagan v${status.version} requires OpenCode ${status.requiredOpenCode}.`,
})
}

export async function runAutomaticUpdateLaunch(input: {
api: TuiPluginApi
meta: TuiPluginMeta
currentVersion: string
now: number
setUpdateStatus: (status: UpdateStatus) => void
showToast?: typeof showUpdateToast
fetchImpl?: typeof fetch
fs?: FileSystem
}): Promise<void> {
const { api, meta, currentVersion, now, setUpdateStatus } = input
const showToast = input.showToast ?? showUpdateToast
const fs = input.fs ?? defaultFileSystem

try {
await cleanupPreparedUpdate(meta, currentVersion, fs)
} catch {
setUpdateStatus({ kind: "broken" })
return
}

const status = await checkForUpdate({
kv: api.kv,
currentVersion,
openCodeVersion: api.app.version,
source: meta.source,
spec: meta.spec,
now,
fetchImpl: input.fetchImpl,
})

if (!status || api.lifecycle.signal.aborted) return

if (status.kind === "ready" && !(await prepareUpdate({ api, meta, currentVersion, status, fs }))) {
setUpdateStatus({ kind: "broken" })
return
}

setUpdateStatus(status)
showToast(api, currentVersion, status)
}
37 changes: 6 additions & 31 deletions src/tui/update-manager.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,17 @@
import type { TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
import { join } from "node:path"
import { isAutomaticUpdateInstall, KAGAN_PACKAGE, parseRelease, type UpdateStatus } from "./updates"
import { cleanupPreparedUpdate } from "./update-cleanup"
import { checkForUpdate, isAutomaticUpdateInstall, KAGAN_PACKAGE, parseRelease, type UpdateStatus } from "./updates"
import {
defaultFileSystem,
type FileSystem,
markerMatches,
stat,
type UpdateMarker,
type UpdatePaths,
updatePaths,
validateWrapper,
validMarkerFile,
stat,
} from "./update-paths"

export async function cleanupPreparedUpdate(
meta: TuiPluginMeta,
currentVersion: string,
fs: FileSystem = defaultFileSystem,
): Promise<void> {
if (!isAutomaticUpdateInstall({ source: meta.source, spec: meta.spec, version: currentVersion })) return
const paths = updatePaths(meta.target, currentVersion)
const markerInfo = await stat(fs, paths.marker)
if (!markerInfo) return
if (!validMarkerFile(markerInfo)) throw new Error("Unsafe Kagan update marker")

const marker = JSON.parse(await fs.readFile(paths.marker, "utf8")) as UpdateMarker
if (!markerMatches(marker, paths, currentVersion)) return
await validateWrapper(fs, paths.current, meta.target, currentVersion)

const backupInfo = await stat(fs, paths.backup)
if (backupInfo) {
await validateWrapper(fs, paths.backup, join(paths.backup, "node_modules", "@kagan-sh", "kagan"))
await fs.rm(paths.backup, { recursive: true })
}
if (!validMarkerFile(await stat(fs, paths.marker))) throw new Error("Unsafe Kagan update marker")
await fs.rm(paths.marker)
}

async function promotePreparedUpdate(
paths: UpdatePaths,
currentVersion: string,
Expand Down Expand Up @@ -75,6 +50,7 @@ export async function prepareUpdate(input: {
return false
}

// Host dedupes plugins.add by module id so this import does not activate a second kagan instance.
const installed = await api.plugins.add(`${KAGAN_PACKAGE}@${status.version}`).catch(() => false)
if (!installed) return false

Expand All @@ -83,9 +59,6 @@ export async function prepareUpdate(input: {
const paths = updatePaths(meta.target, status.version)
await validateWrapper(fs, paths.current, meta.target, currentVersion)
await validateWrapper(fs, paths.prepared, paths.preparedTarget, status.version)
if (await stat(fs, paths.backup)) return false
const markerInfo = await stat(fs, paths.marker)
if (markerInfo && !validMarkerFile(markerInfo)) return false
if (api.lifecycle.signal.aborted) return false

const marker: UpdateMarker = {
Expand All @@ -102,3 +75,5 @@ export async function prepareUpdate(input: {
return false
}
}

export { cleanupPreparedUpdate } from "./update-cleanup"
Loading
Loading