Skip to content

Commit aa86ee8

Browse files
committed
feat: add target option so a loop can wake the parent session
A loop could only ever re-prompt the session that created it, so a background child session had no way to hand control back to the session that spawned it. When the thing it was watching finally happened, the parent sat idle until a human noticed. create_loop now takes target: "self" | "parent". "self" is the default and behaves exactly as before. "parent" resolves the creating session's parentID once, at creation, and the scheduler injects iterations there instead. The resolved id is stored as loop.targetSessionID (null for self loops, so existing persisted records decode unchanged and keep targeting their own session). loopTarget() is the single accessor the scheduler reads, which keeps the change to a handful of call sites in both the V1 and V2 paths: busy deferral, restricted-agent deferral, dynamicPending registration, the prompt itself, and post-send bookkeeping all follow the target. Two cases need the target rather than the creator to stay correct. The iteration prompt tells the receiving session to call stop_loop and schedule_next_run, so requireSessionLoop now accepts the target session as well; otherwise a parent-targeted loop could never be stopped by the session actually running it. Dynamic loops register under the target with sawBusy false when targeting a parent, because the creating child's idle event would otherwise settle the loop before the parent was ever prompted. Creating a parent-targeted loop from a root session fails at create time rather than silently never firing.
1 parent a7c3bed commit aa86ee8

7 files changed

Lines changed: 398 additions & 61 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The OpenCode Loop Plugin adds:
1313
- `/loop <interval> <instruction>` and `/loop <instruction>` (dynamic pacing) as an OpenCode command for TUI, desktop, and web.
1414
- A server-side scheduler with per-loop timers that injects a synthetic iteration prompt only when the session is idle, with busy backoff.
1515
- Dynamic loops where the agent itself picks the delay before each next iteration via `schedule_next_run`, mirroring Claude Code's self-paced `/loop`.
16+
- Parent wake-up: `create_loop` takes `target: "self" | "parent"`, so a background child session can hand control back to the session that spawned it instead of only re-waking itself.
1617
- Agent tools: `create_loop`, `list_loops`, `stop_loop`, `pause_loop`, `resume_loop`, `run_loop`, `schedule_next_run`, and `clear_loops`.
1718
- Persistent loop state that survives OpenCode restarts, with atomic writes and owner-only file permissions.
1819
- A TUI sidebar with live countdowns and a `Loops` command-palette entry to run, pause, resume, or stop loops.
@@ -120,6 +121,22 @@ After creating a loop, the agent immediately performs the first iteration in the
120121

121122
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.
122123

124+
### Waking the parent session
125+
126+
By default a loop wakes the session that created it. Pass `target: "parent"` to `create_loop` and iterations are injected into that session's parent instead:
127+
128+
```
129+
create_loop(
130+
instruction: "Check whether PR #42 has merged. If it has, say so and call stop_loop.",
131+
interval: "5m",
132+
target: "parent"
133+
)
134+
```
135+
136+
This is for background work. A subagent spawned to babysit CI can keep polling on its own schedule and then hand control back to the session that spawned it, instead of that session sitting idle until a human notices. One tool, one flag — `"self"` re-wakes this session, `"parent"` wakes the one above it.
137+
138+
The parent is resolved once, when the loop is created. Creating a parent-targeted loop from a root session fails immediately rather than silently never firing. Both the creating session and the woken parent can `list_loops` and `stop_loop` it, and the loop is stopped if the parent session is deleted.
139+
123140
### How iterations are scheduled
124141

125142
- 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.

‎dist/server.js‎

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var NullableNumber = Schema.NullOr(Schema.Number);
2525
var LoopSchema = Schema.Struct({
2626
id: Schema.String,
2727
sessionID: Schema.String,
28+
targetSessionID: Schema.optionalWith(NullableString, { default: () => null }),
2829
prompt: Schema.String,
2930
mode: Schema.optionalWith(Schema.Literal("interval", "dynamic"), { default: () => "interval" }),
3031
intervalMs: NullableNumber,
@@ -208,6 +209,9 @@ function isOpen(status) {
208209
function snapshot(loop) {
209210
return { ...loop, sampledAt: now() };
210211
}
212+
function loopTarget(loop) {
213+
return loop.targetSessionID ?? loop.sessionID;
214+
}
211215
function requireLoop(state, loopID) {
212216
const loop = state.loops[loopID];
213217
if (!loop)
@@ -223,8 +227,9 @@ async function createLoop(sessionID, options) {
223227
const maxRuns = positiveIntegerOrNull(options.maxRuns);
224228
const maxLoops = positiveIntegerOrNull(options.maxLoopsPerSession) ?? DEFAULT_MAX_LOOPS_PER_SESSION;
225229
const agent = typeof options.agent === "string" && options.agent.trim() ? options.agent.trim() : null;
230+
const target = typeof options.targetSessionID === "string" && options.targetSessionID.trim() && options.targetSessionID !== sessionID ? options.targetSessionID.trim() : null;
226231
return mutate((state) => {
227-
const open = Object.values(state.loops).filter((loop2) => loop2.sessionID === sessionID && isOpen(loop2.status));
232+
const open = Object.values(state.loops).filter((loop) => loop.sessionID === sessionID && isOpen(loop.status));
228233
if (open.length >= maxLoops) {
229234
throw new Error(`this session already has ${open.length} open loop(s); stop one before creating another (limit ${maxLoops})`);
230235
}
@@ -235,6 +240,7 @@ async function createLoop(sessionID, options) {
235240
const loop = {
236241
id,
237242
sessionID,
243+
targetSessionID: target,
238244
prompt,
239245
mode,
240246
intervalMs,
@@ -276,7 +282,7 @@ async function claimDueRun(loopID, leaseMs) {
276282
}
277283
async function listLoops(sessionID) {
278284
const state = await readState();
279-
return Object.values(state.loops).filter((loop) => sessionID == null || loop.sessionID === sessionID).sort((a, b) => a.createdAt - b.createdAt).map(snapshot);
285+
return Object.values(state.loops).filter((loop) => sessionID == null || loop.sessionID === sessionID || loopTarget(loop) === sessionID).sort((a, b) => a.createdAt - b.createdAt).map(snapshot);
280286
}
281287
async function openLoops(sessionID) {
282288
const loops = await listLoops(sessionID);
@@ -327,7 +333,7 @@ async function stopLoopsForSession(sessionID, reason) {
327333
return mutate((state) => {
328334
const stopped = [];
329335
for (const loop of Object.values(state.loops)) {
330-
if (loop.sessionID !== sessionID || !isOpen(loop.status))
336+
if (loopTarget(loop) !== sessionID || !isOpen(loop.status))
331337
continue;
332338
loop.status = "stopped";
333339
loop.nextRunAt = null;
@@ -510,6 +516,7 @@ Preserve each loop's id, cadence, instruction, and status in the compacted conte
510516
}
511517

512518
// src/server.ts
519+
var TARGET_DESCRIPTION = 'Whose session the iteration wakes. "self" (default) re-prompts this session on the cadence. "parent" prompts the session that spawned this one, so a background child can hand control back to its parent when the instruction fires.';
513520
var DEFAULT_COMMAND_NAME = "loop";
514521
var DEFAULT_BUSY_BACKOFF_SECONDS = 60;
515522
var DEFAULT_FAILURE_BACKOFF_SECONDS = 60;
@@ -646,50 +653,51 @@ var server = async ({ client }, options) => {
646653
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
647654
return;
648655
}
649-
if (busySessions.has(loop.sessionID)) {
656+
const target = loopTarget(loop);
657+
if (busySessions.has(target)) {
650658
const deferred = await recordRunDeferred(loopID, "skipped_busy", Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
651659
scheduleTimer(deferred);
652660
return;
653661
}
654-
if (isRestrictedAgent(lastPromptAgentBySession.get(loop.sessionID))) {
662+
if (isRestrictedAgent(lastPromptAgentBySession.get(target))) {
655663
const deferred = await recordRunDeferred(loopID, "skipped_plan", Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
656664
scheduleTimer(deferred);
657665
return;
658666
}
659667
if (loop.mode === "dynamic") {
660-
dynamicPending.set(loopID, { sessionID: loop.sessionID, sawBusy: false });
668+
dynamicPending.set(loopID, { sessionID: target, sawBusy: false });
661669
}
662670
try {
663671
await client.session.promptAsync({
664-
path: { id: loop.sessionID },
672+
path: { id: target },
665673
body: {
666674
...loop.agent ? { agent: loop.agent } : {},
667675
parts: [{ type: "text", text: iterationPrompt(loop) }]
668676
}
669677
});
670678
} catch (error) {
671679
dynamicPending.delete(loopID);
672-
if (!observedSessions.has(loop.sessionID)) {
673-
await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID });
680+
if (!observedSessions.has(target)) {
681+
await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: target });
674682
return;
675683
}
676684
const failed = await recordRunFailed(loopID, error instanceof Error ? error.message : String(error), failureBackoffMs);
677685
scheduleTimer(failed);
678686
await log("error", "Loop iteration prompt failed", { loopID, error: failed.lastError ?? undefined });
679687
return;
680688
}
681-
busySessions.add(loop.sessionID);
682-
observedSessions.add(loop.sessionID);
689+
busySessions.add(target);
690+
observedSessions.add(target);
683691
const sent = await recordRunSent(loopID);
684692
if (sent.mode !== "dynamic" || sent.status !== "active")
685693
dynamicPending.delete(loopID);
686694
scheduleTimer(sent);
687695
}
688696
async function runDueForSession(sessionID) {
689697
const loops = await activeLoops(sessionID);
690-
const now2 = Date.now();
698+
const now = Date.now();
691699
for (const loop of loops) {
692-
if (loop.nextRunAt == null || loop.nextRunAt > now2)
700+
if (loop.nextRunAt == null || loop.nextRunAt > now)
693701
continue;
694702
await runDue(loop.id);
695703
if (busySessions.has(sessionID))
@@ -728,10 +736,19 @@ var server = async ({ client }, options) => {
728736
const loop = await getLoop(loopID);
729737
if (!loop)
730738
throw new Error(`no loop found with id "${loopID}"`);
731-
if (loop.sessionID !== sessionID)
739+
if (loop.sessionID !== sessionID && loopTarget(loop) !== sessionID) {
732740
throw new Error(`loop "${loopID}" belongs to a different session`);
741+
}
733742
return loop;
734743
}
744+
async function requireParentSession(sessionID) {
745+
const result = await client.session.get({ path: { id: sessionID } });
746+
const parentID = result?.data?.parentID;
747+
if (!parentID) {
748+
throw new Error(`session "${sessionID}" has no parent session, so target "parent" has nothing to wake; create the loop from a child session`);
749+
}
750+
return parentID;
751+
}
735752
await rehydrate().catch((error) => log("error", "Failed to rehydrate loops", { error: error instanceof Error ? error.message : String(error) }));
736753
return {
737754
async dispose() {
@@ -747,26 +764,29 @@ var server = async ({ client }, options) => {
747764
},
748765
tool: {
749766
create_loop: {
750-
description: 'Create a recurring loop for this session only when explicitly requested (for example via the /loop command). The scheduler re-injects the instruction while the session is idle. Pass interval for fixed cadence (like "10m"); omit it for a dynamic loop where the agent schedules each next run with schedule_next_run.',
767+
description: 'Create a recurring loop only when explicitly requested (for example via the /loop command). One flag decides whose session wakes up: target "self" (default) re-injects the instruction into THIS session while it is idle; target "parent" prompts the session that spawned this one instead, so a background child can wake its parent when the instruction fires. Pass interval for fixed cadence (like "10m"); omit it for a dynamic loop where the agent schedules each next run with schedule_next_run.',
751768
args: {
752769
instruction: z.string().min(1).max(MAX_PROMPT_CHARS).describe("The instruction to perform on each iteration."),
753770
interval: z.string().optional().describe('Fixed cadence like "30s", "10m", "2h", or "1d". Omit for a dynamically paced loop.'),
754-
max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes.")
771+
max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes."),
772+
target: z.enum(["self", "parent"]).optional().describe(TARGET_DESCRIPTION)
755773
},
756774
async execute(args, context) {
757775
const input = args;
758776
observedSessions.add(context.sessionID);
777+
const targetSessionID = input.target === "parent" ? await requireParentSession(context.sessionID) : null;
759778
const dynamic = !input.interval?.trim();
760779
const loop = await createLoop(context.sessionID, {
761780
prompt: input.instruction,
762781
mode: dynamic ? "dynamic" : "interval",
763782
intervalMs: dynamic ? null : parseInterval(input.interval, minIntervalSeconds),
764783
maxRuns: input.max_runs ?? null,
765784
agent: typeof context.agent === "string" ? context.agent : null,
766-
maxLoopsPerSession
785+
maxLoopsPerSession,
786+
targetSessionID
767787
});
768788
if (loop.mode === "dynamic") {
769-
dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true });
789+
dynamicPending.set(loop.id, { sessionID: loopTarget(loop), sawBusy: loopTarget(loop) === loop.sessionID });
770790
} else {
771791
scheduleTimer(loop);
772792
}
@@ -1045,48 +1065,49 @@ async function setupV2(context) {
10451065
await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`);
10461066
return;
10471067
}
1048-
if (await isSessionBusy(loop.sessionID)) {
1068+
const target = loopTarget(loop);
1069+
if (await isSessionBusy(target)) {
10491070
const deferred = await recordRunDeferred(loopID, "skipped_busy", Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
10501071
scheduleTimer(deferred);
10511072
return;
10521073
}
1053-
if (isRestrictedAgent(lastPromptAgentBySession.get(loop.sessionID))) {
1074+
if (isRestrictedAgent(lastPromptAgentBySession.get(target))) {
10541075
const deferred = await recordRunDeferred(loopID, "skipped_plan", Math.min(loop.intervalMs ?? busyBackoffMs, busyBackoffMs));
10551076
scheduleTimer(deferred);
10561077
return;
10571078
}
10581079
if (loop.mode === "dynamic") {
1059-
dynamicPending.set(loopID, { sessionID: loop.sessionID, sawBusy: false });
1080+
dynamicPending.set(loopID, { sessionID: target, sawBusy: false });
10601081
}
10611082
try {
10621083
await context.session.prompt({
1063-
sessionID: loop.sessionID,
1084+
sessionID: target,
10641085
text: iterationPrompt(loop),
10651086
...loop.agent ? { agents: [{ name: loop.agent }] } : {}
10661087
});
10671088
} catch (error) {
10681089
dynamicPending.delete(loopID);
1069-
if (!observedSessions.has(loop.sessionID)) {
1070-
v2Log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID });
1090+
if (!observedSessions.has(target)) {
1091+
v2Log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: target });
10711092
return;
10721093
}
10731094
const failed = await recordRunFailed(loopID, error instanceof Error ? error.message : String(error), failureBackoffMs);
10741095
scheduleTimer(failed);
10751096
v2Log("error", "Loop iteration prompt failed", { loopID, error: failed.lastError ?? undefined });
10761097
return;
10771098
}
1078-
busySessions.add(loop.sessionID);
1079-
observedSessions.add(loop.sessionID);
1099+
busySessions.add(target);
1100+
observedSessions.add(target);
10801101
const sent = await recordRunSent(loopID);
10811102
if (sent.mode !== "dynamic" || sent.status !== "active")
10821103
dynamicPending.delete(loopID);
10831104
scheduleTimer(sent);
10841105
}
10851106
async function runDueForSession(sessionID) {
10861107
const loops = await activeLoops(sessionID);
1087-
const now2 = Date.now();
1108+
const now = Date.now();
10881109
for (const loop of loops) {
1089-
if (loop.nextRunAt == null || loop.nextRunAt > now2)
1110+
if (loop.nextRunAt == null || loop.nextRunAt > now)
10901111
continue;
10911112
await runDue(loop.id);
10921113
if (busySessions.has(sessionID))
@@ -1125,10 +1146,19 @@ async function setupV2(context) {
11251146
const loop = await getLoop(loopID);
11261147
if (!loop)
11271148
throw new Error(`no loop found with id "${loopID}"`);
1128-
if (loop.sessionID !== sessionID)
1149+
if (loop.sessionID !== sessionID && loopTarget(loop) !== sessionID) {
11291150
throw new Error(`loop "${loopID}" belongs to a different session`);
1151+
}
11301152
return loop;
11311153
}
1154+
async function requireParentSession(sessionID) {
1155+
const info = await context.session.get({ sessionID });
1156+
const parentID = info?.parentID;
1157+
if (!parentID) {
1158+
throw new Error(`session "${sessionID}" has no parent session, so target "parent" has nothing to wake; create the loop from a child session`);
1159+
}
1160+
return parentID;
1161+
}
11321162
async function handleV2Event(event) {
11331163
const data = event.data;
11341164
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
@@ -1190,7 +1220,8 @@ async function setupV2(context) {
11901220
dynamicPending,
11911221
scheduleTimer,
11921222
cancelTimer,
1193-
requireSessionLoop
1223+
requireSessionLoop,
1224+
requireParentSession
11941225
};
11951226
if (registerCommand) {
11961227
registrations.push(await context.command.transform((draft) => {
@@ -1263,7 +1294,7 @@ function loopToolsV2(services) {
12631294
return [
12641295
{
12651296
name: "create_loop",
1266-
description: 'Create a recurring loop for this session only when explicitly requested (for example via the /loop command). The scheduler re-injects the instruction while the session is idle. Pass interval for fixed cadence (like "10m"); omit it for a dynamic loop where the agent schedules each next run with schedule_next_run.',
1297+
description: 'Create a recurring loop only when explicitly requested (for example via the /loop command). One flag decides whose session wakes up: target "self" (default) re-injects the instruction into THIS session while it is idle; target "parent" prompts the session that spawned this one instead, so a background child can wake its parent when the instruction fires. Pass interval for fixed cadence (like "10m"); omit it for a dynamic loop where the agent schedules each next run with schedule_next_run.',
12671298
input: v2ObjectSchema({
12681299
instruction: {
12691300
type: "string",
@@ -1279,23 +1310,30 @@ function loopToolsV2(services) {
12791310
type: "integer",
12801311
minimum: 1,
12811312
description: "Optional maximum number of iterations before the loop completes."
1313+
},
1314+
target: {
1315+
type: "string",
1316+
enum: ["self", "parent"],
1317+
description: TARGET_DESCRIPTION
12821318
}
12831319
}, ["instruction"]),
12841320
options: { codemode: false },
12851321
execute: async (args, toolContext) => {
12861322
const input = args;
12871323
services.observedSessions.add(toolContext.sessionID);
1324+
const targetSessionID = input.target === "parent" ? await services.requireParentSession(toolContext.sessionID) : null;
12881325
const dynamic = !input.interval?.trim();
12891326
const loop = await createLoop(toolContext.sessionID, {
12901327
prompt: input.instruction,
12911328
mode: dynamic ? "dynamic" : "interval",
12921329
intervalMs: dynamic ? null : parseInterval(input.interval, services.minIntervalSeconds),
12931330
maxRuns: input.max_runs ?? null,
12941331
agent: typeof toolContext.agent === "string" ? toolContext.agent : null,
1295-
maxLoopsPerSession: services.maxLoopsPerSession
1332+
maxLoopsPerSession: services.maxLoopsPerSession,
1333+
targetSessionID
12961334
});
12971335
if (loop.mode === "dynamic") {
1298-
services.dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true });
1336+
services.dynamicPending.set(loop.id, { sessionID: loopTarget(loop), sawBusy: loopTarget(loop) === loop.sessionID });
12991337
} else {
13001338
services.scheduleTimer(loop);
13011339
}

0 commit comments

Comments
 (0)