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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,21 @@ For manual installation, add the package to both V2 plugin lists.

OpenCode 2 does not read the V1 `tui.json` file. The server entrypoint is loaded from `opencode.json`; the sidebar and palette integration are loaded from the global `~/.config/opencode/cli.json`.

V2 supports the `/loop` command, all loop tools, persistent state, idle scheduling, dynamic loops, Plan-mode safety, session cleanup, and TUI sidebar/palette integration. The plugin does not yet integrate the V2 compaction hook. V2 still injects the active-loop system reminder before model dispatch, but cannot append the separate loop block directly to a compaction operation.
Use the bundled installer to update both V2 entries at once:

```sh
bun scripts/install.mjs --global
bun scripts/install.mjs --project /path/to/project
```

Run these commands from a source checkout or the installed package directory. The installer preserves existing version pins and options, validates both files before writing, and refuses existing `.jsonc` configurations so they can be edited manually without losing comments.

File-spec installs (local paths) require the entry files at the package root:
`./server.js` and `./tui.js` re-export `./dist/server.js` and `./src/tui.tsx`.
Subpath-only entries are silently skipped by some hosts, so keep the root
bridges even though the built bundle lives in `dist/`.

V2 supports the `/loop` command, all loop tools, persistent state, idle scheduling, dynamic loops, Plan-mode safety, session cleanup, TUI sidebar/palette integration, and the `compaction` hook for preserving loops across compaction. Stale dynamic loops resume with a configurable fallback delay after restart (`dynamic_restart_mode: "fallback"`, default 20m) instead of stopping.

## Usage

Expand Down Expand Up @@ -172,6 +186,8 @@ In OpenCode 1, server options use the package-and-options tuple in `opencode.jso
"max_consecutive_failures": 5,
"max_loop_age_days": 7,
"dynamic_max_delay_seconds": 86400,
"dynamic_restart_mode": "fallback",
"dynamic_restart_fallback_seconds": 1200,
"restricted_agents": ["plan"],
"register_command": true,
"command_name": "loop"
Expand All @@ -197,6 +213,8 @@ In OpenCode 2, use the plugin object form:
"max_consecutive_failures": 5,
"max_loop_age_days": 7,
"dynamic_max_delay_seconds": 86400,
"dynamic_restart_mode": "fallback",
"dynamic_restart_fallback_seconds": 1200,
"restricted_agents": ["plan"],
"register_command": true,
"command_name": "loop"
Expand All @@ -216,6 +234,8 @@ Defaults:
- `max_consecutive_failures`: `5`; number of consecutive provider/model failures after which the loop is paused with an inspectable `blockedReason`.
- `max_loop_age_days`: `7`; loops stop automatically after this age. Set `0` to disable expiry.
- `dynamic_max_delay_seconds`: `86400`; upper clamp for `schedule_next_run` delays.
- `dynamic_restart_mode`: `"fallback"`; stale dynamic loops resume with a fallback delay after restart instead of stopping. Set `"stop"` for the legacy stop behavior.
- `dynamic_restart_fallback_seconds`: `1200`; fallback delay for stale dynamic loops, clamped to `[min_interval_seconds, dynamic_max_delay_seconds]`.
- `restricted_agents`: `["plan"]`; iterations are deferred while the session's last prompt came from one of these agents.
- `register_command`: `true`
- `command_name`: `"loop"`
Expand All @@ -236,7 +256,7 @@ If `XDG_DATA_HOME` is not set, the default is:

Set `OPENCODE_LOOP_STATE_PATH` to use a custom file.

The state file is written atomically with owner-only permissions when the host filesystem supports it. Persisted loops are rehydrated only after authoritative session context establishes a renewable session lease, preventing concurrent OpenCode instances from driving the same session. Dynamic loops that were waiting on the agent to schedule their next run cannot recover on their own after a restart and are stopped with an explanatory reason.
The state file is written atomically with owner-only permissions when the host filesystem supports it. Persisted loops are rehydrated only after authoritative session context establishes a renewable session lease, preventing concurrent OpenCode instances from driving the same session. Dynamic loops that were waiting on the agent to schedule their next run resume after the configured restart fallback delay once their session is observed. Set `dynamic_restart_mode: "stop"` to stop these loops with an explanatory reason instead.


Session lease revisions identify ownership epochs; heartbeats keep the current epoch stable. Claims and automatic dynamic-loop cleanup are checked against that session epoch. Pausing, stopping, or explicitly rescheduling a loop invalidates any pending delivery claim.
Expand Down
68 changes: 65 additions & 3 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24017,7 +24017,7 @@ async function stopLoop(loopID, reason) {
async function stopLoopIfUnchanged(loopID, expectedUpdatedAt, reason, sessionLease) {
return mutate5((state) => {
const loop3 = state.loops[loopID];
if (!loop3 || sessionLease && !matchesSessionLease(state, sessionLease))
if (!loop3 || sessionLease && (sessionLease.sessionID !== loop3.sessionID || !matchesSessionLease(state, sessionLease)))
return null;
if (loop3.updatedAt !== expectedUpdatedAt || loop3.status !== "active" || loop3.mode !== "dynamic" || loop3.nextRunAt != null) {
return null;
Expand All @@ -24028,6 +24028,25 @@ async function stopLoopIfUnchanged(loopID, expectedUpdatedAt, reason, sessionLea
return snapshot(loop3);
});
}
async function rescheduleDynamicIfUnchanged(loopID, expectedUpdatedAt, delayMs, reason, sessionLease) {
const delay3 = positiveIntegerOrNull(Math.round(delayMs));
if (delay3 == null)
throw new Error("delay must be a positive number of milliseconds");
return mutate5((state) => {
const loop3 = state.loops[loopID];
if (!loop3 || sessionLease && (sessionLease.sessionID !== loop3.sessionID || !matchesSessionLease(state, sessionLease)))
return null;
if (loop3.updatedAt !== expectedUpdatedAt || loop3.status !== "active" || loop3.mode !== "dynamic" || loop3.nextRunAt != null) {
return null;
}
loop3.scheduleRevision += 1;
loop3.nextRunAt = now2() + delay3;
loop3.nextRetryAt = null;
loop3.lastReason = reason.trim().slice(0, 400) || loop3.lastReason;
loop3.updatedAt = now2();
return snapshot(loop3);
});
}
async function stopLoopsForSession(sessionID, reason) {
return mutate5((state) => {
const stopped = [];
Expand Down Expand Up @@ -24256,6 +24275,7 @@ var DEFAULT_MAX_FAILURE_BACKOFF_SECONDS = 60 * 60;
var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
var DEFAULT_MAX_LOOP_AGE_DAYS = 7;
var DEFAULT_DYNAMIC_MAX_DELAY_SECONDS = 24 * 60 * 60;
var DEFAULT_DYNAMIC_RESTART_FALLBACK_SECONDS = 20 * 60;
var RUN_CLAIM_LEASE_MS = 30000;
var SESSION_LEASE_MS = 90000;
var SESSION_LEASE_HEARTBEAT_MS = 30000;
Expand All @@ -24277,6 +24297,15 @@ function restrictedAgentSet(options) {
const names = Array.isArray(options?.restricted_agents) ? options.restricted_agents : DEFAULT_RESTRICTED_AGENTS;
return new Set(names.map((name) => typeof name === "string" ? name.trim().toLowerCase() : "").filter(Boolean));
}
function dynamicRestartMode(options) {
return typeof options?.dynamic_restart_mode === "string" && options.dynamic_restart_mode.trim().toLowerCase() === "stop" ? "stop" : "fallback";
}
function dynamicRestartFallbackMs(options, minIntervalSeconds, dynamicMaxDelaySeconds) {
const fallbackSeconds = positiveNumberOr(options?.dynamic_restart_fallback_seconds, DEFAULT_DYNAMIC_RESTART_FALLBACK_SECONDS);
const minMs = (minIntervalSeconds ?? DEFAULT_MIN_INTERVAL_SECONDS) * 1000;
const maxMs = (dynamicMaxDelaySeconds ?? DEFAULT_DYNAMIC_MAX_DELAY_SECONDS) * 1000;
return Math.min(Math.max(Math.round(fallbackSeconds * 1000), minMs), Math.max(maxMs, minMs));
}
function registerDesktopCommand(config2, commandName, minIntervalSeconds) {
config2.command ??= {};
if (config2.command[commandName])
Expand Down Expand Up @@ -24339,6 +24368,8 @@ var server = async ({ client, directory }, options) => {
const maxLoopAgeMs = nonNegativeNumberOr(options?.max_loop_age_days, DEFAULT_MAX_LOOP_AGE_DAYS) * 24 * 60 * 60 * 1000;
const dynamicMaxDelaySeconds = positiveNumberOr(options?.dynamic_max_delay_seconds, DEFAULT_DYNAMIC_MAX_DELAY_SECONDS);
const restrictedAgents = restrictedAgentSet(options);
const restartMode = dynamicRestartMode(options);
const restartFallbackMs = dynamicRestartFallbackMs(options, minIntervalSeconds, dynamicMaxDelaySeconds);
let disposed = false;
const timers = new Map;
const sendingLoops = new Set;
Expand Down Expand Up @@ -24604,8 +24635,16 @@ var server = async ({ client, directory }, options) => {
for (const [loopID, candidate] of staleDynamicCandidates) {
if (candidate.sessionID !== sessionID)
continue;
if (restartMode === "fallback") {
const rescheduled = await rescheduleDynamicIfUnchanged(loopID, candidate.updatedAt, restartFallbackMs, "restarted with fallback delay; reschedule or stop after this iteration", lease);
if (rescheduled) {
scheduleTimer(rescheduled);
await log3("info", "Dynamic loop resumed with fallback delay after restart", { loopID });
}
} else {
await stopLoopIfUnchanged(loopID, candidate.updatedAt, "not rescheduled before OpenCode restarted", lease);
}
staleDynamicCandidates.delete(loopID);
await stopLoopIfUnchanged(loopID, candidate.updatedAt, "not rescheduled before OpenCode restarted", lease);
}
}
async function requireSessionLoop(loopID, sessionID) {
Expand Down Expand Up @@ -24869,6 +24908,8 @@ async function setupV2(context4) {
const maxLoopAgeMs = nonNegativeNumberOr(options.max_loop_age_days, DEFAULT_MAX_LOOP_AGE_DAYS) * 24 * 60 * 60 * 1000;
const dynamicMaxDelaySeconds = positiveNumberOr(options.dynamic_max_delay_seconds, DEFAULT_DYNAMIC_MAX_DELAY_SECONDS);
const restrictedAgents = restrictedAgentSet(options);
const restartMode = dynamicRestartMode(options);
const restartFallbackMs = dynamicRestartFallbackMs(options, minIntervalSeconds, dynamicMaxDelaySeconds);
let disposed = false;
const timers = new Map;
const sendingLoops = new Set;
Expand Down Expand Up @@ -25169,8 +25210,19 @@ async function setupV2(context4) {
for (const [loopID, candidate] of staleDynamicCandidates) {
if (candidate.sessionID !== sessionID)
continue;
if (restartMode === "fallback") {
const rescheduled = await rescheduleDynamicIfUnchanged(loopID, candidate.updatedAt, restartFallbackMs, "restarted with fallback delay; reschedule or stop after this iteration", lease);
if (rescheduled) {
scheduleTimer(rescheduled);
v2Log("info", "Dynamic loop resumed with fallback delay after restart", {
loopID,
sessionID
});
}
} else {
await stopLoopIfUnchanged(loopID, candidate.updatedAt, "not rescheduled before OpenCode restarted", lease);
}
staleDynamicCandidates.delete(loopID);
await stopLoopIfUnchanged(loopID, candidate.updatedAt, "not rescheduled before OpenCode restarted", lease);
}
}
async function requireSessionLoop(loopID, sessionID) {
Expand Down Expand Up @@ -25300,6 +25352,16 @@ async function setupV2(context4) {
return;
sessionContext.system.push({ type: "text", text: reminder });
}));
registrations.push(await context4.session.hook("compaction", async (sessionCompaction) => {
await observeSession(sessionCompaction.sessionID);
const loops = await openLoops(sessionCompaction.sessionID);
const contextText = compactionContext(loops);
if (!contextText)
return;
if (sessionCompaction.system.some((part) => part.type === "text" && part.text.includes("OpenCode loop mode is tracking recurring loops for this session across compaction.")))
return;
sessionCompaction.system.push({ type: "text", text: contextText });
}));
await rehydrate().catch(() => v2Log("error", "Failed to rehydrate loops", { category: "state" }));
const leaseHeartbeat = setInterval(() => {
for (const sessionID of new Set([...observedSessions, ...keepAliveSessions])) {
Expand Down
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ export default tseslint.config(
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["scripts/**/*.mjs"],
languageOptions: {
globals: {
process: "readonly",
console: "readonly",
},
},
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,14 @@
"author": "Prevalentware",
"type": "module",
"exports": {
"./server": {
"import": "./dist/server.js"
},
"./tui": {
"import": "./src/tui.tsx"
}
"./server": "./server.js",
"./tui": "./tui.js"
},
"files": [
"dist",
"server.js",
"tui.js",
"scripts/install.mjs",
"src/tui.tsx",
"LICENSE",
"README.md"
Expand All @@ -46,6 +45,7 @@
"clean": "rm -rf dist",
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external zod",
"ci:version": "bun scripts/resolve-ci-version.ts",
"install:global": "bun scripts/install.mjs --global",
"lint": "eslint .",
"pack:dry-run": "npm pack --dry-run",
"test": "bun test",
Expand Down
107 changes: 107 additions & 0 deletions scripts/install.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import { randomUUID } from "node:crypto"
import { homedir } from "node:os"
import { dirname, join, resolve } from "node:path"

const PACKAGE_NAME = "@prevalentware/opencode-loop-plugin"

function configDir() {
return resolve(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")
}

function readJson(path) {
if (!existsSync(path)) return {}
try {
const value = JSON.parse(readFileSync(path, "utf8"))
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected an object")
return value
} catch {
throw new Error(`expected a JSON object: ${path}; comments and trailing commas are not supported, edit manually`)
}
}

function ensurePluginList(value) {
if (value == null) return []
if (Array.isArray(value)) return value
throw new Error(`"plugins" must be an array`)
}

function pluginKey(entry) {
const spec = typeof entry === "string" ? entry : entry?.package
if (typeof spec !== "string") return null
return spec.replace(/^(?:(@[^/]+\/[^@/]+)|([^@./][^@/]*))@.+$/, "$1$2")
}

function addPlugin(list, pkg) {
if (list.some((entry) => pluginKey(entry) === pluginKey(pkg))) return { list, added: false }
return { list: [...list, pkg], added: true }
}

function writeJson(path, value) {
const target = existsSync(path) ? realpathSync(path) : path
const mode = existsSync(target) ? statSync(target).mode & 0o777 : 0o600
mkdirSync(dirname(target), { recursive: true })
const temporary = `${target}.${randomUUID()}.tmp`
try {
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode })
renameSync(temporary, target)
} finally {
rmSync(temporary, { force: true })
}
}

function main() {
const args = process.argv.slice(2)
let projectDir = null
let global = false
let pkg = PACKAGE_NAME
const seen = new Set()
for (let index = 0; index < args.length; index++) {
const flag = args[index]
if (!["--global", "--project", "--package"].includes(flag) || seen.has(flag)) throw new Error(`invalid argument: ${flag}`)
seen.add(flag)
if (flag === "--global") {
global = true
continue
}
const value = args[++index]
if (!value || value.startsWith("--")) throw new Error(`missing value for ${flag}`)
if (flag === "--project") projectDir = resolve(value)
else pkg = value
}
if (global && projectDir) throw new Error("choose --global or --project")

const serverPaths = projectDir
? [join(projectDir, "opencode.json"), join(projectDir, ".opencode", "opencode.json")]
: [join(configDir(), "opencode.json")]
for (const path of [...serverPaths, join(configDir(), "cli.json")]) {
const jsonc = path.replace(/\.json$/, ".jsonc")
if (existsSync(jsonc)) throw new Error(`edit the existing JSONC configuration manually: ${jsonc}`)
}
const serverConfigs = serverPaths.map((path) => {
const current = readJson(path)
return { path, plugins: ensurePluginList(current.plugins) }
})
const serverPath = serverConfigs.find(({ plugins }) => plugins.some((entry) => pluginKey(entry) === pluginKey(pkg)))?.path
?? serverPaths.find(existsSync) ?? serverPaths[0]
const targets = []
targets.push({ kind: projectDir ? "server(project)" : "server(global)", path: serverPath })
targets.push({ kind: "tui", path: join(configDir(), "cli.json") })

const updates = targets.map((target) => {
const current = readJson(target.path)
const { list, added } = addPlugin(ensurePluginList(current.plugins), pkg)
return { ...target, current, list, added }
})

for (const { kind, path, current, list, added } of updates) {
if (added) {
writeJson(path, { ...current, plugins: list })
console.log(`added ${pkg} to ${kind}: ${path}`)
} else {
console.log(`already present in ${kind}: ${path}`)
}
}
}

main()
4 changes: 4 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Root server entrypoint bridge. File-spec installs resolve subpath entries
// unreliably (silently skipped on some hosts), so keep a root entry that
// re-exports the built bundle.
export { default } from "./dist/server.js";
Loading
Loading