pi-cc-loop 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * pi-cc-loop — a Claude Code compatible `/loop` for the Pi coding agent.
3
3
  *
4
- * Mirrors Claude Code v2.1.306:
5
- *
6
4
  * /loop 5m check the deploy fixed interval, driven by a 5-field cron
7
5
  * /loop check the deploy self-paced: the model picks the next delay
8
- * /loop uses .pi/loop.md, or the built-in maintenance prompt
6
+ * /loop 15m | /loop the loop.md tasks (or the built-in maintenance
7
+ * prompt), on a fixed interval or self-paced
9
8
  * /loop stop | status | list manage the jobs
10
9
  *
11
10
  * Two mechanisms, same as Claude Code:
@@ -16,66 +15,64 @@
16
15
  * self-paced `schedule_wakeup` lets the model choose the next delay
17
16
  * (60-3600s). If the model forgets to reschedule, the runtime
18
17
  * arms one 20 minute keepalive; if it still forgets, the
19
- * chain ends.
18
+ * chain ends. Esc on one of its turns cancels it.
20
19
  *
21
20
  * The tools are named after their Claude Code counterparts:
22
- * CronCreate -> cron_create
23
- * CronDelete -> cron_delete
24
- * CronList -> cron_list
21
+ * CronCreate -> cron_create, CronDelete -> cron_delete, CronList -> cron_list,
25
22
  * ScheduleWakeup -> schedule_wakeup
23
+ *
24
+ * Module map: schedule.ts (job store, dispatch, run times), cron.ts (cron
25
+ * syntax, intervals, labels), prompts.ts (model-facing text), loopfile.ts
26
+ * (loop.md), durable.ts (.pi/scheduled_tasks.json and its lock), renderers.ts
27
+ * (tool rows, tick message).
26
28
  */
27
29
 
28
- import fs from "node:fs";
29
- import os from "node:os";
30
- import path from "node:path";
31
- import type {
32
- ExtensionAPI,
33
- ExtensionCommandContext,
34
- ExtensionContext,
35
- Theme,
36
- ToolRenderers,
37
- } from "@earendil-works/pi-coding-agent";
38
- import { Container, Text } from "@earendil-works/pi-tui";
30
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
39
31
  import { Type } from "typebox";
32
+ import { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
40
33
  import {
41
- describeCron,
42
34
  humanSchedule,
43
- intervalToCron,
44
35
  isUnreachable,
45
36
  parseCron,
46
- parseEveryClause,
47
- parseIntervalToken,
48
37
  } from "./cron.ts";
38
+ import { DurableRuntime, type Placement } from "./durable.ts";
39
+ import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
49
40
  import {
50
- configPath,
51
- loadConfig,
52
- saveConfig,
53
- syncPiCcExtensionsExcludes,
54
- type PiCcConfig,
55
- } from "./config.ts";
56
- import { generateId, LoopScheduler, type LoopJob } from "./schedule.ts";
41
+ CRON_CREATE_DESCRIPTION,
42
+ CRON_CREATE_SNIPPET,
43
+ CRON_DELETE_DESCRIPTION,
44
+ CRON_DELETE_SNIPPET,
45
+ CRON_LIST_DESCRIPTION,
46
+ CRON_LIST_SNIPPET,
47
+ cronTickGuidelines,
48
+ LOOP_FILE_SENTINEL,
49
+ loopGuidelines,
50
+ missedJobsMessage,
51
+ monitorGuidelines,
52
+ provenance,
53
+ SCHEDULE_WAKEUP_DESCRIPTION,
54
+ scheduledTaskProvenance,
55
+ SCHEDULE_WAKEUP_SNIPPET,
56
+ loopCommandGuidelines,
57
+ } from "./prompts.ts";
58
+ import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
57
59
  import {
58
- bodyLines,
59
- contentText,
60
- firstLine,
61
- formatStamp,
62
- renderBlock,
63
- renderCard,
64
- stripProvenance,
65
- } from "./render.ts";
60
+ DEFAULT_TIMING,
61
+ generateId,
62
+ LoopScheduler,
63
+ nextRunTime,
64
+ storedJob,
65
+ type LoopJob,
66
+ type TimingConfig,
67
+ } from "./schedule.ts";
68
+ import { truncate } from "./render.ts";
66
69
 
67
70
  const STATE_ENTRY = "pi-cc-loop-state";
68
- const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
69
- const WIDGET_KEY = "pi-cc-loop";
71
+ /** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
72
+ const MONITOR_EVENT_TYPE = "Monitor";
73
+ /** Footer status key, shared with every other extension's status line. */
74
+ const STATUS_KEY = "loop";
70
75
  const CONFIG_CHANNEL = "pi-cc:config";
71
- const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
72
- const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
73
-
74
- /**
75
- * The event channel pi-cc-monitor publishes on. When a monitor names a loop
76
- * chain, its events supersede that chain's fallback heartbeat.
77
- */
78
- const MONITOR_WAKE_CHANNEL = "monitor:wake";
79
76
 
80
77
  /**
81
78
  * The event channel this extension publishes on when a chain ends. pi-cc-monitor
@@ -88,81 +85,18 @@ const LOOP_STOPPED_CHANNEL = "loop:stopped";
88
85
  const MAX_JOBS = 50;
89
86
  const MIN_DELAY_SECONDS = 60;
90
87
  const MAX_DELAY_SECONDS = 3600;
88
+ /** A wakeup just under the 5 minute prompt cache lifetime is pulled inside it. */
91
89
  const DEFAULT_KEEPALIVE_SECONDS = 1200;
92
- const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
93
-
94
- const MAINTENANCE_PROMPT = [
95
- "You are running a scheduled maintenance pass.",
96
- "Work through the following, in order:",
97
- "1. Continue any unfinished work from the conversation.",
98
- "2. Tend to the current branch's pull request: review comments, failed CI runs, merge conflicts.",
99
- "3. Run cleanup passes such as bug hunts or simplification when nothing else is pending.",
100
- "Do not start new initiatives outside that scope, and only perform irreversible actions",
101
- "(push, delete) that the transcript already authorized.",
102
- ].join("\n");
103
-
104
- /**
105
- * The rules for a loop whose interval the command layer could not parse.
106
- *
107
- * Claude Code's /loop is a skill: the model reads the user's input and
108
- * applies the skill's rules itself, so an interval written in any language is
109
- * understood. The command layer here only recognises the English token forms
110
- * (`5m`, `every 20m`); when it finds none, these rules take over so the model
111
- * can work the schedule out itself.
112
- */
113
- const LOOP_SCHEDULE_RULES = [
114
- "This is a `/loop` request. Its prompt is: {prompt}",
115
- "",
116
- "Decide how it should repeat, then set it up.",
117
- "",
118
- "**If the prompt asks for a fixed interval** (\"every 30 seconds\", \"each 5 minutes\",",
119
- "\"every hour\", \"5m\", \"every 5m\"): create a recurring job with the `cron_create` tool.",
120
- "- Convert the interval to a 5-field cron expression in local time (\"M H DoM Mon DoW\").",
121
- " `s` rounds up to the nearest minute (the scheduler's minimum granularity is 1 minute),",
122
- " `m` are minutes, `h` are hours, `d` are days. Examples: `5m` -> `*/5 * * * *`,",
123
- " `1h` -> `0 * * * *`, `1d` -> `0 0 * * *`. If the interval does not cleanly divide its",
124
- " unit, round to the nearest clean interval and tell the user what you rounded to.",
125
- "- Then confirm to the user: what is scheduled, the cron expression, the human-readable",
126
- " cadence, that recurring jobs auto-expire after 7 days, and the job id they can cancel",
127
- " with `cron_delete`.",
128
- "",
129
- "**Otherwise the interval is open-ended.** After each iteration, call the `schedule_wakeup`",
130
- "tool with:",
131
- "- `delaySeconds`: seconds until the next iteration (60-3600). Default to 1200-1800; pick",
132
- " something shorter only when you observed fast-changing external state.",
133
- "- `reason`: one short sentence on why you picked that delay.",
134
- "- `prompt`: the same /loop prompt verbatim: {quoted}",
135
- "If the task is complete or can no longer make progress, call `schedule_wakeup` with",
136
- "`stop: true` instead of scheduling another iteration.",
137
- ].join("\n");
138
-
139
- const LOOP_GUIDELINES = [
140
- "A `/loop` chain is active. Its prompt is: {prompt}",
141
- "After this iteration finishes, call the `schedule_wakeup` tool with:",
142
- "- `delaySeconds`: seconds until the next iteration (60-3600). Default to 1200-1800;",
143
- " pick something shorter only when you observed fast-changing external state.",
144
- "- `reason`: one short sentence on why you picked that delay.",
145
- "- `prompt`: the same /loop prompt verbatim: {quoted}",
146
- "If the task is complete or can no longer make progress, call `schedule_wakeup` with",
147
- "`stop: true` instead of scheduling another iteration.",
148
- ].join("\n");
149
-
150
- const MONITOR_GUIDELINES = [
151
- "This iteration is waiting on an external event.",
152
- "Arm the `Monitor` tool with `loopPrompt` set to {quoted} so the event wakes this chain",
153
- "immediately instead of waiting for the next heartbeat. Keep the `schedule_wakeup` call too:",
154
- "its delay is then only the fallback heartbeat for the case where no event ever arrives.",
155
- ].join("\n");
156
-
157
- const PROVENANCE = "[loop tick #{n} · scheduled by /loop, not typed by the user]";
90
+ const DEFAULT_MAX_AGE_DAYS = 7;
91
+ /** How many stopped prompts are remembered (and persisted). */
92
+ const MAX_STOPPED_PROMPTS = 20;
158
93
 
159
94
  interface PersistedState {
160
95
  version: 1;
96
+ /** Session-only jobs; durable jobs live in .pi/scheduled_tasks.json. */
161
97
  jobs: LoopJob[];
162
98
  /** Prompts whose chain was stopped and must not be rescheduled. */
163
99
  stopped?: string[];
164
- /** Whether the status widget is shown. Claude Code has no such widget. */
165
- widget?: boolean;
166
100
  }
167
101
 
168
102
  function envNumber(name: string, fallback: number): number {
@@ -172,13 +106,12 @@ function envNumber(name: string, fallback: number): number {
172
106
  return Number.isFinite(value) ? value : fallback;
173
107
  }
174
108
 
175
- function maxAgeMs(): number {
176
- const days = envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_MS / 86_400_000);
177
- return Math.max(0, days) * 86_400_000;
109
+ function maxAgeDays(): number {
110
+ return Math.max(0, envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS));
178
111
  }
179
112
 
180
- function jitterFraction(): number {
181
- return Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", 0)));
113
+ function maxAgeMs(): number {
114
+ return maxAgeDays() * 86_400_000;
182
115
  }
183
116
 
184
117
  /**
@@ -190,92 +123,23 @@ function keepaliveSeconds(): number {
190
123
  return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
191
124
  }
192
125
 
193
- function formatDuration(ms: number): string {
194
- if (!Number.isFinite(ms) || ms < 0) return "unknown";
195
- const totalSeconds = Math.round(ms / 1000);
196
- if (totalSeconds < 60) return `${totalSeconds}s`;
197
- const minutes = Math.floor(totalSeconds / 60);
198
- const seconds = totalSeconds % 60;
199
- if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
200
- const hours = Math.floor(minutes / 60);
201
- const restMinutes = minutes % 60;
202
- if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
203
- const days = Math.floor(hours / 24);
204
- return `${days}d${hours % 24}h`;
205
- }
206
-
207
- function formatClock(epochMs: number | undefined): string {
208
- if (!epochMs) return "unknown";
209
- return new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
210
- }
211
-
212
- function truncate(text: string, max: number): string {
213
- const clean = text.replace(/\s+/g, " ").trim();
214
- return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
215
- }
216
-
217
- /** A compact one-line call row for a tool. */
218
- function toolCall(label: string, detail: string, theme: Theme): Text {
219
- const title = theme.fg("toolTitle", theme.bold(label));
220
- return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
221
- }
222
-
223
- /** A compact one-line result row for a tool. */
224
- function toolResult(
225
- summary: string,
226
- detail: string | undefined,
227
- options: { expanded: boolean; isPartial: boolean; isError: boolean },
228
- theme: Theme,
229
- ): Text {
230
- if (options.isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
231
- const prefix = options.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
232
- const body = options.isError ? theme.fg("error", summary) : theme.fg("toolOutput", summary);
233
- const text = prefix + body;
234
- if (options.expanded && detail) return new Text(`${text}\n${theme.fg("dim", detail)}`, 0, 0);
235
- return new Text(text, 0, 0);
236
- }
237
-
238
- /** Hide the call row once the result has arrived, leaving one result line. */
239
- function completedCall(): Container {
240
- return new Container();
241
- }
242
-
243
- /** The first text line of a tool result, used as a fallback summary. */
244
- function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
245
- for (const block of result.content ?? []) {
246
- if (block?.type === "text" && typeof block.text === "string") {
247
- return block.text.split("\n")[0] ?? "";
248
- }
249
- }
250
- return "";
126
+ /** Claude Code's offsets; `PI_LOOP_JITTER=0` runs jobs exactly on their cron minute. */
127
+ function timingConfig(): TimingConfig {
128
+ const fraction = Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", DEFAULT_TIMING.recurringFrac)));
129
+ return {
130
+ ...DEFAULT_TIMING,
131
+ recurringFrac: fraction,
132
+ oneShotMaxMs: fraction > 0 ? DEFAULT_TIMING.oneShotMaxMs : 0,
133
+ };
251
134
  }
252
135
 
253
- function formatDelay(seconds: number | undefined): string {
254
- if (seconds === undefined) return "unknown";
255
- if (seconds < 60) return `${seconds}s`;
256
- return `${Math.round(seconds / 60)}m`;
136
+ function durableSyncMs(): number {
137
+ return Math.max(0, envNumber("PI_LOOP_DURABLE_SYNC_MS", 5000));
257
138
  }
258
139
 
259
- function jobSummary(job: LoopJob): string {
260
- if (job.kind === "cron" && job.cron) {
261
- return `${job.id} ${humanSchedule(job.cron)} · next ${formatClock(job.nextFireAt)}`;
262
- }
263
- return `${job.id} self-paced · ${job.scheduledFor ? `next ${formatClock(job.scheduledFor)}` : "unscheduled"}`;
264
- }
265
-
266
- function resolveLoopFile(cwd: string): { path: string; content: string } | null {
267
- const candidates = [path.join(cwd, ".pi", "loop.md"), path.join(os.homedir(), ".pi", "loop.md")];
268
- for (const candidate of candidates) {
269
- try {
270
- if (fs.existsSync(candidate)) {
271
- const content = fs.readFileSync(candidate, "utf8");
272
- if (content.trim()) return { path: candidate, content };
273
- }
274
- } catch {
275
- // ignore unreadable candidates
276
- }
277
- }
278
- return null;
140
+ /** Models used to Claude Code may echo the prompt with its `/loop ` prefix. */
141
+ function normalizePrompt(prompt: string): string {
142
+ return prompt.trim().replace(/^\/loop\s+/i, "").trim();
279
143
  }
280
144
 
281
145
  /** The live scheduler, for tests. */
@@ -285,53 +149,41 @@ export function getScheduler(): LoopScheduler | null {
285
149
  return activeScheduler;
286
150
  }
287
151
 
288
- /**
289
- * Pull the `--durable` and `--max N` flags out of the command line.
290
- * Flags may appear anywhere; the remaining tokens are the prompt.
291
- */
292
- function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
293
- const tokens = raw.split(/\s+/).filter(Boolean);
294
- const kept: string[] = [];
295
- let durable = false;
296
- let maxFires: number | undefined;
297
-
298
- for (let i = 0; i < tokens.length; i++) {
299
- const token = tokens[i];
300
- if (token.toLowerCase() === "--durable") {
301
- durable = true;
302
- continue;
303
- }
304
- const inline = /^--max=(\d+)$/i.exec(token);
305
- if (inline) {
306
- maxFires = Math.max(1, Number(inline[1]));
307
- continue;
308
- }
309
- if (/^--max$/i.test(token)) {
310
- const next = tokens[i + 1];
311
- if (next && /^\d+$/.test(next)) {
312
- maxFires = Math.max(1, Number(next));
313
- i++;
314
- }
315
- continue;
316
- }
317
- kept.push(token);
318
- }
319
-
320
- return { body: kept.join(" "), durable, maxFires };
321
- }
322
-
323
152
  export default function piCcLoopExtension(pi: ExtensionAPI): void {
153
+ /** Cached context for UI helpers. */
154
+ let lastCtx: ExtensionContext | undefined;
155
+ /**
156
+ * Prompts whose chain was stopped. A stopped chain must not be resurrected
157
+ * by a late `schedule_wakeup` call from a turn that a still-running monitor
158
+ * woke up.
159
+ */
160
+ const stoppedPrompts = new Set<string>();
161
+ /** Signature of the last persisted state, to avoid redundant session entries. */
162
+ let lastPersistedSignature = "";
163
+ /**
164
+ * How many times each prompt has been run as a loop tick. The immediate run
165
+ * of `/loop` and the fires of the job created afterwards share a counter.
166
+ */
167
+ const tickCounters = new Map<string, number>();
168
+ const loopFile = new LoopFileDelivery();
169
+ let durable: DurableRuntime | undefined;
170
+ /**
171
+ * What the current run belongs to, derived from its starting message:
172
+ * `chainId` is the self-paced chain whose tick (or monitor) started it,
173
+ * `cronId` the fixed-interval job whose tick started it, `armed` the chains
174
+ * it armed, `tick` the tick number of a /loop run.
175
+ */
176
+ let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
177
+
324
178
  const scheduler = new LoopScheduler({
325
- dispatch: (job) => {
326
- deliverTick(job);
179
+ dispatch: (job) => deliverTick(job),
180
+ afterDispatch: (job, finished) => {
181
+ if (job.durable) durable?.afterFire(job, finished);
327
182
  },
328
183
  onChange: (jobs) => {
329
184
  persist(jobs);
330
- refreshWidget(jobs);
185
+ refreshStatus(jobs);
331
186
  },
332
- maxAgeMs,
333
- jitterFraction,
334
- jitterCapMs: () => 30 * 60 * 1000,
335
187
  canDispatch: () => {
336
188
  const ctx = lastCtx;
337
189
  if (!ctx) return false;
@@ -341,151 +193,141 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
341
193
  return false;
342
194
  }
343
195
  },
196
+ timing: timingConfig,
197
+ maxAgeMs,
344
198
  onExpire: (job) => {
345
- if (job.kind === "wakeup") {
346
- // The job is already gone from the store; retire it so the chain
347
- // cannot come back and the monitor stops with it.
348
- stoppedPrompts.add(job.prompt);
349
- try {
350
- pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: job.prompt, reason: "expired" });
351
- } catch {
352
- // ignore
353
- }
354
- }
199
+ const ctx = lastCtx;
200
+ if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
201
+ },
202
+ onScan: (now) => {
203
+ durable?.maintain(now);
204
+ // The countdown is coarse, so this is a string compare most seconds.
205
+ refreshStatus();
355
206
  },
356
207
  });
357
208
  activeScheduler = scheduler;
358
209
 
359
- /** Chain that the current run belongs to, injected as a system guideline. */
360
- let runChainId: string | undefined;
361
- /**
362
- * Rules for a loop whose interval the command line did not contain. The
363
- * model applies them itself, the way Claude Code's /loop skill does.
364
- */
365
- let pendingScheduleRules: string | undefined;
366
- /** One-off note attached to the next run. */
367
- let pendingNote: string | undefined;
368
- /** Cached context for UI helpers. */
369
- let lastCtx: ExtensionContext | undefined;
370
- /**
371
- * Prompts whose chain was stopped. A stopped chain must not be resurrected
372
- * by a late `schedule_wakeup` call from a turn that a still-running monitor
373
- * woke up.
374
- */
375
- const stoppedPrompts = new Set<string>();
376
- /**
377
- * Whether the status widget is shown.
378
- *
379
- * Claude Code's main session shows no loop status at all: the job id only
380
- * appears once, in the CronCreate tool result, and the countdown only in
381
- * agent view. The widget is therefore off by default here too; `/loop
382
- * widget on` turns it on for people who want the at-a-glance status that
383
- * agent view provides in Claude Code.
384
- */
385
- let widgetEnabled = process.env.PI_LOOP_WIDGET === "1";
386
- /** Signature of the last persisted state, to avoid empty session entries. */
387
- let lastPersistedSignature = "";
388
-
389
210
  // ---------------------------------------------------------------- helpers
390
211
 
212
+ /** What a job's persisted signature depends on; per-fire bookkeeping is left out. */
213
+ function signatureOf(job: LoopJob): unknown[] {
214
+ if (job.kind === "wakeup") {
215
+ return [job.id, job.kind, job.prompt, job.scheduledFor ?? null, job.keepaliveUsed ?? false, job.chainStartedAt ?? null];
216
+ }
217
+ return [
218
+ job.id,
219
+ job.kind,
220
+ job.cron,
221
+ job.prompt,
222
+ job.recurring,
223
+ job.createdAt,
224
+ job.maxFires ?? null,
225
+ job.maxFires !== undefined ? (job.fireCount ?? 0) : null,
226
+ ];
227
+ }
228
+
229
+ /**
230
+ * Save the session's jobs as a session entry. Durable jobs are left out:
231
+ * they live in .pi/scheduled_tasks.json, and restoring them from a session
232
+ * would run them twice. A new entry is only written when something that
233
+ * matters for a restore changed, not on every fire.
234
+ */
391
235
  function persist(jobs: LoopJob[]): void {
392
- const signature = JSON.stringify(
393
- jobs.map((job) => [
394
- job.id,
395
- job.kind,
396
- job.cron,
397
- job.prompt,
398
- job.recurring,
399
- job.nextFireAt,
400
- job.scheduledFor,
401
- job.keepaliveUsed,
402
- job.awaitingReschedule,
403
- job.durable,
404
- job.reason,
405
- job.maxFires,
406
- job.fireCount,
407
- ]),
408
- );
236
+ const kept = jobs.filter((job) => !job.durable).map(storedJob);
237
+ const stopped = [...stoppedPrompts];
238
+ const signature = JSON.stringify([kept.map(signatureOf), stopped]);
409
239
  if (signature === lastPersistedSignature) return;
410
240
  lastPersistedSignature = signature;
411
-
412
- const durable = jobs.filter((job) => job.durable);
413
241
  try {
414
- pi.appendEntry(STATE_ENTRY, {
415
- version: 1,
416
- jobs,
417
- stopped: [...stoppedPrompts],
418
- widget: widgetEnabled,
419
- } satisfies PersistedState);
242
+ pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
420
243
  } catch {
421
244
  // session persistence is best effort
422
245
  }
246
+ }
247
+
248
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
423
249
  try {
424
- // Use the session cwd, not process.cwd(): the user can move the session
425
- // with /cd without restarting the process.
426
- const dir = path.join(lastCtx?.cwd ?? process.cwd(), ".pi");
427
- const file = path.join(dir, "scheduled_tasks.json");
428
- if (durable.length === 0) {
429
- if (fs.existsSync(file)) fs.rmSync(file);
430
- return;
431
- }
432
- fs.mkdirSync(dir, { recursive: true });
433
- const payload: PersistedState = { version: 1, jobs: durable };
434
- fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
250
+ if (ctx.hasUI) ctx.ui.notify(message, level);
435
251
  } catch {
436
- // durable persistence is best effort
252
+ // a stale context must never break the loop
437
253
  }
438
254
  }
439
255
 
440
- function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
441
- if (ctx.hasUI) ctx.ui.notify(message, level);
256
+ /**
257
+ * Rules for a message we are about to send, keyed by its text. A user
258
+ * message carries no details, so the run's guidelines and the loop it
259
+ * belongs to wait here for before_agent_start to pick them up.
260
+ */
261
+ const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
262
+ [];
263
+
264
+ function queueRun(run: (typeof queuedRuns)[number]): void {
265
+ queuedRuns.push(run);
266
+ if (queuedRuns.length > 8) queuedRuns.shift();
267
+ }
268
+
269
+ function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
270
+ const index = queuedRuns.findIndex((run) => run.content === content);
271
+ return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
272
+ }
273
+
274
+ /**
275
+ * One line in Pi's footer, the same place other extensions show status.
276
+ * Claude Code has no loop status in its footer; a one-line summary is enough
277
+ * to know that something is armed and when it next fires.
278
+ */
279
+ function statusText(jobs: LoopJob[]): string | undefined {
280
+ if (!jobs.length) return undefined;
281
+ const now = Date.now();
282
+ const next = Math.min(
283
+ ...jobs.map((job) => {
284
+ if (job.pending) return now;
285
+ const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
286
+ return at ?? now;
287
+ }),
288
+ );
289
+ return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
442
290
  }
443
291
 
444
- function refreshWidget(jobs: LoopJob[]): void {
292
+ let lastStatusText: string | undefined;
293
+ let statusSet = false;
294
+
295
+ function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
445
296
  const ctx = lastCtx;
446
297
  if (!ctx || !ctx.hasUI) return;
447
- if (!widgetEnabled) {
448
- ctx.ui.setWidget(WIDGET_KEY, undefined);
449
- return;
450
- }
451
- const lines: string[] = [];
452
- for (const job of jobs) {
453
- if (job.kind === "cron") {
454
- const at = job.nextFireAt;
455
- const inText = at ? `in ${formatDuration(at - Date.now())}` : "pending";
456
- lines.push(
457
- ctx.ui.theme.fg(
458
- "accent",
459
- `loop ${job.id} · fixed ${job.cron} · next ${formatClock(at)} (${inText}) · ${truncate(job.prompt, 28)}`,
460
- ),
461
- );
462
- } else {
463
- const at = job.scheduledFor;
464
- const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
465
- lines.push(
466
- ctx.ui.theme.fg("accent", `loop ${job.id} · self-paced · ${inText} · ${truncate(job.prompt, 28)}`),
467
- );
468
- }
298
+ try {
299
+ // Older Pi versions have no footer status API.
300
+ if (typeof ctx.ui.setStatus !== "function") return;
301
+ const text = loadConfig().footer ? statusText(jobs) : undefined;
302
+ // Every call repaints, even with the same text, so speak only on change.
303
+ if (text === lastStatusText && (text !== undefined || !statusSet)) return;
304
+ lastStatusText = text;
305
+ statusSet = text !== undefined;
306
+ ctx.ui.setStatus(STATUS_KEY, text);
307
+ } catch {
308
+ // a stale context must never break the loop
469
309
  }
470
- ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
471
310
  }
472
311
 
473
- function findChain(prompt: string): LoopJob | undefined {
474
- return scheduler.findChain(prompt);
312
+ function rememberStopped(prompt: string): void {
313
+ stoppedPrompts.delete(prompt);
314
+ stoppedPrompts.add(prompt);
315
+ while (stoppedPrompts.size > MAX_STOPPED_PROMPTS) {
316
+ const oldest = stoppedPrompts.values().next().value;
317
+ if (oldest === undefined) break;
318
+ stoppedPrompts.delete(oldest);
319
+ }
475
320
  }
476
321
 
477
322
  /**
478
- * End a chain for good.
479
- *
480
- * Retiring means: the job is removed, the prompt is remembered so a late
481
- * `schedule_wakeup` cannot resurrect the chain, and pi-cc-monitor is told to
482
- * stop any monitor that belongs to it. Claude Code requires the last part:
483
- * "call ScheduleWakeup with stop: true and TaskStop any Monitor you armed".
484
- * Without it, a monitor that keeps firing wakes the agent, the agent
323
+ * End a self-paced chain for good: the job is removed, the prompt is
324
+ * remembered so a late `schedule_wakeup` cannot resurrect it, and
325
+ * pi-cc-monitor is told to stop the monitors that belong to it. Without the
326
+ * last part a monitor that keeps firing wakes the agent, the agent
485
327
  * reschedules, and the loop the user just stopped comes back.
486
328
  */
487
- function retireChain(chain: LoopJob, reason: string): void {
488
- stoppedPrompts.add(chain.prompt);
329
+ function retireChain(chain: LoopJob, reason: string, options: { silent?: boolean } = {}): void {
330
+ rememberStopped(chain.prompt);
489
331
  scheduler.remove(chain.id);
490
332
  try {
491
333
  pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
@@ -493,105 +335,133 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
493
335
  // a listener must never break the loop
494
336
  }
495
337
  const ctx = lastCtx;
496
- if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
338
+ if (ctx && !options.silent) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
497
339
  }
498
340
 
499
- function endChain(chain: LoopJob, reason: string): void {
500
- retireChain(chain, reason);
341
+ function ensureDurable(): DurableRuntime {
342
+ durable ??= createDurable(lastCtx?.cwd ?? process.cwd());
343
+ return durable;
501
344
  }
502
345
 
503
- function expireOldJobs(ctx: ExtensionContext): void {
504
- const limit = maxAgeMs();
505
- if (limit <= 0) return;
506
- const now = Date.now();
507
- for (const job of scheduler.list()) {
508
- if (now - job.createdAt < limit) continue;
509
- if (job.kind === "wakeup") {
510
- retireChain(job, "expired");
511
- } else {
512
- scheduler.remove(job.id);
513
- notify(ctx, `loop ${job.id} expired after ${Math.round(limit / 86_400_000)} days`, "info");
514
- }
346
+ function createDurable(cwd: string): DurableRuntime {
347
+ return new DurableRuntime({
348
+ cwd,
349
+ scheduler,
350
+ timing: timingConfig,
351
+ maxAgeMs,
352
+ syncMs: durableSyncMs,
353
+ onMissed: reportMissed,
354
+ });
355
+ }
356
+
357
+ /** Add a job; a durable job is saved to disk and may run in another session. */
358
+ function addJob(job: LoopJob): Placement {
359
+ if (job.durable) return ensureDurable().add(job);
360
+ scheduler.add(job);
361
+ return { runsHere: true };
362
+ }
363
+
364
+ function removeJob(job: LoopJob): void {
365
+ scheduler.remove(job.id);
366
+ if (job.durable) durable?.remove(job.id);
367
+ }
368
+
369
+ /** This session's jobs plus the durable jobs it can see but does not run. */
370
+ function listJobs(): Array<LoopJob & { foreignReason?: "other-session" | "other-folder" }> {
371
+ return [...scheduler.list(), ...(durable?.foreignJobs() ?? [])];
372
+ }
373
+
374
+ function foreignNote(job: { foreignReason?: string }): string {
375
+ if (job.foreignReason === "other-folder") return " (copied from another folder: listed, not run here)";
376
+ if (job.foreignReason === "other-session") return " (run by another Pi session in this project)";
377
+ return "";
378
+ }
379
+
380
+ /** Durable one-shots that came due while no session ran them: ask before running. */
381
+ function reportMissed(jobs: LoopJob[]): void {
382
+ const lines = jobs.map((job) => {
383
+ const due = nextRunTime(job, job.createdAt, timingConfig());
384
+ const when = due
385
+ ? ` (was due ${new Date(due).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })})`
386
+ : "";
387
+ return `- job ${job.id}${when}: ${displayPrompt(job.prompt)}`;
388
+ });
389
+ try {
390
+ // Attached to the next turn rather than starting one at startup.
391
+ pi.sendMessage(
392
+ {
393
+ customType: TICK_MESSAGE_TYPE,
394
+ content: missedJobsMessage(lines),
395
+ display: true,
396
+ details: { missed: true, prompt: lines.join("\n") },
397
+ },
398
+ { deliverAs: "followUp" },
399
+ );
400
+ } catch {
401
+ // best effort
402
+ }
403
+ const ctx = lastCtx;
404
+ if (ctx) {
405
+ notify(
406
+ ctx,
407
+ `${jobs.length} durable one-shot job${jobs.length === 1 ? " was" : "s were"} missed while Pi was closed; you will be asked before ${jobs.length === 1 ? "it runs" : "they run"}`,
408
+ "warning",
409
+ );
515
410
  }
516
411
  }
517
412
 
518
413
  /**
519
- * Deliver a scheduled tick.
520
- *
521
- * `followUp` is the low-priority lane: when the agent is busy the message
522
- * waits until the current turn ends instead of cutting into it. That is
523
- * Claude Code's "A scheduled prompt fires between your turns, not while
524
- * Claude is mid-response."
414
+ * A scheduled fire is a custom message, not a user message: the provenance
415
+ * line in its content is addressed to the model, and only a custom message
416
+ * can draw something else for the reader (`renderScheduledMessage`).
417
+ */
418
+ const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
419
+
420
+ /** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
421
+ function resolvePrompt(prompt: string, mode: TickMode): string {
422
+ return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
423
+ }
424
+
425
+ /**
426
+ * Deliver a scheduled tick. `followUp` is the low-priority lane: Claude
427
+ * Code's "A scheduled prompt fires between your turns, not while Claude is
428
+ * mid-response."
525
429
  */
526
430
  function deliverTick(job: LoopJob): void {
527
- // Continue the shared per-prompt counter: the immediate run of /loop and
528
- // the first fire of the job the model created afterwards are one loop.
529
431
  const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
530
432
  tickCounters.set(job.prompt, tick);
531
- scheduler.update(job.id, { tickCount: tick, lastTickAt: Date.now() });
532
- const content = `${PROVENANCE.replace("{n}", String(tick))}\n${job.prompt}`;
533
- // A self-paced tick needs the chain guidelines too, so the model knows
534
- // it has to schedule the next iteration.
535
- runChainId = job.kind === "wakeup" ? job.id : undefined;
433
+ job.tickCount = tick;
434
+ const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
435
+ // A fixed-interval tick is what it is - a scheduled task - and a
436
+ // self-paced tick keeps the loop framing. The rules for the run are
437
+ // rebuilt from `details` in before_agent_start.
438
+ const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
439
+ const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
536
440
  pi.sendMessage(
537
441
  {
538
442
  customType: TICK_MESSAGE_TYPE,
539
- content,
443
+ content: `${stamp}\n${body}`,
540
444
  display: true,
541
- details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
445
+ details: {
446
+ kind: job.kind,
447
+ tickCount: tick,
448
+ jobId: job.id,
449
+ cron: job.cron,
450
+ prompt: body,
451
+ reason: job.reason,
452
+ },
542
453
  },
454
+ // `triggerTurn` is what starts the run: without it the message is
455
+ // only recorded and nothing ever happens.
543
456
  { deliverAs: "followUp", triggerTurn: true },
544
457
  );
545
458
 
546
- const cap = job.maxFires;
547
- if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
459
+ if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
548
460
  const ctx = lastCtx;
549
- if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${cap} fires`, "info");
461
+ if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${job.maxFires} fires`, "info");
550
462
  }
551
463
  }
552
464
 
553
- /**
554
- * Run the prompt right away, the way Claude Code's skill does
555
- * ("Then immediately execute the parsed prompt now").
556
- *
557
- * `steer` is the right lane here: it lands in the current turn when the
558
- * agent is already working, which is what executing the prompt inside the
559
- * skill turn means.
560
- */
561
- /**
562
- * How many times each prompt has been run as a loop tick.
563
- *
564
- * The immediate run of `/loop` and the first fire of the job the model
565
- * creates afterwards belong to the same loop, so they share one counter.
566
- */
567
- const tickCounters = new Map<string, number>();
568
-
569
- function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
570
- const tick = (tickCounters.get(prompt) ?? 0) + 1;
571
- tickCounters.set(prompt, tick);
572
- if (job) {
573
- scheduler.update(job.id, {
574
- tickCount: tick,
575
- fireCount: (job.fireCount ?? 0) + 1,
576
- lastTickAt: Date.now(),
577
- });
578
- }
579
- runChainId = job?.kind === "wakeup" ? job.id : undefined;
580
- pi.sendMessage(
581
- {
582
- customType: TICK_MESSAGE_TYPE,
583
- content: `${PROVENANCE.replace("{n}", String(tick))}\n${prompt}`,
584
- display: true,
585
- details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
586
- },
587
- { deliverAs: "steer", triggerTurn: true },
588
- );
589
- }
590
-
591
- function scheduleNote(text: string): void {
592
- pendingNote = text;
593
- }
594
-
595
465
  /** True when the pi-cc-monitor extension registered its Monitor tool. */
596
466
  function monitorToolAvailable(): boolean {
597
467
  try {
@@ -602,44 +472,84 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
602
472
  }
603
473
 
604
474
  /** The tool names to exclude from pi-cc-extensions' global styling. */
605
- function piCcToolNames(): string[] {
606
- return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
475
+ /**
476
+ * A run finished without an abort: self-paced chains whose tick the model
477
+ * did not reschedule get their one keepalive, or end.
478
+ */
479
+ function settleChains(ctx: ExtensionContext): void {
480
+ const now = Date.now();
481
+ for (const chain of scheduler.byKind("wakeup")) {
482
+ // The keepalive turn is the model's last chance to reschedule.
483
+ if (chain.keepaliveTurn) {
484
+ retireChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
485
+ continue;
486
+ }
487
+ if (!chain.awaitingReschedule) continue;
488
+
489
+ const limit = maxAgeMs();
490
+ if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
491
+ retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
492
+ continue;
493
+ }
494
+ if (chain.keepaliveUsed) continue;
495
+
496
+ const fallback = keepaliveSeconds();
497
+ if (fallback <= 0) {
498
+ retireChain(chain, "the model did not reschedule (keepalive disabled)");
499
+ continue;
500
+ }
501
+ scheduler.update(chain.id, {
502
+ scheduledFor: now + fallback * 1000,
503
+ keepaliveUsed: true,
504
+ awaitingReschedule: false,
505
+ });
506
+ notify(
507
+ ctx,
508
+ `loop ${chain.id}: the model did not reschedule \u2014 keepalive armed (${Math.round(fallback / 60)} min)`,
509
+ "warning",
510
+ );
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Esc interrupted a turn that belongs to a self-paced loop (its tick, the
516
+ * /loop run that armed it, or a turn its monitor woke): cancel that loop,
517
+ * with Claude Code's notice ("A `Pending wakeup cancelled` notice confirms
518
+ * that the loop won't fire again").
519
+ *
520
+ * Claude Code cancels every self-paced loop on any Esc, idle or not. Here
521
+ * an interrupted unrelated turn leaves the loops alone; `/loop stop` stops
522
+ * them. Fixed-interval jobs keep running either way, as in Claude Code.
523
+ */
524
+ function cancelCurrentLoop(ctx: ExtensionContext): void {
525
+ const ids = new Set([...(currentRun.chainId ? [currentRun.chainId] : []), ...(currentRun.armed ?? [])]);
526
+ const chains = [...ids]
527
+ .map((id) => scheduler.get(id))
528
+ .filter((chain): chain is LoopJob => chain?.kind === "wakeup");
529
+ if (!chains.length) return;
530
+ for (const chain of chains) retireChain(chain, "pending wakeup cancelled (Esc)", { silent: true });
531
+ notify(ctx, `Pending wakeup cancelled \u00b7 loop ${chains.map((chain) => chain.id).join(", ")} ended`, "info");
607
532
  }
608
533
 
609
534
  // ---------------------------------------------------------------- command
610
535
 
611
536
  pi.registerCommand("loop", {
612
- description:
613
- "Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
537
+ description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
614
538
  handler: async (args: string, ctx: ExtensionCommandContext) => {
615
539
  lastCtx = ctx;
616
540
  const raw = args.trim();
617
-
618
- if (!raw) {
619
- await startLoop(ctx, null, "", false, undefined);
620
- return;
621
- }
622
-
623
541
  const [head, ...rest] = raw.split(/\s+/);
624
- switch (head.toLowerCase()) {
625
- case "stop":
626
- await stopLoops(ctx, rest.join(" "));
627
- return;
628
- case "status":
629
- case "list":
630
- case "jobs":
631
- showStatus(ctx);
632
- return;
633
- case "widget": {
542
+
543
+ // Settings configure the extension, not a loop: they stay in code.
544
+ switch ((head ?? "").toLowerCase()) {
545
+ case "footer":
546
+ case "statusline": {
634
547
  const arg = (rest[0] ?? "").toLowerCase();
635
- if (arg !== "on" && arg !== "off") {
636
- notify(ctx, `Usage: /loop widget on|off (currently ${widgetEnabled ? "on" : "off"})`, "info");
548
+ if (!arg) {
549
+ notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
637
550
  return;
638
551
  }
639
- widgetEnabled = arg === "on";
640
- persist(scheduler.list());
641
- refreshWidget(scheduler.list());
642
- notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
552
+ setSetting(ctx, "footer", arg);
643
553
  return;
644
554
  }
645
555
  case "settings":
@@ -654,11 +564,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
654
564
  setSetting(ctx, "messages", value);
655
565
  return;
656
566
  }
657
- if (name === "tools" || name === "tool" || name === "rows") {
658
- setSetting(ctx, "tools", value);
567
+ if (name === "footer" || name === "status") {
568
+ setSetting(ctx, "footer", value);
659
569
  return;
660
570
  }
661
- notify(ctx, "Usage: /loop settings [messages|tools] [on|off]", "info");
571
+ notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
662
572
  return;
663
573
  }
664
574
  case "help":
@@ -670,210 +580,52 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
670
580
  break;
671
581
  }
672
582
 
673
- // Flags are stripped before parsing so they never interfere with the
674
- // interval token.
675
- const flags = extractFlags(raw);
676
- const body = flags.body;
677
-
678
- let intervalSeconds: number | null = null;
679
- let prompt = body;
680
-
681
- // Rule 1: leading interval token, e.g. "5m check the deploy"
682
- const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
683
- if (leading) {
684
- const parsed = parseIntervalToken(leading[1]);
685
- if (parsed !== null) {
686
- intervalSeconds = parsed;
687
- prompt = leading[2].trim();
688
- }
689
- }
690
-
691
- // Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
692
- if (intervalSeconds === null) {
693
- const every = parseEveryClause(body);
694
- if (every) {
695
- intervalSeconds = every.interval;
696
- prompt = every.prompt;
697
- }
698
- }
699
-
700
- // Rule 3: no interval -> self-paced
701
- await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
583
+ // Everything else becomes a normal user message, the way Claude Code's
584
+ // /loop skill does: the conversation keeps the request, and the model
585
+ // drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
586
+ // The per-run rules travel with the run through before_agent_start.
587
+ const text = raw ? `/loop ${raw}` : "/loop";
588
+ queueRun({ content: text, guidelines: [loopCommandGuidelines(raw)] });
589
+ // Pi runs an extension command even while a turn is streaming, and its
590
+ // sendUserMessage throws unless a lane is named: "Agent is already
591
+ // processing. Specify streamingBehavior". `followUp` is that lane - the
592
+ // request waits for the current turn to end, which is where a /loop
593
+ // belongs anyway. When idle the option is ignored and the turn starts.
594
+ pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
702
595
  },
703
596
  });
704
597
 
705
- async function startLoop(
706
- ctx: ExtensionCommandContext,
707
- intervalSeconds: number | null,
708
- prompt: string,
709
- durable: boolean,
710
- maxFires: number | undefined,
711
- ): Promise<void> {
712
- expireOldJobs(ctx);
713
-
714
- // Starting a chain again clears the stop marker: the user asked for it.
715
- stoppedPrompts.delete(prompt);
716
-
717
- let finalPrompt = prompt;
718
-
719
- if (!finalPrompt) {
720
- const loopFile = resolveLoopFile(ctx.cwd);
721
- if (loopFile) {
722
- finalPrompt = `Work the tasks from the loop file at ${loopFile.path}:\n\n${loopFile.content}`;
723
- } else {
724
- finalPrompt = MAINTENANCE_PROMPT;
725
- }
726
- }
727
-
728
- if (intervalSeconds === null) {
729
- // The interval could not be parsed from the command line. Claude Code's
730
- // /loop is a skill, so the model reads the input and works the schedule
731
- // out itself; the rules below let it do the same here. No chain is
732
- // created up front: the model picks cron_create (fixed interval) or
733
- // schedule_wakeup (open ended), and schedule_wakeup creates the chain.
734
- let rules = LOOP_SCHEDULE_RULES.replace("{prompt}", finalPrompt).replace(
735
- "{quoted}",
736
- `"${finalPrompt}"`,
737
- );
738
- if (durable) {
739
- rules +=
740
- "\n- The user asked for the job to survive a restart: create it with `durable: true`.";
741
- }
742
- if (maxFires !== undefined) {
743
- rules +=
744
- `\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
745
- }
746
- pendingScheduleRules = rules;
747
- runNow(ctx, finalPrompt);
748
- return;
749
- }
750
-
751
- const spec = intervalToCron(intervalSeconds);
752
- if (!spec) {
753
- notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
754
- return;
755
- }
756
- if (!parseCron(spec.cron)) {
757
- notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
758
- return;
759
- }
760
- if (scheduler.size >= MAX_JOBS) {
761
- notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
762
- return;
763
- }
764
-
765
- const job = scheduler.add({
766
- id: generateId(),
767
- kind: "cron",
768
- cron: spec.cron,
769
- prompt: finalPrompt,
770
- recurring: true,
771
- createdAt: Date.now(),
772
- durable,
773
- maxFires,
774
- label: humanSchedule(spec.cron),
775
- source: "loop_command",
776
- });
777
-
778
- const expiryDays = Math.round(maxAgeMs() / 86_400_000);
779
- notify(
780
- ctx,
781
- `Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)}) · ${spec.cron} · expires in ${expiryDays}d · cancel with /loop stop ${job.id}` +
782
- (spec.note ? ` · ${spec.note}` : ""),
783
- "info",
784
- );
785
- scheduleNote(
786
- `A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
787
- `next run ${formatClock(job.nextFireAt)}, auto-expires after ${expiryDays} days. ` +
788
- `The user cancels it with \`/loop stop ${job.id}\`.` +
789
- (spec.note ? ` The requested interval was adjusted: ${spec.note}.` : ""),
790
- );
791
-
792
- runNow(ctx, finalPrompt, job);
793
- }
794
-
795
- async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
796
- if (target) {
797
- const job = scheduler.get(target);
798
- if (!job) {
799
- notify(ctx, `No loop with id ${target}`, "warning");
800
- return;
801
- }
802
- if (job.kind === "wakeup") {
803
- retireChain(job, "stopped by the user");
804
- } else {
805
- scheduler.remove(target);
806
- notify(ctx, `Cancelled loop ${target}`, "info");
807
- }
808
- return;
809
- }
810
- const jobs = scheduler.list();
811
- if (!jobs.length) {
812
- notify(ctx, "No active loops", "info");
813
- return;
814
- }
815
- for (const job of jobs) {
816
- if (job.kind === "wakeup") {
817
- // Retiring also tells pi-cc-monitor to stop the monitors that
818
- // belong to this chain.
819
- retireChain(job, "stopped by the user");
820
- } else {
821
- scheduler.remove(job.id);
822
- }
823
- }
824
- notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
825
- }
826
-
827
- function showStatus(ctx: ExtensionContext): void {
828
- const jobs = scheduler.list();
829
- if (!jobs.length) {
830
- notify(ctx, "No active loops", "info");
831
- return;
832
- }
833
- const lines = jobs.map((job) => {
834
- if (job.kind === "cron") {
835
- const cap = job.maxFires !== undefined ? ` · ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
836
- return `${job.id} cron ${job.cron} (${job.label ?? describeCron(job.cron ?? "")}) next ${formatClock(job.nextFireAt)}${cap} ${truncate(job.prompt, 60)}`;
837
- }
838
- const at = job.scheduledFor;
839
- const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
840
- return `${job.id} self-paced ${inText} ${truncate(job.prompt, 60)}`;
841
- });
842
- if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
843
- }
844
-
845
598
  function showHelp(ctx: ExtensionContext): void {
846
599
  const help = [
847
600
  "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
848
601
  "/loop <prompt> self-paced: the agent picks the next delay",
849
- "/loop use .pi/loop.md, or the built-in maintenance prompt",
850
- "/loop stop [id] cancel one loop, or all of them",
851
- "/loop status list the active loops",
852
- "/loop widget on|off show or hide the status line above the editor",
602
+ "/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
603
+ "/loop footer on|off show or hide the one-line summary in the footer",
853
604
  "/loop settings show UI settings",
854
605
  "/loop settings messages on|off compact two-line markers",
855
- "/loop settings tools on|off one-line tool results",
606
+ "/loop settings footer on|off the one-line footer summary",
856
607
  "",
608
+ "Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
857
609
  "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
858
610
  " --max N stop the loop after N fires.",
859
611
  ].join("\n");
860
- if (ctx.hasUI) ctx.ui.notify(help, "info");
612
+ notify(ctx, help, "info");
861
613
  }
862
614
 
863
615
  function showSettings(ctx: ExtensionContext): void {
864
616
  const config = loadConfig();
865
617
  const lines = [
866
- `pi-cc settings · ${configPath()}`,
618
+ `pi-cc settings \u00b7 ${configPath()}`,
867
619
  ` messages: ${config.compactMessages ? "compact" : "card"}`,
868
- ` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
620
+ ` footer: ${config.footer ? "summary" : "hidden"}`,
869
621
  "",
870
622
  "Usage: /loop settings messages on|off",
871
- " /loop settings tools on|off",
623
+ " /loop settings footer on|off",
872
624
  ];
873
- if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
625
+ notify(ctx, lines.join("\n"), "info");
874
626
  }
875
627
 
876
- function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
628
+ function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
877
629
  const value = (raw ?? "").toLowerCase();
878
630
  const on = value === "on" || value === "true" || value === "1";
879
631
  const off = value === "off" || value === "false" || value === "0";
@@ -881,670 +633,526 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
881
633
  showSettings(ctx);
882
634
  return;
883
635
  }
884
- const patch: Partial<PiCcConfig> =
885
- key === "messages" ? { compactMessages: on } : { compactToolRows: on };
636
+ const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
886
637
  const next = saveConfig(patch);
887
- let compatChanged = false;
888
- if (key === "tools") {
889
- compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
890
- }
891
638
  try {
892
639
  pi.events.emit(CONFIG_CHANNEL, next);
893
640
  } catch {
894
641
  // another extension must not break the command
895
642
  }
896
- notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
897
- if (compatChanged) {
898
- notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
899
- }
643
+ notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
644
+ if (key === "footer") refreshStatus();
900
645
  }
901
646
 
902
647
  // ------------------------------------------------------------------ tools
903
648
 
904
- function registerTools(): void {
905
-
906
- pi.registerTool({
907
- name: "cron_create",
908
- label: "Create Scheduled Job",
909
- description:
910
- "Schedule a prompt to run on a 5-field cron expression in local time. Use this for fixed-interval " +
911
- "work such as polling a deployment or re-running a check. One-shot reminders should set " +
912
- "recurring to false. The job id can be cancelled with cron_delete.",
913
- parameters: Type.Object({
914
- cron: Type.String({
649
+ pi.registerTool({
650
+ name: "cron_create",
651
+ // Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
652
+ label: "CronCreate",
653
+ description: CRON_CREATE_DESCRIPTION,
654
+ promptSnippet: CRON_CREATE_SNIPPET,
655
+ parameters: Type.Object({
656
+ cron: Type.String({
657
+ description:
658
+ 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
659
+ }),
660
+ prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
661
+ recurring: Type.Optional(
662
+ Type.Boolean({
915
663
  description:
916
- 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
664
+ "true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete.",
917
665
  }),
918
- prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
919
- recurring: Type.Optional(
920
- Type.Boolean({
921
- description:
922
- "true (default) = fire on every cron match until deleted or auto-expired. false = fire once at the next match, then auto-delete.",
923
- }),
924
- ),
925
- durable: Type.Optional(
926
- Type.Boolean({ description: "Write the job to .pi/scheduled_tasks.json so it survives a restart." }),
927
- ),
928
- maxFires: Type.Optional(
929
- Type.Number({
930
- minimum: 1,
931
- description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
932
- }),
933
- ),
934
- }),
935
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
936
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
937
- lastCtx = ctx;
938
- const fields = parseCron(params.cron);
939
- if (!fields) {
940
- throw new Error(
941
- `Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`,
942
- );
943
- }
944
- if (isUnreachable(params.cron)) {
945
- throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
946
- }
947
- if (scheduler.size >= MAX_JOBS) {
948
- throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
949
- }
950
- if (!params.prompt?.trim()) {
951
- throw new Error("prompt must not be empty.");
952
- }
666
+ ),
667
+ durable: Type.Optional(
668
+ Type.Boolean({
669
+ description:
670
+ "true = persist to .pi/scheduled_tasks.json and survive restarts. false (default) = this session only. Use true only when the user asks the task to survive across sessions.",
671
+ }),
672
+ ),
673
+ maxFires: Type.Optional(
674
+ Type.Integer({
675
+ minimum: 1,
676
+ description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
677
+ }),
678
+ ),
679
+ }),
680
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
681
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
682
+ lastCtx = ctx;
683
+ if (!parseCron(params.cron)) {
684
+ throw new Error(`Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`);
685
+ }
686
+ if (isUnreachable(params.cron)) {
687
+ throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
688
+ }
689
+ if (scheduler.size >= MAX_JOBS) {
690
+ throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
691
+ }
692
+ if (!params.prompt?.trim()) {
693
+ throw new Error("prompt must not be empty.");
694
+ }
953
695
 
954
- const job = scheduler.add({
955
- id: generateId(),
956
- kind: "cron",
957
- cron: params.cron.trim(),
958
- prompt: params.prompt,
959
- recurring: params.recurring ?? true,
960
- createdAt: Date.now(),
961
- durable: params.durable ?? false,
962
- maxFires: params.maxFires,
963
- // Continue the tick numbering of the /loop run that led here.
964
- tickCount: tickCounters.get(params.prompt) ?? 0,
965
- label: humanSchedule(params.cron),
966
- source: "cron_create",
967
- });
696
+ const recurring = params.recurring ?? true;
697
+ const job: LoopJob = {
698
+ id: generateId(),
699
+ kind: "cron",
700
+ cron: params.cron.trim(),
701
+ prompt: params.prompt,
702
+ recurring,
703
+ createdAt: Date.now(),
704
+ durable: params.durable ?? false,
705
+ maxFires: params.maxFires,
706
+ // Continue the tick numbering of the /loop run that led here.
707
+ tickCount: tickCounters.get(params.prompt) ?? currentRun.tick ?? 0,
708
+ label: humanSchedule(params.cron),
709
+ source: "cron_create",
710
+ };
711
+ const placement = addJob(job);
712
+ const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
713
+
714
+ const lines = [`Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)})`];
715
+ if (job.durable) {
716
+ lines.push(
717
+ placement.runsHere
718
+ ? "Durable: saved to .pi/scheduled_tasks.json, survives restarts."
719
+ : `Durable: saved to .pi/scheduled_tasks.json. Another Pi session in this project (pid ${placement.holderPid ?? "?"}) holds the scheduler lock and runs it.`,
720
+ );
721
+ } else {
722
+ lines.push("Session-only: restored if this session is resumed, gone otherwise.");
723
+ }
724
+ if (recurring) {
725
+ lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
726
+ }
727
+ lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
728
+
729
+ return {
730
+ content: [{ type: "text", text: lines.join("\n") }],
731
+ details: {
732
+ id: job.id,
733
+ cron: job.cron,
734
+ recurring: job.recurring,
735
+ durable: job.durable,
736
+ nextFireAt: nextAt,
737
+ prompt: job.prompt,
738
+ runsHere: placement.runsHere,
739
+ },
740
+ };
741
+ },
742
+ });
968
743
 
744
+ pi.registerTool({
745
+ name: "cron_delete",
746
+ label: "CronDelete",
747
+ description: CRON_DELETE_DESCRIPTION,
748
+ promptSnippet: CRON_DELETE_SNIPPET,
749
+ parameters: Type.Object({
750
+ id: Type.String({ description: "The job id returned by cron_create, or the id of a self-paced loop." }),
751
+ }),
752
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
753
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
754
+ lastCtx = ctx;
755
+ const job = scheduler.get(params.id);
756
+ if (job) {
757
+ // A self-paced chain ends the same way /loop stop ends it.
758
+ if (job.kind === "wakeup") retireChain(job, "cancelled with cron_delete");
759
+ else removeJob(job);
760
+ } else if (!durable?.remove(params.id)) {
761
+ return {
762
+ content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
763
+ details: { cancelled: false, id: params.id },
764
+ };
765
+ }
766
+ return {
767
+ content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
768
+ details: { cancelled: true, id: params.id },
769
+ };
770
+ },
771
+ });
772
+
773
+ pi.registerTool({
774
+ name: "cron_list",
775
+ label: "CronList",
776
+ description: CRON_LIST_DESCRIPTION,
777
+ promptSnippet: CRON_LIST_SNIPPET,
778
+ parameters: Type.Object({}),
779
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
780
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
781
+ lastCtx = ctx;
782
+ const jobs = listJobs();
783
+ if (!jobs.length) {
784
+ return {
785
+ content: [{ type: "text" as const, text: "No scheduled jobs." }],
786
+ details: { jobs: [] },
787
+ };
788
+ }
789
+ const text = jobs
790
+ .map((job) => {
791
+ const prompt = truncate(displayPrompt(job.prompt), 80);
792
+ if (job.kind === "cron") {
793
+ return `${job.id} cron ${job.cron} ${humanSchedule(job.cron ?? "")} recurring=${job.recurring} next ${formatClock(job.nextFireAt)}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
794
+ }
795
+ return `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${prompt}`;
796
+ })
797
+ .join("\n");
798
+ return {
799
+ content: [{ type: "text" as const, text }],
800
+ details: { jobs: jobs.map((job) => storedJob(job)) },
801
+ };
802
+ },
803
+ });
804
+
805
+ pi.registerTool({
806
+ name: "schedule_wakeup",
807
+ label: "ScheduleWakeup",
808
+ description: SCHEDULE_WAKEUP_DESCRIPTION,
809
+ promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
810
+ parameters: Type.Object({
811
+ delaySeconds: Type.Optional(
812
+ Type.Number({
813
+ description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
814
+ }),
815
+ ),
816
+ reason: Type.Optional(
817
+ Type.String({
818
+ description:
819
+ "One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
820
+ }),
821
+ ),
822
+ prompt: Type.Optional(
823
+ Type.String({
824
+ description: `The /loop input to fire on wake-up, verbatim, so the next firing repeats the task. For a bare /loop pass the literal sentinel "${LOOP_FILE_SENTINEL}".`,
825
+ }),
826
+ ),
827
+ stop: Type.Optional(
828
+ Type.Boolean({
829
+ description: "Set to true to end the self-paced loop immediately. Omit every other field.",
830
+ }),
831
+ ),
832
+ }),
833
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
834
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
835
+ lastCtx = ctx;
836
+
837
+ if (params.stop === true) {
838
+ // Stop the loop this turn belongs to; otherwise the named one, or all.
839
+ const current = currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined;
840
+ const named = params.prompt ? scheduler.findChain(normalizePrompt(params.prompt)) : undefined;
841
+ const chains = current ? [current] : named ? [named] : scheduler.byKind("wakeup");
842
+ for (const chain of chains) retireChain(chain, "stopped by the agent");
969
843
  return {
970
844
  content: [
971
845
  {
972
- type: "text",
973
- text:
974
- `Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)})\n` +
975
- `Cancel it with cron_delete { "id": "${job.id}" }.`,
846
+ type: "text" as const,
847
+ text: chains.length
848
+ ? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
849
+ "Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
850
+ : "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
851
+ "If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
976
852
  },
977
853
  ],
978
- details: {
979
- id: job.id,
980
- cron: job.cron,
981
- recurring: job.recurring,
982
- durable: job.durable,
983
- nextFireAt: job.nextFireAt,
984
- prompt: job.prompt,
985
- },
854
+ details: { stopped: true, cancelledWakeups: chains.length },
986
855
  };
987
- },
988
- });
856
+ }
989
857
 
990
- pi.registerTool({
991
- name: "cron_delete",
992
- label: "Cancel Scheduled Job",
993
- description: "Cancel a scheduled job by its id. Use it to stop a recurring /loop.",
994
- parameters: Type.Object({
995
- id: Type.String({ description: "The job id returned by cron_create." }),
996
- }),
997
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
998
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
999
- lastCtx = ctx;
1000
- const job = scheduler.get(params.id);
1001
- if (!job) {
1002
- return {
1003
- content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
1004
- details: { cancelled: false, id: params.id },
1005
- };
1006
- }
1007
- scheduler.remove(params.id);
1008
- return {
1009
- content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
1010
- details: { cancelled: true, id: params.id },
1011
- };
1012
- },
1013
- });
858
+ if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
859
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
860
+ }
861
+ if (!params.reason || !params.reason.trim()) {
862
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
863
+ }
864
+ const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
865
+ if (!prompt) {
866
+ throw new Error("`prompt` is required when `stop` is not true.");
867
+ }
1014
868
 
1015
- pi.registerTool({
1016
- name: "cron_list",
1017
- label: "List Scheduled Jobs",
1018
- description: "List the scheduled jobs of this session with their ids, cron expressions and next run times.",
1019
- parameters: Type.Object({}),
1020
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1021
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1022
- lastCtx = ctx;
1023
- const jobs = scheduler.list();
1024
- if (!jobs.length) {
1025
- return {
1026
- content: [{ type: "text" as const, text: "No scheduled jobs." }],
1027
- details: { jobs: [] },
1028
- };
1029
- }
1030
- const text = jobs
1031
- .map((job) =>
1032
- job.kind === "cron"
1033
- ? `${job.id} cron ${job.cron} recurring=${job.recurring} next ${formatClock(job.nextFireAt)} ${truncate(job.prompt, 80)}`
1034
- : `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${truncate(job.prompt, 80)}`,
1035
- )
1036
- .join("\n");
869
+ if (currentRun.cronId) {
870
+ // Not an error: the request is already satisfied by the schedule.
871
+ // Returning it as a result (instead of throwing) keeps the transcript
872
+ // calm and tells the model nothing is missing.
873
+ const cronJob = scheduler.get(currentRun.cronId);
1037
874
  return {
1038
- content: [{ type: "text" as const, text }],
1039
- details: { jobs },
875
+ content: [
876
+ {
877
+ type: "text" as const,
878
+ text:
879
+ `No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
880
+ `${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
881
+ `Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
882
+ },
883
+ ],
884
+ details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
1040
885
  };
1041
- },
1042
- });
886
+ }
1043
887
 
1044
- pi.registerTool({
1045
- name: "schedule_wakeup",
1046
- label: "Schedule Next Loop Iteration",
1047
- description:
1048
- "Schedule the next iteration of a self-paced /loop, or end the loop. " +
1049
- "Pass delaySeconds (60-3600), a one-sentence reason, and the same /loop prompt verbatim. " +
1050
- "Pass stop: true when the task is complete or can no longer make progress.",
1051
- parameters: Type.Object({
1052
- delaySeconds: Type.Optional(
1053
- Type.Number({
1054
- description:
1055
- "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
1056
- }),
1057
- ),
1058
- reason: Type.Optional(
1059
- Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
1060
- ),
1061
- prompt: Type.Optional(
1062
- Type.String({
1063
- description:
1064
- "The /loop input to fire on wake-up. Pass the same /loop input verbatim each turn so the next firing continues the loop.",
1065
- }),
1066
- ),
1067
- stop: Type.Optional(
1068
- Type.Boolean({
1069
- description: "Set to true to end the self-paced loop immediately. All other fields are ignored.",
1070
- }),
1071
- ),
1072
- }),
1073
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1074
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1075
- lastCtx = ctx;
1076
-
1077
- if (params.stop === true) {
1078
- const chains = scheduler.byKind("wakeup");
1079
- for (const chain of chains) {
1080
- // Retiring also stops the monitors that belong to this chain.
1081
- retireChain(chain, "stopped by the agent");
1082
- }
888
+ // Claude Code rounds and clamps; only a change after rounding counts as clamped.
889
+ const requested = params.delaySeconds;
890
+ const rounded = Number.isFinite(requested) ? Math.round(requested) : requested > 0 ? MAX_DELAY_SECONDS : MIN_DELAY_SECONDS;
891
+ let delay = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, rounded));
892
+ const wasClamped = !Number.isFinite(requested) || delay !== rounded;
893
+
894
+ const now = Date.now();
895
+ // A tick of a chain re-arms that chain even if the model reworded the prompt.
896
+ let chain =
897
+ scheduler.findChain(prompt) ?? (currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined);
898
+ if (chain) {
899
+ const limit = maxAgeMs();
900
+ if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
901
+ retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
1083
902
  return {
1084
903
  content: [
1085
904
  {
1086
905
  type: "text" as const,
1087
- text: chains.length
1088
- ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1089
- : "Loop ended. Nothing was pending.",
906
+ text:
907
+ `Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
908
+ "do not re-issue. Tell the user they can start a new /loop.",
1090
909
  },
1091
910
  ],
1092
- details: { stopped: true, cancelledWakeups: chains.length },
911
+ details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
1093
912
  };
1094
913
  }
1095
-
1096
- if (typeof params.delaySeconds !== "number" || !Number.isFinite(params.delaySeconds)) {
1097
- throw new Error("delaySeconds is required unless stop is true.");
1098
- }
1099
- const prompt = params.prompt?.trim();
1100
- if (!prompt) {
1101
- throw new Error("prompt is required unless stop is true.");
1102
- }
1103
-
1104
- const requested = params.delaySeconds;
1105
- const clamped = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, requested));
1106
- const wasClamped = clamped !== requested;
1107
-
1108
- let chain = findChain(prompt);
1109
- if (!chain) {
1110
- if (stoppedPrompts.has(prompt)) {
1111
- throw new Error(
1112
- `The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
1113
- "Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
1114
- );
1115
- }
1116
- chain = scheduler.add({
1117
- id: generateId(),
1118
- kind: "wakeup",
1119
- prompt,
1120
- recurring: false,
1121
- createdAt: Date.now(),
1122
- chainStartedAt: Date.now(),
1123
- durable: false,
1124
- source: "schedule_wakeup",
1125
- });
914
+ } else {
915
+ if (stoppedPrompts.has(prompt)) {
916
+ throw new Error(
917
+ `The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
918
+ "Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
919
+ );
1126
920
  }
1127
-
1128
- const scheduledFor = Date.now() + clamped * 1000;
1129
- scheduler.update(chain.id, {
1130
- scheduledFor,
1131
- keepaliveUsed: false,
1132
- keepaliveTurn: false,
1133
- awaitingReschedule: false,
1134
- reason: params.reason,
1135
- });
1136
-
1137
- return {
1138
- content: [
1139
- {
1140
- type: "text" as const,
1141
- text:
1142
- `Next iteration scheduled in ${clamped}s` +
1143
- (wasClamped ? ` (clamped from ${requested}s)` : "") +
1144
- ` for loop ${chain.id}.`,
1145
- },
1146
- ],
1147
- details: {
1148
- id: chain.id,
1149
- scheduledFor,
1150
- clampedDelaySeconds: clamped,
1151
- wasClamped,
1152
- stopped: false,
1153
- },
921
+ chain = {
922
+ id: generateId(),
923
+ kind: "wakeup",
924
+ prompt,
925
+ recurring: false,
926
+ createdAt: now,
927
+ chainStartedAt: now,
928
+ durable: false,
929
+ tickCount: tickCounters.get(prompt) ?? currentRun.tick ?? 0,
930
+ source: "schedule_wakeup",
1154
931
  };
1155
- },
1156
- });
1157
- }
1158
-
1159
- registerTools();
932
+ addJob(chain);
933
+ }
1160
934
 
1161
- // ---------------------------------------------------------------- rendering
935
+ // The run that arms a chain belongs to it: Esc in this run cancels it.
936
+ (currentRun.armed ??= new Set()).add(chain.id);
937
+ const scheduledFor = now + delay * 1000;
938
+ scheduler.update(chain.id, {
939
+ scheduledFor,
940
+ keepaliveUsed: false,
941
+ keepaliveTurn: false,
942
+ awaitingReschedule: false,
943
+ reason: params.reason,
944
+ });
1162
945
 
1163
- /**
1164
- * Compact one-line tool rows.
1165
- *
1166
- * The resolver is consulted every time a tool row renders, so setting
1167
- * `compactToolRows` to false falls back to Pi's default rendering without
1168
- * re-registering the tool or reloading the extension.
1169
- */
1170
- function registerToolRenderers(): void {
1171
- if (typeof pi.registerToolRenderer !== "function") return;
1172
- const renderers: Record<string, ToolRenderers> = {
1173
- cron_create: {
1174
- renderCall(args: any, theme: Theme, context: any) {
1175
- if (!context.isPartial && context.executionStarted) return completedCall();
1176
- return toolCall("Schedule", `${args.cron ?? ""} · ${truncate(args.prompt ?? "", 40)}`, theme);
1177
- },
1178
- renderResult(result: any, options: any, theme: Theme, context: any) {
1179
- if (context.isError) {
1180
- return toolResult(
1181
- resultText(result) || "Failed",
1182
- undefined,
1183
- { expanded: options.expanded, isPartial: options.isPartial, isError: true },
1184
- theme,
1185
- );
1186
- }
1187
- const d = (result.details ?? {}) as {
1188
- id?: string;
1189
- cron?: string;
1190
- nextFireAt?: number;
1191
- recurring?: boolean;
1192
- durable?: boolean;
1193
- prompt?: string;
1194
- };
1195
- const summary =
1196
- `Scheduled ${d.id ?? "?"} · ${humanSchedule(d.cron ?? "")} · next ${formatClock(d.nextFireAt)}` +
1197
- (d.recurring === false ? " · one-shot" : "") +
1198
- (d.durable ? " · durable" : "");
1199
- return toolResult(
1200
- summary,
1201
- d.prompt,
1202
- { expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
1203
- theme,
1204
- );
1205
- },
1206
- },
1207
- cron_delete: {
1208
- renderCall(args: any, theme: Theme, context: any) {
1209
- if (!context.isPartial && context.executionStarted) return completedCall();
1210
- return toolCall("Cancel", args.id ?? "", theme);
1211
- },
1212
- renderResult(result: any, options: any, theme: Theme, context: any) {
1213
- if (context.isError) {
1214
- return toolResult(
1215
- resultText(result) || "Failed",
1216
- undefined,
1217
- { expanded: options.expanded, isPartial: options.isPartial, isError: true },
1218
- theme,
1219
- );
1220
- }
1221
- const d = (result.details ?? {}) as { cancelled?: boolean; id?: string };
1222
- const summary = d.cancelled ? `Cancelled ${d.id ?? "?"}` : `No job ${d.id ?? "?"}`;
1223
- return toolResult(
1224
- summary,
1225
- undefined,
1226
- { expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
1227
- theme,
1228
- );
1229
- },
1230
- },
1231
- cron_list: {
1232
- renderCall(_args: any, theme: Theme, context: any) {
1233
- if (!context.isPartial && context.executionStarted) return completedCall();
1234
- return toolCall("List scheduled jobs", "", theme);
1235
- },
1236
- renderResult(result: any, options: any, theme: Theme, context: any) {
1237
- if (context.isError) {
1238
- return toolResult(
1239
- resultText(result) || "Failed",
1240
- undefined,
1241
- { expanded: options.expanded, isPartial: options.isPartial, isError: true },
1242
- theme,
1243
- );
1244
- }
1245
- const d = (result.details ?? {}) as { jobs?: LoopJob[] };
1246
- const jobs = d.jobs ?? [];
1247
- let summary: string;
1248
- if (!jobs.length) summary = "No scheduled jobs";
1249
- else if (jobs.length === 1) summary = `1 job · ${jobSummary(jobs[0])}`;
1250
- else {
1251
- const shown = jobs.slice(0, 2).map(jobSummary).join(" · ");
1252
- summary = `${jobs.length} jobs · ${shown}${jobs.length > 2 ? " …" : ""}`;
1253
- }
1254
- const detail = options.expanded
1255
- ? jobs.map((job) => `${jobSummary(job)} · ${truncate(job.prompt, 60)}`).join("\n")
1256
- : undefined;
1257
- return toolResult(
1258
- summary || resultText(result),
1259
- detail,
1260
- { expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
1261
- theme,
1262
- );
1263
- },
1264
- },
1265
- schedule_wakeup: {
1266
- renderCall(args: any, theme: Theme, context: any) {
1267
- if (!context.isPartial && context.executionStarted) return completedCall();
1268
- if (args.stop === true) return toolCall("Stop loop", "", theme);
1269
- const reason = args.reason ? ` · ${truncate(args.reason, 40)}` : "";
1270
- return toolCall("Wakeup", `${formatDelay(args.delaySeconds)}${reason}`, theme);
1271
- },
1272
- renderResult(result: any, options: any, theme: Theme, context: any) {
1273
- if (context.isError) {
1274
- return toolResult(
1275
- resultText(result) || "Failed",
1276
- undefined,
1277
- { expanded: options.expanded, isPartial: options.isPartial, isError: true },
1278
- theme,
1279
- );
1280
- }
1281
- const d = (result.details ?? {}) as {
1282
- id?: string;
1283
- scheduledFor?: number;
1284
- clampedDelaySeconds?: number;
1285
- wasClamped?: boolean;
1286
- stopped?: boolean;
1287
- cancelledWakeups?: number;
1288
- };
1289
- const summary = d.stopped
1290
- ? `Stopped · ${d.cancelledWakeups ?? 0} wakeup${d.cancelledWakeups === 1 ? "" : "s"} cancelled`
1291
- : `Wakeup in ${formatDelay(d.clampedDelaySeconds)} · loop ${d.id ?? "?"}${d.wasClamped ? " (clamped)" : ""}`;
1292
- const detail = options.expanded
1293
- ? [context.args.reason, d.scheduledFor ? `at ${formatClock(d.scheduledFor)}` : undefined]
1294
- .filter(Boolean)
1295
- .join(" · ")
1296
- : undefined;
1297
- return toolResult(
1298
- summary,
1299
- detail || undefined,
1300
- { expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
1301
- theme,
1302
- );
946
+ const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
947
+ const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
948
+ return {
949
+ content: [
950
+ {
951
+ type: "text" as const,
952
+ text:
953
+ `Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
954
+ "This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
955
+ },
956
+ ],
957
+ details: {
958
+ id: chain.id,
959
+ scheduledFor,
960
+ clampedDelaySeconds: delay,
961
+ wasClamped,
962
+ stopped: false,
1303
963
  },
1304
- },
1305
- };
1306
-
1307
- pi.registerToolRenderer((toolName, next) => {
1308
- if (!loadConfig().compactToolRows) return next();
1309
- return renderers[toolName] ?? next();
1310
- });
1311
- }
964
+ // Claude Code ends the turn when ScheduleWakeup returns (on models
965
+ // that support it); the guidelines ask for the update first. Pi only
966
+ // ends the turn when every tool in the batch agrees.
967
+ terminate: true,
968
+ };
969
+ },
970
+ });
1312
971
 
1313
- registerToolRenderers();
1314
972
 
1315
973
  // ----------------------------------------------------------------- events
1316
974
 
1317
975
  /**
1318
- * Draw the tick as a quiet two-line block instead of Pi's default
1319
- * `[pi-cc-loop-tick]` title.
1320
- */
1321
- pi.registerMessageRenderer(TICK_MESSAGE_TYPE, (message, options, theme) => {
1322
- const details = (message.details ?? {}) as {
1323
- tickCount?: number;
1324
- jobId?: string;
1325
- cron?: string;
1326
- immediate?: boolean;
1327
- };
1328
- const text = contentText(message.content);
1329
- const prompt = stripProvenance(text, /^\[loop tick[^\]]*\]\n?/);
1330
- if (!loadConfig().compactMessages) {
1331
- const summary = firstLine(prompt);
1332
- const header = `loop tick #${details.tickCount ?? "?"}${summary ? ` · ${summary}` : ""}`;
1333
- return renderCard({
1334
- header,
1335
- body: prompt,
1336
- expanded: options.expanded,
1337
- outputPad: options.outputPad,
1338
- theme,
1339
- });
1340
- }
1341
- const label = details.tickCount !== undefined ? `loop #${details.tickCount}` : "loop";
1342
- const stamp = formatStamp(message.timestamp);
1343
- const header = stamp
1344
- ? `${theme.fg("accent", theme.bold(label))}${theme.fg("dim", " · ")}${theme.fg("muted", stamp)}`
1345
- : theme.fg("accent", theme.bold(label));
1346
-
1347
- const meta: string[] = [];
1348
- if (details.jobId) meta.push(`job ${details.jobId}`);
1349
- if (details.cron) meta.push(`cron ${details.cron}`);
1350
- else if (details.immediate) meta.push("immediate run");
1351
- else meta.push("self-paced");
1352
- meta.push("scheduled by /loop");
1353
-
1354
- return renderBlock({
1355
- header,
1356
- body: bodyLines(prompt),
1357
- meta,
1358
- expanded: options.expanded,
1359
- outputPad: options.outputPad,
1360
- theme,
1361
- maxBodyLines: 3,
1362
- });
1363
- });
1364
-
1365
- /**
1366
- * A monitor event supersedes the chain's pending fallback heartbeat.
976
+ * Give a run the instructions of the loop message that started it, and
977
+ * remember which chain (if any) the run belongs to.
1367
978
  *
1368
- * The handler is deliberately synchronous: pi-cc-monitor emits the bus
1369
- * event before it sends the notification, so this must land before the
1370
- * woken turn starts. Otherwise the turn would end, find no reschedule, and
1371
- * arm a keepalive on top of the event that just arrived.
979
+ * A monitor event does not touch the chain: like Claude Code, the pending
980
+ * wakeup stays armed as the fallback heartbeat, and the model re-arms it if
981
+ * it wants a different time.
1372
982
  */
1373
- pi.events.on(MONITOR_WAKE_CHANNEL, (data) => {
1374
- const wake = data as { loopPrompt?: string } | undefined;
1375
- if (!wake?.loopPrompt) return;
1376
- const chain = scheduler.findChain(wake.loopPrompt);
1377
- if (!chain) return;
1378
- scheduler.update(chain.id, { scheduledFor: undefined, awaitingReschedule: true });
1379
- });
983
+ pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
1380
984
 
1381
985
  pi.on("before_agent_start", (event, ctx) => {
1382
986
  lastCtx = ctx;
1383
- const notes: string[] = [];
1384
-
1385
- if (pendingNote) {
1386
- notes.push(pendingNote);
1387
- pendingNote = undefined;
987
+ currentRun = {};
988
+
989
+ // Our own messages are ordinary user messages now, so the instructions
990
+ // for the run and the loop it belongs to travel with the queued entry.
991
+ if (event.messageType === "user") {
992
+ const queued = takeQueuedRun(event.prompt);
993
+ if (!queued) return;
994
+ currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick };
995
+ if (!queued.guidelines.length) return;
996
+ const options = event.systemPromptOptions;
997
+ options.promptGuidelines = [...(options.promptGuidelines ?? []), ...queued.guidelines];
998
+ return;
1388
999
  }
1389
-
1390
- const chainId = runChainId;
1391
- runChainId = undefined;
1392
- if (chainId) {
1393
- const chain = scheduler.get(chainId);
1394
- if (chain) {
1395
- notes.push(LOOP_GUIDELINES.replace("{prompt}", chain.prompt).replace("{quoted}", `"${chain.prompt}"`));
1396
- // Self-paced chains can be woken by an event instead of the timer.
1397
- // Only mention the Monitor tool when it is actually installed.
1398
- if (chain.kind === "wakeup" && monitorToolAvailable()) {
1399
- notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"${chain.prompt}"`));
1000
+ if (event.messageType !== "custom") return;
1001
+
1002
+ if (event.message.customType === TICK_MESSAGE_TYPE) {
1003
+ const details = (event.message.details ?? {}) as {
1004
+ kind?: "cron" | "wakeup";
1005
+ jobId?: string;
1006
+ cron?: string;
1007
+ tickCount?: number;
1008
+ };
1009
+ currentRun = {
1010
+ chainId: details.kind === "wakeup" ? details.jobId : undefined,
1011
+ cronId: details.kind === "cron" ? details.jobId : undefined,
1012
+ tick: details.tickCount,
1013
+ };
1014
+ const guidelines =
1015
+ details.kind === "cron" && details.jobId
1016
+ ? [cronTickGuidelines(details.jobId, humanSchedule(details.cron ?? "* * * * *"))]
1017
+ : [];
1018
+ if (details.kind === "wakeup") {
1019
+ const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
1020
+ if (chain) {
1021
+ guidelines.push(loopGuidelines(chain.prompt));
1022
+ if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
1400
1023
  }
1401
1024
  }
1025
+ if (!guidelines.length) return;
1026
+ const options = event.systemPromptOptions;
1027
+ options.promptGuidelines = [...(options.promptGuidelines ?? []), ...guidelines];
1028
+ return;
1402
1029
  }
1403
1030
 
1404
- if (pendingScheduleRules) {
1405
- notes.push(pendingScheduleRules);
1406
- pendingScheduleRules = undefined;
1407
- if (monitorToolAvailable()) {
1408
- notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"the /loop prompt"`));
1409
- }
1031
+ if (event.message.customType === MONITOR_EVENT_TYPE) {
1032
+ // A monitor bound to a loop woke the agent: the run belongs to that loop.
1033
+ const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
1034
+ const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
1035
+ if (chain) currentRun.chainId = chain.id;
1036
+ return;
1410
1037
  }
1411
-
1412
- if (!notes.length) return;
1413
- const options = event.systemPromptOptions;
1414
- options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
1415
- });
1416
-
1417
- pi.on("agent_start", async (_event, ctx) => {
1418
- lastCtx = ctx;
1419
- scheduler.noteTurnStarted();
1420
1038
  });
1421
1039
 
1422
- pi.on("agent_end", async (_event, ctx) => {
1040
+ /**
1041
+ * The run is over and Pi will not continue on its own. Esc on a loop's own
1042
+ * turn cancels that loop; chains whose tick was not rescheduled get their
1043
+ * keepalive. Then a pending tick may go out.
1044
+ */
1045
+ pi.on("agent_settled", async (event, ctx) => {
1423
1046
  lastCtx = ctx;
1424
- expireOldJobs(ctx);
1425
-
1426
- const now = Date.now();
1427
- for (const chain of scheduler.byKind("wakeup")) {
1428
- // The keepalive turn is the model's last chance to reschedule.
1429
- if (chain.keepaliveTurn) {
1430
- scheduler.update(chain.id, { keepaliveTurn: false, awaitingReschedule: false });
1431
- endChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
1432
- continue;
1433
- }
1434
-
1435
- // The model rescheduled: it armed the next wakeup itself.
1436
- if (!chain.awaitingReschedule) continue;
1437
-
1438
- const limit = maxAgeMs();
1439
- if (limit > 0 && chain.chainStartedAt !== undefined && now - chain.chainStartedAt >= limit) {
1440
- scheduler.update(chain.id, { awaitingReschedule: false });
1441
- endChain(chain, "aged out");
1442
- continue;
1443
- }
1444
-
1445
- if (chain.keepaliveUsed) continue;
1446
-
1447
- const fallback = keepaliveSeconds();
1448
- if (fallback <= 0) {
1449
- scheduler.update(chain.id, { awaitingReschedule: false });
1450
- endChain(chain, "the model did not reschedule (keepalive disabled)");
1451
- continue;
1452
- }
1453
-
1454
- scheduler.update(chain.id, {
1455
- scheduledFor: now + fallback * 1000,
1456
- keepaliveUsed: true,
1457
- awaitingReschedule: false,
1458
- });
1459
- notify(
1460
- ctx,
1461
- `loop ${chain.id}: the model did not reschedule — keepalive armed (${Math.round(fallback / 60)} min)`,
1462
- "warning",
1463
- );
1464
- }
1047
+ if ((event as { aborted?: boolean } | undefined)?.aborted) cancelCurrentLoop(ctx);
1048
+ settleChains(ctx);
1049
+ currentRun = {};
1050
+ scheduler.notifyIdle();
1465
1051
  });
1466
1052
 
1467
- pi.on("agent_settled", async (_event, ctx) => {
1468
- lastCtx = ctx;
1469
- // The agent is idle now: a pending tick may go out.
1470
- scheduler.notifyIdle();
1053
+ /** After a compaction, send the full loop.md tasks again on the next tick. */
1054
+ pi.on("session_compact", async () => {
1055
+ loopFile.reset();
1471
1056
  });
1472
1057
 
1473
- pi.on("session_start", async (_event, ctx) => {
1058
+ pi.on("session_start", async (event, ctx) => {
1474
1059
  lastCtx = ctx;
1475
- restoreState(ctx);
1476
- expireOldJobs(ctx);
1060
+ // Another process may have edited the shared settings file.
1061
+ invalidateConfig();
1062
+ durable?.stop();
1063
+ restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1064
+ durable = createDurable(ctx.cwd);
1065
+ durable.start();
1477
1066
  scheduler.start();
1478
- if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
1479
- notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
1480
- }
1481
- });
1482
-
1483
- pi.on("session_before_switch", async () => {
1484
- for (const chain of scheduler.byKind("wakeup")) {
1485
- retireChain(chain, "the session was switched");
1486
- }
1487
- scheduler.clear(false);
1488
1067
  });
1489
1068
 
1490
1069
  pi.on("session_shutdown", async () => {
1070
+ // Pi clears extension statuses when it unloads, but a reload should not
1071
+ // leave a stale count behind until then.
1072
+ try {
1073
+ lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
1074
+ } catch {
1075
+ // the context may already be invalid
1076
+ }
1491
1077
  scheduler.stop();
1078
+ durable?.stop();
1079
+ durable = undefined;
1492
1080
  scheduler.clear(false);
1493
1081
  });
1494
1082
 
1495
1083
  // -------------------------------------------------------------- restoring
1496
1084
 
1497
- function restoreState(ctx: ExtensionContext): void {
1085
+ /**
1086
+ * Restore the session's jobs from its last state entry, following Claude
1087
+ * Code's resume rules: "restores the tasks scheduled with CronCreate,
1088
+ * except recurring tasks that have expired and one-shot tasks whose
1089
+ * scheduled time has passed. A self-paced /loop isn't restored". A restored
1090
+ * recurring job does not catch up on missed runs.
1091
+ *
1092
+ * `/reload` is different: the process keeps running, so a self-paced
1093
+ * wakeup that is still in the future is kept (Claude Code re-arms a pending
1094
+ * wakeup when its process restarts in time).
1095
+ */
1096
+ function restoreState(ctx: ExtensionContext, reason: string | undefined): void {
1498
1097
  scheduler.clear(false);
1098
+ lastPersistedSignature = "";
1499
1099
 
1500
1100
  const entries = ctx.sessionManager.getEntries();
1101
+ let data: PersistedState | undefined;
1501
1102
  for (let i = entries.length - 1; i >= 0; i--) {
1502
1103
  const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
1503
1104
  if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.jobs) {
1504
- for (const prompt of entry.data.stopped ?? []) stoppedPrompts.add(prompt);
1505
- if (typeof entry.data.widget === "boolean") widgetEnabled = entry.data.widget;
1506
- for (const job of entry.data.jobs) {
1507
- if (job.kind === "wakeup") {
1508
- // A self-paced chain is not restored: the wakeup that was pending
1509
- // died with the previous process.
1510
- scheduler.restore({ ...job, scheduledFor: undefined, keepaliveUsed: true });
1511
- } else {
1512
- scheduler.restore(job);
1513
- scheduler.arm(job);
1514
- }
1515
- }
1105
+ data = entry.data;
1516
1106
  break;
1517
1107
  }
1518
1108
  }
1109
+ if (!data) {
1110
+ return;
1111
+ }
1519
1112
 
1520
- // Durable jobs live in .pi/scheduled_tasks.json and are restored even
1521
- // across a restart.
1522
- const file = path.join(ctx.cwd, ".pi", "scheduled_tasks.json");
1523
- try {
1524
- if (fs.existsSync(file)) {
1525
- const payload = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedState;
1526
- for (const job of payload.jobs ?? []) {
1527
- if (scheduler.get(job.id)) continue;
1528
- if (job.kind === "wakeup") {
1529
- scheduler.restore({ ...job, scheduledFor: undefined, keepaliveUsed: true });
1530
- } else {
1531
- scheduler.restore(job);
1532
- scheduler.arm(job);
1533
- }
1113
+ for (const prompt of data.stopped ?? []) rememberStopped(prompt);
1114
+
1115
+ const now = Date.now();
1116
+ const limit = maxAgeMs();
1117
+ let droppedChains = 0;
1118
+ let droppedOneShots = 0;
1119
+ for (const saved of data.jobs) {
1120
+ // Durable jobs come from .pi/scheduled_tasks.json, never from a session.
1121
+ if (saved.durable) continue;
1122
+ const job = storedJob(saved);
1123
+ if (job.kind === "wakeup") {
1124
+ if (reason === "reload" && job.scheduledFor !== undefined && job.scheduledFor > now) {
1125
+ scheduler.restore(job);
1126
+ } else {
1127
+ droppedChains++;
1534
1128
  }
1129
+ continue;
1535
1130
  }
1536
- } catch {
1537
- // ignore a malformed file
1131
+ if (!job.recurring) {
1132
+ const due = nextRunTime(job, job.createdAt, timingConfig());
1133
+ if (due === null || due <= now) {
1134
+ droppedOneShots++;
1135
+ continue;
1136
+ }
1137
+ } else if (limit > 0 && now - job.createdAt >= limit) {
1138
+ continue;
1139
+ }
1140
+ scheduler.restore(job);
1141
+ scheduler.arm(job, now);
1538
1142
  }
1539
1143
 
1540
- const pending = scheduler.byKind("wakeup");
1541
- if (pending.length) {
1542
- notify(
1543
- ctx,
1544
- `${pending.length} self-paced loop(s) were not restored: their wakeup did not survive the restart. Re-run /loop to restart them.`,
1545
- "warning",
1144
+ const notes: string[] = [];
1145
+ if (droppedChains) {
1146
+ notes.push(
1147
+ `${droppedChains} self-paced loop${droppedChains === 1 ? " was" : "s were"} not restored: run /loop again to restart ${droppedChains === 1 ? "it" : "them"}`,
1148
+ );
1149
+ }
1150
+ if (droppedOneShots) {
1151
+ notes.push(
1152
+ `${droppedOneShots} one-shot job${droppedOneShots === 1 ? "" : "s"} dropped: the scheduled time passed while the session was closed`,
1546
1153
  );
1547
1154
  }
1548
- refreshWidget(scheduler.list());
1155
+ if (notes.length) notify(ctx, notes.join(" \u00b7 "), "warning");
1156
+ scheduler.refresh();
1549
1157
  }
1550
1158
  }