The ultimate OpenCode plugin for session management. One plugin replaces three: auto-recovery, todo-reminders, and review-on-completion — all with zero conflicts.
| Feature | Replaces | What It Does |
|---|---|---|
| Stall Recovery | Manual intervention | Detects stuck sessions, aborts them, sends continue |
| Todo Context | opencode-todo-reminder |
Fetches open todos, includes them in recovery messages |
| Review on Completion | opencode-auto-review-completed-todos |
Sends review prompt when all todos are done |
| Nudger | Nothing — unique feature | Gentle reminders for idle sessions with open todos |
| 4-Layer Compaction | Nothing — unique feature | Opportunistic/Proactive/Hard/Emergency — all with token reduction |
| Question Auto-Answer | Nothing — unique feature | Auto-replies to AI multi-choice questions with recommended option |
| Plan-Aware Continue | Nothing — unique feature | Detects planning phase and uses continueWithPlanMessage when recovering |
| Tool-Text Recovery | Nothing — unique feature | Detects XML tool calls in reasoning, sends recovery prompt |
| Hallucination Loop Detection | Nothing — unique feature | Breaks infinite loops with abort+resume |
| Prompt Guard | Nothing — unique feature | Prevents duplicate injections across plugin instances |
| Custom Prompts | Nothing — unique feature | Per-session custom prompts with template variables |
| Session Monitor | Nothing — unique feature | Detects orphan parents after subagent completion |
| Terminal Timer | Nothing — unique feature | Shows elapsed time in terminal title bar |
| Session Status File | Nothing — unique feature | Real-time JSON status for external monitoring |
| Stall Pattern Detection | Nothing — unique feature | Tracks which part types cause the most stalls |
| Terminal Progress Bar | Nothing — unique feature | OSC 9;4 progress in terminal tabs (iTerm2, WezTerm, etc.) |
Delegated to other plugins:
- 🔄 Toast notifications →
@mohak34/opencode-notifieror similar |
📊 Want to understand the internals? See the Flow Chart for a detailed breakdown of every state transition, guard check, and decision point.
The plugin is split into focused modules following the factory pattern:
index.ts Main plugin — event routing, module wiring
├── terminal.ts Terminal title, progress bar, statusLine hook
├── nudge.ts Idle nudges with loop protection
├── status-file.ts Atomic status file writes
├── recovery.ts Stall recovery (abort + continue)
├── compaction.ts 4-layer compaction (opportunistic/proactive/hard/emergency)
├── review.ts Review + continue prompt delivery
├── session-monitor.ts Orphan parent detection
├── stop-conditions.ts Stop condition evaluation
├── test-runner.ts Test execution, gate files, lock contention detection
├── todo-poller.ts Periodic todo API polling
├── tokens.ts SQLite real token counts from OpenCode DB
├── dangerous-commands.ts Dangerous command detection and blocking
├── shared.ts Utilities, prompt guard, token estimation
├── config.ts Plugin config interface, validation, defaults
└── session-state.ts SessionState interface, token counting
├── types.ts TypedPluginInput type alias (OpenCode SDK bridge)
Each module is initialized early and receives its dependencies:
createTerminalModule({ config, sessions, log, input })
createNudgeModule({ config, sessions, log, isDisposed, input })
createStatusFileModule({ config, sessions, log })
createRecoveryModule({ config, sessions, log, input, isDisposed, writeStatusFile, cancelNudge })
createCompactionModule({ config, sessions, log, input })
createReviewModule({ config, sessions, log, input, isDisposed, writeStatusFile, isTokenLimitError, forceCompact })1. Synthetic Message Filtering
All plugin-generated prompts use synthetic: true. Our event handler ignores these to prevent infinite loops:
// Our prompts are synthetic:
body: { parts: [{ type: "text", text: "...", synthetic: true }] }
// Our handler ignores them:
if (part?.synthetic === true) return;2. Event-Driven, Not Polling
- Timers are only set when session is
busy - All timers cleared on
idle,error,deleted - No background loops or CPU usage when session is idle
3. Progress Tracking Real progress events reset recovery attempts:
text,step-finish,reasoning,tool,step-start,subtask,file
Synthetic events (our own prompts) are ignored.
4. Plan Awareness When plan content is detected, stall monitoring pauses until execution begins.
5. Status File Writes Every meaningful event writes the status file atomically. This enables external monitoring without debug mode.
Some models output XML tool calls inside their reasoning/text fields instead of using the proper tool-calling mechanism. The plugin detects this during recovery and sends a specialized prompt to execute the tool call.
[Stall detected → recover(sid)]
│
▼
Scan ~20 recent messages for XML tool-like patterns
│
├── Detects 17+ patterns:
│ <function=...>, <invoke>, <tool_call>, <tool_call>,
│ <invoke name="...">, <function_calls>,
│ ```json tool calls, <|tool_call|>,
│ <use_tools>, <function_chain>, <execute>, <run_tool>,
│ <system-reminder> (role confusion),
│ + truncated/unclosed tag patterns
│
├── Also detects truncated/unclosed tags
│
└── Found XML tool calls?
│
├──YES ──► Send specialized recovery prompt:
│ "I noticed you have a tool call generated in your
│ thinking/reasoning. Please execute it using the
│ proper tool calling mechanism instead of XML tags."
│
└──NO ──► Use standard continue message
Why this matters: Models that output XML instead of executing tool calls get stuck — they think they ran the tool but actually didn't. This recovery prompt breaks that cycle.
Trade-off: Regex patterns may have rare false positives on legitimate XML in code (e.g., JSX, XML examples in documentation).
When a model gets stuck repeating the same broken output (e.g., generating the same error over and over), the plugin detects the pattern and forces a short delay before continuing, breaking the cycle.
[Continue sent]
│
▼
Record timestamp in sliding window
│
▼
Check: 3+ continues within 10 minutes?
│
├──YES ──► Short delay (3s) then continue
│ The delay breaks the hallucination cycle
│
└──NO ──► Normal continue flow
Why this matters: Without this, a hallucinating model can generate the same broken output → plugin sends continue → model generates same broken output → infinite loop. The delay breaks the hallucination cycle — the model gets a moment to reset instead of looping the same broken output.
Trade-off: 3-in-10min threshold may catch legitimate rapid continues (e.g., fast-paced debugging sessions), but false positives are rare and only result in one extra abort+resume.
When multiple plugin instances or race conditions try to inject the same prompt, the prompt guard blocks duplicates.
[About to send nudge/continue/review]
│
▼
Fetch recent messages (last ~15)
│
▼
Check: similar prompt content already sent within 30 seconds?
│
├──YES ──► This is a duplicate — skip
│
└──NO ──► Send the prompt (normal flow)
Why this matters: If two plugin instances or rapid events both trigger a continue, the session gets flooded with duplicate messages. The prompt guard ensures only one goes through.
Key detail: Uses text similarity (not exact match) — "Please continue working" and "Continue working on tasks" are treated as similar enough to dedupe.
Fail-open: If the message fetch fails, the prompt is allowed through (better to have a rare duplicate than miss a needed continue).
Send dynamic, context-aware prompts to specific sessions with full template variable support. Perfect for integrations, external triggers, or programmatic session management.
import { sendCustomPrompt } from "opencode-auto-continue";
// Send a custom prompt to a session
await sendCustomPrompt(sessionId, {
message: "Custom analysis request: {contextSummary}",
includeTodoContext: true,
includeContextSummary: true,
customPrompt: "Focus on performance bottlenecks"
});When to use: External integrations, webhooks, CLI tooling, or when you need to inject context-aware messages without manual intervention.
Template variables available:
| Variable | Description |
|---|---|
{pending} |
Number of open tasks |
{total} |
Total tasks |
{completed} |
Completed tasks |
{todoList} |
Comma-separated pending tasks (max 5) |
{attempts} |
Current recovery attempt |
{maxAttempts} |
Max recovery attempts |
{contextSummary} |
Session context summary (when includeContextSummary: true) |
API:
function sendCustomPrompt(
sessionId: string,
options: {
message: string; // Message template with {variables}
includeTodoContext?: boolean; // Include todo list in message
includeContextSummary?: boolean; // Include context summary
customPrompt?: string; // Additional custom prompt text
}
): Promise<{
success: boolean;
message: string; // Final rendered message
todos?: Todo[]; // Todo context (if fetched)
customPrompt?: string; // Custom prompt text (if provided)
contextSummary?: string; // Context summary (if requested)
}>Example with all options:
const result = await sendCustomPrompt("abc123", {
message: "🔄 Recovery attempt {attempts}/{maxAttempts}. {contextSummary}",
includeTodoContext: true,
includeContextSummary: true,
customPrompt: "Please prioritize the API integration task"
});
// Result:
// {
// success: true,
// message: "🔄 Recovery attempt 1/3. Working on 2 tasks: fix auth, update docs",
// todos: [{ id: "1", content: "fix auth", status: "in_progress" }, ...],
// customPrompt: "Please prioritize the API integration task",
// contextSummary: "Working on 2 tasks: fix auth, update docs"
// }When the AI is in a planning phase (detected by plan-related text in messages), recovery uses a plan-aware continue message instead of the generic one.
What works now:
- Planning detection: The plugin detects when the AI outputs planning text (e.g., "Here's my plan:") and sets a
planningflag - Plan-aware continue: When recovering during planning, it uses
continueWithPlanMessageinstead ofshortContinueMessage
Note: The plugin does not parse external plan files (PLAN.md, ROADMAP.md, etc.). Planning is detected dynamically from the AI's output text.
When all todos are completed, the review prompt fires once per session:
[All todos completed]
│
▼
Review fires (debounced 500ms)
│
▼
AI receives review prompt
The default review message asks the AI to run tests and verify everything passes. The AI may create new fix todos if it finds failures.
Note: Review can fire multiple times per session. After a review fires and the AI creates new pending todos, the todo poller's processTodos() resets reviewFired = false when the reviewCooldownMs has elapsed, enabling another review cycle. If the AI completes all todos without creating new ones, review fires once and stays done. Additionally, if all todos complete while reviewFired is still set (e.g., from a previous cycle), the stale flag is reset after cooldown expires, ensuring reviews fire reliably in multi-cycle scenarios.
Config:
{
"reviewMessage": "All tasks have been completed. Please run the test suite...",
"reviewOnComplete": true
}[session.status busy]
│
▼
Start stall timer (stallTimeoutMs)
│
├──[Progress event]──► Reset timer, reset attempts
│
├──[Timer fires]──► Check: still busy?
│ │
│ YES │ NO (idle) ──► Clear timer, wait for session.idle
│ │
│ Check: attempts < maxRecoveries?
│ │
│ NO──► Exponential backoff
│ │
│ Check: session too old? (maxSessionAgeMs)
│ │
│ YES──► Give up
│ │
│ session.abort()
│ │
│ Poll until idle (abortPollIntervalMs)
│ │
│ Wait waitAfterAbortMs
│ │
│ Check: attempts < maxRecoveries?
│ │
│ NO──► Exponential backoff
│ │
│ Fetch todos for context (if includeTodoContext)
│ │
│ Build message with template vars
│ │
│ Set needsContinue + continueMessageText
│ │
│ Increment attempts, autoSubmitCount
│ │
│ Cancel any pending nudge
│
└──[session.idle]──► Clear timer, send any queued continue
(if recovery in progress, schedules delayed fallback)
Recovery module (createRecoveryModule):
- Located in
src/recovery.ts - Called from event handlers in
index.ts - Receives
writeStatusFileandcancelNudgeas dependencies - Uses
input as anyfor all client API calls - Sends continue via
sendContinue()— guarded bycontinueInProgressflag and prompt guard - If
session.idleorsession.status(idle)fires whileaborting=true, both handlers schedule a 3-second delayed fallback that callssendContinue()withcontinueInProgressguard, ensuring the continue fires even if the primary path was blocked
Exponential backoff:
- After
maxRecoveriesattempts, delay doubles each time - Max delay capped at
maxBackoffMs(30 min default) - Backoff resets when recovery succeeds
Before sending continue, todos are fetched:
[About to send continue]
│
▼
Fetch session.todos()
│
▼
Filter: pending/in_progress tasks
│
├──[Has pending]──► Format: "You have 3 tasks: fix bug, update docs, refactor"
│
└──[No pending]──► Use default message
The nudge system prevents sessions from going idle with pending todos. It follows the same pattern as opencode-todo-reminder:
[session.idle] ──► scheduleNudge() ──► setTimeout(nudgeIdleDelayMs)
│
[Timer fires] ──► injectNudge()
│
[Check cooldown] ──► YES ──► send nudge
│
NO ──► skip
Nudge scheduling (scheduleNudge):
- Fires on every
session.idlewith pending todos (NO wasBusy dedup) - Also fires from the periodic todo poller (
processTodos) as a fallback whensession.idleevents are unreliable — this ensures nudges still fire even if the idle event stream is disrupted - Schedules via
setTimeoutwithnudgeIdleDelayMs(default 0 = immediate) - Resets nudge timer on
todo.updatedwith pending todos - Cancels pending nudge on
message.updated(user),session.error,session.deleted
Nudge injection (injectNudge):
- Check hard compaction (tokens >
hardCompactAtTokens→ await compaction first) - Check failure backoff (5s cooldown after nudge failure)
- Check cooldown (
nudgeCooldownMsdefault 30s) - Check session status (busy/retry → schedule retry, skip)
- Check user message cooldown (skip if user messaged recently)
- Check
nudgePausedflag (set on MessageAbortedError, cleared on user message) - Run test commands if
testOnIdleenabled (failures change nudge message to "fix tests") - Check loop protection (
nudgeMaxSubmitsdefault 10) - Fetch todos via API for context
- Send nudge via
session.prompt()
Loop protection:
nudgeCountincrements each successful nudgelastTodoSnapshot= serializedid:statusof all todos- If snapshot unchanged after
nudgeMaxSubmitsnudges → pause next cycle - If snapshot changes (user added/completed todos) → reset counter
Abort detection:
MessageAbortedErrorsetsnudgePaused = true- Next nudge cycle is skipped
- Cleared when user sends a message
[todo.updated] → allCompleted?
│
├──YES──► Debounce 500ms
│ │
│ └──Check: reviewFired == false?
│ │
│ └──YES──► Send review prompt
│ │
│ └──reviewFired = true
│ │
│ [Compaction may fire here]
│
└──NO──► Clear any pending debounce
Compaction during review: If tokens exceed opportunisticCompactAtTokens after review fires, opportunistic compaction triggers during the review cycle. This is safe — the review prompt still reaches the AI because session.compacted does not clear reviewFired. The session.compacted handler checks !s.compacting before sending continue, so it waits until compaction is fully done before sending its own continue (chained after compaction completes).
Multi-cycle review: After a review fires and the AI creates new pending todos, processTodos() resets reviewFired = false when reviewCooldownMs has elapsed, enabling another review cycle when todos complete again.
A passive monitoring layer that watches for session lifecycle issues the event system might miss.
Orphan Parent Detection: When a subagent finishes but the parent session stays stuck as "busy" forever:
- Monitors
busyCountacross all sessions via 5s timer - Detects when count drops from >1 to 1 (subagent completion signal)
- Waits
orphanWaitMs(15s default) for natural parent resume - If parent still busy → triggers recovery (abort + continue)
Why timers instead of events? Orphan detection requires watching busyCount over time — a single event can't detect "was >1 now =1".
Config:
| Option | Type | Default | Description |
|---|---|---|---|
sessionMonitorEnabled |
boolean | true |
Enable session monitoring layer |
subagentWaitMs |
number | 15000 |
Wait after subagent finish before treating parent as orphan |
Integration:
touchSession()called on: session.created, session.status(busy/retry), message.part.updated(real progress)- Orphan detection calls recovery.ts when parent stuck
- State shares the same sessions Map with all other modules
npm install @dracondev/opencode-auto-continuenpm install github:DraconDev/opencode-auto-continueFor developing or testing the plugin locally:
git clone https://github.com/DraconDev/opencode-auto-continue
cd opencode-auto-continue
npm install
npm run build
# Create plugin directory if it doesn't exist
mkdir -p ~/.config/opencode/plugins/opencode-auto-continue
# Copy all compiled files (not just index.js!)
cp -r dist/* ~/.config/opencode/plugins/opencode-auto-continue/
# Create package.json so OpenCode can resolve the plugin
cat > ~/.config/opencode/plugins/opencode-auto-continue/package.json << 'EOF'
{
"name": "opencode-auto-continue",
"version": "7.8.344",
"main": "./index.js",
"types": "./index.d.ts"
}
EOFThen register it in your OpenCode config (see Plugin Registration below).
Add the plugin to your ~/.config/opencode/opencode.json:
{
"plugin": [
"@mohak34/opencode-notifier@latest",
["opencode-auto-continue", {
"stallTimeoutMs": 45000,
"maxRecoveries": 3,
"sessionMonitorEnabled": true,
"nudgeEnabled": true,
"autoCompact": true,
"debug": false
}]
]
}Important: The plugin name opencode-auto-continue must match the directory name in ~/.config/opencode/plugins/.
Local (development):
- Plugin files in
~/.config/opencode/plugins/opencode-auto-continue/ - Reference in config:
["opencode-auto-continue", { ... }](no version, no scope) - Requires
package.jsonin plugin directory
npm (production):
npm install -g opencode-auto-continue
# or
opencode plugin opencode-auto-continue@latest --global- Reference in config:
["opencode-auto-continue", { ... }]or"opencode-auto-continue"
Minimal configuration with sensible defaults:
["opencode-auto-continue", {
"stallTimeoutMs": 180000,
"maxRecoveries": 3,
"sessionMonitorEnabled": true,
"nudgeEnabled": true,
"autoCompact": true,
"debug": false
}]{
"plugin": [
["file:///home/dracon/Dev/opencode-auto-continue/dist/index.js", {
"stallTimeoutMs": 45000,
"maxRecoveries": 3,
"waitAfterAbortMs": 5000,
"cooldownMs": 60000,
"nudgeEnabled": true,
"nudgeIdleDelayMs": 0,
"nudgeMaxSubmits": 10,
"nudgeCooldownMs": 30000,
"autoCompact": true,
"autoAnswerQuestions": false,
"maxSessionAgeMs": 7200000,
"proactiveCompactAtTokens": 80000,
"opportunisticCompactAtTokens": 60000,
"hardCompactAtTokens": 100000,
"compactMaxRetries": 3,
"compactReductionFactor": 0.7,
"compactionVerifyWaitMs": 30000,
"compactRetryDelayMs": 3000,
"shortContinueMessage": "Continue. Create todos for any untracked work before starting it.",
"tokenLimitPatterns": ["context length", "maximum context length", "token count exceeds", "too many tokens", "payload too large", "token limit exceeded"],
"terminalTitleEnabled": true,
"statusFileEnabled": true,
"statusFilePath": "",
"maxStatusHistory": 10,
"statusFileRotate": 5,
"recoveryHistogramEnabled": true,
"stallPatternDetection": true,
"terminalProgressEnabled": true,
"sessionMonitorEnabled": true,
"debug": false
}]
]
}| Option | Default | Description |
|---|---|---|
stallTimeoutMs |
180000 |
Time without activity before recovery (3 min) |
busyStallTimeoutMs |
180000 |
Time without real output when session reports busy (3 min) |
textOnlyStallTimeoutMs |
180000 |
Time with only text/reasoning output before stall (3 min) |
toolLoopMaxRepeats |
5 |
Max consecutive same-tool calls before tool loop detection |
toolLoopWindowMs |
120000 |
Window for tool loop detection (2 min) |
planningTimeoutMs |
300000 |
Max time in planning state before forced recovery (5 min) |
tokenEstimateMultiplier |
1.0 |
Multiplier for text-based token estimation |
waitAfterAbortMs |
5000 |
Pause between abort and continue (5s) |
maxRecoveries |
3 |
Max recovery attempts before exponential backoff |
cooldownMs |
60000 |
Time between recovery attempts (1 min) |
abortPollIntervalMs |
200 |
Poll interval after abort |
abortPollMaxTimeMs |
5000 |
Max poll time after abort |
abortPollMaxFailures |
3 |
Max poll failures before giving up |
maxBackoffMs |
1800000 |
Max backoff delay (30 min) |
| Option | Default | Description |
|---|---|---|
includeTodoContext |
true |
Fetch and include todos in messages |
| Option | Default | Description |
|---|---|---|
reviewOnComplete |
true |
Send review when all todos done |
reviewMessage |
"..." |
Review prompt text (TDD-focused, includes {testOutput} template) |
reviewWithoutTestsMessage |
"..." |
Review prompt without test output |
reviewDebounceMs |
500 |
Debounce before triggering review |
reviewCooldownMs |
60000 |
Min time between reviews |
| Option | Default | Description |
|---|---|---|
shortContinueMessage |
"Continue..." |
Short continue prompt |
continueWithPlanMessage |
"Finish your plan..." |
Continue when plan detected |
continueMessage |
"Continue from where you left off..." |
Default continue (TDD + TodoWrite) |
continueWithTodosMessage |
"You have {pending}..." |
Continue with todo context (TDD + TodoWrite) |
maxAttemptsMessage |
"..." |
Shown after max recovery attempts |
| Option | Default | Description |
|---|---|---|
nudgeEnabled |
true |
Send continue prompts for incomplete todos |
nudgeIdleDelayMs |
0 |
Delay after session.idle before sending nudge |
nudgeMessage |
"You have {pending}..." |
Nudge message (TDD + TodoWrite) |
nudgeCooldownMs |
30000 |
Min time between nudges (30s) |
nudgeMaxSubmits |
10 |
Max nudges before loop protection pauses |
includeTodoContext |
true |
Include pending todos in nudge message |
todoPollIntervalMs |
30000 |
Periodic todo API poll interval (0=disable) |
| Option | Default | Description |
|---|---|---|
autoCompact |
true |
Enable proactive and opportunistic compaction |
compactCooldownMs |
60000 |
Min time between soft compactions |
proactiveCompactAtTokens |
80000 |
Token threshold for proactive compaction |
opportunisticCompactAtTokens |
60000 |
Token threshold for opportunistic compaction |
hardCompactAtTokens |
100000 |
Token threshold for mandatory blocking compaction |
hardCompactMaxWaitMs |
30000 |
Max wait for hard compaction before proceeding anyway |
hardCompactBypassCooldown |
true |
Hard compaction ignores cooldown |
compactRetryDelayMs |
3000 |
Delay between compaction retries |
compactMaxRetries |
3 |
Max compaction retry attempts |
compactionVerifyWaitMs |
30000 |
Max wait for compaction verification |
compactReductionFactor |
0.7 |
Fraction of tokens remaining after compaction (0.7 = 70% remain, 30% removed) |
compactionSafetyTimeoutMs |
15000 |
Safety timeout to clear stuck compacting flag |
compactionGracePeriodMs |
10000 |
Grace period after compaction — all layers skip while DB updates |
compactionFailBackoffMs |
60000 |
After compaction fails, all layers skip for this period to prevent spam |
The plugin handles 4-layer compaction: opportunistic at 60k, proactive at 80k, hard at 100k, and emergency on token limit errors.
If you frequently hit token limits with large pastes (HTML, JSON, etc.), consider lowering your model's context window.
| Option | Default | Description |
|---|---|---|
terminalTitleEnabled |
true |
Update terminal title with elapsed time |
terminalProgressEnabled |
true |
OSC 9;4 terminal tab progress bar |
showToasts |
true |
Show toast notifications |
| Option | Default | Description |
|---|---|---|
statusFileEnabled |
true |
Enable real-time status file writes |
statusFilePath |
"" |
Custom path (default: ~/.opencode/logs/auto-force-resume.status) |
maxStatusHistory |
10 |
Number of history entries to keep per session |
statusFileRotate |
5 |
Number of rotated archives to keep |
recoveryHistogramEnabled |
true |
Track recovery time histogram (min/max/median) |
stallPatternDetection |
true |
Track which part types cause stalls |
| Option | Default | Description |
|---|---|---|
autoAnswerQuestions |
false |
Auto-answer AI multiple-choice questions with first (recommended) option |
When enabled, the plugin intercepts question.asked events and replies with the first option automatically. This prevents sessions from stalling when the AI asks follow-up questions. Uses OpenCode SDK internal _client property — no public API available in v1.
| Option | Default | Description |
|---|---|---|
testOnIdle |
true |
Auto-run testCommands when session goes idle; inject failures into nudge |
testCommands |
["cargo test"] |
Shell commands to run for test verification (sequentially) |
testCommandTimeoutMs |
300000 |
Per-command timeout in ms (5 minutes) |
testCommandGates |
{} |
Gate files for test commands (e.g., {"cargo": "Cargo.toml"}) — prevents running tests in non-project directories |
When enabled, the plugin runs tests automatically before each nudge. If tests fail, the nudge message becomes "Tests are failing. Fix these before continuing...". At review time, test output is injected via {testOutput} template variable. Continue/nudge messages include TDD instructions.
| Option | Default | Description |
|---|---|---|
stopFilePath |
"" |
Path to a stop file — plugin pauses when file exists |
maxRuntimeMs |
0 |
Max session runtime in ms (0=disabled) |
untilMarker |
"" |
Stop when this marker text appears in output |
| Option | Default | Description |
|---|---|---|
debug |
false |
Enable debug logging to file |
| Option | Default | Description |
|---|---|---|
dangerousCommandBlocking |
true |
Abort session if AI tries blocked commands (sudo, rm -rf /~, chmod 777, etc.) |
dangerousCommandInjection |
true |
Inject dangerous commands policy into system prompt (visible every turn, no wasted AI turn) |
Use in any message template:
| Variable | Description |
|---|---|
{pending} |
Number of open tasks |
{total} |
Total tasks |
{completed} |
Completed tasks |
{todoList} |
Comma-separated pending tasks (max 5) |
{attempts} |
Current recovery attempt |
{maxAttempts} |
Max recovery attempts |
The plugin writes a real-time JSON status file for external monitoring.
- Default:
~/.opencode/logs/auto-force-resume.status - Custom: Set
statusFilePathin config
# Watch status file updates
watch -n 1 'cat ~/.opencode/logs/auto-force-resume.status'
# Or use tail
tail -f ~/.opencode/logs/auto-force-resume.status
# Pretty print with jq
watch -n 1 'cat ~/.opencode/logs/auto-force-resume.status | jq .'{
"version": "6.61.0",
"timestamp": "2026-05-05T13:00:00.000Z",
"sessions": {
"abc123": {
"elapsed": "5m 32s",
"status": "active",
"recovery": {
"attempts": 2,
"successful": 1,
"failed": 0,
"lastAttempt": "2026-05-05T12:58:00.000Z",
"lastSuccess": "2026-05-05T12:55:00.000Z",
"inBackoff": false,
"backoffAttempts": 0,
"nextRetryIn": null,
"avgRecoveryTime": "3s",
"recoveryRate": "100%",
"histogram": {
"min": "1s",
"max": "12s",
"median": "3s",
"samples": 15
}
},
"stall": {
"detections": 3,
"lastDetectionAt": "2026-05-05T12:58:00.000Z",
"lastPartType": "reasoning",
"patterns": [
{"type": "tool", "count": 15},
{"type": "reasoning", "count": 8},
{"type": "text", "count": 5}
]
},
"compaction": {
"proactiveTriggers": 0,
"tokenLimitTriggers": 2,
"successful": 1,
"lastCompactAt": "2026-05-05T12:50:00.000Z",
"estimatedTokens": 85000,
"threshold": 100000
},
"nudge": {
"sent": 1,
"lastNudgeAt": "2026-05-05T12:45:00.000Z"
},
"todos": {
"hasOpenTodos": true
},
"autoSubmits": 1,
"userCancelled": false,
"planning": false,
"compacting": false,
"sessionCreatedAt": "2026-05-05T12:54:28.000Z",
"history": [
{"timestamp": "2026-05-05T12:54:28.000Z", "status": "active", "actionDuration": "idle", "progressAgo": "0s"},
{"timestamp": "2026-05-05T12:55:00.000Z", "status": "recovering", "actionDuration": "32s", "progressAgo": "32s"}
]
}
}
}| Field | Description |
|---|---|
version |
Plugin version |
timestamp |
ISO timestamp of last update |
sessions.{id}.elapsed |
Total session duration |
sessions.{id}.status |
Current status: active, recovering, compacting, planning |
recovery.attempts |
Total recovery attempts |
recovery.successful |
Successful recoveries |
recovery.failed |
Failed recoveries |
recovery.inBackoff |
Currently in exponential backoff |
recovery.nextRetryIn |
Time until next retry attempt |
recovery.avgRecoveryTime |
Average recovery duration |
recovery.recoveryRate |
Success percentage |
recovery.histogram |
Min/max/median recovery times |
stall.detections |
Total stall detections |
stall.lastPartType |
Part type that preceded last stall |
stall.patterns |
Top 5 part types causing stalls |
history |
Rolling buffer of recent status snapshots |
When statusFileRotate > 0, old status files are kept:
~/.opencode/logs/auto-force-resume.status.1(most recent archive)~/.opencode/logs/auto-force-resume.status.2- etc.
The plugin manages context with four compaction layers, each with different triggers and urgency:
| Layer | Threshold | Style | When It Fires |
|---|---|---|---|
| Opportunistic | 60k tokens | Fire-and-forget | Post-recovery, on-idle, pre-nudge, post-review |
| Proactive | 80k tokens | Fire-and-forget | Token updates, session create, pre-continue |
| Hard | 100k tokens | Blocking gate | Before recovery, nudge, or continue |
| Emergency | Token limit error | Retry 3x | On session.error with limit message |
At opportunisticCompactAtTokens (default: 60k), the plugin cleans up context during idle moments before the next operation pushes tokens higher. This is low-priority "housekeeping" compaction.
Triggers: After recovery success, session idle, before nudge, after review.
When autoCompact: true and estimated tokens exceed proactiveCompactAtTokens (default: 80k), the plugin triggers session.summarize() to reduce context before hitting hard limits.
When tokens exceed hardCompactAtTokens (default: 100k), the hard compactor blocks until compaction succeeds or times out. Recovery, nudge, and continue all await it before proceeding.
- Always fires (ignores
autoCompactflag) - Fires even when session is planning — at 100k+ tokens, the context danger outweighs any planning concern
- Bypasses cooldown by default (
hardCompactBypassCooldown: true) - Respects
hardCompactMaxWaitMs(default 30s, scaled up for massive sessions) — returns false if exceeded but doesn't strand the session
When a token limit error is detected:
- Parse exact token counts from error message
- Call
session.summarize()with progressive verification - Wait 2s → check if session idled
- Wait 3s → check again
- Wait 5s → check again
- If compaction fails → schedule recovery with backoff instead of abandoning session
Post-compaction token reset:
After compaction completes, estimated tokens are recalculated using compactReductionFactor:
estimatedTokens = estimatedTokens * compactReductionFactor
With default factor 0.7: estimatedTokens = estimatedTokens * 0.7 (70% remain, 30% removed)
The factor represents the fraction of tokens remaining after compaction. Compaction removes ~30% of context at default settings.
- Opportunistic (60k): Gentle cleanup during idle moments
- Proactive (80k): Pre-emptive before limits hit
- Hard (100k): Mandatory gate — blocks operations until compacted
- Emergency: Safety net for edge cases that slip through
After session.compacted fires, the SQLite DB still holds pre-compaction token counts (they accumulate over the session's lifetime — tokens_input is a lifetime total, not current context). Without intervention, getTokenCount() would return values like 29M tokens from the DB instead of the actual ~70k context size.
Initial fix (v7.8.1904):
-
Grace period guard (
compactionGracePeriodMs, default 10s): All 3 compaction layers skip iflastCompactionAtis within this window, even ifhardCompactBypassCooldown: true. Prevents triggering while DB values are stale. -
realTokensBaselinetracking onsession.compacted: SetsrealTokensBaseline = realTokens(the lifetime token total at compaction time). After this,getTokenCount()switches from cumulative DB values toestimatedTokens(already reduced bycompactReductionFactor). This prevents the massive token count discrepancy (29M DB vs 70k actual) from triggering false compaction.
Drift problem (v7.14.35 fix): After repeated compactions, estimatedTokens could drift downward because the reduction factor is applied each time while accumulation may undershoot (e.g., assistant message text is estimated from info.tokens which isn't always available). This caused compaction to stop firing even as the context grew. The fix: getTokenCount() now uses Math.max(estimatedTokens, realTokens - realTokensBaseline) — a floor set by the actual DB growth since last compaction prevents the count from dropping below real context growth:
// session-state.ts — getTokenCount()
if (s.realTokensBaseline > 0 && s.realTokens > 0) {
const growth = Math.max(0, s.realTokens - s.realTokensBaseline);
return Math.max(s.estimatedTokens, growth); // floor by DB growth
}The refreshRealTokens() throttle:
realTokens > 0 && now - lastRealTokenRefreshAt < 10s→ return cached- After baseline is set: returns
Math.max(estimatedTokens, growth)where growth is new tokens since last compaction - Immediately after compaction:
growth = 0→ returnsestimatedTokens(no loop) - After 50k new tokens:
growth = 50k→ returnsmax(estimatedTokens, 50k)(prevents drift)
forceCompact() (emergency) is NOT blocked by grace period — token limit errors require immediate action.
The verify wait (compactionVerifyWaitMs) is scaled by token count to accommodate very large sessions:
| Token Count | Multiplier | Example (30s default) |
|---|---|---|
| < 200k | 1× | 30s wait |
| 200k–500k | 2× | 60s wait |
| > 500k | 3× | 90s wait |
Sessions with millions of tokens (e.g., 29M from cumulative DB counts) need significantly more time for session.summarize() to complete.
The plugin estimates token usage from three actual data sources:
| Source | Event | What's Available |
|---|---|---|
| 1. Error messages | session.error |
Exact counts: "You requested a total of 264230 tokens: 232230 input, 32000 output" |
| 2. step-finish parts | message.part.updated |
{ input, output, reasoning, cache } per completion |
| 3. AssistantMessage | message.updated |
{ input, output, reasoning, cache } per message |
// message.updated (AssistantMessage.tokens)
if (info?.role === "assistant" && info?.tokens) {
s.estimatedTokens += tokens.input + tokens.output + tokens.reasoning;
}
// message.part.updated (step-finish.tokens)
if (partType === "step-finish" && part?.tokens) {
s.estimatedTokens = Math.max(s.estimatedTokens, totalStepTokens);
}
// session.error (parseTokensFromError)
const { total, input, output } = parseTokensFromError(err);
s.estimatedTokens = Math.max(s.estimatedTokens, total);Ratios used for text-based estimation (fallback only):
- English text: ~0.35 tokens/char
- Code: ~0.50 tokens/char
- Digits/numbers: ~0.25 tokens/char
The OpenCode SDK's SessionStatus type is only:
type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry", attempt, message, next }There are NO token count fields in session.status(). The plugin relies on the three sources above instead.
Important: Our estimatedTokens is a running sum of all message tokens we've seen. This WILL exceed the actual context window because:
- Old messages get dropped from context as new ones are added
- Pre-existing context (before plugin started) isn't counted
This is intentional — we'd rather over-estimate and compact early than hit the limit.
After compaction, estimatedTokens is reduced by compactReductionFactor. If it drifts too low (e.g., because info.tokens is missing for assistant messages), getTokenCount() uses the actual DB growth as a floor:
growth = max(0, realTokens - realTokensBaseline)
effective = max(estimatedTokens, growth)
This ensures that even if estimatedTokens undershoots, we never ignore more than 80k tokens of new content between compactions.
-
Check the status file:
watch -n 2 'cat ~/.opencode/logs/auto-force-resume.status'Look for
"compaction"section - if"lastCompactAt"is set, emergency compaction fired. -
Watch debug logs:
tail -f ~/.opencode/logs/auto-force-resume.log | grep -i compact
You should see entries like:
"token limit error detected (hit #1) for session: abc123" "attempting compaction for session: abc123" "compaction successful for session: abc123 after 2000ms wait" "compaction reduced tokens from ~ 85000 to ~ 25500"
When terminalTitleEnabled: true, the plugin updates your terminal title to show session timer:
⏱️ 3m 12s | Last: 45s ago
This uses OSC (Operating System Command) escape sequences:
OSC 0: Sets both icon name and window titleOSC 2: Sets window title (fallback)
Works in: iTerm2, WezTerm, Windows Terminal, GNOME Terminal, Ghostty, macOS Terminal
When session goes idle, title resets to opencode.
When terminalProgressEnabled: true, the plugin sends OSC 9;4 sequences to show progress in terminal tabs:
# Set progress to 50%:
printf '\e]9;4;1;50\e\\'
# Clear progress:
printf '\e]9;4;0\e\\'This shows a progress indicator in terminal tabs (iTerm2, WezTerm, Windows Terminal).
Progress calculation: (time_since_last_progress / stallTimeoutMs) * 100
- 0% = Just started, fresh progress
- 100% = About to trigger recovery
- 99% = Max (never reaches 100% until recovery fires)
| Event | Action |
|---|---|
session.created |
Initialize session state, inject dangerous command warning |
session.status (busy) |
Start/reset stall timer (status pings do NOT count as progress) |
session.status (idle) |
Send queued continue if needsContinue; if recovery in progress (aborting=true), schedule 3s delayed fallback; trigger opportunistic compaction |
session.status (retry) |
Treat as busy (progress indicator) |
session.compacted |
Clear compacting flag, reset estimates, queue continue, re-schedule nudge |
session.idle |
Poll todos, schedule nudge if open todos exist; if recovery in progress (aborting=true), schedule 3s delayed fallback instead of sending directly |
session.updated |
Write status file |
session.deleted / session.ended |
Full session cleanup |
message.updated (assistant) |
Accumulate token counts |
message.updated (user) |
Reset all counters, cancel nudge |
message.part.updated (real progress) |
Update timestamps, reset attempts, trigger compaction check |
message.part.updated (text/reasoning with tool-call-as-text) |
Do NOT reset progress — model stuck generating XML |
message.part.updated (compaction) |
Set compacting = true, start safety timeout |
message.part.updated (plan text) |
Set planning = true, schedule planning timeout |
message.part.updated (tool/file/subtask/step) |
Clear planning flag, update tool loop counter |
todo.updated (all done) |
Trigger review after debounce |
todo.updated (has pending) |
Set hasOpenTodos = true |
question.asked |
Auto-reply with first option if autoAnswerQuestions enabled |
message.created / message.part.added |
Reset timer, reset attempts |
message.updated (user) |
Reset counters, cancel nudge |
session.error (MessageAbortedError) |
Set userCancelled, clear timer |
session.error (token limit) |
Trigger emergency compaction |
session.error (other) |
Clear timer, monitoring pauses |
todo.updated |
Check completion, trigger review/nudge |
session.idle |
Trigger nudge for pending todos |
session.deleted |
Clear all session state |
["opencode-auto-continue", {
"maxRecoveries": 0,
"stallTimeoutMs": 999999999
}]["opencode-auto-continue", {
"stallTimeoutMs": 10000,
"cooldownMs": 5000,
"maxRecoveries": 10,
"waitAfterAbortMs": 500
}]["opencode-auto-continue", {
"stallTimeoutMs": 600000,
"maxSessionAgeMs": 14400000
}]["opencode-auto-continue", {
"continueMessage": "Hey! You stopped. Keep going!",
"continueWithTodosMessage": "Hey! You have {pending} tasks left: {todoList}. Keep going!",
"nudgeMessage": "Don't forget about your {pending} open tasks!",
"reviewMessage": "Great job! Please summarize what we accomplished."
}]For programmatic control, use the sendCustomPrompt API:
import { sendCustomPrompt } from "opencode-auto-continue";
// Inject a custom prompt with full context
await sendCustomPrompt(sessionId, {
message: "⚡ Priority task: {contextSummary}",
includeTodoContext: true,
includeContextSummary: true,
customPrompt: "Focus on the authentication bug first"
});Available in both recovery and nudge flows. See Custom Prompts section above for full API reference.
["opencode-auto-continue", {
"nudgeEnabled": false,
"reviewOnComplete": false,
"autoCompact": false,
"terminalTitleEnabled": false,
"statusFileEnabled": false,
"terminalProgressEnabled": false
}]Note: Toast notifications are handled by separate plugins like @mohak34/opencode-notifier. This plugin focuses purely on session continuity.
["opencode-auto-continue", {
"debug": true
}]Check logs:
tail -f ~/.opencode/logs/auto-force-resume.log["opencode-auto-continue", {
"statusFilePath": "/tmp/my-opencode-status.json",
"statusFileRotate": 3
}]["opencode-auto-continue", {
"tokenLimitPatterns": [
"context length",
"maximum context length",
"token count exceeds",
"too many tokens",
"custom error pattern"
],
"compactMaxRetries": 5,
"compactRetryDelayMs": 5000
}]["opencode-auto-continue", {
"recoveryHistogramEnabled": true
}]Tracks recovery times to show you average/min/max/median recovery duration.
["opencode-auto-continue", {
"stallPatternDetection": true
}]Shows which part types (tool, reasoning, text, etc.) are most associated with stalls.
Remove from opencode.json:
// REMOVE THIS:
"opencode-todo-reminder"Our plugin provides:
- ✅ Todo-aware messages
- ✅ Loop protection
- ✅ User abort handling
- ❌ Toast notifications (install
@mohak34/opencode-notifierseparately)
Remove from opencode.json:
// REMOVE THIS:
"opencode-auto-review-completed-todos"Our plugin provides:
- ✅ Review on completion
- ✅ Debounced triggering
- ✅ One-shot per session
Our plugin provides terminal title updates automatically:
["opencode-auto-continue", {
"terminalTitleEnabled": true
}]Note: For toast notifications, install a separate notification plugin:
opencode plugin @mohak34/opencode-notifier@latest --globalCause: Missing package.json in plugin directory or incorrect path
Fix:
- Ensure plugin files are in
~/.config/opencode/plugins/opencode-auto-continue/ - Create
package.jsonin that directory:{ "name": "opencode-auto-continue", "version": "7.8.235", "main": "./index.js" } - Ensure
opencode.jsonuses correct name:["opencode-auto-continue", { ... }] - Restart OpenCode after making changes
Cause: Plugin not added to plugin array in opencode.json
Fix: Add to ~/.config/opencode/opencode.json:
{
"plugin": [
["opencode-auto-continue", {
"stallTimeoutMs": 45000,
"maxRecoveries": 3
}]
]
}Cause: Another plugin sending prompts with synthetic: false
Fix: Remove other prompt-sending plugins (todo-reminder, auto-review)
Cause: Events not being filtered
Fix: Ensure synthetic: true is set on all prompts (our plugin does this automatically)
Cause: Session not staying busy long enough
Fix: Reduce stallTimeoutMs (e.g., 60000 for 1 minute)
Cause: Timeout too short
Fix: Increase stallTimeoutMs (e.g., 300000 for 5 minutes)
Cause: statusFileEnabled: false or disk full
Fix: Check config or disk space
Cause: Terminal doesn't support OSC sequences Fix: Use iTerm2, WezTerm, Windows Terminal, or Ghostty
Cause: Terminal doesn't support OSC 9;4 Fix: Use iTerm2 (3.6.6+), WezTerm, Windows Terminal, or Ghostty
Bug: reviewFired stuck true after multi-cycle workflows (Bug A)
When todos went from "pending with cooldown active" directly to "all completed", the reviewFired flag remained set permanently, preventing the review prompt from ever firing again. Fix: processTodos() now detects this stale state and resets reviewFired = false after cooldown expires, ensuring reviews fire reliably in multi-cycle scenarios.
Bug: continue lost when session.idle fires during active recovery (Bug B)
When session.idle or session.status(idle) fired while recovery was in progress (aborting=true and needsContinue=true), the handler skipped calling sendContinue() and relied on recovery to send it. If recovery's call failed (e.g., blocked by prompt guard or concurrency guard), the continue was lost permanently — no future event would trigger it. Fix: both handleSessionIdle and handleSessionStatus now schedule a 3-second delayed fallback that fires sendContinue() with a continueInProgress guard, ensuring the continue is sent even if the primary path failed.
Bug: periodic poll skipped on fresh todo.updated but no nudge/review triggered (Bug C)
When a todo.updated event arrived within 10 seconds of the last, pollAndProcess() skipped the API poll and also skipped calling processTodos(). This meant the scheduleNudge fallback and review debounce timer were never started for sessions with pending todos. Fix: pollAndProcess() now reprocesses cached todos (s.lastKnownTodos) even when the poll is skipped due to event freshness, ensuring nudge scheduling and review triggers work correctly in this path.
Delayed continue fallback: Both handleSessionIdle and handleSessionStatus now schedule a 3-second delayed fallback when session.idle/status(idle) fires while aborting=true and needsContinue=true. The fallback is guarded by isDisposed() and continueInProgress to prevent double-sends.
Nudge fallback from todo poller: processTodos() calls scheduleNudge(sessionId) when hasPending && !nudgePaused, providing a fallback nudge path independent of session.idle events.
Refactored reviewFired reset logic: processTodos() now resets reviewFired earlier in a separate branch when allCompleted && reviewFired && !inCooldown, then falls through to the existing allCompleted && !reviewFired branch — eliminating ~20 lines of duplicated debounce/compact logic.
- Memory: One SessionState per active session (~150 bytes each)
- Timers: Max 1 timer per session (stall recovery)
- Polling: Status polling only during recovery (not continuous)
- File I/O: Status file uses atomic writes (
.tmp+ rename) - CPU: Event-driven, no background loops
- Dependencies: Zero external dependencies at runtime
This project is dual-licensed:
- AGPL-3.0-only — See LICENSE for the full text. This is the default license for open source use.
- Commercial License — For organizations that prefer not to comply with AGPLv3's source disclosure requirements. See COMMERCIAL-LICENSE.md for details.
By contributing to this project, you agree to the terms in CLA.md.