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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The OpenCode Loop Plugin adds:
- `/loop <interval> <instruction>` and `/loop <instruction>` (dynamic pacing) as an OpenCode command for TUI, desktop, and web.
- A server-side scheduler with per-loop timers that injects a synthetic iteration prompt only when the session is idle, with busy backoff.
- Dynamic loops where the agent itself picks the delay before each next iteration via `schedule_next_run`, mirroring Claude Code's self-paced `/loop`.
- Keep-alive loops: `create_loop({ keep_alive: true })` keeps a background or subagent session scheduled across turns and exempts it from age expiry.
- Agent tools: `create_loop`, `list_loops`, `stop_loop`, `pause_loop`, `resume_loop`, `run_loop`, `schedule_next_run`, and `clear_loops`.
- Persistent loop state that survives OpenCode restarts, with atomic writes and owner-only file permissions.
- A TUI sidebar with live countdowns and a `Loops` command-palette entry to run, pause, resume, or stop loops.
Expand Down Expand Up @@ -120,6 +121,26 @@ After creating a loop, the agent immediately performs the first iteration in the

A dynamic loop mirrors Claude Code's self-paced `/loop`: at the end of each iteration the agent calls `schedule_next_run` with a delay in seconds and a one-sentence reason ("watching CI run"), or calls `stop_loop` to end the loop. If an iteration ends without doing either, the loop ends — exactly like omitting `ScheduleWakeup` in Claude Code.

### Keeping a background session alive

A subagent's session normally has no future of its own: the turn that spawned it ends, the parent detaches, and nothing re-enters that session again. `keep_alive` gives it one.

```
create_loop(
instruction: "Check whether the migration finished. If it has, report the result and call stop_loop.",
interval: "5m",
keep_alive: true
)
```

The session keeps being re-prompted on the cadence and is exempt from age expiry. After an OpenCode restart, the plugin verifies that the session is accessible in the current host location before acquiring its ownership lease and restoring the timer. Sessions in another location or unavailable to this host remain dormant; ordinary loops still require an authoritative session observation.

Explicit `max_runs`, session deletion, and manual pause/stop still apply. Failed injections retain bounded backoff and pause after `max_consecutive_failures`; inspect the loop and use `resume_loop` after resolving the failure. Call `stop_loop` when the work resolves or is abandoned.

`keep_alive` requires a fixed interval. Dynamic pacing hands the next run to the agent every turn, so a single missed `schedule_next_run` would end the very future the flag exists to hold open; that combination is rejected when the loop is created.

Handing a result back to whoever asked for the work stays the agent's job. This flag only keeps the session running long enough to do it.

### How iterations are scheduled

- Iterations only run while the session is idle. If a loop comes due while the session is busy, it is deferred with a short backoff and retried when the session goes idle.
Expand Down
85 changes: 68 additions & 17 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var LoopSchema = Schema.Struct({
sessionID: Schema.String,
prompt: Schema.String,
mode: Schema.optionalWith(Schema.Literal("interval", "dynamic"), { default: () => "interval" }),
keepAlive: Schema.optionalWith(Schema.Boolean, { default: () => false }),
intervalMs: NullableNumber,
status: Schema.Literal("active", "paused", "stopped", "completed"),
createdAt: Schema.Number,
Expand Down Expand Up @@ -298,6 +299,9 @@ function requireLoop(state, loopID) {
async function createLoop(sessionID, options) {
const prompt = validatePrompt(options.prompt);
const mode = options.mode === "dynamic" ? "dynamic" : "interval";
const keepAlive = options.keepAlive === true;
if (keepAlive && mode !== "interval")
throw new Error("keep-alive loops require a fixed interval");
const intervalMs = mode === "interval" ? positiveIntegerOrNull(options.intervalMs) : null;
if (mode === "interval" && intervalMs == null)
throw new Error("interval loops require a positive interval");
Expand All @@ -318,6 +322,7 @@ async function createLoop(sessionID, options) {
sessionID,
prompt,
mode,
keepAlive,
intervalMs,
status: "active",
createdAt: timestamp,
Expand Down Expand Up @@ -740,6 +745,7 @@ Preserve each loop's id, cadence, instruction, and status in the compacted conte
}

// src/server.ts
var KEEP_ALIVE_DESCRIPTION = "Keep this session scheduled across turns and exempt from age expiry. Requires a fixed interval. Explicit max_runs, session deletion, and the failure-pause threshold still apply. Use it in a background or subagent session whose work outlives the turn that started it: the scheduler keeps re-prompting the session on the cadence, and the loop is never ended automatically by age expiry. You remain responsible for calling stop_loop once the work is resolved or abandoned.";
var DEFAULT_COMMAND_NAME = "loop";
var DEFAULT_BUSY_BACKOFF_SECONDS = 60;
var DEFAULT_FAILURE_BACKOFF_SECONDS = 60;
Expand Down Expand Up @@ -818,7 +824,7 @@ async function toolResult(sessionID, extra = {}) {
const loops = await listLoops(sessionID);
return JSON.stringify({ ...extra, loops, report: formatLoops(loops) }, null, 2);
}
var server = async ({ client }, options) => {
var server = async ({ client, directory }, options) => {
const registerCommand = options?.register_command ?? true;
const commandName = commandNameFromOptions(options);
const minIntervalSeconds = positiveNumberOr(options?.min_interval_seconds, DEFAULT_MIN_INTERVAL_SECONDS);
Expand All @@ -839,6 +845,7 @@ var server = async ({ client }, options) => {
const ownedSessions = new Map;
const ownershipQueues = new Map;
const observedSessions = new Set;
const keepAliveSessions = new Set;
const staleDynamicCandidates = new Map;
const lastPromptAgentBySession = new Map;
const dynamicPending = new Map;
Expand All @@ -863,11 +870,29 @@ var server = async ({ client }, options) => {
dynamicPending.delete(loopID);
}
}
async function recoveredSessionBusy(sessionID) {
try {
const response = await client.session.status();
const status = response.data?.[sessionID];
return !response.data || status != null && status.type !== "idle";
} catch {
return true;
}
}
async function keepAliveSessionAvailable(sessionID) {
try {
const response = await client.session.get({ path: { id: sessionID } });
return response.data?.id === sessionID && (!directory || response.data.directory === directory);
} catch {
return false;
}
}
async function acquireOwnership(sessionID) {
return enqueueSessionOperation(ownershipQueues, sessionID, async () => {
if (disposed)
return false;
observedSessions.add(sessionID);
if (!observedSessions.has(sessionID) && !await keepAliveSessionAvailable(sessionID))
return false;
const lease = await acquireSessionLease(sessionID, instanceID, SESSION_LEASE_MS);
if (!lease) {
await loseOwnership(sessionID);
Expand Down Expand Up @@ -897,7 +922,7 @@ var server = async ({ client }, options) => {
}
function scheduleTimer(loop, minimumDelayMs = 0) {
cancelTimer(loop.id);
if (disposed || loop.status !== "active" || loop.nextRunAt == null || !ownedSessions.has(loop.sessionID))
if (disposed || loop.status !== "active" || loop.nextRunAt == null || !ownedSessions.has(loop.sessionID) || !observedSessions.has(loop.sessionID) && !loop.keepAlive)
return;
const delay = Math.max(minimumDelayMs, loop.nextRunAt - Date.now());
const timer = setTimeout(() => {
Expand Down Expand Up @@ -936,7 +961,7 @@ var server = async ({ client }, options) => {
scheduleTimer(loop);
return;
}
if (!observedSessions.has(loop.sessionID)) {
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
scheduleTimer(loop, Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
await log("info", "Skipping loop because session ownership is unknown", { loopID, category: "ownership" });
return;
Expand All @@ -950,11 +975,11 @@ var server = async ({ client }, options) => {
return;
}
loop = claimed.loop;
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoopClaimed(loopID, claimed, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
return;
}
if (busySessions.has(loop.sessionID)) {
if (busySessions.has(loop.sessionID) || loop.keepAlive && !observedSessions.has(loop.sessionID) && await recoveredSessionBusy(loop.sessionID)) {
const deferred = await recordRunDeferredClaimed(loopID, claimed, "skipped_busy", Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
if (deferred)
scheduleTimer(deferred);
Expand Down Expand Up @@ -1050,6 +1075,8 @@ var server = async ({ client }, options) => {
async function rehydrate() {
const loops = await activeLoops();
for (const loop of loops) {
if (loop.keepAlive)
keepAliveSessions.add(loop.sessionID);
if (loop.nextRunAt == null) {
if (loop.mode === "dynamic") {
staleDynamicCandidates.set(loop.id, { sessionID: loop.sessionID, updatedAt: loop.updatedAt });
Expand All @@ -1061,6 +1088,8 @@ var server = async ({ client }, options) => {
continue;
}
}
for (const sessionID of keepAliveSessions)
await acquireOwnership(sessionID);
}
async function observeSession(sessionID) {
observedSessions.add(sessionID);
Expand All @@ -1087,8 +1116,8 @@ var server = async ({ client }, options) => {
}
await rehydrate().catch(() => log("error", "Failed to rehydrate loops", { category: "state" }));
const leaseHeartbeat = setInterval(() => {
for (const sessionID of observedSessions) {
const operation = ownedSessions.has(sessionID) ? renewOwnership(sessionID) : observeSession(sessionID);
for (const sessionID of new Set([...observedSessions, ...keepAliveSessions])) {
const operation = ownedSessions.has(sessionID) ? renewOwnership(sessionID) : observedSessions.has(sessionID) ? observeSession(sessionID) : acquireOwnership(sessionID);
operation.catch(() => log("error", "Loop lease heartbeat failed", { category: "state" }));
}
}, SESSION_LEASE_HEARTBEAT_MS);
Expand Down Expand Up @@ -1116,7 +1145,8 @@ var server = async ({ client }, options) => {
args: {
instruction: z.string().min(1).max(MAX_PROMPT_CHARS).describe("The instruction to perform on each iteration."),
interval: z.string().optional().describe('Fixed cadence like "30s", "10m", "2h", or "1d". Omit for a dynamically paced loop.'),
max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes.")
max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes."),
keep_alive: z.boolean().optional().describe(KEEP_ALIVE_DESCRIPTION)
},
async execute(args, context) {
const input = args;
Expand All @@ -1128,7 +1158,8 @@ var server = async ({ client }, options) => {
intervalMs: dynamic ? null : parseInterval(input.interval, minIntervalSeconds),
maxRuns: input.max_runs ?? null,
agent: typeof context.agent === "string" ? context.agent : null,
maxLoopsPerSession
maxLoopsPerSession,
keepAlive: input.keep_alive === true
});
if (loop.mode === "dynamic") {
dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true });
Expand Down Expand Up @@ -1343,6 +1374,7 @@ async function setupV2(context) {
const ownedSessions = new Map;
const ownershipQueues = new Map;
const observedSessions = new Set;
const keepAliveSessions = new Set;
const staleDynamicCandidates = new Map;
const lastPromptAgentBySession = new Map;
const dynamicPending = new Map;
Expand Down Expand Up @@ -1379,11 +1411,21 @@ async function setupV2(context) {
dynamicPending.delete(loopID);
}
}
async function keepAliveSessionAvailable(sessionID) {
try {
const session = await context.session.get({ sessionID });
const location = context.location;
return session.id === sessionID && (!location || session.location.directory === location.directory && session.location.workspaceID === location.workspaceID);
} catch {
return false;
}
}
async function acquireOwnership(sessionID) {
return enqueueSessionOperation(ownershipQueues, sessionID, async () => {
if (disposed)
return false;
observedSessions.add(sessionID);
if (!observedSessions.has(sessionID) && !await keepAliveSessionAvailable(sessionID))
return false;
const lease = await acquireSessionLease(sessionID, instanceID, SESSION_LEASE_MS);
if (!lease) {
await loseOwnership(sessionID);
Expand Down Expand Up @@ -1413,7 +1455,7 @@ async function setupV2(context) {
}
function scheduleTimer(loop, minimumDelayMs = 0) {
cancelTimer(loop.id);
if (disposed || loop.status !== "active" || loop.nextRunAt == null || !ownedSessions.has(loop.sessionID))
if (disposed || loop.status !== "active" || loop.nextRunAt == null || !ownedSessions.has(loop.sessionID) || !observedSessions.has(loop.sessionID) && !loop.keepAlive)
return;
const delay = Math.max(minimumDelayMs, loop.nextRunAt - Date.now());
const timer = setTimeout(() => {
Expand Down Expand Up @@ -1452,7 +1494,7 @@ async function setupV2(context) {
scheduleTimer(loop);
return;
}
if (!observedSessions.has(loop.sessionID)) {
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
scheduleTimer(loop, Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
v2Log("info", "Skipping loop because session ownership is unknown", { loopID, category: "ownership" });
return;
Expand All @@ -1466,7 +1508,7 @@ async function setupV2(context) {
return;
}
loop = claimed.loop;
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoopClaimed(loopID, claimed, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
return;
}
Expand Down Expand Up @@ -1564,6 +1606,8 @@ async function setupV2(context) {
async function rehydrate() {
const loops = await activeLoops();
for (const loop of loops) {
if (loop.keepAlive)
keepAliveSessions.add(loop.sessionID);
if (loop.nextRunAt == null) {
if (loop.mode === "dynamic") {
staleDynamicCandidates.set(loop.id, { sessionID: loop.sessionID, updatedAt: loop.updatedAt });
Expand All @@ -1575,6 +1619,8 @@ async function setupV2(context) {
continue;
}
}
for (const sessionID of keepAliveSessions)
await acquireOwnership(sessionID);
}
async function observeSession(sessionID) {
observedSessions.add(sessionID);
Expand Down Expand Up @@ -1700,8 +1746,8 @@ async function setupV2(context) {
}));
await rehydrate().catch(() => v2Log("error", "Failed to rehydrate loops", { category: "state" }));
const leaseHeartbeat = setInterval(() => {
for (const sessionID of observedSessions) {
const operation = ownedSessions.has(sessionID) ? renewOwnership(sessionID) : observeSession(sessionID);
for (const sessionID of new Set([...observedSessions, ...keepAliveSessions])) {
const operation = ownedSessions.has(sessionID) ? renewOwnership(sessionID) : observedSessions.has(sessionID) ? observeSession(sessionID) : acquireOwnership(sessionID);
operation.catch(() => v2Log("error", "Loop lease heartbeat failed", { category: "state" }));
}
}, SESSION_LEASE_HEARTBEAT_MS);
Expand Down Expand Up @@ -1762,6 +1808,10 @@ function loopToolsV2(services) {
type: "integer",
minimum: 1,
description: "Optional maximum number of iterations before the loop completes."
},
keep_alive: {
type: "boolean",
description: KEEP_ALIVE_DESCRIPTION
}
}, ["instruction"]),
options: { codemode: false },
Expand All @@ -1775,7 +1825,8 @@ function loopToolsV2(services) {
intervalMs: dynamic ? null : parseInterval(input.interval, services.minIntervalSeconds),
maxRuns: input.max_runs ?? null,
agent: typeof toolContext.agent === "string" ? toolContext.agent : null,
maxLoopsPerSession: services.maxLoopsPerSession
maxLoopsPerSession: services.maxLoopsPerSession,
keepAlive: input.keep_alive === true
});
if (loop.mode === "dynamic") {
services.dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true });
Expand Down
Loading
Loading