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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul

Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it.

The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. It checks the shared goal state file every second so usage and checkpoints stay current during a long run. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
20 changes: 11 additions & 9 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { z } from "zod";
// src/state.ts
import { randomUUID as randomUUID2 } from "crypto";
import { mkdir, readFile } from "fs/promises";
import { homedir } from "os";
import { dirname as dirname2, join } from "path";
import { dirname as dirname2 } from "path";
import { Data, Effect, Schema } from "effect";

// src/atomic-write.ts
Expand Down Expand Up @@ -117,6 +116,16 @@ async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
}
}

// src/state-path.ts
import { homedir } from "os";
import { join } from "path";
function statePath() {
if (process.env.OPENCODE_GOAL_STATE_PATH)
return process.env.OPENCODE_GOAL_STATE_PATH;
const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
return join(dataHome, "opencode-goal-plugin", "goals.json");
}

// src/state.ts
class StateReadError extends Data.TaggedError("StateReadError") {
}
Expand Down Expand Up @@ -232,13 +241,6 @@ var StateSchema = Schema.Struct({
})
});
var PersistedStateSchema = Schema.Union(LegacyStateSchema, StateSchema);
function defaultStateFile() {
const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
return join(dataHome, "opencode-goal-plugin", "goals.json");
}
function statePath() {
return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile();
}
function nowSeconds() {
return Math.floor(Date.now() / 1000);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"dist",
"src/tui.ts",
"src/i18n.ts",
"src/state-path.ts",
"LICENSE",
"README.md"
],
Expand Down
10 changes: 10 additions & 0 deletions src/state-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { homedir } from "node:os"
import { join } from "node:path"

export function statePath() {
if (process.env.OPENCODE_GOAL_STATE_PATH) return process.env.OPENCODE_GOAL_STATE_PATH
const dataHome =
process.env.XDG_DATA_HOME ||
(process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"))
return join(dataHome, "opencode-goal-plugin", "goals.json")
}
17 changes: 4 additions & 13 deletions src/state.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto"
import { readFileSync } from "node:fs"
import { mkdir, readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
import { dirname } from "node:path"
import { Data, Effect, Schema } from "effect"
import { atomicWriteFile } from "./atomic-write"
import { statePath } from "./state-path"

export { statePath } from "./state-path"

export type GoalStatus = "active" | "paused" | "budgetLimited" | "usageLimited" | "complete" | "unmet" | "cancelled"
export type MutableGoalStatus = "active" | "paused"
Expand Down Expand Up @@ -320,17 +322,6 @@ export type GoalListItem = Pick<
| "stopReason"
> & { remainingTokens: number | null }

function defaultStateFile() {
const dataHome =
process.env.XDG_DATA_HOME ||
(process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"))
return join(dataHome, "opencode-goal-plugin", "goals.json")
}

export function statePath() {
return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile()
}

function nowSeconds() {
return Math.floor(Date.now() / 1000)
}
Expand Down
146 changes: 114 additions & 32 deletions src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { Plugin as TuiPluginV2 } from "@opencode/plugin/tui"
import type { SessionMessageInfo } from "@opencode/client"
import { createElement, insert, setProp } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { readFile, stat } from "node:fs/promises"
import type { GoalMessages } from "./i18n"
import { messagesFor, presentGoalLastStatus, presentGoalStatus, presentGoalStopReason, resolveLocale } from "./i18n"
import { statePath } from "./state-path"

type GoalCheckpoint = {
summary: string
Expand Down Expand Up @@ -413,6 +415,66 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
return true
}

/** Read only the current session from the server's atomic state file. */
export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
try {
const state: unknown = JSON.parse(await readFile(statePath(), "utf8"))
if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined
const stored = state.goals[sessionID]
if (stored === undefined) return null
if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
const sampledAt = currentEpochSeconds()
const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"
? Math.max(0, sampledAt - stored.lastAccountedAt)
: 0
const tokenBudget = stored.tokenBudget
const tokensUsed = stored.tokensUsed
const snapshot = {
...stored,
timeUsedSeconds: timeUsedSeconds + activeSeconds,
sampledAt,
remainingTokens: typeof tokenBudget === "number" && typeof tokensUsed === "number"
? Math.max(0, tokenBudget - tokensUsed)
: null,
}
return isGoalSnapshot(snapshot) ? snapshot : undefined
} catch {
// Keep the last valid snapshot when the file is unavailable or incomplete.
return undefined
}
}

function usePersistedGoal(sessionID: string) {
const [persisted, setPersisted] = createSignal<GoalSnapshot | null | undefined>(undefined)
let disposed = false
let reading = false
let lastFile: { ino: number; mtimeMs: number; size: number } | undefined
const refresh = async () => {
if (reading) return
reading = true
try {
const file = await stat(statePath())
if (lastFile && lastFile.ino === file.ino && lastFile.mtimeMs === file.mtimeMs && lastFile.size === file.size) return
const next = await readPersistedGoal(sessionID)
if (next === undefined) return
lastFile = { ino: file.ino, mtimeMs: file.mtimeMs, size: file.size }
if (!disposed && (next !== null || persisted() !== undefined)) setPersisted(() => next)
} catch {
// Missing or unreadable state leaves the last valid view in place.
} finally {
reading = false
}
}
void refresh()
const timer = setInterval(() => void refresh(), 1000)
onCleanup(() => {
disposed = true
clearInterval(timer)
})
return persisted
}

function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
if (part.type !== "tool") return undefined
if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined
Expand Down Expand Up @@ -474,39 +536,50 @@ export function formatGoal(goal: GoalSnapshot | null, messages: GoalMessages, lo
function GoalSidebar(api: TuiPluginApi, messages: GoalMessages, locale: ReturnType<typeof resolveLocale>, sessionID: string) {
const theme = api.theme.current
const state = goalStateFromSession(api, sessionID)
const goal = state.goal
if (!goal) return null
if (goal.status === "complete" || goal.status === "unmet") {
const elapsed = liveTimeUsedSeconds(goal)
const label = goal.status === "complete" ? messages.tui.achieved : messages.tui.unmet
return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [
`${label} (${formatDurationBadge(elapsed)})`,
])
}
if (!state.goal) return null
const persisted = usePersistedGoal(sessionID)
const goal = createMemo(() => {
const live = persisted()
if (live === undefined) return state.goal
if (live && state.goal && (state.goal.createdAt > live.createdAt || state.goal.updatedAt > live.updatedAt)) return state.goal
return live
})
const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
if (goal.status === "active") {
const active = createMemo(() => goal()?.status === "active")
createEffect(() => {
if (!active()) return
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
onCleanup(() => clearInterval(timer))
}
return box({}, [
text({ fg: theme.text }, [messages.tui.title]),
text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(goal.status, locale)}`]),
text({ fg: theme.textMuted }, [() => `${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]),
text({ fg: theme.textMuted }, [
`${messages.tui.tokens}: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
]),
text({ fg: theme.textMuted }, [
`${messages.tui.autoContinues}: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
]),
...(goal.lastCheckpoint
? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${goal.lastCheckpoint.summary}`])]
: []),
...(goal.stopReason
? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(goal.stopReason, locale)}`])]
: []),
...(goal.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(goal.lastStatus, locale)])] : []),
text({ fg: theme.textMuted }, [goal.objective]),
])
})
return box({}, [() => {
const snapshot = goal()
if (!snapshot) return null
if (snapshot.status === "complete" || snapshot.status === "unmet") {
const elapsed = liveTimeUsedSeconds(snapshot)
return text({ fg: snapshot.status === "complete" ? theme.primary : theme.textMuted }, [
`${snapshot.status === "complete" ? messages.tui.achieved : messages.tui.unmet} (${formatDurationBadge(elapsed)})`,
])
}
return box({}, [
text({ fg: theme.text }, [messages.tui.title]),
text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(snapshot.status, locale)}`]),
text({ fg: theme.textMuted }, [`${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]),
text({ fg: theme.textMuted }, [
`${messages.tui.tokens}: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`,
]),
text({ fg: theme.textMuted }, [
`${messages.tui.autoContinues}: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`,
]),
...(snapshot.lastCheckpoint
? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${snapshot.lastCheckpoint.summary}`])]
: []),
...(snapshot.stopReason
? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(snapshot.stopReason, locale)}`])]
: []),
...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(snapshot.lastStatus, locale)])] : []),
text({ fg: theme.textMuted }, [snapshot.objective]),
])
}])
}

function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) {
Expand Down Expand Up @@ -657,8 +730,16 @@ function GoalSidebarV2(
const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, {
initial: { goal: null },
})
const persisted = usePersistedGoal(sessionID)
const messageGoal = createMemo(() => goalFromV2Messages(api.data.session.message.list(sessionID)))
const goal = createMemo<GoalSnapshot | null>(() => {
const found = goalFromV2Messages(api.data.session.message.list(sessionID))
const found = messageGoal()
if (found === null) return null
const live = persisted()
if (live !== undefined) {
if (found && live && (found.createdAt > live.createdAt || found.updatedAt > live.updatedAt)) return found
return live
}
return found === undefined ? cache.goal : found
})
createEffect(() =>
Expand All @@ -668,8 +749,9 @@ function GoalSidebarV2(
)

const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
const active = createMemo(() => goal()?.status === "active")
createEffect(() => {
if (goal()?.status !== "active") return
if (!active()) return
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
onCleanup(() => clearInterval(timer))
})
Expand Down
Loading
Loading