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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Server options can be configured in `opencode.json`:
"defer_while_tasks_active": true,
"max_auto_turns": 25,
"min_continue_interval_seconds": 3,
"max_turn_time": 300,
"max_prompt_failures": 3,
"default_token_budget": 200000,
"max_goal_duration_seconds": 1800,
Expand All @@ -105,6 +106,7 @@ Defaults:
- `defer_while_tasks_active`: `true`; when enabled, goal auto-continuation waits for active OpenCode Task child sessions and their orchestrator reconciliation before sending the next goal prompt.
- `max_auto_turns`: `25`
- `min_continue_interval_seconds`: `3`
- `max_turn_time`: unset by default; set a positive number of seconds to retry one active-goal continuation prompt when a model turn remains busy for that long. Each new busy event resets the watchdog. Idle, built-in retry, session deletion, active Task children, and restricted agents suppress the retry. Watchdog retries are independent of `min_continue_interval_seconds` and do not consume auto-turn, no-progress, or prompt-failure budgets.
- `max_prompt_failures`: `3`
- `default_token_budget`: unset by default; when set, new goals inherit this token budget.
- `max_goal_duration_seconds`: unset by default; when set, new goals inherit this elapsed-time safety limit.
Expand Down Expand Up @@ -174,7 +176,7 @@ The state file is written atomically with owner-only permissions when the host f

## Credits

This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from Willy Topete's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, Willy.
This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William.

## Development

Expand Down Expand Up @@ -213,6 +215,6 @@ 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 OpenCode idle events, including `session.idle` and `session.status` idle notifications. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative.
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 OpenCode idle events, including `session.idle` and `session.status` idle notifications. 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, no-progress, or prompt-failure budgets. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn. During compaction, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative.

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.
103 changes: 100 additions & 3 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ var DEFAULT_RESTRICTED_AGENTS = ["plan"];
var GOAL_SYSTEM_MARKER = "OpenCode goal mode";
var TASK_SETTLE_DELAY_MS = 25;
var SNAPSHOT_IDLE_HOLD_MS = 250;
var MAX_TIMER_DELAY_MS = 2147483647;
var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
var PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.';
var activeContinuations = new Set;
Expand Down Expand Up @@ -833,6 +834,11 @@ function commandNameFromOptions(options) {
function positiveIntegerOrNull2(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
}
function timeoutMillisecondsFromSeconds(value) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
return null;
return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS);
}
function registerDesktopCommand(config, commandName) {
config.command ??= {};
if (config.command[commandName])
Expand Down Expand Up @@ -1002,8 +1008,12 @@ function sessionIDFromEvent(event) {
if (typeof direct === "string")
return direct;
const info = event.properties?.info;
if (typeof info === "object" && info !== null && typeof info.sessionID === "string") {
return info.sessionID;
if (typeof info === "object" && info !== null) {
if (typeof info.sessionID === "string")
return info.sessionID;
if (event.type === "session.deleted" && typeof info.id === "string") {
return info.id;
}
}
return;
}
Expand Down Expand Up @@ -1304,12 +1314,14 @@ var server = async ({ client }, options) => {
const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
const minInterval = positiveIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
const registerCommand = options?.register_command ?? true;
const commandName = commandNameFromOptions(options);
const taskTracker = new TaskTracker;
const taskDeferredSessions = new Set;
const scheduledContinuations = new Map;
const turnWatchdogs = new Map;
const busySessions = new Set;
const planAgents = restrictedAgentSet(options);
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
Expand All @@ -1335,6 +1347,75 @@ var server = async ({ client }, options) => {
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
};
}
function clearTurnWatchdog(sessionID) {
const watchdog = turnWatchdogs.get(sessionID);
if (!watchdog)
return;
clearTimeout(watchdog.timer);
turnWatchdogs.delete(sessionID);
}
function armTurnWatchdog(sessionID) {
if (maxTurnTimeMs == null)
return;
clearTurnWatchdog(sessionID);
const watchdog = {
timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs)
};
const maybeUnref = watchdog.timer;
if (typeof maybeUnref.unref === "function")
maybeUnref.unref();
turnWatchdogs.set(sessionID, watchdog);
}
async function runTurnWatchdog(sessionID, watchdog) {
let claimedContinuation = false;
try {
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
return;
const goal = await getGoal(sessionID);
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
return;
if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent))
return;
const latestAssistant = await fetchLatestAssistant(client, sessionID);
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
return;
const latestTurnAgent = agentFromMessage(latestAssistant);
if (isPlanAgent(latestTurnAgent))
return;
const taskStatus = await taskBlockStatus(sessionID);
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
return;
if (taskStatus && taskStatus.blocked)
return;
const current = await getGoal(sessionID);
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
return;
if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuations.has(sessionID))
return;
turnWatchdogs.delete(sessionID);
activeContinuations.add(sessionID);
claimedContinuation = true;
await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null);
} catch (error) {
try {
await client.app?.log?.({
body: {
service: "opencode-goal-plugin",
level: "error",
message: "Turn watchdog retry failed",
extra: { error: error instanceof Error ? error.message : String(error) }
}
});
} catch {
return;
}
} finally {
if (claimedContinuation)
activeContinuations.delete(sessionID);
if (turnWatchdogs.get(sessionID) === watchdog)
turnWatchdogs.delete(sessionID);
}
}
function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
if (scheduledContinuations.has(sessionID))
return;
Expand Down Expand Up @@ -1406,6 +1487,9 @@ var server = async ({ client }, options) => {
for (const timer of scheduledContinuations.values())
clearTimeout(timer);
scheduledContinuations.clear();
for (const watchdog of turnWatchdogs.values())
clearTimeout(watchdog.timer);
turnWatchdogs.clear();
},
async config(config) {
if (!registerCommand)
Expand Down Expand Up @@ -1560,17 +1644,30 @@ var server = async ({ client }, options) => {
if (isRecord(status) && typeof status.type === "string") {
if (status.type === "busy")
busySessions.add(sessionID);
if (status.type === "idle")
if (status.type === "busy")
armTurnWatchdog(sessionID);
if (status.type === "idle") {
busySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
}
if (status.type === "retry")
clearTurnWatchdog(sessionID);
taskTracker.observeSessionStatus(sessionID, status.type);
}
}
if (sessionID && eventType === "session.idle") {
busySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
taskTracker.observeSessionStatus(sessionID, "idle");
}
if (sessionID && eventType === "session.deleted") {
busySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
const scheduled = scheduledContinuations.get(sessionID);
if (scheduled)
clearTimeout(scheduled);
scheduledContinuations.delete(sessionID);
taskDeferredSessions.delete(sessionID);
taskTracker.observeSessionDeleted(sessionID);
}
if (sessionID && event.type === "message.updated") {
Expand Down
96 changes: 92 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Options = {
defer_while_tasks_active?: boolean
max_auto_turns?: number
min_continue_interval_seconds?: number
max_turn_time?: number
max_prompt_failures?: number
register_command?: boolean
command_name?: string
Expand Down Expand Up @@ -62,6 +63,7 @@ const DEFAULT_RESTRICTED_AGENTS = ["plan"]
const GOAL_SYSTEM_MARKER = "OpenCode goal mode"
const TASK_SETTLE_DELAY_MS = 25
const SNAPSHOT_IDLE_HOLD_MS = 250
const MAX_TIMER_DELAY_MS = 2_147_483_647
const TASK_TERMINAL_STATES = new Set<TaskState>(["completed", "error", "cancelled"])
const PLAN_MODE_CREATE_NOTICE =
'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.'
Expand Down Expand Up @@ -94,6 +96,10 @@ type SnapshotIdleHold = {
expiresAt: number
}

type TurnWatchdog = {
timer: ReturnType<typeof setTimeout>
}

function restrictedAgentSet(options?: Options) {
if (options?.allow_goal_execution_from_plan === true) return new Set<string>()
const names = Array.isArray(options?.restricted_agents) ? options.restricted_agents : DEFAULT_RESTRICTED_AGENTS
Expand Down Expand Up @@ -134,6 +140,11 @@ function positiveIntegerOrNull(value: unknown) {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null
}

function timeoutMillisecondsFromSeconds(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null
return Math.min(Math.ceil(value * 1000), MAX_TIMER_DELAY_MS)
}

function registerDesktopCommand(config: Config, commandName: string) {
config.command ??= {}
if (config.command[commandName]) return
Expand Down Expand Up @@ -292,12 +303,15 @@ function isIdleEvent(event: { type?: string; properties?: Record<string, unknown
return event.type === "session.status" && typeof status === "object" && status !== null && (status as { type?: unknown }).type === "idle"
}

function sessionIDFromEvent(event: { properties?: Record<string, unknown> }) {
function sessionIDFromEvent(event: { type?: string; properties?: Record<string, unknown> }) {
const direct = event.properties?.sessionID
if (typeof direct === "string") return direct
const info = event.properties?.info
if (typeof info === "object" && info !== null && typeof (info as { sessionID?: unknown }).sessionID === "string") {
return (info as { sessionID: string }).sessionID
if (typeof info === "object" && info !== null) {
if (typeof (info as { sessionID?: unknown }).sessionID === "string") return (info as { sessionID: string }).sessionID
if (event.type === "session.deleted" && typeof (info as { id?: unknown }).id === "string") {
return (info as { id: string }).id
}
}
return undefined
}
Expand Down Expand Up @@ -606,12 +620,14 @@ const server: Plugin = async ({ client }, options?: Options) => {
const deferWhileTasksActive = options?.defer_while_tasks_active ?? true
const maxAutoTurns = positiveIntegerOrNull(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS
const minInterval = positiveIntegerOrNull(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS
const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time)
const maxPromptFailures = positiveIntegerOrNull(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES
const registerCommand = options?.register_command ?? true
const commandName = commandNameFromOptions(options)
const taskTracker = new TaskTracker()
const taskDeferredSessions = new Set<string>()
const scheduledContinuations = new Map<string, ReturnType<typeof setTimeout>>()
const turnWatchdogs = new Map<string, TurnWatchdog>()
const busySessions = new Set<string>()
const planAgents = restrictedAgentSet(options)
const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase())
Expand Down Expand Up @@ -639,6 +655,65 @@ const server: Plugin = async ({ client }, options?: Options) => {
}
}

function clearTurnWatchdog(sessionID: string) {
const watchdog = turnWatchdogs.get(sessionID)
if (!watchdog) return
clearTimeout(watchdog.timer)
turnWatchdogs.delete(sessionID)
}

function armTurnWatchdog(sessionID: string) {
if (maxTurnTimeMs == null) return
clearTurnWatchdog(sessionID)
const watchdog: TurnWatchdog = {
timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs),
}
const maybeUnref = watchdog.timer as { unref?: () => void }
if (typeof maybeUnref.unref === "function") maybeUnref.unref()
turnWatchdogs.set(sessionID, watchdog)
}

async function runTurnWatchdog(sessionID: string, watchdog: TurnWatchdog) {
let claimedContinuation = false
try {
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return
const goal = await getGoal(sessionID)
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return
if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent)) return
const latestAssistant = await fetchLatestAssistant(client, sessionID)
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return
const latestTurnAgent = agentFromMessage(latestAssistant)
if (isPlanAgent(latestTurnAgent)) return
const taskStatus = await taskBlockStatus(sessionID)
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return
if (taskStatus && taskStatus.blocked) return
const current = await getGoal(sessionID)
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID)) return
if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuations.has(sessionID)) return

turnWatchdogs.delete(sessionID)
activeContinuations.add(sessionID)
claimedContinuation = true
await sendContinuation(client, sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestTurnAgent ?? null)
} catch (error) {
try {
await client.app?.log?.({
body: {
service: "opencode-goal-plugin",
level: "error",
message: "Turn watchdog retry failed",
extra: { error: error instanceof Error ? error.message : String(error) },
},
})
} catch {
return
}
} finally {
if (claimedContinuation) activeContinuations.delete(sessionID)
if (turnWatchdogs.get(sessionID) === watchdog) turnWatchdogs.delete(sessionID)
}
}

function scheduleSettledContinuation(sessionID: string, delayMs = TASK_SETTLE_DELAY_MS) {
if (scheduledContinuations.has(sessionID)) return
const timer = setTimeout(() => {
Expand Down Expand Up @@ -706,6 +781,8 @@ const server: Plugin = async ({ client }, options?: Options) => {
async dispose() {
for (const timer of scheduledContinuations.values()) clearTimeout(timer)
scheduledContinuations.clear()
for (const watchdog of turnWatchdogs.values()) clearTimeout(watchdog.timer)
turnWatchdogs.clear()
},
async config(config) {
if (!registerCommand) return
Expand Down Expand Up @@ -876,16 +953,27 @@ const server: Plugin = async ({ client }, options?: Options) => {
const status = (event as { properties?: Record<string, unknown> }).properties?.status
if (isRecord(status) && typeof status.type === "string") {
if (status.type === "busy") busySessions.add(sessionID)
if (status.type === "idle") busySessions.delete(sessionID)
if (status.type === "busy") armTurnWatchdog(sessionID)
if (status.type === "idle") {
busySessions.delete(sessionID)
clearTurnWatchdog(sessionID)
}
if (status.type === "retry") clearTurnWatchdog(sessionID)
taskTracker.observeSessionStatus(sessionID, status.type)
}
}
if (sessionID && eventType === "session.idle") {
busySessions.delete(sessionID)
clearTurnWatchdog(sessionID)
taskTracker.observeSessionStatus(sessionID, "idle")
}
if (sessionID && eventType === "session.deleted") {
busySessions.delete(sessionID)
clearTurnWatchdog(sessionID)
const scheduled = scheduledContinuations.get(sessionID)
if (scheduled) clearTimeout(scheduled)
scheduledContinuations.delete(sessionID)
taskDeferredSessions.delete(sessionID)
taskTracker.observeSessionDeleted(sessionID)
}
if (sessionID && (event as { type?: string }).type === "message.updated") {
Expand Down
Loading