diff --git a/README.md b/README.md index 4a4286f..e62bfa5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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" @@ -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" @@ -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"` @@ -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. diff --git a/dist/server.js b/dist/server.js index bfcfe5f..aacdfed 100644 --- a/dist/server.js +++ b/dist/server.js @@ -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; @@ -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 = []; @@ -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; @@ -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]) @@ -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; @@ -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) { @@ -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; @@ -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) { @@ -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])) { diff --git a/eslint.config.js b/eslint.config.js index 0a612ea..f809cac 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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: { diff --git a/package.json b/package.json index c799e46..438347f 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100644 index 0000000..b88aea4 --- /dev/null +++ b/scripts/install.mjs @@ -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() diff --git a/server.js b/server.js new file mode 100644 index 0000000..dfeb171 --- /dev/null +++ b/server.js @@ -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"; diff --git a/src/server.ts b/src/server.ts index 96c731e..187a33e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,7 @@ import { ownsSessionLease, parseInterval, pauseLoop, + rescheduleDynamicIfUnchanged, resumeLoop, scheduleNextRun, stopLoop, @@ -50,6 +51,8 @@ type Options = { max_loop_age_days?: number dynamic_max_delay_seconds?: number restricted_agents?: string[] + dynamic_restart_mode?: string + dynamic_restart_fallback_seconds?: number } type PendingDynamicTurn = { sessionID: string; sawBusy: boolean } @@ -64,6 +67,7 @@ const DEFAULT_MAX_FAILURE_BACKOFF_SECONDS = 60 * 60 const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5 const DEFAULT_MAX_LOOP_AGE_DAYS = 7 const DEFAULT_DYNAMIC_MAX_DELAY_SECONDS = 24 * 60 * 60 +const DEFAULT_DYNAMIC_RESTART_FALLBACK_SECONDS = 20 * 60 const RUN_CLAIM_LEASE_MS = 30_000 const SESSION_LEASE_MS = 90_000 const SESSION_LEASE_HEARTBEAT_MS = 30_000 @@ -89,6 +93,20 @@ function restrictedAgentSet(options?: Options) { return new Set(names.map((name) => (typeof name === "string" ? name.trim().toLowerCase() : "")).filter(Boolean)) } +function dynamicRestartMode(options?: Options) { + return typeof options?.dynamic_restart_mode === "string" && options.dynamic_restart_mode.trim().toLowerCase() === "stop" ? "stop" : "fallback" +} + +function dynamicRestartFallbackMs(options?: Options, minIntervalSeconds?: number, dynamicMaxDelaySeconds?: number) { + 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(config: Config, commandName: string, minIntervalSeconds: number) { config.command ??= {} if (config.command[commandName]) return @@ -154,6 +172,8 @@ const server: Plugin = async ({ client, directory }, options?: 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>() @@ -434,10 +454,20 @@ const server: Plugin = async ({ client, directory }, options?: Options) => { if (!lease) return 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 log("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, - ) } } @@ -729,6 +759,8 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise>() @@ -1040,10 +1072,23 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { + 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])) { diff --git a/src/state.ts b/src/state.ts index fab3ad0..d8da6c4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -649,7 +649,7 @@ export async function stopLoopIfUnchanged( ) { return mutate((state) => { const loop = state.loops[loopID] - if (!loop || (sessionLease && !matchesSessionLease(state, sessionLease))) return null + if (!loop || (sessionLease && (sessionLease.sessionID !== loop.sessionID || !matchesSessionLease(state, sessionLease)))) return null if ( loop.updatedAt !== expectedUpdatedAt || loop.status !== "active" || @@ -665,6 +665,35 @@ export async function stopLoopIfUnchanged( }) } +export async function rescheduleDynamicIfUnchanged( + loopID: string, + expectedUpdatedAt: number, + delayMs: number, + reason: string, + sessionLease?: SessionLease, +) { + const delay = positiveIntegerOrNull(Math.round(delayMs)) + if (delay == null) throw new Error("delay must be a positive number of milliseconds") + return mutate((state) => { + const loop = state.loops[loopID] + if (!loop || (sessionLease && (sessionLease.sessionID !== loop.sessionID || !matchesSessionLease(state, sessionLease)))) return null + if ( + loop.updatedAt !== expectedUpdatedAt || + loop.status !== "active" || + loop.mode !== "dynamic" || + loop.nextRunAt != null + ) { + return null + } + loop.scheduleRevision += 1 + loop.nextRunAt = now() + delay + loop.nextRetryAt = null + loop.lastReason = reason.trim().slice(0, 400) || loop.lastReason + loop.updatedAt = now() + return snapshot(loop) + }) +} + export async function stopLoopsForSession(sessionID: string, reason: string) { return mutate((state) => { const stopped: LoopSnapshot[] = [] diff --git a/test/install.test.ts b/test/install.test.ts new file mode 100644 index 0000000..b12e6b0 --- /dev/null +++ b/test/install.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +let directory = "" +let config = "" +const packageName = "@prevalentware/opencode-loop-plugin" + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "loop-install-")) + config = join(directory, "config", "opencode") + await mkdir(config, { recursive: true }) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function install(...args: string[]) { + const child = Bun.spawn([process.execPath, join(import.meta.dir, "../scripts/install.mjs"), ...args], { + cwd: directory, + env: { ...process.env, XDG_CONFIG_HOME: join(directory, "config") }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]) + return { code, stdout, stderr } +} + +test("installer writes both V2 configs and preserves pinned entries and options on repeat", async () => { + const existing = { plugins: [{ package: `${packageName}@0.1.13`, options: { dynamic_restart_mode: "stop" } }], model: "test/model" } + const original = JSON.stringify(existing) + await writeFile(join(config, "opencode.json"), original) + expect((await install("--global")).code).toBe(0) + expect(await readFile(join(config, "opencode.json"), "utf8")).toBe(original) + expect(JSON.parse(await readFile(join(config, "cli.json"), "utf8"))).toEqual({ plugins: [packageName] }) + expect((await install("--global")).code).toBe(0) + expect(JSON.parse(await readFile(join(config, "cli.json"), "utf8"))).toEqual({ plugins: [packageName] }) +}) + +test("project installer changes the project server config and global TUI config", async () => { + const project = join(directory, "project") + expect((await install("--project", project)).code).toBe(0) + expect(JSON.parse(await readFile(join(project, "opencode.json"), "utf8"))).toEqual({ plugins: [packageName] }) + expect(JSON.parse(await readFile(join(config, "cli.json"), "utf8"))).toEqual({ plugins: [packageName] }) + expect(await Bun.file(join(config, "opencode.json")).exists()).toBe(false) +}) + +test.each(["null", "[]", '"invalid"', '{"plugins":{}}', "{broken"])("installer validates both documents before writing: %s", async (invalid) => { + const original = '{"model":"test/model"}' + await writeFile(join(config, "opencode.json"), original) + await writeFile(join(config, "cli.json"), invalid) + expect((await install("--global")).code).not.toBe(0) + expect(await readFile(join(config, "opencode.json"), "utf8")).toBe(original) + expect(await readFile(join(config, "cli.json"), "utf8")).toBe(invalid) +}) + +test.each([ + { args: ["--project"] }, + { args: ["--project", "--package", packageName] }, + { args: ["--global", "--project", "."] }, + { args: ["--unknown"] }, +])("installer rejects invalid arguments: %j", async ({ args }) => { + expect((await install(...args)).code).not.toBe(0) + expect(await Bun.file(join(config, "opencode.json")).exists()).toBe(false) + expect(await Bun.file(join(config, "cli.json")).exists()).toBe(false) +}) + +test("installer refuses JSONC before creating a competing JSON configuration", async () => { + const original = '{\n// keep this comment\n"plugins": []\n}' + await writeFile(join(config, "cli.jsonc"), original) + expect((await install("--global")).code).not.toBe(0) + expect(await readFile(join(config, "cli.jsonc"), "utf8")).toBe(original) + expect(await Bun.file(join(config, "opencode.json")).exists()).toBe(false) +}) + +test("project installer respects nested pinned config even when a root config exists", async () => { + const project = join(directory, "project") + await mkdir(join(project, ".opencode"), { recursive: true }) + const nested = JSON.stringify({ plugins: [`${packageName}@0.1.13`] }) + await writeFile(join(project, ".opencode", "opencode.json"), nested) + await writeFile(join(project, "opencode.json"), "{}") + expect((await install("--project", project)).code).toBe(0) + expect(await readFile(join(project, "opencode.json"), "utf8")).toBe("{}") + expect(await readFile(join(project, ".opencode", "opencode.json"), "utf8")).toBe(nested) +}) + +test("project installer refuses nested JSONC before writing either config", async () => { + const project = join(directory, "project") + await mkdir(join(project, ".opencode"), { recursive: true }) + await writeFile(join(project, ".opencode", "opencode.jsonc"), "{}") + expect((await install("--project", project)).code).not.toBe(0) + expect(await Bun.file(join(project, "opencode.json")).exists()).toBe(false) + expect(await Bun.file(join(config, "cli.json")).exists()).toBe(false) +}) + +test("installer preserves config symlinks and existing permissions", async () => { + const target = join(directory, "dotfiles.json") + await writeFile(target, "{}", { mode: 0o640 }) + const mode = (await stat(target)).mode & 0o777 + const link = join(config, "opencode.json") + await symlink(target, link) + expect((await install("--global")).code).toBe(0) + expect((await lstat(link)).isSymbolicLink()).toBe(true) + expect((await stat(target)).mode & 0o777).toBe(mode) + expect(JSON.parse(await readFile(target, "utf8"))).toEqual({ plugins: [packageName] }) +}) diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index f481f35..2aff693 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -342,6 +342,58 @@ test("V2 context hook injects a session-specific loop reminder", async () => { await cleanup() }) +test("V2 compaction preserves open loops per session without duplicating context", async () => { + const mock = makeMockContext() + const cleanup = await plugin.setup(mock as never) + try { + await loopTool(mock, "create_loop").execute({ instruction: "preserve this task", interval: "1m" }, toolContext()) + const compaction = mock.hooks.compaction + if (!compaction) throw new Error("compaction hook not registered") + const input = { sessionID: "ses_v2", system: [{ type: "text", text: "OpenCode loop mode reminder: existing per-turn context" }] } + await compaction(input) + await compaction(input) + expect(input.system).toHaveLength(2) + expect(input.system[1]?.text).toContain("preserve this task") + expect(input.system[1]?.text).toContain("across compaction") + const other = { sessionID: "ses_other", system: [] } + await compaction(other) + expect(other.system).toHaveLength(0) + } finally { + mock.stream.end() + await cleanup() + } +}) + +test.each([ + { mode: "fallback", fallback: 0.1, maximum: 60, delay: 1000 }, + { mode: "fallback", fallback: 1200, maximum: 2, delay: 2000 }, + { mode: "fallback", fallback: undefined, maximum: 86400, delay: 1200000 }, + { mode: "stop", fallback: 1, maximum: 60, delay: null }, +])("V2 recovers stale dynamic loops using restart options %j", async ({ mode, fallback, maximum, delay }) => { + const loop = await createLoop("ses_v2", { prompt: "watch CI", mode: "dynamic" }) + const mock = makeMockContext({ dynamic_restart_mode: mode, dynamic_restart_fallback_seconds: fallback, dynamic_max_delay_seconds: maximum }) + const cleanup = await plugin.setup(mock as never) + try { + expect((await getLoop(loop.id))?.nextRunAt).toBeNull() + const started = Date.now() + await mock.hooks.context?.({ sessionID: "ses_v2", system: [] }) + const recovered = await getLoop(loop.id) + if (delay === null) { + expect(recovered?.status).toBe("stopped") + expect(recovered?.stopReason).toContain("restarted") + } else { + expect(recovered?.status).toBe("active") + expect(recovered?.nextRunAt).toBeGreaterThanOrEqual(started + delay) + expect(recovered?.nextRunAt).toBeLessThanOrEqual(Date.now() + delay) + await mock.hooks.context?.({ sessionID: "ses_v2", system: [] }) + expect((await getLoop(loop.id))?.nextRunAt).toBe(recovered?.nextRunAt) + } + } finally { + mock.stream.end() + await cleanup() + } +}) + test("V2 busy/idle events defer and then inject loop iterations", async () => { const mock = makeMockContext() const cleanup = await plugin.setup(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index 63bc7cb..b12fcf1 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -291,6 +291,26 @@ test("system transform merges a loop reminder for sessions with open loops", asy await hooks.dispose?.() }) +test.each(["fallback", "stop"])("V1 recovers stale dynamic loops in %s mode", async (mode) => { + const loop = await createLoop("ses_1", { prompt: "watch CI", mode: "dynamic" }) + const hooks = await makeServer([], { dynamic_restart_mode: mode, dynamic_restart_fallback_seconds: 120 }) + try { + expect((await getLoop(loop.id))?.nextRunAt).toBeNull() + const started = Date.now() + await idle(hooks, "ses_1") + const recovered = await getLoop(loop.id) + if (mode === "stop") expect(recovered?.status).toBe("stopped") + else { + expect(recovered?.status).toBe("active") + expect(recovered?.nextRunAt).toBeGreaterThanOrEqual(started + 120000) + await idle(hooks, "ses_1") + expect((await getLoop(loop.id))?.nextRunAt).toBe(recovered?.nextRunAt) + } + } finally { + await hooks.dispose?.() + } +}) + test("rehydrated loops resume after an authoritative idle event", async () => { const sent: SentPrompt[] = [] const first = await makeServer(sent) diff --git a/test/state.test.ts b/test/state.test.ts index 6b5a3f3..d44b451 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -22,6 +22,7 @@ import { recordRunSentClaimed, recordRunFailedClaimed, recordRunDeferredClaimed, + rescheduleDynamicIfUnchanged, stopLoopClaimed, recordRunFailed, recordRunSent, @@ -172,6 +173,21 @@ test("conditional dynamic cleanup ignores missing and changed loops", async () = expect((await getLoop(scheduled.id))?.status).toBe("active") }) +test("stale dynamic loops resume with fallback delay instead of stopping", async () => { + const created = await createLoop("ses_1", { prompt: "watch CI", mode: "dynamic" }) + const resumed = await rescheduleDynamicIfUnchanged(created.id, created.updatedAt, 120_000, "restart fallback") + expect(resumed?.nextRunAt).toBeGreaterThan(Date.now()) + expect(resumed?.status).toBe("active") + expect(resumed?.lastReason).toBe("restart fallback") + + const changed = (await getLoop(created.id))! + expect(await rescheduleDynamicIfUnchanged(created.id, created.updatedAt, 120_000, "stale")).toBeNull() + const after = await getLoop(created.id) + expect(after?.nextRunAt).toBe(changed.nextRunAt) + expect(after?.updatedAt).toBe(changed.updatedAt) + expect(after?.scheduleRevision).toBe(changed.scheduleRevision) +}) + test("serializes state mutations across processes", async () => { const script = ` import { createLoop } from "./src/state.ts" @@ -399,7 +415,12 @@ test("orphan dynamic cleanup is fenced by session ownership", async () => { await releaseSessionLease("ses_1", "a", lease.revision) await acquireSessionLease("ses_1", "b", 60_000) expect(await stopLoopIfUnchanged(loop.id, loop.updatedAt, "stale", lease)).toBeNull() + expect(await rescheduleDynamicIfUnchanged(loop.id, loop.updatedAt, 60_000, "restart", lease)).toBeNull() + const foreign = (await acquireSessionLease("ses_other", "a", 60_000))! + expect(await stopLoopIfUnchanged(loop.id, loop.updatedAt, "stale", foreign)).toBeNull() + expect(await rescheduleDynamicIfUnchanged(loop.id, loop.updatedAt, 60_000, "restart", foreign)).toBeNull() expect((await getLoop(loop.id))?.status).toBe("active") + expect((await getLoop(loop.id))?.nextRunAt).toBeNull() }) test("maximum failure backoff caps even the first retry", async () => { diff --git a/tui.js b/tui.js new file mode 100644 index 0000000..58867aa --- /dev/null +++ b/tui.js @@ -0,0 +1,3 @@ +// Root TUI entrypoint bridge. See server.js for why the root entry exists. +export * from "./src/tui.tsx"; +export { default } from "./src/tui.tsx";