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 @@ -122,6 +122,7 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso
"max_auto_turns": 25,
"min_continue_interval_seconds": 3,
"max_turn_time": 300,
"max_task_block_seconds": 900,
"max_prompt_failures": 3,
"default_token_budget": 200000,
"max_goal_duration_seconds": 1800,
Expand Down Expand Up @@ -157,7 +158,8 @@ In OpenCode 2, use the plugin object form instead:
Defaults:

- `auto_continue`: `true`
- `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.
- `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. A deferral re-checks child sessions on a short timer, so a goal deferred by a task never depends on a further idle event to resume.
- `max_task_block_seconds`: `900`; wall-clock ceiling on how long a single Task child session may defer goal continuation. A child that stays listed but never reports a terminal state, or a terminal child whose result is never reconciled, stops blocking once the ceiling passes. Set a smaller value for shorter subagents, `0` to remove the ceiling, or disable deferral entirely with `defer_while_tasks_active: false`.
- `max_auto_turns`: `25`
- `min_continue_interval_seconds`: `3`
- Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
Expand Down Expand Up @@ -291,6 +293,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 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. 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 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, 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.
58 changes: 45 additions & 13 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,8 @@ var DEFAULT_COMMAND_NAME = "goal";
var DEFAULT_RESTRICTED_AGENTS = ["plan"];
var TASK_SETTLE_DELAY_MS = 25;
var SNAPSHOT_IDLE_HOLD_MS = 250;
var DEFAULT_MAX_TASK_BLOCK_SECONDS = 900;
var TASK_BLOCK_RETRY_MS = 1000;
var MAX_TIMER_DELAY_MS = 2147483647;
var STALE_PENDING_MS = 30000;
var RETRY_SETTLE_MS = 25;
Expand Down Expand Up @@ -1644,6 +1646,12 @@ function toolOutputFailed(output) {
return true;
return false;
}
function taskBlockExpired(task, maxBlockMs, now) {
if (maxBlockMs == null)
return false;
const blockingSince = task.state === "running" ? task.runningSince : task.terminalAt;
return blockingSince != null && now - blockingSince >= maxBlockMs;
}
function sessionIDFromEvent(event) {
const direct = event.properties?.sessionID;
if (typeof direct === "string")
Expand Down Expand Up @@ -1769,13 +1777,17 @@ class TaskTracker {
if (marker)
this.observeAssistant(sessionID, marker);
}
hasBlockingTasks(parentSessionID) {
hasBlockingTasks(parentSessionID, maxBlockMs = null) {
this.pruneExpiredSnapshotIdleHolds();
const now = Date.now();
for (const task of this.tasks.values()) {
if (task.parentSessionID !== parentSessionID)
continue;
if (task.state === "running" || task.terminalUnreconciled)
return true;
if (task.state !== "running" && !task.terminalUnreconciled)
continue;
if (taskBlockExpired(task, maxBlockMs, now))
continue;
return true;
}
for (const hold of this.snapshotIdleHolds.values()) {
if (hold.parentSessionID === parentSessionID)
Expand Down Expand Up @@ -1836,6 +1848,7 @@ class TaskTracker {
parentSessionID,
state: "running",
terminalUnreconciled: false,
runningSince: existing?.state === "running" ? existing.runningSince ?? Date.now() : Date.now(),
terminalAt: null,
lastAssistantMessageIDAtTerminal: existing?.lastAssistantMessageIDAtTerminal ?? null
});
Expand All @@ -1851,13 +1864,15 @@ class TaskTracker {
if (existing && TASK_TERMINAL_STATES.has(existing.state) && !existing.terminalUnreconciled && !options.resetReconciled) {
return;
}
const continuesExistingTerminal = existing != null && TASK_TERMINAL_STATES.has(existing.state) && existing.state === state && existing.terminalUnreconciled && !options.resetReconciled;
this.tasks.set(taskID, {
taskID,
parentSessionID: resolvedParentSessionID,
state,
terminalUnreconciled: true,
terminalAt: Date.now(),
lastAssistantMessageIDAtTerminal: this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
runningSince: null,
terminalAt: continuesExistingTerminal ? existing.terminalAt ?? Date.now() : Date.now(),
lastAssistantMessageIDAtTerminal: continuesExistingTerminal ? existing.lastAssistantMessageIDAtTerminal : this.latestAssistantBySession.get(resolvedParentSessionID)?.id ?? null
});
}
markSnapshotIdle(parentSessionID, taskID) {
Expand Down Expand Up @@ -2010,6 +2025,13 @@ async function createGoalFromTool(input, context, services) {
function isClosedGoal(goal) {
return goal.status === "complete" || goal.status === "unmet";
}
function taskDeferralGoalContinuable(goal) {
if (!goal)
return false;
if (goal.status === "budgetLimited" || goal.status === "usageLimited")
return !goal.budgetWrapupSent;
return goal.status === "active";
}
function existingGoalResult(goal, requestedObjective, planningOnly) {
const reused = goal.objective === requestedObjective;
return JSON.stringify({
Expand Down Expand Up @@ -2097,6 +2119,7 @@ var server = async ({ client }, options) => {
const maxAutoTurns = positiveIntegerOrNull2(options?.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
const minInterval = nonNegativeIntegerOrNull2(options?.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options?.max_turn_time);
const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options?.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS);
const maxPromptFailures = positiveIntegerOrNull2(options?.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
const registerCommand = options?.register_command ?? true;
const commandName = commandNameFromOptions(options);
Expand Down Expand Up @@ -2129,7 +2152,7 @@ var server = async ({ client }, options) => {
return false;
await taskTracker.refreshLiveChildren(client, sessionID);
return {
blocked: taskTracker.hasBlockingTasks(sessionID),
blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs),
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
};
}
Expand Down Expand Up @@ -2267,10 +2290,14 @@ var server = async ({ client }, options) => {
taskTracker.observeAssistantMessage(sessionID, latestAssistant);
const taskStatus = await taskBlockStatus(sessionID);
if (taskStatus && taskStatus.blocked) {
taskDeferredSessions.add(sessionID);
if (taskStatus.retryAt != null) {
scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
const deferralGoal = await getGoalInternal(sessionID);
if (!taskDeferralGoalContinuable(deferralGoal)) {
taskDeferredSessions.delete(sessionID);
cancelScheduledContinuation(sessionID);
return;
}
taskDeferredSessions.add(sessionID);
scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null);
return;
}
if (busySessions.has(sessionID))
Expand Down Expand Up @@ -2671,6 +2698,7 @@ async function setupV2(context) {
const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
const minInterval = nonNegativeIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time);
const maxTaskBlockMs = timeoutMillisecondsFromSeconds(options.max_task_block_seconds ?? DEFAULT_MAX_TASK_BLOCK_SECONDS);
const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
const registerCommand = options.register_command ?? true;
const commandName = commandNameFromOptions(options);
Expand Down Expand Up @@ -2719,7 +2747,7 @@ async function setupV2(context) {
if (!deferWhileTasksActive)
return false;
return {
blocked: taskTracker.hasBlockingTasks(sessionID),
blocked: taskTracker.hasBlockingTasks(sessionID, maxTaskBlockMs),
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
};
}
Expand Down Expand Up @@ -2849,10 +2877,14 @@ async function setupV2(context) {
}
const taskStatus = taskBlockStatus(sessionID);
if (taskStatus && taskStatus.blocked) {
taskDeferredSessions.add(sessionID);
if (taskStatus.retryAt != null) {
scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now(), scheduled != null);
const deferralGoal = await getGoalInternal(sessionID);
if (!taskDeferralGoalContinuable(deferralGoal)) {
taskDeferredSessions.delete(sessionID);
cancelScheduledContinuation(sessionID);
return;
}
taskDeferredSessions.add(sessionID);
scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null);
return;
}
if (busySessions.has(sessionID))
Expand Down
Loading