diff --git a/packages/core/src/services/monitorRegistry.test.ts b/packages/core/src/services/monitorRegistry.test.ts
index 42009218f1d..4526e2b4d7b 100644
--- a/packages/core/src/services/monitorRegistry.test.ts
+++ b/packages/core/src/services/monitorRegistry.test.ts
@@ -279,7 +279,7 @@ describe('MonitorRegistry', () => {
it('completes a monitor and emits terminal notification', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
- registry.register(createEntry());
+ registry.register(createEntry({ command: 'grep "a&b" < /dev/null' }));
registry.complete('mon-1', 0);
@@ -290,6 +290,9 @@ describe('MonitorRegistry', () => {
const [displayText, modelText] = callback.mock.calls[0] as [string, string];
expect(displayText).toContain('completed');
expect(modelText).toContain('completed');
+ expect(modelText).toContain(
+ 'grep "a&b" < /dev/null',
+ );
expect(modelText).toContain('Exited with code 0');
});
diff --git a/packages/core/src/services/monitorRegistry.ts b/packages/core/src/services/monitorRegistry.ts
index 77262747580..01e4fdcd49b 100644
--- a/packages/core/src/services/monitorRegistry.ts
+++ b/packages/core/src/services/monitorRegistry.ts
@@ -562,6 +562,7 @@ export class MonitorRegistry {
`${escapeXml(entry.status)}`,
`${entry.eventCount}`,
`Monitor "${escapeXml(desc)}" ${statusText}. Total events: ${entry.eventCount}.${entry.droppedLines > 0 ? ` ${entry.droppedLines} lines dropped due to throttling.` : ''}`,
+ `${escapeXml(stripDisplayControlChars(entry.command))}`,
);
if (detail) {
xmlParts.push(
diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md
index 761ffa09685..26a04be7fca 100644
--- a/packages/core/src/skills/bundled/loop/SKILL.md
+++ b/packages/core/src/skills/bundled/loop/SKILL.md
@@ -43,19 +43,24 @@ Examples:
Use this path only when the user supplied a prompt and no interval.
1. Do not call CronCreate for this path.
-2. Run the parsed prompt immediately now.
+2. If this tick opens with a `` block (a monitor or background event re-invoked you, not a bare `/loop` wakeup prompt), handle that event before re-running the prompt.
+ - If the notification says the watched condition was met, cancel any pending fallback LoopWakeup with CronDelete if you still have its ID, then finish the loop.
+ - If a monitor auto-stopped on idle or max-events, restart it once if the watch is still useful, re-arm the fallback, report the restart count to the user, and include that count in the LoopWakeup prompt or reason (for example, `monitor restarted 1/1 time`) so it survives context compaction. If it auto-stops again on the next tick, end the loop and report the repeated auto-stop to the user.
+ - If the signal is ambiguous, re-arm a shorter follow-up and investigate on the next tick. If the signal remains ambiguous for three consecutive ticks, end the loop and report that the watch could not reach a clear conclusion.
+3. Run the parsed prompt immediately now.
- If it is a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.
-3. Before ending the turn, decide whether another check is useful.
+4. Before ending the turn, decide whether another check is useful.
- Call LoopWakeup only if continued follow-up is useful.
- Do not call LoopWakeup if the task is complete.
- Do not call LoopWakeup if the task is blocked on user input or external state that cannot be checked later.
- Do not call LoopWakeup just to keep polling when no useful next check exists.
-4. When scheduling a continuation, call LoopWakeup with:
- - `delaySeconds`: the next useful delay in seconds. The runtime clamps to 60–3600 (1–60 min); follow the tool's own guidance on picking a value (it accounts for the prompt-cache window).
- - `prompt`: `/loop ${original prompt}`
- - `reason`: a short reason for the chosen delay.
-5. Briefly tell the user what was done now. If a wakeup was scheduled, include when the next check is expected. If no wakeup was scheduled, say the loop is complete or not continuing.
+ - If you started a background agent or a Monitor, it wakes you via a terminal `` on exit, failure, cancellation, or monitor auto-stop — so set LoopWakeup as a long fallback rather than a short poll. Do not omit it just because something is watching: the work may hang, or a Monitor may auto-stop on idle or max-events (and one owned by another agent routes its notification only to that agent). Omit LoopWakeup only on the terminal conditions above (complete, blocked, or repeated monitor auto-stop).
+5. When scheduling a continuation, call LoopWakeup with:
+ - `delaySeconds`: the next useful delay in seconds. The runtime clamps to 60–3600 (1–60 min); follow the tool's own guidance on picking a value — it accounts for the prompt-cache window and for the fallback-heartbeat case when a background task will wake you.
+ - `prompt`: `/loop ${original prompt}` plus any state the next tick must preserve, such as `monitor restarted 1/1 time`.
+ - `reason`: a short reason for the chosen delay. Include the monitor restart count here when re-arming after an auto-stop.
+6. Briefly tell the user what was done now. If a wakeup was scheduled, include when the next check is expected. If no wakeup was scheduled because a notification ended the loop, mention whether the stale fallback was cancelled; if the wakeup ID was lost, ignore or answer the stale wakeup briefly when it fires.
## Fixed-interval recurring path
diff --git a/packages/core/src/skills/bundled/loop/SKILL.test.ts b/packages/core/src/skills/bundled/loop/SKILL.test.ts
index cabaefaa2b8..feafafc0539 100644
--- a/packages/core/src/skills/bundled/loop/SKILL.test.ts
+++ b/packages/core/src/skills/bundled/loop/SKILL.test.ts
@@ -47,6 +47,38 @@ describe('bundled loop skill', () => {
expect(body).not.toContain('delayMinutes');
});
+ it('teaches the self-paced loop to lean on monitor/background-task notifications', () => {
+ const { body } = loadLoopSkill();
+
+ expect(body).toContain('');
+ expect(body).toContain('set LoopWakeup as a long fallback');
+ expect(body).toContain('auto-stop on idle or max-events');
+ expect(body).toContain('handle that event before re-running the prompt');
+ expect(body).toContain('terminal ``');
+ expect(body).not.toContain('per stdout line');
+ expect(body).toContain(
+ 'If the notification says the watched condition was met',
+ );
+ expect(body).toContain('cancel any pending fallback LoopWakeup');
+ expect(body).toContain('CronDelete');
+ expect(body).toContain('If a monitor auto-stopped');
+ expect(body).toContain('restart it once');
+ expect(body).toContain('monitor restarted 1/1 time');
+ expect(body).toContain('so it survives context compaction');
+ expect(body).toContain('report the repeated auto-stop to the user');
+ expect(body).toContain('report the restart count');
+ expect(body).toContain('If the signal is ambiguous');
+ expect(body).toContain('three consecutive ticks');
+ expect(body).toContain('Do not omit it just because something is watching');
+ expect(body).toContain('the work may hang');
+ expect(body).toContain(
+ 'one owned by another agent routes its notification only to that agent',
+ );
+ expect(body).toContain('repeated monitor auto-stop');
+ expect(body).toContain('not a bare `/loop` wakeup prompt');
+ expect(body).toContain('stale fallback was cancelled');
+ });
+
it('keeps fixed-interval inputs on the recurring cron path', () => {
const { body } = loadLoopSkill();
diff --git a/packages/core/src/tools/loop-wakeup.test.ts b/packages/core/src/tools/loop-wakeup.test.ts
index 525be3a8f47..e792d957ea1 100644
--- a/packages/core/src/tools/loop-wakeup.test.ts
+++ b/packages/core/src/tools/loop-wakeup.test.ts
@@ -46,6 +46,22 @@ describe('LoopWakeupTool', () => {
expect(tool.name).toBe('loop_wakeup');
});
+ it('documents the fallback-heartbeat semantics for monitor/background work', () => {
+ expect(tool.description).toContain('fallback heartbeat');
+ expect(tool.description).toContain('');
+ expect(tool.description).toContain('terminal ``');
+ expect(tool.description).not.toContain('per stdout line');
+ const params = tool.schema.parametersJsonSchema as {
+ properties: { delaySeconds: { description: string } };
+ };
+ const delay = params.properties.delaySeconds.description;
+ // Both sides of the rule: long fallback when something else wakes you,
+ // short poll only when you are the sole watcher.
+ expect(delay).toContain('1200-1800s');
+ expect(delay).toContain('60-270s');
+ expect(delay).toContain('Monitor');
+ });
+
it('uses ask permission because it schedules future model input', async () => {
const invocation = tool.build({
delaySeconds: 300,
diff --git a/packages/core/src/tools/loop-wakeup.ts b/packages/core/src/tools/loop-wakeup.ts
index d870ff488ce..73fb0f728df 100644
--- a/packages/core/src/tools/loop-wakeup.ts
+++ b/packages/core/src/tools/loop-wakeup.ts
@@ -125,14 +125,14 @@ export class LoopWakeupTool extends BaseDeclarativeTool<
super(
LoopWakeupTool.Name,
ToolDisplayNames.LOOP_WAKEUP,
- 'Schedule when to resume work in a self-paced loop iteration (always pass the `prompt` arg). Call this before ending the turn to keep the loop alive; omit the call to end the loop. Session-only and one-shot — it does not persist or recur. A self-paced wakeup chain may run for at most 24h.',
+ 'Schedule when to resume work in a self-paced loop iteration (always pass the `prompt` arg). Call this before ending the turn to keep the loop alive; omit the call to end the loop. Session-only and one-shot — it does not persist or recur. A self-paced wakeup chain may run for at most 24h. When a background task you started will wake you on its own — a backgrounded agent or a Monitor sends a terminal `` on exit, failure, cancellation, or monitor auto-stop — keep this wakeup as a long fallback heartbeat rather than a poll; see `delaySeconds`.',
Kind.Other,
{
type: 'object',
properties: {
delaySeconds: {
type: 'number',
- description: `Seconds from now to wake up. Clamped to [${WAKEUP_MIN_SECONDS}, ${WAKEUP_MAX_SECONDS}]. Prefer 60-270s for fast-changing state, 1200s+ when there is no reason to check sooner.`,
+ description: `Seconds from now to wake up. Clamped to [${WAKEUP_MIN_SECONDS}, ${WAKEUP_MAX_SECONDS}]. Use 60-270s only when actively polling external state that nothing else reports (a CI run, a remote queue) — staying inside the ~5-min prompt-cache window. When a background task you started will wake you via a \`\` once it finishes, that is the real wake signal — use 1200-1800s here as a fallback for when it never arrives (the task hangs, a Monitor auto-stops on idle or max-events, or another agent owns it). With no specific signal to watch, default to 1200s+.`,
},
prompt: {
type: 'string',