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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,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`
- Fast V2 executions that finish inside this interval schedule a delayed continuation; they do not require another user message to wake up.
- `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 never consume auto-turn or no-progress budgets, but recognized transport failures still count toward the `max_prompt_failures` ceiling.
- `max_prompt_failures`: `3`; consecutive transport or no-response continuation failures pause the goal at this ceiling. Prompt delivery alone does not reset the count; substantive assistant or tool progress, a new goal, or an explicit resume does.
- `default_token_budget`: unset by default; when set, new goals inherit this token budget.
Expand Down Expand Up @@ -253,6 +254,17 @@ bun run build
npm pack --dry-run
```

With `opencode2` installed, run `bun run build && bun run smoke:v2` to exercise the
native V2 lifecycle, not just mocked events. The smoke test uses a private server,
isolated home/config/database/goal state, and a deterministic local model with no
real provider credentials. It invokes `/goal`, requires **two** automatic
continuations with the default minimum interval, then verifies completion. A
second loaded location checks that server-wide events do not duplicate delivery.
The test prints its temporary artifact directory and shuts down its private
server. Use `OPENCODE_V2_BIN` to select another V2 binary, or run
`bun run smoke:v2 @prevalentware/opencode-goal-plugin@<version>` to install and
verify an exact published package in the isolated environment.

## Publishing

This package is set up for npm Trusted Publishing from GitHub Actions. On every push to `main`, CI runs typecheck, lint, and unit tests in parallel. If they all pass, the publish job computes the next patch version from the latest version on npm, builds the package, and runs `npm publish`.
Expand All @@ -279,6 +291,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. 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.
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.

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: 56 additions & 2 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,7 @@ async function setupV2(context) {
const planAgents = restrictedAgentSet(options);
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
const activeContinuationsV2 = new Set;
const stoppedExecutions = new Set;
const latestStepBySession = new Map;
const stepTextBuffers = new Map;
const stepTokenSums = new Map;
Expand Down Expand Up @@ -2833,6 +2834,8 @@ async function setupV2(context) {
async function runAutoContinue(sessionID, fromTaskDeferral = false, scheduled) {
if (disposed)
return;
if (stoppedExecutions.has(sessionID))
return;
if (busySessions.has(sessionID))
return;
if (activeContinuationsV2.has(sessionID))
Expand Down Expand Up @@ -2913,8 +2916,13 @@ async function setupV2(context) {
if (nativeRetrySessions.has(sessionID))
return;
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
if (!goal)
if (!goal) {
const waiting = await getGoalInternal(sessionID);
if (waiting?.status === "active" && waiting.pendingAttempt == null && waiting.lastContinuationAt != null && minInterval > 0) {
scheduleSettledContinuation(sessionID, continuationDelayFromSnapshot(minInterval, waiting.lastContinuationAt), scheduled != null);
}
return;
}
attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
if (nativeRetrySessions.has(sessionID)) {
await rollbackContinuationAttempt(sessionID);
Expand Down Expand Up @@ -2955,6 +2963,23 @@ async function setupV2(context) {
async function handleV2Event(event) {
const data = event.data;
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
if (context.location && event.location && (event.location.directory !== context.location.directory || event.location.workspaceID !== context.location.workspaceID)) {
if (event.type === "session.created" && sessionID && typeof data.parentID === "string") {
taskTracker.observeSessionCreated({ properties: { info: { id: sessionID, parentID: data.parentID } } });
} else if (sessionID) {
if (event.type === "session.execution.started")
taskTracker.observeSessionStatus(sessionID, "busy");
if (["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) {
taskTracker.observeSessionStatus(sessionID, "idle");
}
if (event.type === "session.status" && isRecord(data.status) && typeof data.status.type === "string") {
taskTracker.observeSessionStatus(sessionID, data.status.type);
}
if (event.type === "session.deleted")
taskTracker.observeSessionDeleted(sessionID);
}
return;
}
switch (event.type) {
case "session.created": {
const parentID = data.parentID;
Expand All @@ -2963,10 +2988,13 @@ async function setupV2(context) {
}
return;
}
case "session.execution.started":
case "session.retry.scheduled":
case "session.status": {
const status = data.status;
const status = event.type === "session.execution.started" ? { type: "busy" } : event.type === "session.retry.scheduled" ? { type: "retry" } : data.status;
if (sessionID && isRecord(status) && typeof status.type === "string") {
if (status.type === "busy") {
stoppedExecutions.delete(sessionID);
busySessions.add(sessionID);
nativeRetrySessions.delete(sessionID);
armTurnWatchdog(sessionID);
Expand All @@ -2992,6 +3020,7 @@ async function setupV2(context) {
}
return;
}
case "session.execution.succeeded":
case "session.idle": {
if (sessionID) {
busySessions.delete(sessionID);
Expand All @@ -3007,9 +3036,25 @@ async function setupV2(context) {
}
return;
}
case "session.execution.interrupted": {
if (!sessionID)
return;
stoppedExecutions.add(sessionID);
busySessions.delete(sessionID);
nativeRetrySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
watchdogRescuedSessions.delete(sessionID);
cancelScheduledContinuation(sessionID);
taskDeferredSessions.delete(sessionID);
taskTracker.observeSessionStatus(sessionID, "idle");
return;
}
case "session.execution.failed":
case "session.error": {
if (!sessionID)
return;
if (event.type === "session.execution.failed")
nativeRetrySessions.delete(sessionID);
const inNativeRetry = nativeRetrySessions.has(sessionID);
busySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
Expand All @@ -3018,6 +3063,13 @@ async function setupV2(context) {
nativeRetrySessions.delete(sessionID);
watchdogRescuedSessions.delete(sessionID);
const errorMessage = transportErrorMessageFromEvent(data);
if (event.type === "session.execution.failed" && !isTransportError(errorMessage)) {
stoppedExecutions.add(sessionID);
cancelScheduledContinuation(sessionID);
taskDeferredSessions.delete(sessionID);
}
if (event.type === "session.execution.failed")
taskTracker.observeSessionStatus(sessionID, "idle");
if (errorMessage && isTransportError(errorMessage)) {
const goal = await getGoalInternal(sessionID);
if (goal?.status === "active") {
Expand All @@ -3041,6 +3093,7 @@ async function setupV2(context) {
case "session.deleted": {
if (!sessionID)
return;
stoppedExecutions.delete(sessionID);
busySessions.delete(sessionID);
clearTurnWatchdog(sessionID);
watchdogRescuedSessions.delete(sessionID);
Expand Down Expand Up @@ -3317,6 +3370,7 @@ async function setupV2(context) {
clearTimeout(watchdog.timer);
turnWatchdogs.clear();
activeContinuationsV2.clear();
stoppedExecutions.clear();
nativeRetrySessions.clear();
locallyDeliveredPendingSessions.clear();
watchdogRescuedSessions.clear();
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"lint": "eslint .",
"pack:dry-run": "npm pack --dry-run",
"test": "bun test",
"smoke:v2": "bun scripts/smoke-v2-lifecycle.ts",
"test:coverage": "bun test --coverage",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run test && bun run build"
Expand Down
Loading