pi-cc-loop 0.1.1 → 0.2.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,27 +15,21 @@
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, loadConfig, saveConfig, syncPiCcExtensionsExcludes, type PiCcConfig } from "./config.ts";
40
33
  import {
41
34
  describeCron,
42
35
  humanSchedule,
@@ -46,37 +39,45 @@ import {
46
39
  parseEveryClause,
47
40
  parseIntervalToken,
48
41
  } from "./cron.ts";
42
+ import { DurableRuntime, type Placement } from "./durable.ts";
43
+ import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
49
44
  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";
45
+ CRON_CREATE_DESCRIPTION,
46
+ CRON_CREATE_SNIPPET,
47
+ CRON_DELETE_DESCRIPTION,
48
+ CRON_DELETE_SNIPPET,
49
+ CRON_LIST_DESCRIPTION,
50
+ CRON_LIST_SNIPPET,
51
+ LOOP_FILE_SENTINEL,
52
+ loopGuidelines,
53
+ missedJobsMessage,
54
+ monitorGuidelines,
55
+ provenance,
56
+ SCHEDULE_WAKEUP_DESCRIPTION,
57
+ SCHEDULE_WAKEUP_SNIPPET,
58
+ scheduleRules,
59
+ } from "./prompts.ts";
60
+ import { formatClock, loopToolRenderers, renderTickMessage } from "./renderers.ts";
57
61
  import {
58
- bodyLines,
59
- contentText,
60
- firstLine,
61
- formatStamp,
62
- renderBlock,
63
- renderCard,
64
- stripProvenance,
65
- } from "./render.ts";
62
+ DEFAULT_TIMING,
63
+ generateId,
64
+ LoopScheduler,
65
+ nextRunTime,
66
+ storedJob,
67
+ type LoopJob,
68
+ type TimingConfig,
69
+ } from "./schedule.ts";
70
+ import { truncate } from "./tool-rows.ts";
66
71
 
67
72
  const STATE_ENTRY = "pi-cc-loop-state";
68
- const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
73
+ export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
74
+ /** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
75
+ const MONITOR_EVENT_TYPE = "pi-cc-monitor-event";
69
76
  const WIDGET_KEY = "pi-cc-loop";
70
77
  const CONFIG_CHANNEL = "pi-cc:config";
71
78
  const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
72
79
  const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
73
80
 
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
-
80
81
  /**
81
82
  * The event channel this extension publishes on when a chain ends. pi-cc-monitor
82
83
  * listens to it and stops any monitor that belongs to the chain, the way
@@ -88,76 +89,15 @@ const LOOP_STOPPED_CHANNEL = "loop:stopped";
88
89
  const MAX_JOBS = 50;
89
90
  const MIN_DELAY_SECONDS = 60;
90
91
  const MAX_DELAY_SECONDS = 3600;
92
+ /** A wakeup just under the 5 minute prompt cache lifetime is pulled inside it. */
91
93
  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]";
94
+ const DEFAULT_MAX_AGE_DAYS = 7;
95
+ /** How many stopped prompts are remembered (and persisted). */
96
+ const MAX_STOPPED_PROMPTS = 20;
158
97
 
159
98
  interface PersistedState {
160
99
  version: 1;
100
+ /** Session-only jobs; durable jobs live in .pi/scheduled_tasks.json. */
161
101
  jobs: LoopJob[];
162
102
  /** Prompts whose chain was stopped and must not be rescheduled. */
163
103
  stopped?: string[];
@@ -172,13 +112,12 @@ function envNumber(name: string, fallback: number): number {
172
112
  return Number.isFinite(value) ? value : fallback;
173
113
  }
174
114
 
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;
115
+ function maxAgeDays(): number {
116
+ return Math.max(0, envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS));
178
117
  }
179
118
 
180
- function jitterFraction(): number {
181
- return Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", 0)));
119
+ function maxAgeMs(): number {
120
+ return maxAgeDays() * 86_400_000;
182
121
  }
183
122
 
184
123
  /**
@@ -190,92 +129,23 @@ function keepaliveSeconds(): number {
190
129
  return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
191
130
  }
192
131
 
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();
132
+ /** Claude Code's offsets; `PI_LOOP_JITTER=0` runs jobs exactly on their cron minute. */
133
+ function timingConfig(): TimingConfig {
134
+ const fraction = Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", DEFAULT_TIMING.recurringFrac)));
135
+ return {
136
+ ...DEFAULT_TIMING,
137
+ recurringFrac: fraction,
138
+ oneShotMaxMs: fraction > 0 ? DEFAULT_TIMING.oneShotMaxMs : 0,
139
+ };
241
140
  }
242
141
 
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 "";
142
+ function durableSyncMs(): number {
143
+ return Math.max(0, envNumber("PI_LOOP_DURABLE_SYNC_MS", 5000));
251
144
  }
252
145
 
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`;
257
- }
258
-
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;
146
+ /** Models used to Claude Code may echo the prompt with its `/loop ` prefix. */
147
+ function normalizePrompt(prompt: string): string {
148
+ return prompt.trim().replace(/^\/loop\s+/i, "").trim();
279
149
  }
280
150
 
281
151
  /** The live scheduler, for tests. */
@@ -321,17 +191,44 @@ function extractFlags(raw: string): { body: string; durable: boolean; maxFires?:
321
191
  }
322
192
 
323
193
  export default function piCcLoopExtension(pi: ExtensionAPI): void {
194
+ /** Cached context for UI helpers. */
195
+ let lastCtx: ExtensionContext | undefined;
196
+ /**
197
+ * Prompts whose chain was stopped. A stopped chain must not be resurrected
198
+ * by a late `schedule_wakeup` call from a turn that a still-running monitor
199
+ * woke up.
200
+ */
201
+ const stoppedPrompts = new Set<string>();
202
+ /**
203
+ * Whether the status widget is shown. Claude Code's main session shows no
204
+ * loop status at all, so it is off by default; `/loop widget on` turns it on.
205
+ */
206
+ let widgetEnabled = process.env.PI_LOOP_WIDGET === "1";
207
+ /** Signature of the last persisted state, to avoid redundant session entries. */
208
+ let lastPersistedSignature = "";
209
+ /**
210
+ * How many times each prompt has been run as a loop tick. The immediate run
211
+ * of `/loop` and the fires of the job created afterwards share a counter.
212
+ */
213
+ const tickCounters = new Map<string, number>();
214
+ const loopFile = new LoopFileDelivery();
215
+ let durable: DurableRuntime | undefined;
216
+ /**
217
+ * What the current run belongs to, derived from its starting message:
218
+ * `chainId` is the self-paced chain whose tick (or monitor) started it,
219
+ * `armed` the chains it armed, `tick` the tick number of a /loop run.
220
+ */
221
+ let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
222
+
324
223
  const scheduler = new LoopScheduler({
325
- dispatch: (job) => {
326
- deliverTick(job);
224
+ dispatch: (job) => deliverTick(job),
225
+ afterDispatch: (job, finished) => {
226
+ if (job.durable) durable?.afterFire(job, finished);
327
227
  },
328
228
  onChange: (jobs) => {
329
229
  persist(jobs);
330
230
  refreshWidget(jobs);
331
231
  },
332
- maxAgeMs,
333
- jitterFraction,
334
- jitterCapMs: () => 30 * 60 * 1000,
335
232
  canDispatch: () => {
336
233
  const ctx = lastCtx;
337
234
  if (!ctx) return false;
@@ -341,104 +238,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
341
238
  return false;
342
239
  }
343
240
  },
241
+ timing: timingConfig,
242
+ maxAgeMs,
344
243
  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
- }
244
+ const ctx = lastCtx;
245
+ if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
355
246
  },
247
+ onScan: (now) => durable?.maintain(now),
356
248
  });
357
249
  activeScheduler = scheduler;
358
250
 
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
251
  // ---------------------------------------------------------------- helpers
390
252
 
253
+ /** What a job's persisted signature depends on; per-fire bookkeeping is left out. */
254
+ function signatureOf(job: LoopJob): unknown[] {
255
+ if (job.kind === "wakeup") {
256
+ return [job.id, job.kind, job.prompt, job.scheduledFor ?? null, job.keepaliveUsed ?? false, job.chainStartedAt ?? null];
257
+ }
258
+ return [
259
+ job.id,
260
+ job.kind,
261
+ job.cron,
262
+ job.prompt,
263
+ job.recurring,
264
+ job.createdAt,
265
+ job.maxFires ?? null,
266
+ job.maxFires !== undefined ? (job.fireCount ?? 0) : null,
267
+ ];
268
+ }
269
+
270
+ /**
271
+ * Save the session's jobs as a session entry. Durable jobs are left out:
272
+ * they live in .pi/scheduled_tasks.json, and restoring them from a session
273
+ * would run them twice. A new entry is only written when something that
274
+ * matters for a restore changed, not on every fire.
275
+ */
391
276
  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
- );
277
+ const kept = jobs.filter((job) => !job.durable).map(storedJob);
278
+ const stopped = [...stoppedPrompts];
279
+ const signature = JSON.stringify([kept.map(signatureOf), stopped, widgetEnabled]);
409
280
  if (signature === lastPersistedSignature) return;
410
281
  lastPersistedSignature = signature;
411
-
412
- const durable = jobs.filter((job) => job.durable);
413
282
  try {
414
- pi.appendEntry(STATE_ENTRY, {
415
- version: 1,
416
- jobs,
417
- stopped: [...stoppedPrompts],
418
- widget: widgetEnabled,
419
- } satisfies PersistedState);
283
+ pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped, widget: widgetEnabled } satisfies PersistedState);
420
284
  } catch {
421
285
  // session persistence is best effort
422
286
  }
423
- 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");
435
- } catch {
436
- // durable persistence is best effort
437
- }
438
287
  }
439
288
 
440
289
  function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
441
- if (ctx.hasUI) ctx.ui.notify(message, level);
290
+ try {
291
+ if (ctx.hasUI) ctx.ui.notify(message, level);
292
+ } catch {
293
+ // a stale context must never break the loop
294
+ }
442
295
  }
443
296
 
444
297
  function refreshWidget(jobs: LoopJob[]): void {
@@ -448,44 +301,35 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
448
301
  ctx.ui.setWidget(WIDGET_KEY, undefined);
449
302
  return;
450
303
  }
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
- }
469
- }
304
+ // Absolute times only: the widget is not redrawn every second.
305
+ const lines = jobs.map((job) => {
306
+ const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
307
+ const when = at ? `next ${formatClock(at)}` : job.kind === "wakeup" ? "awaiting reschedule" : "pending";
308
+ const kind = job.kind === "cron" ? `fixed ${job.cron}` : "self-paced";
309
+ return ctx.ui.theme.fg("accent", `loop ${job.id} \u00b7 ${kind} \u00b7 ${when} \u00b7 ${truncate(displayPrompt(job.prompt), 28)}`);
310
+ });
470
311
  ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
471
312
  }
472
313
 
473
- function findChain(prompt: string): LoopJob | undefined {
474
- return scheduler.findChain(prompt);
314
+ function rememberStopped(prompt: string): void {
315
+ stoppedPrompts.delete(prompt);
316
+ stoppedPrompts.add(prompt);
317
+ while (stoppedPrompts.size > MAX_STOPPED_PROMPTS) {
318
+ const oldest = stoppedPrompts.values().next().value;
319
+ if (oldest === undefined) break;
320
+ stoppedPrompts.delete(oldest);
321
+ }
475
322
  }
476
323
 
477
324
  /**
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
325
+ * End a self-paced chain for good: the job is removed, the prompt is
326
+ * remembered so a late `schedule_wakeup` cannot resurrect it, and
327
+ * pi-cc-monitor is told to stop the monitors that belong to it. Without the
328
+ * last part a monitor that keeps firing wakes the agent, the agent
485
329
  * reschedules, and the loop the user just stopped comes back.
486
330
  */
487
- function retireChain(chain: LoopJob, reason: string): void {
488
- stoppedPrompts.add(chain.prompt);
331
+ function retireChain(chain: LoopJob, reason: string, options: { silent?: boolean } = {}): void {
332
+ rememberStopped(chain.prompt);
489
333
  scheduler.remove(chain.id);
490
334
  try {
491
335
  pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
@@ -493,103 +337,140 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
493
337
  // a listener must never break the loop
494
338
  }
495
339
  const ctx = lastCtx;
496
- if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
340
+ if (ctx && !options.silent) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
497
341
  }
498
342
 
499
- function endChain(chain: LoopJob, reason: string): void {
500
- retireChain(chain, reason);
343
+ function ensureDurable(): DurableRuntime {
344
+ durable ??= createDurable(lastCtx?.cwd ?? process.cwd());
345
+ return durable;
501
346
  }
502
347
 
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
- }
348
+ function createDurable(cwd: string): DurableRuntime {
349
+ return new DurableRuntime({
350
+ cwd,
351
+ scheduler,
352
+ timing: timingConfig,
353
+ maxAgeMs,
354
+ syncMs: durableSyncMs,
355
+ onMissed: reportMissed,
356
+ });
357
+ }
358
+
359
+ /** Add a job; a durable job is saved to disk and may run in another session. */
360
+ function addJob(job: LoopJob): Placement {
361
+ if (job.durable) return ensureDurable().add(job);
362
+ scheduler.add(job);
363
+ return { runsHere: true };
364
+ }
365
+
366
+ function removeJob(job: LoopJob): void {
367
+ scheduler.remove(job.id);
368
+ if (job.durable) durable?.remove(job.id);
369
+ }
370
+
371
+ /** This session's jobs plus the durable jobs it can see but does not run. */
372
+ function listJobs(): Array<LoopJob & { foreignReason?: "other-session" | "other-folder" }> {
373
+ return [...scheduler.list(), ...(durable?.foreignJobs() ?? [])];
374
+ }
375
+
376
+ function foreignNote(job: { foreignReason?: string }): string {
377
+ if (job.foreignReason === "other-folder") return " (copied from another folder: listed, not run here)";
378
+ if (job.foreignReason === "other-session") return " (run by another Pi session in this project)";
379
+ return "";
380
+ }
381
+
382
+ /** Durable one-shots that came due while no session ran them: ask before running. */
383
+ function reportMissed(jobs: LoopJob[]): void {
384
+ const lines = jobs.map((job) => {
385
+ const due = nextRunTime(job, job.createdAt, timingConfig());
386
+ const when = due
387
+ ? ` (was due ${new Date(due).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })})`
388
+ : "";
389
+ return `- job ${job.id}${when}: ${displayPrompt(job.prompt)}`;
390
+ });
391
+ try {
392
+ // Attached to the next turn rather than starting one at startup.
393
+ pi.sendMessage(
394
+ {
395
+ customType: TICK_MESSAGE_TYPE,
396
+ content: missedJobsMessage(lines),
397
+ display: true,
398
+ details: { missed: true, jobIds: jobs.map((job) => job.id) },
399
+ },
400
+ { deliverAs: "nextTurn" },
401
+ );
402
+ } catch {
403
+ // best effort
404
+ }
405
+ const ctx = lastCtx;
406
+ if (ctx) {
407
+ notify(
408
+ ctx,
409
+ `${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"}`,
410
+ "warning",
411
+ );
515
412
  }
516
413
  }
517
414
 
415
+ /** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
416
+ function resolvePrompt(prompt: string, mode: TickMode): string {
417
+ return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
418
+ }
419
+
518
420
  /**
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."
421
+ * Deliver a scheduled tick. `followUp` is the low-priority lane: Claude
422
+ * Code's "A scheduled prompt fires between your turns, not while Claude is
423
+ * mid-response."
525
424
  */
526
425
  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
426
  const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
530
427
  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;
428
+ job.tickCount = tick;
429
+ const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
430
+ // before_agent_start finds the job through `details.jobId` and gives a
431
+ // self-paced tick its chain guidelines.
536
432
  pi.sendMessage(
537
433
  {
538
434
  customType: TICK_MESSAGE_TYPE,
539
- content,
435
+ content: `${provenance(tick)}\n${body}`,
540
436
  display: true,
541
437
  details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
542
438
  },
543
439
  { deliverAs: "followUp", triggerTurn: true },
544
440
  );
545
441
 
546
- const cap = job.maxFires;
547
- if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
442
+ if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
548
443
  const ctx = lastCtx;
549
- if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${cap} fires`, "info");
444
+ if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${job.maxFires} fires`, "info");
550
445
  }
551
446
  }
552
447
 
553
448
  /**
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.
449
+ * Run the prompt right away, the way Claude Code's skill does ("Then
450
+ * immediately execute the parsed prompt now").
563
451
  *
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.
452
+ * `guidelines` are the instructions for this one run. They travel on the
453
+ * message (`details` never reaches the model) and before_agent_start adds
454
+ * them to the run the message starts, so they cannot leak into another run.
455
+ * The caller waits until the agent is idle, so the message starts its own run.
566
456
  */
567
- const tickCounters = new Map<string, number>();
568
-
569
- function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
457
+ function runNow(ctx: ExtensionContext, prompt: string, job: LoopJob | undefined, guidelines: string[]): void {
570
458
  const tick = (tickCounters.get(prompt) ?? 0) + 1;
571
459
  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
- });
460
+ if (job && scheduler.get(job.id)) {
461
+ scheduler.update(job.id, { tickCount: tick, fireCount: (job.fireCount ?? 0) + 1, lastFiredAt: Date.now() });
578
462
  }
579
- runChainId = job?.kind === "wakeup" ? job.id : undefined;
463
+ const body = resolvePrompt(prompt, job?.kind === "cron" ? "cron" : "wakeup");
580
464
  pi.sendMessage(
581
465
  {
582
466
  customType: TICK_MESSAGE_TYPE,
583
- content: `${PROVENANCE.replace("{n}", String(tick))}\n${prompt}`,
467
+ content: `${provenance(tick)}\n${body}`,
584
468
  display: true,
585
- details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
469
+ details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now(), guidelines },
586
470
  },
587
471
  { deliverAs: "steer", triggerTurn: true },
588
472
  );
589
- }
590
-
591
- function scheduleNote(text: string): void {
592
- pendingNote = text;
473
+ lastCtx = ctx;
593
474
  }
594
475
 
595
476
  /** True when the pi-cc-monitor extension registered its Monitor tool. */
@@ -606,22 +487,76 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
606
487
  return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
607
488
  }
608
489
 
490
+ /**
491
+ * A run finished without an abort: self-paced chains whose tick the model
492
+ * did not reschedule get their one keepalive, or end.
493
+ */
494
+ function settleChains(ctx: ExtensionContext): void {
495
+ const now = Date.now();
496
+ for (const chain of scheduler.byKind("wakeup")) {
497
+ // The keepalive turn is the model's last chance to reschedule.
498
+ if (chain.keepaliveTurn) {
499
+ retireChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
500
+ continue;
501
+ }
502
+ if (!chain.awaitingReschedule) continue;
503
+
504
+ const limit = maxAgeMs();
505
+ if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
506
+ retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
507
+ continue;
508
+ }
509
+ if (chain.keepaliveUsed) continue;
510
+
511
+ const fallback = keepaliveSeconds();
512
+ if (fallback <= 0) {
513
+ retireChain(chain, "the model did not reschedule (keepalive disabled)");
514
+ continue;
515
+ }
516
+ scheduler.update(chain.id, {
517
+ scheduledFor: now + fallback * 1000,
518
+ keepaliveUsed: true,
519
+ awaitingReschedule: false,
520
+ });
521
+ notify(
522
+ ctx,
523
+ `loop ${chain.id}: the model did not reschedule \u2014 keepalive armed (${Math.round(fallback / 60)} min)`,
524
+ "warning",
525
+ );
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Esc interrupted a turn that belongs to a self-paced loop (its tick, the
531
+ * /loop run that armed it, or a turn its monitor woke): cancel that loop,
532
+ * with Claude Code's notice ("A `Pending wakeup cancelled` notice confirms
533
+ * that the loop won't fire again").
534
+ *
535
+ * Claude Code cancels every self-paced loop on any Esc, idle or not. Here
536
+ * an interrupted unrelated turn leaves the loops alone; `/loop stop` stops
537
+ * them. Fixed-interval jobs keep running either way, as in Claude Code.
538
+ */
539
+ function cancelCurrentLoop(ctx: ExtensionContext): void {
540
+ const ids = new Set([...(currentRun.chainId ? [currentRun.chainId] : []), ...(currentRun.armed ?? [])]);
541
+ const chains = [...ids]
542
+ .map((id) => scheduler.get(id))
543
+ .filter((chain): chain is LoopJob => chain?.kind === "wakeup");
544
+ if (!chains.length) return;
545
+ for (const chain of chains) retireChain(chain, "pending wakeup cancelled (Esc)", { silent: true });
546
+ notify(ctx, `Pending wakeup cancelled \u00b7 loop ${chains.map((chain) => chain.id).join(", ")} ended`, "info");
547
+ }
548
+
609
549
  // ---------------------------------------------------------------- command
610
550
 
611
551
  pi.registerCommand("loop", {
612
552
  description:
613
- "Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
553
+ "Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop [interval] | /loop stop [id] | /loop status | /loop settings",
614
554
  handler: async (args: string, ctx: ExtensionCommandContext) => {
615
555
  lastCtx = ctx;
616
556
  const raw = args.trim();
617
557
 
618
- if (!raw) {
619
- await startLoop(ctx, null, "", false, undefined);
620
- return;
621
- }
622
-
623
558
  const [head, ...rest] = raw.split(/\s+/);
624
- switch (head.toLowerCase()) {
559
+ switch ((head ?? "").toLowerCase()) {
625
560
  case "stop":
626
561
  await stopLoops(ctx, rest.join(" "));
627
562
  return;
@@ -678,26 +613,32 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
678
613
  let intervalSeconds: number | null = null;
679
614
  let prompt = body;
680
615
 
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();
616
+ const intervalOnly = parseIntervalToken(body);
617
+ if (intervalOnly !== null) {
618
+ // "/loop 15m": the loop.md tasks (or maintenance) on a fixed schedule.
619
+ intervalSeconds = intervalOnly;
620
+ prompt = "";
621
+ } else {
622
+ // Rule 1: leading interval token, e.g. "5m check the deploy"
623
+ const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
624
+ if (leading) {
625
+ const parsed = parseIntervalToken(leading[1]);
626
+ if (parsed !== null) {
627
+ intervalSeconds = parsed;
628
+ prompt = leading[2].trim();
629
+ }
688
630
  }
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;
631
+ // Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
632
+ if (intervalSeconds === null) {
633
+ const every = parseEveryClause(body);
634
+ if (every) {
635
+ intervalSeconds = every.interval;
636
+ prompt = every.prompt;
637
+ }
697
638
  }
698
639
  }
699
640
 
700
- // Rule 3: no interval -> self-paced
641
+ // Rule 3: no interval -> the model decides (self-paced unless it asks for one)
701
642
  await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
702
643
  },
703
644
  });
@@ -706,105 +647,115 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
706
647
  ctx: ExtensionCommandContext,
707
648
  intervalSeconds: number | null,
708
649
  prompt: string,
709
- durable: boolean,
650
+ durableFlag: boolean,
710
651
  maxFires: number | undefined,
711
652
  ): Promise<void> {
712
- expireOldJobs(ctx);
653
+ // Claude Code queues a /loop typed while it is busy. Wait for the current
654
+ // turn rather than steering into it: the immediate run has to start its
655
+ // own turn, or its instructions would never reach the model.
656
+ if (!ctx.isIdle()) {
657
+ notify(ctx, "/loop starts when the current turn finishes", "info");
658
+ await ctx.waitForIdle();
659
+ }
713
660
 
661
+ // Without a prompt the loop runs the loop.md tasks, resolved at each fire.
662
+ const finalPrompt = prompt || LOOP_FILE_SENTINEL;
714
663
  // 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
- }
664
+ stoppedPrompts.delete(finalPrompt);
665
+ // A new loop starts by sending the full task list again.
666
+ if (isLoopFileSentinel(finalPrompt)) loopFile.reset();
727
667
 
728
668
  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}\`.`;
669
+ let guidelines: string[];
670
+ if (!prompt) {
671
+ // A bare /loop is self-paced, like Claude Code's.
672
+ guidelines = [loopGuidelines(finalPrompt)];
673
+ if (monitorToolAvailable()) guidelines.push(monitorGuidelines(finalPrompt));
674
+ } else {
675
+ // The interval could not be parsed from the command line. Claude
676
+ // Code's /loop is a skill, so the model reads the input and works
677
+ // the schedule out itself; the rules below let it do the same here.
678
+ let rules = scheduleRules(finalPrompt);
679
+ if (durableFlag) {
680
+ rules += "\n- The user asked for the job to survive a restart: create it with `durable: true`.";
681
+ }
682
+ if (maxFires !== undefined) {
683
+ rules += `\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
684
+ }
685
+ guidelines = [rules];
686
+ if (monitorToolAvailable()) {
687
+ guidelines.push(`If you choose the open-ended mode:\n${monitorGuidelines(finalPrompt)}`);
688
+ }
745
689
  }
746
- pendingScheduleRules = rules;
747
- runNow(ctx, finalPrompt);
690
+ runNow(ctx, finalPrompt, undefined, guidelines);
748
691
  return;
749
692
  }
750
693
 
751
694
  const spec = intervalToCron(intervalSeconds);
752
- if (!spec) {
695
+ if (!spec || !parseCron(spec.cron)) {
753
696
  notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
754
697
  return;
755
698
  }
756
- if (!parseCron(spec.cron)) {
757
- notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
758
- return;
759
- }
760
699
  if (scheduler.size >= MAX_JOBS) {
761
700
  notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
762
701
  return;
763
702
  }
764
703
 
765
- const job = scheduler.add({
704
+ const job: LoopJob = {
766
705
  id: generateId(),
767
706
  kind: "cron",
768
707
  cron: spec.cron,
769
708
  prompt: finalPrompt,
770
709
  recurring: true,
771
710
  createdAt: Date.now(),
772
- durable,
711
+ durable: durableFlag,
773
712
  maxFires,
774
713
  label: humanSchedule(spec.cron),
775
714
  source: "loop_command",
776
- });
715
+ };
716
+ let placement: Placement;
717
+ try {
718
+ placement = addJob(job);
719
+ } catch (error) {
720
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
721
+ return;
722
+ }
723
+ const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
777
724
 
778
- const expiryDays = Math.round(maxAgeMs() / 86_400_000);
725
+ const days = maxAgeDays();
779
726
  notify(
780
727
  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}` : ""),
728
+ `Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)}) \u00b7 ${spec.cron} \u00b7 expires in ${days}d \u00b7 cancel with /loop stop ${job.id}` +
729
+ (spec.note ? ` \u00b7 ${spec.note}` : "") +
730
+ (placement.runsHere ? "" : ` \u00b7 runs in the Pi session holding the project lock (pid ${placement.holderPid ?? "?"})`),
783
731
  "info",
784
732
  );
785
- scheduleNote(
733
+ const note =
786
734
  `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
- );
735
+ `next run ${formatClock(nextAt)}, auto-expires after ${days} days (it runs one final time, then it is deleted). ` +
736
+ `The user cancels it with \`/loop stop ${job.id}\`.` +
737
+ (spec.note ? ` The requested interval was adjusted: ${spec.note}.` : "");
791
738
 
792
- runNow(ctx, finalPrompt, job);
739
+ runNow(ctx, finalPrompt, job, [note]);
793
740
  }
794
741
 
795
742
  async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
796
743
  if (target) {
797
744
  const job = scheduler.get(target);
798
- if (!job) {
799
- notify(ctx, `No loop with id ${target}`, "warning");
745
+ if (job) {
746
+ if (job.kind === "wakeup") {
747
+ retireChain(job, "stopped by the user");
748
+ } else {
749
+ removeJob(job);
750
+ notify(ctx, `Cancelled loop ${target}`, "info");
751
+ }
800
752
  return;
801
753
  }
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");
754
+ if (durable?.remove(target)) {
755
+ notify(ctx, `Cancelled durable job ${target}`, "info");
756
+ return;
807
757
  }
758
+ notify(ctx, `No loop with id ${target}`, "warning");
808
759
  return;
809
760
  }
810
761
  const jobs = scheduler.list();
@@ -813,40 +764,38 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
813
764
  return;
814
765
  }
815
766
  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
- }
767
+ // Retiring also tells pi-cc-monitor to stop the chain's monitors.
768
+ if (job.kind === "wakeup") retireChain(job, "stopped by the user");
769
+ else removeJob(job);
823
770
  }
824
771
  notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
825
772
  }
826
773
 
827
774
  function showStatus(ctx: ExtensionContext): void {
828
- const jobs = scheduler.list();
775
+ const jobs = listJobs();
829
776
  if (!jobs.length) {
830
777
  notify(ctx, "No active loops", "info");
831
778
  return;
832
779
  }
833
780
  const lines = jobs.map((job) => {
781
+ const prompt = truncate(displayPrompt(job.prompt), 60);
834
782
  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)}`;
783
+ const cap = job.maxFires !== undefined ? ` \u00b7 ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
784
+ const label = job.label ?? describeCron(job.cron ?? "");
785
+ return `${job.id} cron ${job.cron} (${label}) next ${formatClock(job.nextFireAt)}${cap}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
837
786
  }
838
787
  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)}`;
788
+ const when = at && at > Date.now() ? `next ${formatClock(at)}` : "awaiting reschedule";
789
+ return `${job.id} self-paced ${when} ${prompt}`;
841
790
  });
842
- if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
791
+ notify(ctx, lines.join("\n"), "info");
843
792
  }
844
793
 
845
794
  function showHelp(ctx: ExtensionContext): void {
846
795
  const help = [
847
796
  "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
848
797
  "/loop <prompt> self-paced: the agent picks the next delay",
849
- "/loop use .pi/loop.md, or the built-in maintenance prompt",
798
+ "/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
850
799
  "/loop stop [id] cancel one loop, or all of them",
851
800
  "/loop status list the active loops",
852
801
  "/loop widget on|off show or hide the status line above the editor",
@@ -854,23 +803,24 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
854
803
  "/loop settings messages on|off compact two-line markers",
855
804
  "/loop settings tools on|off one-line tool results",
856
805
  "",
806
+ "Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
857
807
  "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
858
808
  " --max N stop the loop after N fires.",
859
809
  ].join("\n");
860
- if (ctx.hasUI) ctx.ui.notify(help, "info");
810
+ notify(ctx, help, "info");
861
811
  }
862
812
 
863
813
  function showSettings(ctx: ExtensionContext): void {
864
814
  const config = loadConfig();
865
815
  const lines = [
866
- `pi-cc settings · ${configPath()}`,
816
+ `pi-cc settings \u00b7 ${configPath()}`,
867
817
  ` messages: ${config.compactMessages ? "compact" : "card"}`,
868
818
  ` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
869
819
  "",
870
820
  "Usage: /loop settings messages on|off",
871
821
  " /loop settings tools on|off",
872
822
  ];
873
- if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
823
+ notify(ctx, lines.join("\n"), "info");
874
824
  }
875
825
 
876
826
  function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
@@ -881,8 +831,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
881
831
  showSettings(ctx);
882
832
  return;
883
833
  }
884
- const patch: Partial<PiCcConfig> =
885
- key === "messages" ? { compactMessages: on } : { compactToolRows: on };
834
+ const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { compactToolRows: on };
886
835
  const next = saveConfig(patch);
887
836
  let compatChanged = false;
888
837
  if (key === "tools") {
@@ -895,518 +844,355 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
895
844
  }
896
845
  notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
897
846
  if (compatChanged) {
898
- notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
847
+ notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
899
848
  }
900
849
  }
901
850
 
902
851
  // ------------------------------------------------------------------ tools
903
852
 
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({
853
+ pi.registerTool({
854
+ name: "cron_create",
855
+ label: "Create Scheduled Job",
856
+ description: CRON_CREATE_DESCRIPTION,
857
+ promptSnippet: CRON_CREATE_SNIPPET,
858
+ parameters: Type.Object({
859
+ cron: Type.String({
860
+ description:
861
+ 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
862
+ }),
863
+ prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
864
+ recurring: Type.Optional(
865
+ Type.Boolean({
915
866
  description:
916
- 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
867
+ "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
868
  }),
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
- }
869
+ ),
870
+ durable: Type.Optional(
871
+ Type.Boolean({
872
+ description:
873
+ "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.",
874
+ }),
875
+ ),
876
+ maxFires: Type.Optional(
877
+ Type.Integer({
878
+ minimum: 1,
879
+ description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
880
+ }),
881
+ ),
882
+ }),
883
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
884
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
885
+ lastCtx = ctx;
886
+ if (!parseCron(params.cron)) {
887
+ throw new Error(`Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`);
888
+ }
889
+ if (isUnreachable(params.cron)) {
890
+ throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
891
+ }
892
+ if (scheduler.size >= MAX_JOBS) {
893
+ throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
894
+ }
895
+ if (!params.prompt?.trim()) {
896
+ throw new Error("prompt must not be empty.");
897
+ }
953
898
 
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
- });
899
+ const recurring = params.recurring ?? true;
900
+ const job: LoopJob = {
901
+ id: generateId(),
902
+ kind: "cron",
903
+ cron: params.cron.trim(),
904
+ prompt: params.prompt,
905
+ recurring,
906
+ createdAt: Date.now(),
907
+ durable: params.durable ?? false,
908
+ maxFires: params.maxFires,
909
+ // Continue the tick numbering of the /loop run that led here.
910
+ tickCount: tickCounters.get(params.prompt) ?? currentRun.tick ?? 0,
911
+ label: humanSchedule(params.cron),
912
+ source: "cron_create",
913
+ };
914
+ const placement = addJob(job);
915
+ const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
916
+
917
+ const lines = [`Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)})`];
918
+ if (job.durable) {
919
+ lines.push(
920
+ placement.runsHere
921
+ ? "Durable: saved to .pi/scheduled_tasks.json, survives restarts."
922
+ : `Durable: saved to .pi/scheduled_tasks.json. Another Pi session in this project (pid ${placement.holderPid ?? "?"}) holds the scheduler lock and runs it.`,
923
+ );
924
+ } else {
925
+ lines.push("Session-only: restored if this session is resumed, gone otherwise.");
926
+ }
927
+ if (recurring) {
928
+ lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
929
+ }
930
+ lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
931
+
932
+ return {
933
+ content: [{ type: "text", text: lines.join("\n") }],
934
+ details: {
935
+ id: job.id,
936
+ cron: job.cron,
937
+ recurring: job.recurring,
938
+ durable: job.durable,
939
+ nextFireAt: nextAt,
940
+ prompt: job.prompt,
941
+ runsHere: placement.runsHere,
942
+ },
943
+ };
944
+ },
945
+ });
968
946
 
947
+ pi.registerTool({
948
+ name: "cron_delete",
949
+ label: "Cancel Scheduled Job",
950
+ description: CRON_DELETE_DESCRIPTION,
951
+ promptSnippet: CRON_DELETE_SNIPPET,
952
+ parameters: Type.Object({
953
+ id: Type.String({ description: "The job id returned by cron_create, or the id of a self-paced loop." }),
954
+ }),
955
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
956
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
957
+ lastCtx = ctx;
958
+ const job = scheduler.get(params.id);
959
+ if (job) {
960
+ // A self-paced chain ends the same way /loop stop ends it.
961
+ if (job.kind === "wakeup") retireChain(job, "cancelled with cron_delete");
962
+ else removeJob(job);
963
+ } else if (!durable?.remove(params.id)) {
969
964
  return {
970
- content: [
971
- {
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}" }.`,
976
- },
977
- ],
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
- },
965
+ content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
966
+ details: { cancelled: false, id: params.id },
986
967
  };
987
- },
988
- });
968
+ }
969
+ return {
970
+ content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
971
+ details: { cancelled: true, id: params.id },
972
+ };
973
+ },
974
+ });
989
975
 
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);
976
+ pi.registerTool({
977
+ name: "cron_list",
978
+ label: "List Scheduled Jobs",
979
+ description: CRON_LIST_DESCRIPTION,
980
+ promptSnippet: CRON_LIST_SNIPPET,
981
+ parameters: Type.Object({}),
982
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
983
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
984
+ lastCtx = ctx;
985
+ const jobs = listJobs();
986
+ if (!jobs.length) {
1008
987
  return {
1009
- content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
1010
- details: { cancelled: true, id: params.id },
988
+ content: [{ type: "text" as const, text: "No scheduled jobs." }],
989
+ details: { jobs: [] },
1011
990
  };
1012
- },
1013
- });
991
+ }
992
+ const text = jobs
993
+ .map((job) => {
994
+ const prompt = truncate(displayPrompt(job.prompt), 80);
995
+ if (job.kind === "cron") {
996
+ return `${job.id} cron ${job.cron} ${humanSchedule(job.cron ?? "")} recurring=${job.recurring} next ${formatClock(job.nextFireAt)}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
997
+ }
998
+ return `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${prompt}`;
999
+ })
1000
+ .join("\n");
1001
+ return {
1002
+ content: [{ type: "text" as const, text }],
1003
+ details: { jobs: jobs.map((job) => storedJob(job)) },
1004
+ };
1005
+ },
1006
+ });
1014
1007
 
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");
1008
+ pi.registerTool({
1009
+ name: "schedule_wakeup",
1010
+ label: "Schedule Next Loop Iteration",
1011
+ description: SCHEDULE_WAKEUP_DESCRIPTION,
1012
+ promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
1013
+ parameters: Type.Object({
1014
+ delaySeconds: Type.Optional(
1015
+ Type.Number({
1016
+ description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
1017
+ }),
1018
+ ),
1019
+ reason: Type.Optional(
1020
+ Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
1021
+ ),
1022
+ prompt: Type.Optional(
1023
+ Type.String({
1024
+ 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}".`,
1025
+ }),
1026
+ ),
1027
+ stop: Type.Optional(
1028
+ Type.Boolean({
1029
+ description: "Set to true to end the self-paced loop immediately. Omit every other field.",
1030
+ }),
1031
+ ),
1032
+ }),
1033
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1034
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1035
+ lastCtx = ctx;
1036
+
1037
+ if (params.stop === true) {
1038
+ // Stop the loop this turn belongs to; otherwise the named one, or all.
1039
+ const current = currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined;
1040
+ const named = params.prompt ? scheduler.findChain(normalizePrompt(params.prompt)) : undefined;
1041
+ const chains = current ? [current] : named ? [named] : scheduler.byKind("wakeup");
1042
+ for (const chain of chains) retireChain(chain, "stopped by the agent");
1037
1043
  return {
1038
- content: [{ type: "text" as const, text }],
1039
- details: { jobs },
1044
+ content: [
1045
+ {
1046
+ type: "text" as const,
1047
+ text: chains.length
1048
+ ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1049
+ : "Loop ended. Nothing was pending.",
1050
+ },
1051
+ ],
1052
+ details: { stopped: true, cancelledWakeups: chains.length },
1040
1053
  };
1041
- },
1042
- });
1054
+ }
1043
1055
 
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
- }
1056
+ if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
1057
+ throw new Error("delaySeconds is required unless stop is true.");
1058
+ }
1059
+ const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
1060
+ if (!prompt) {
1061
+ throw new Error("prompt is required unless stop is true.");
1062
+ }
1063
+
1064
+ // Claude Code rounds and clamps; only a change after rounding counts as clamped.
1065
+ const requested = params.delaySeconds;
1066
+ const rounded = Number.isFinite(requested) ? Math.round(requested) : requested > 0 ? MAX_DELAY_SECONDS : MIN_DELAY_SECONDS;
1067
+ let delay = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, rounded));
1068
+ const wasClamped = !Number.isFinite(requested) || delay !== rounded;
1069
+
1070
+ const now = Date.now();
1071
+ // A tick of a chain re-arms that chain even if the model reworded the prompt.
1072
+ let chain =
1073
+ scheduler.findChain(prompt) ?? (currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined);
1074
+ if (chain) {
1075
+ const limit = maxAgeMs();
1076
+ if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
1077
+ retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
1083
1078
  return {
1084
1079
  content: [
1085
1080
  {
1086
1081
  type: "text" as const,
1087
- text: chains.length
1088
- ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1089
- : "Loop ended. Nothing was pending.",
1082
+ text:
1083
+ `The /loop has reached its ${maxAgeDays()}-day limit and has ended. Do not reschedule it; ` +
1084
+ "tell the user they can start a new /loop.",
1090
1085
  },
1091
1086
  ],
1092
- details: { stopped: true, cancelledWakeups: chains.length },
1087
+ details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
1093
1088
  };
1094
1089
  }
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.");
1090
+ } else {
1091
+ if (stoppedPrompts.has(prompt)) {
1092
+ throw new Error(
1093
+ `The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
1094
+ "Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
1095
+ );
1102
1096
  }
1097
+ chain = {
1098
+ id: generateId(),
1099
+ kind: "wakeup",
1100
+ prompt,
1101
+ recurring: false,
1102
+ createdAt: now,
1103
+ chainStartedAt: now,
1104
+ durable: false,
1105
+ tickCount: tickCounters.get(prompt) ?? currentRun.tick ?? 0,
1106
+ source: "schedule_wakeup",
1107
+ };
1108
+ addJob(chain);
1109
+ }
1103
1110
 
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
- });
1126
- }
1111
+ // The run that arms a chain belongs to it: Esc in this run cancels it.
1112
+ (currentRun.armed ??= new Set()).add(chain.id);
1113
+ const scheduledFor = now + delay * 1000;
1114
+ scheduler.update(chain.id, {
1115
+ scheduledFor,
1116
+ keepaliveUsed: false,
1117
+ keepaliveTurn: false,
1118
+ awaitingReschedule: false,
1119
+ reason: params.reason,
1120
+ });
1127
1121
 
1128
- const scheduledFor = Date.now() + clamped * 1000;
1129
- scheduler.update(chain.id, {
1122
+ const note = wasClamped ? ` (clamped from ${requested}s)` : "";
1123
+ return {
1124
+ content: [{ type: "text" as const, text: `Next iteration scheduled in ${delay}s${note} for loop ${chain.id}.` }],
1125
+ details: {
1126
+ id: chain.id,
1130
1127
  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
- },
1154
- };
1155
- },
1156
- });
1157
- }
1158
-
1159
- registerTools();
1128
+ clampedDelaySeconds: delay,
1129
+ wasClamped,
1130
+ stopped: false,
1131
+ },
1132
+ // Claude Code ends the turn when ScheduleWakeup returns (on models
1133
+ // that support it); the guidelines ask for the update first. Pi only
1134
+ // ends the turn when every tool in the batch agrees.
1135
+ terminate: true,
1136
+ };
1137
+ },
1138
+ });
1160
1139
 
1161
1140
  // ---------------------------------------------------------------- rendering
1162
1141
 
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
- );
1303
- },
1304
- },
1305
- };
1306
-
1142
+ if (typeof pi.registerToolRenderer === "function") {
1143
+ const renderers = loopToolRenderers();
1144
+ // The resolver runs every time a row renders, so the setting applies
1145
+ // without re-registering anything.
1307
1146
  pi.registerToolRenderer((toolName, next) => {
1308
1147
  if (!loadConfig().compactToolRows) return next();
1309
1148
  return renderers[toolName] ?? next();
1310
1149
  });
1311
1150
  }
1312
1151
 
1313
- registerToolRenderers();
1152
+ pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderTickMessage);
1314
1153
 
1315
1154
  // ----------------------------------------------------------------- events
1316
1155
 
1317
1156
  /**
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.
1157
+ * Give a run the instructions of the loop message that started it, and
1158
+ * remember which chain (if any) the run belongs to.
1367
1159
  *
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.
1160
+ * A monitor event does not touch the chain: like Claude Code, the pending
1161
+ * wakeup stays armed as the fallback heartbeat, and the model re-arms it if
1162
+ * it wants a different time.
1372
1163
  */
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
- });
1380
-
1381
1164
  pi.on("before_agent_start", (event, ctx) => {
1382
1165
  lastCtx = ctx;
1383
- const notes: string[] = [];
1384
-
1385
- if (pendingNote) {
1386
- notes.push(pendingNote);
1387
- pendingNote = undefined;
1388
- }
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}"`));
1400
- }
1401
- }
1166
+ currentRun = {};
1167
+ if (event.messageType !== "custom") return;
1168
+
1169
+ if (event.message.customType === MONITOR_EVENT_TYPE) {
1170
+ // A monitor bound to a loop woke the agent: the run belongs to that loop.
1171
+ const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
1172
+ const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
1173
+ if (chain) currentRun.chainId = chain.id;
1174
+ return;
1402
1175
  }
1176
+ if (event.message.customType !== TICK_MESSAGE_TYPE) return;
1403
1177
 
1404
- if (pendingScheduleRules) {
1405
- notes.push(pendingScheduleRules);
1406
- pendingScheduleRules = undefined;
1407
- if (monitorToolAvailable()) {
1408
- notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"the /loop prompt"`));
1409
- }
1178
+ const details = (event.message.details ?? {}) as {
1179
+ jobId?: string;
1180
+ guidelines?: unknown;
1181
+ immediate?: boolean;
1182
+ tickCount?: number;
1183
+ };
1184
+ const notes = Array.isArray(details.guidelines)
1185
+ ? details.guidelines.filter((note): note is string => typeof note === "string")
1186
+ : [];
1187
+ if (details.immediate && typeof details.tickCount === "number") currentRun.tick = details.tickCount;
1188
+
1189
+ // A self-paced tick has to schedule the next iteration.
1190
+ const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
1191
+ if (chain?.kind === "wakeup") {
1192
+ currentRun.chainId = chain.id;
1193
+ notes.push(loopGuidelines(chain.prompt));
1194
+ // Only mention the Monitor tool when it is actually installed.
1195
+ if (monitorToolAvailable()) notes.push(monitorGuidelines(chain.prompt));
1410
1196
  }
1411
1197
 
1412
1198
  if (!notes.length) return;
@@ -1414,137 +1200,118 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1414
1200
  options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
1415
1201
  });
1416
1202
 
1417
- pi.on("agent_start", async (_event, ctx) => {
1418
- lastCtx = ctx;
1419
- scheduler.noteTurnStarted();
1420
- });
1421
-
1422
- pi.on("agent_end", async (_event, ctx) => {
1203
+ /**
1204
+ * The run is over and Pi will not continue on its own. Esc on a loop's own
1205
+ * turn cancels that loop; chains whose tick was not rescheduled get their
1206
+ * keepalive. Then a pending tick may go out.
1207
+ */
1208
+ pi.on("agent_settled", async (event, ctx) => {
1423
1209
  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
- }
1210
+ if ((event as { aborted?: boolean } | undefined)?.aborted) cancelCurrentLoop(ctx);
1211
+ settleChains(ctx);
1212
+ currentRun = {};
1213
+ scheduler.notifyIdle();
1465
1214
  });
1466
1215
 
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();
1216
+ /** After a compaction, send the full loop.md tasks again on the next tick. */
1217
+ pi.on("session_compact", async () => {
1218
+ loopFile.reset();
1471
1219
  });
1472
1220
 
1473
- pi.on("session_start", async (_event, ctx) => {
1221
+ pi.on("session_start", async (event, ctx) => {
1474
1222
  lastCtx = ctx;
1475
- restoreState(ctx);
1476
- expireOldJobs(ctx);
1223
+ durable?.stop();
1224
+ restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1225
+ durable = createDurable(ctx.cwd);
1226
+ durable.start();
1477
1227
  scheduler.start();
1478
1228
  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");
1229
+ notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
1486
1230
  }
1487
- scheduler.clear(false);
1488
1231
  });
1489
1232
 
1490
1233
  pi.on("session_shutdown", async () => {
1491
1234
  scheduler.stop();
1235
+ durable?.stop();
1236
+ durable = undefined;
1492
1237
  scheduler.clear(false);
1493
1238
  });
1494
1239
 
1495
1240
  // -------------------------------------------------------------- restoring
1496
1241
 
1497
- function restoreState(ctx: ExtensionContext): void {
1242
+ /**
1243
+ * Restore the session's jobs from its last state entry, following Claude
1244
+ * Code's resume rules: "restores the tasks scheduled with CronCreate,
1245
+ * except recurring tasks that have expired and one-shot tasks whose
1246
+ * scheduled time has passed. A self-paced /loop isn't restored". A restored
1247
+ * recurring job does not catch up on missed runs.
1248
+ *
1249
+ * `/reload` is different: the process keeps running, so a self-paced
1250
+ * wakeup that is still in the future is kept (Claude Code re-arms a pending
1251
+ * wakeup when its process restarts in time).
1252
+ */
1253
+ function restoreState(ctx: ExtensionContext, reason: string | undefined): void {
1498
1254
  scheduler.clear(false);
1255
+ lastPersistedSignature = "";
1499
1256
 
1500
1257
  const entries = ctx.sessionManager.getEntries();
1258
+ let data: PersistedState | undefined;
1501
1259
  for (let i = entries.length - 1; i >= 0; i--) {
1502
1260
  const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
1503
1261
  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
- }
1262
+ data = entry.data;
1516
1263
  break;
1517
1264
  }
1518
1265
  }
1266
+ if (!data) {
1267
+ refreshWidget(scheduler.list());
1268
+ return;
1269
+ }
1519
1270
 
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
- }
1271
+ for (const prompt of data.stopped ?? []) rememberStopped(prompt);
1272
+ if (typeof data.widget === "boolean") widgetEnabled = data.widget;
1273
+
1274
+ const now = Date.now();
1275
+ const limit = maxAgeMs();
1276
+ let droppedChains = 0;
1277
+ let droppedOneShots = 0;
1278
+ for (const saved of data.jobs) {
1279
+ // Durable jobs come from .pi/scheduled_tasks.json, never from a session.
1280
+ if (saved.durable) continue;
1281
+ const job = storedJob(saved);
1282
+ if (job.kind === "wakeup") {
1283
+ if (reason === "reload" && job.scheduledFor !== undefined && job.scheduledFor > now) {
1284
+ scheduler.restore(job);
1285
+ } else {
1286
+ droppedChains++;
1534
1287
  }
1288
+ continue;
1535
1289
  }
1536
- } catch {
1537
- // ignore a malformed file
1290
+ if (!job.recurring) {
1291
+ const due = nextRunTime(job, job.createdAt, timingConfig());
1292
+ if (due === null || due <= now) {
1293
+ droppedOneShots++;
1294
+ continue;
1295
+ }
1296
+ } else if (limit > 0 && now - job.createdAt >= limit) {
1297
+ continue;
1298
+ }
1299
+ scheduler.restore(job);
1300
+ scheduler.arm(job, now);
1538
1301
  }
1539
1302
 
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",
1303
+ const notes: string[] = [];
1304
+ if (droppedChains) {
1305
+ notes.push(
1306
+ `${droppedChains} self-paced loop${droppedChains === 1 ? " was" : "s were"} not restored: run /loop again to restart ${droppedChains === 1 ? "it" : "them"}`,
1307
+ );
1308
+ }
1309
+ if (droppedOneShots) {
1310
+ notes.push(
1311
+ `${droppedOneShots} one-shot job${droppedOneShots === 1 ? "" : "s"} dropped: the scheduled time passed while the session was closed`,
1546
1312
  );
1547
1313
  }
1548
- refreshWidget(scheduler.list());
1314
+ if (notes.length) notify(ctx, notes.join(" \u00b7 "), "warning");
1315
+ scheduler.refresh();
1549
1316
  }
1550
1317
  }