Skip to content
Open
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
19 changes: 19 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 })` holds a background or subagent session running across turns until the agent stops it, so work that outlives the turn that started it is never ended by the scheduler.
- 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,24 @@ 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 until the agent calls `stop_loop` — because the work is resolved, or because it is giving up. The scheduler never ends a keep-alive loop on its own: it is exempt from the age expiry that stops ordinary loops, it survives an OpenCode restart, and it keeps retrying in a detached session this process never observed instead of quietly stalling.

`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
37 changes: 25 additions & 12 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 @@ -217,14 +218,17 @@ 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");
const maxRuns = positiveIntegerOrNull(options.maxRuns);
const maxLoops = positiveIntegerOrNull(options.maxLoopsPerSession) ?? DEFAULT_MAX_LOOPS_PER_SESSION;
const agent = typeof options.agent === "string" && options.agent.trim() ? options.agent.trim() : null;
return mutate((state) => {
const open = Object.values(state.loops).filter((loop2) => loop2.sessionID === sessionID && isOpen(loop2.status));
const open = Object.values(state.loops).filter((loop) => loop.sessionID === sessionID && isOpen(loop.status));
if (open.length >= maxLoops) {
throw new Error(`this session already has ${open.length} open loop(s); stop one before creating another (limit ${maxLoops})`);
}
Expand All @@ -237,6 +241,7 @@ async function createLoop(sessionID, options) {
sessionID,
prompt,
mode,
keepAlive,
intervalMs,
status: "active",
createdAt: timestamp,
Expand Down Expand Up @@ -510,6 +515,7 @@ Preserve each loop's id, cadence, instruction, and status in the compacted conte
}

// src/server.ts
var KEEP_ALIVE_DESCRIPTION = "Keep this session alive across turns until the agent explicitly stops the loop. Requires a fixed interval. 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 @@ -642,7 +648,7 @@ var server = async ({ client }, options) => {
return;
}
loop = claimed;
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
return;
}
Expand All @@ -669,7 +675,7 @@ var server = async ({ client }, options) => {
});
} catch (error) {
dynamicPending.delete(loopID);
if (!observedSessions.has(loop.sessionID)) {
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID });
return;
}
Expand All @@ -687,9 +693,9 @@ var server = async ({ client }, options) => {
}
async function runDueForSession(sessionID) {
const loops = await activeLoops(sessionID);
const now2 = Date.now();
const now = Date.now();
for (const loop of loops) {
if (loop.nextRunAt == null || loop.nextRunAt > now2)
if (loop.nextRunAt == null || loop.nextRunAt > now)
continue;
await runDue(loop.id);
if (busySessions.has(sessionID))
Expand Down Expand Up @@ -751,7 +757,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 @@ -763,7 +770,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 @@ -1041,7 +1049,7 @@ async function setupV2(context) {
return;
}
loop = claimed;
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
return;
}
Expand All @@ -1066,7 +1074,7 @@ async function setupV2(context) {
});
} catch (error) {
dynamicPending.delete(loopID);
if (!observedSessions.has(loop.sessionID)) {
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
v2Log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID });
return;
}
Expand All @@ -1084,9 +1092,9 @@ async function setupV2(context) {
}
async function runDueForSession(sessionID) {
const loops = await activeLoops(sessionID);
const now2 = Date.now();
const now = Date.now();
for (const loop of loops) {
if (loop.nextRunAt == null || loop.nextRunAt > now2)
if (loop.nextRunAt == null || loop.nextRunAt > now)
continue;
await runDue(loop.id);
if (busySessions.has(sessionID))
Expand Down Expand Up @@ -1279,6 +1287,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 @@ -1292,7 +1304,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
32 changes: 26 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ type Options = {
restricted_agents?: string[]
}

const KEEP_ALIVE_DESCRIPTION =
"Keep this session alive across turns until the agent explicitly stops the loop. Requires a fixed interval. 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."

const DEFAULT_COMMAND_NAME = "loop"
const DEFAULT_BUSY_BACKOFF_SECONDS = 60
const DEFAULT_FAILURE_BACKOFF_SECONDS = 60
Expand Down Expand Up @@ -185,7 +188,9 @@ const server: Plugin = async ({ client }, options?: Options) => {
return
}
loop = claimed
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
// A keep-alive loop ends only when the agent says so, so age expiry and the
// other automatic stops do not apply to it.
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86_400_000)} days`)
return
}
Expand Down Expand Up @@ -216,7 +221,10 @@ const server: Plugin = async ({ client }, options?: Options) => {
})
} catch (error) {
dynamicPending.delete(loopID)
if (!observedSessions.has(loop.sessionID)) {
// A keep-alive loop is expected to run in a detached session this process
// may never observe, so it re-arms instead of being left to the claim
// lease. claimDueRun still keeps concurrent processes off the same run.
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
// Likely a session owned by another OpenCode process sharing the state
// file: leave its record alone and stop driving it from this process.
await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID })
Expand Down Expand Up @@ -302,9 +310,10 @@ const server: Plugin = async ({ client }, options?: Options) => {
.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."),
keep_alive: z.boolean().optional().describe(KEEP_ALIVE_DESCRIPTION),
},
async execute(args, context) {
const input = args as { instruction: string; interval?: string; max_runs?: number }
const input = args as { instruction: string; interval?: string; max_runs?: number; keep_alive?: boolean }
observedSessions.add(context.sessionID)
const dynamic = !input.interval?.trim()
const loop = await createLoop(context.sessionID, {
Expand All @@ -314,6 +323,7 @@ const server: Plugin = async ({ client }, options?: Options) => {
maxRuns: input.max_runs ?? null,
agent: typeof context.agent === "string" ? context.agent : null,
maxLoopsPerSession,
keepAlive: input.keep_alive === true,
})
if (loop.mode === "dynamic") {
dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true })
Expand Down Expand Up @@ -617,7 +627,9 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
return
}
loop = claimed
if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
// A keep-alive loop ends only when the agent says so, so age expiry and the
// other automatic stops do not apply to it.
if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) {
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86_400_000)} days`)
return
}
Expand Down Expand Up @@ -646,7 +658,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
})
} catch (error) {
dynamicPending.delete(loopID)
if (!observedSessions.has(loop.sessionID)) {
// A keep-alive loop is expected to run in a detached session this process
// may never observe, so it re-arms instead of being left to the claim
// lease. claimDueRun still keeps concurrent processes off the same run.
if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) {
// Likely a session owned by another OpenCode process sharing the state
// file: leave its record alone and stop driving it from this process.
v2Log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID })
Expand Down Expand Up @@ -870,12 +885,16 @@ function loopToolsV2(services: LoopServices): ToolV2Info[] {
minimum: 1,
description: "Optional maximum number of iterations before the loop completes.",
},
keep_alive: {
type: "boolean",
description: KEEP_ALIVE_DESCRIPTION,
},
},
["instruction"],
),
options: { codemode: false },
execute: async (args, toolContext) => {
const input = args as { instruction: string; interval?: string; max_runs?: number }
const input = args as { instruction: string; interval?: string; max_runs?: number; keep_alive?: boolean }
services.observedSessions.add(toolContext.sessionID)
const dynamic = !input.interval?.trim()
const loop = await createLoop(toolContext.sessionID, {
Expand All @@ -885,6 +904,7 @@ function loopToolsV2(services: LoopServices): ToolV2Info[] {
maxRuns: input.max_runs ?? null,
agent: typeof toolContext.agent === "string" ? toolContext.agent : null,
maxLoopsPerSession: services.maxLoopsPerSession,
keepAlive: input.keep_alive === true,
})
if (loop.mode === "dynamic") {
services.dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true })
Expand Down
9 changes: 9 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ export type CreateLoopOptions = {
maxRuns?: number | null
agent?: string | null
maxLoopsPerSession?: number | null
keepAlive?: boolean
}

export type Loop = {
id: string
sessionID: string
prompt: string
mode: LoopMode
keepAlive: boolean
intervalMs: number | null
status: LoopStatus
createdAt: number
Expand Down Expand Up @@ -65,6 +67,7 @@ const LoopSchema = Schema.Struct({
sessionID: Schema.String,
prompt: Schema.String,
mode: Schema.optionalWith(Schema.Literal("interval", "dynamic"), { default: () => "interval" as const }),
keepAlive: Schema.optionalWith(Schema.Boolean, { default: () => false }),
intervalMs: NullableNumber,
status: Schema.Literal("active", "paused", "stopped", "completed"),
createdAt: Schema.Number,
Expand Down Expand Up @@ -293,6 +296,11 @@ function requireLoop(state: State, loopID: string) {
export async function createLoop(sessionID: string, options: CreateLoopOptions) {
const prompt = validatePrompt(options.prompt)
const mode: LoopMode = options.mode === "dynamic" ? "dynamic" : "interval"
const keepAlive = options.keepAlive === true
// A keep-alive future needs a self-sufficient heartbeat. Dynamic pacing puts
// the next run in the agent's hands every turn, so one missed reschedule
// would end the very future the flag promises to hold open.
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")
const maxRuns = positiveIntegerOrNull(options.maxRuns)
Expand All @@ -311,6 +319,7 @@ export async function createLoop(sessionID: string, options: CreateLoopOptions)
sessionID,
prompt,
mode,
keepAlive,
intervalMs,
status: "active",
createdAt: timestamp,
Expand Down
50 changes: 50 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,53 @@ test("V2 cleanup disposes registrations, clears timers, and stops the event cons
expect(mock.promptCalls).toHaveLength(0)
expect((await getLoop(created.created))?.status).toBe("active")
})

test("V2 create_loop advertises keep_alive without making it required", async () => {
const mock = makeMockContext()
const cleanup = await plugin.setup(mock as never)

const input = loopTool(mock, "create_loop").input as {
properties: Record<string, { type?: string }>
required: string[]
}
expect(input.properties.keep_alive?.type).toBe("boolean")
expect(input.required).toEqual(["instruction"])

mock.stream.end()
await cleanup()
})

test("V2 keep-alive loop survives age expiry and keeps its future open", async () => {
const mock = makeMockContext({ max_loop_age_days: 7 })
const cleanup = await plugin.setup(mock as never)

const created = JSON.parse(
contentOf(
await loopTool(mock, "create_loop").execute(
{ instruction: "hold the future open", interval: "1s", keep_alive: true },
toolContext("ses_keep"),
),
),
) as { created: string; loop: { keepAlive: boolean } }
expect(created.loop.keepAlive).toBe(true)

await waitFor(() => mock.promptCalls.length >= 1)
expect(mock.promptCalls[0]?.sessionID).toBe("ses_keep")
expect((await getLoop(created.created))?.status).toBe("active")

await waitFor(async () => (await getLoop(created.created))?.runCount === 1)
mock.stream.end()
await cleanup()
})

test("V2 keep_alive requires a fixed interval", async () => {
const mock = makeMockContext()
const cleanup = await plugin.setup(mock as never)

await expect(
loopTool(mock, "create_loop").execute({ instruction: "no cadence", keep_alive: true }, toolContext("ses_bad")),
).rejects.toThrow(/keep-alive loops require a fixed interval/)

mock.stream.end()
await cleanup()
})
Loading