pi-cc-loop 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,1550 @@
1
+ /**
2
+ * pi-cc-loop — a Claude Code compatible `/loop` for the Pi coding agent.
3
+ *
4
+ * Mirrors Claude Code v2.1.306:
5
+ *
6
+ * /loop 5m check the deploy fixed interval, driven by a 5-field cron
7
+ * /loop check the deploy self-paced: the model picks the next delay
8
+ * /loop uses .pi/loop.md, or the built-in maintenance prompt
9
+ * /loop stop | status | list manage the jobs
10
+ *
11
+ * Two mechanisms, same as Claude Code:
12
+ *
13
+ * fixed interval a cron job fires on a timer. The scheduler enqueues the
14
+ * prompt at low priority, between turns, and coalesces
15
+ * missed intervals into a single tick.
16
+ * self-paced `schedule_wakeup` lets the model choose the next delay
17
+ * (60-3600s). If the model forgets to reschedule, the runtime
18
+ * arms one 20 minute keepalive; if it still forgets, the
19
+ * chain ends.
20
+ *
21
+ * The tools are named after their Claude Code counterparts:
22
+ * CronCreate -> cron_create
23
+ * CronDelete -> cron_delete
24
+ * CronList -> cron_list
25
+ * ScheduleWakeup -> schedule_wakeup
26
+ */
27
+
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";
39
+ import { Type } from "typebox";
40
+ import {
41
+ describeCron,
42
+ humanSchedule,
43
+ intervalToCron,
44
+ isUnreachable,
45
+ parseCron,
46
+ parseEveryClause,
47
+ parseIntervalToken,
48
+ } from "./cron.ts";
49
+ 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";
57
+ import {
58
+ bodyLines,
59
+ contentText,
60
+ firstLine,
61
+ formatStamp,
62
+ renderBlock,
63
+ renderCard,
64
+ stripProvenance,
65
+ } from "./render.ts";
66
+
67
+ const STATE_ENTRY = "pi-cc-loop-state";
68
+ const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
69
+ const WIDGET_KEY = "pi-cc-loop";
70
+ const CONFIG_CHANNEL = "pi-cc:config";
71
+ const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
72
+ const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
73
+
74
+ /**
75
+ * The event channel pi-cc-monitor publishes on. When a monitor names a loop
76
+ * chain, its events supersede that chain's fallback heartbeat.
77
+ */
78
+ const MONITOR_WAKE_CHANNEL = "monitor:wake";
79
+
80
+ /**
81
+ * The event channel this extension publishes on when a chain ends. pi-cc-monitor
82
+ * listens to it and stops any monitor that belongs to the chain, the way
83
+ * Claude Code requires ("call ScheduleWakeup with stop: true and TaskStop any
84
+ * Monitor you armed").
85
+ */
86
+ const LOOP_STOPPED_CHANNEL = "loop:stopped";
87
+
88
+ const MAX_JOBS = 50;
89
+ const MIN_DELAY_SECONDS = 60;
90
+ const MAX_DELAY_SECONDS = 3600;
91
+ 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]";
158
+
159
+ interface PersistedState {
160
+ version: 1;
161
+ jobs: LoopJob[];
162
+ /** Prompts whose chain was stopped and must not be rescheduled. */
163
+ stopped?: string[];
164
+ /** Whether the status widget is shown. Claude Code has no such widget. */
165
+ widget?: boolean;
166
+ }
167
+
168
+ function envNumber(name: string, fallback: number): number {
169
+ const raw = process.env[name];
170
+ if (raw === undefined) return fallback;
171
+ const value = Number(raw);
172
+ return Number.isFinite(value) ? value : fallback;
173
+ }
174
+
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;
178
+ }
179
+
180
+ function jitterFraction(): number {
181
+ return Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", 0)));
182
+ }
183
+
184
+ /**
185
+ * Fallback delay used when the model forgot to reschedule a self-paced loop.
186
+ * Set to 0 to disable the fallback: the chain then ends as soon as a turn
187
+ * finishes without a reschedule.
188
+ */
189
+ function keepaliveSeconds(): number {
190
+ return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
191
+ }
192
+
193
+ function formatDuration(ms: number): string {
194
+ if (!Number.isFinite(ms) || ms < 0) return "unknown";
195
+ const totalSeconds = Math.round(ms / 1000);
196
+ if (totalSeconds < 60) return `${totalSeconds}s`;
197
+ const minutes = Math.floor(totalSeconds / 60);
198
+ const seconds = totalSeconds % 60;
199
+ if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
200
+ const hours = Math.floor(minutes / 60);
201
+ const restMinutes = minutes % 60;
202
+ if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
203
+ const days = Math.floor(hours / 24);
204
+ return `${days}d${hours % 24}h`;
205
+ }
206
+
207
+ function formatClock(epochMs: number | undefined): string {
208
+ if (!epochMs) return "unknown";
209
+ return new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
210
+ }
211
+
212
+ function truncate(text: string, max: number): string {
213
+ const clean = text.replace(/\s+/g, " ").trim();
214
+ return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
215
+ }
216
+
217
+ /** A compact one-line call row for a tool. */
218
+ function toolCall(label: string, detail: string, theme: Theme): Text {
219
+ const title = theme.fg("toolTitle", theme.bold(label));
220
+ return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
221
+ }
222
+
223
+ /** A compact one-line result row for a tool. */
224
+ function toolResult(
225
+ summary: string,
226
+ detail: string | undefined,
227
+ options: { expanded: boolean; isPartial: boolean; isError: boolean },
228
+ theme: Theme,
229
+ ): Text {
230
+ if (options.isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
231
+ const prefix = options.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
232
+ const body = options.isError ? theme.fg("error", summary) : theme.fg("toolOutput", summary);
233
+ const text = prefix + body;
234
+ if (options.expanded && detail) return new Text(`${text}\n${theme.fg("dim", detail)}`, 0, 0);
235
+ return new Text(text, 0, 0);
236
+ }
237
+
238
+ /** Hide the call row once the result has arrived, leaving one result line. */
239
+ function completedCall(): Container {
240
+ return new Container();
241
+ }
242
+
243
+ /** The first text line of a tool result, used as a fallback summary. */
244
+ function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
245
+ for (const block of result.content ?? []) {
246
+ if (block?.type === "text" && typeof block.text === "string") {
247
+ return block.text.split("\n")[0] ?? "";
248
+ }
249
+ }
250
+ return "";
251
+ }
252
+
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;
279
+ }
280
+
281
+ /** The live scheduler, for tests. */
282
+ let activeScheduler: LoopScheduler | null = null;
283
+
284
+ export function getScheduler(): LoopScheduler | null {
285
+ return activeScheduler;
286
+ }
287
+
288
+ /**
289
+ * Pull the `--durable` and `--max N` flags out of the command line.
290
+ * Flags may appear anywhere; the remaining tokens are the prompt.
291
+ */
292
+ function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
293
+ const tokens = raw.split(/\s+/).filter(Boolean);
294
+ const kept: string[] = [];
295
+ let durable = false;
296
+ let maxFires: number | undefined;
297
+
298
+ for (let i = 0; i < tokens.length; i++) {
299
+ const token = tokens[i];
300
+ if (token.toLowerCase() === "--durable") {
301
+ durable = true;
302
+ continue;
303
+ }
304
+ const inline = /^--max=(\d+)$/i.exec(token);
305
+ if (inline) {
306
+ maxFires = Math.max(1, Number(inline[1]));
307
+ continue;
308
+ }
309
+ if (/^--max$/i.test(token)) {
310
+ const next = tokens[i + 1];
311
+ if (next && /^\d+$/.test(next)) {
312
+ maxFires = Math.max(1, Number(next));
313
+ i++;
314
+ }
315
+ continue;
316
+ }
317
+ kept.push(token);
318
+ }
319
+
320
+ return { body: kept.join(" "), durable, maxFires };
321
+ }
322
+
323
+ export default function piCcLoopExtension(pi: ExtensionAPI): void {
324
+ const scheduler = new LoopScheduler({
325
+ dispatch: (job) => {
326
+ deliverTick(job);
327
+ },
328
+ onChange: (jobs) => {
329
+ persist(jobs);
330
+ refreshWidget(jobs);
331
+ },
332
+ maxAgeMs,
333
+ jitterFraction,
334
+ jitterCapMs: () => 30 * 60 * 1000,
335
+ canDispatch: () => {
336
+ const ctx = lastCtx;
337
+ if (!ctx) return false;
338
+ try {
339
+ return ctx.isIdle() && !ctx.hasPendingMessages();
340
+ } catch {
341
+ return false;
342
+ }
343
+ },
344
+ 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
+ }
355
+ },
356
+ });
357
+ activeScheduler = scheduler;
358
+
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
+ // ---------------------------------------------------------------- helpers
390
+
391
+ 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
+ );
409
+ if (signature === lastPersistedSignature) return;
410
+ lastPersistedSignature = signature;
411
+
412
+ const durable = jobs.filter((job) => job.durable);
413
+ try {
414
+ pi.appendEntry(STATE_ENTRY, {
415
+ version: 1,
416
+ jobs,
417
+ stopped: [...stoppedPrompts],
418
+ widget: widgetEnabled,
419
+ } satisfies PersistedState);
420
+ } catch {
421
+ // session persistence is best effort
422
+ }
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
+ }
439
+
440
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
441
+ if (ctx.hasUI) ctx.ui.notify(message, level);
442
+ }
443
+
444
+ function refreshWidget(jobs: LoopJob[]): void {
445
+ const ctx = lastCtx;
446
+ if (!ctx || !ctx.hasUI) return;
447
+ if (!widgetEnabled) {
448
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
449
+ return;
450
+ }
451
+ const lines: string[] = [];
452
+ for (const job of jobs) {
453
+ if (job.kind === "cron") {
454
+ const at = job.nextFireAt;
455
+ const inText = at ? `in ${formatDuration(at - Date.now())}` : "pending";
456
+ lines.push(
457
+ ctx.ui.theme.fg(
458
+ "accent",
459
+ `loop ${job.id} · fixed ${job.cron} · next ${formatClock(at)} (${inText}) · ${truncate(job.prompt, 28)}`,
460
+ ),
461
+ );
462
+ } else {
463
+ const at = job.scheduledFor;
464
+ const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
465
+ lines.push(
466
+ ctx.ui.theme.fg("accent", `loop ${job.id} · self-paced · ${inText} · ${truncate(job.prompt, 28)}`),
467
+ );
468
+ }
469
+ }
470
+ ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
471
+ }
472
+
473
+ function findChain(prompt: string): LoopJob | undefined {
474
+ return scheduler.findChain(prompt);
475
+ }
476
+
477
+ /**
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
485
+ * reschedules, and the loop the user just stopped comes back.
486
+ */
487
+ function retireChain(chain: LoopJob, reason: string): void {
488
+ stoppedPrompts.add(chain.prompt);
489
+ scheduler.remove(chain.id);
490
+ try {
491
+ pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
492
+ } catch {
493
+ // a listener must never break the loop
494
+ }
495
+ const ctx = lastCtx;
496
+ if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
497
+ }
498
+
499
+ function endChain(chain: LoopJob, reason: string): void {
500
+ retireChain(chain, reason);
501
+ }
502
+
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
+ }
515
+ }
516
+ }
517
+
518
+ /**
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."
525
+ */
526
+ 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
+ const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
530
+ 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;
536
+ pi.sendMessage(
537
+ {
538
+ customType: TICK_MESSAGE_TYPE,
539
+ content,
540
+ display: true,
541
+ details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
542
+ },
543
+ { deliverAs: "followUp", triggerTurn: true },
544
+ );
545
+
546
+ const cap = job.maxFires;
547
+ if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
548
+ const ctx = lastCtx;
549
+ if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${cap} fires`, "info");
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Run the prompt right away, the way Claude Code's skill does
555
+ * ("Then immediately execute the parsed prompt now").
556
+ *
557
+ * `steer` is the right lane here: it lands in the current turn when the
558
+ * agent is already working, which is what executing the prompt inside the
559
+ * skill turn means.
560
+ */
561
+ /**
562
+ * How many times each prompt has been run as a loop tick.
563
+ *
564
+ * The immediate run of `/loop` and the first fire of the job the model
565
+ * creates afterwards belong to the same loop, so they share one counter.
566
+ */
567
+ const tickCounters = new Map<string, number>();
568
+
569
+ function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
570
+ const tick = (tickCounters.get(prompt) ?? 0) + 1;
571
+ tickCounters.set(prompt, tick);
572
+ if (job) {
573
+ scheduler.update(job.id, {
574
+ tickCount: tick,
575
+ fireCount: (job.fireCount ?? 0) + 1,
576
+ lastTickAt: Date.now(),
577
+ });
578
+ }
579
+ runChainId = job?.kind === "wakeup" ? job.id : undefined;
580
+ pi.sendMessage(
581
+ {
582
+ customType: TICK_MESSAGE_TYPE,
583
+ content: `${PROVENANCE.replace("{n}", String(tick))}\n${prompt}`,
584
+ display: true,
585
+ details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
586
+ },
587
+ { deliverAs: "steer", triggerTurn: true },
588
+ );
589
+ }
590
+
591
+ function scheduleNote(text: string): void {
592
+ pendingNote = text;
593
+ }
594
+
595
+ /** True when the pi-cc-monitor extension registered its Monitor tool. */
596
+ function monitorToolAvailable(): boolean {
597
+ try {
598
+ return pi.getAllTools().some((tool) => tool.name === "Monitor");
599
+ } catch {
600
+ return false;
601
+ }
602
+ }
603
+
604
+ /** The tool names to exclude from pi-cc-extensions' global styling. */
605
+ function piCcToolNames(): string[] {
606
+ return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
607
+ }
608
+
609
+ // ---------------------------------------------------------------- command
610
+
611
+ pi.registerCommand("loop", {
612
+ description:
613
+ "Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
614
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
615
+ lastCtx = ctx;
616
+ const raw = args.trim();
617
+
618
+ if (!raw) {
619
+ await startLoop(ctx, null, "", false, undefined);
620
+ return;
621
+ }
622
+
623
+ const [head, ...rest] = raw.split(/\s+/);
624
+ switch (head.toLowerCase()) {
625
+ case "stop":
626
+ await stopLoops(ctx, rest.join(" "));
627
+ return;
628
+ case "status":
629
+ case "list":
630
+ case "jobs":
631
+ showStatus(ctx);
632
+ return;
633
+ case "widget": {
634
+ const arg = (rest[0] ?? "").toLowerCase();
635
+ if (arg !== "on" && arg !== "off") {
636
+ notify(ctx, `Usage: /loop widget on|off (currently ${widgetEnabled ? "on" : "off"})`, "info");
637
+ return;
638
+ }
639
+ widgetEnabled = arg === "on";
640
+ persist(scheduler.list());
641
+ refreshWidget(scheduler.list());
642
+ notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
643
+ return;
644
+ }
645
+ case "settings":
646
+ case "config": {
647
+ const [key, value] = rest;
648
+ if (!key) {
649
+ showSettings(ctx);
650
+ return;
651
+ }
652
+ const name = key.toLowerCase();
653
+ if (name === "messages" || name === "message") {
654
+ setSetting(ctx, "messages", value);
655
+ return;
656
+ }
657
+ if (name === "tools" || name === "tool" || name === "rows") {
658
+ setSetting(ctx, "tools", value);
659
+ return;
660
+ }
661
+ notify(ctx, "Usage: /loop settings [messages|tools] [on|off]", "info");
662
+ return;
663
+ }
664
+ case "help":
665
+ case "--help":
666
+ case "-h":
667
+ showHelp(ctx);
668
+ return;
669
+ default:
670
+ break;
671
+ }
672
+
673
+ // Flags are stripped before parsing so they never interfere with the
674
+ // interval token.
675
+ const flags = extractFlags(raw);
676
+ const body = flags.body;
677
+
678
+ let intervalSeconds: number | null = null;
679
+ let prompt = body;
680
+
681
+ // Rule 1: leading interval token, e.g. "5m check the deploy"
682
+ const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
683
+ if (leading) {
684
+ const parsed = parseIntervalToken(leading[1]);
685
+ if (parsed !== null) {
686
+ intervalSeconds = parsed;
687
+ prompt = leading[2].trim();
688
+ }
689
+ }
690
+
691
+ // Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
692
+ if (intervalSeconds === null) {
693
+ const every = parseEveryClause(body);
694
+ if (every) {
695
+ intervalSeconds = every.interval;
696
+ prompt = every.prompt;
697
+ }
698
+ }
699
+
700
+ // Rule 3: no interval -> self-paced
701
+ await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
702
+ },
703
+ });
704
+
705
+ async function startLoop(
706
+ ctx: ExtensionCommandContext,
707
+ intervalSeconds: number | null,
708
+ prompt: string,
709
+ durable: boolean,
710
+ maxFires: number | undefined,
711
+ ): Promise<void> {
712
+ expireOldJobs(ctx);
713
+
714
+ // Starting a chain again clears the stop marker: the user asked for it.
715
+ stoppedPrompts.delete(prompt);
716
+
717
+ let finalPrompt = prompt;
718
+
719
+ if (!finalPrompt) {
720
+ const loopFile = resolveLoopFile(ctx.cwd);
721
+ if (loopFile) {
722
+ finalPrompt = `Work the tasks from the loop file at ${loopFile.path}:\n\n${loopFile.content}`;
723
+ } else {
724
+ finalPrompt = MAINTENANCE_PROMPT;
725
+ }
726
+ }
727
+
728
+ if (intervalSeconds === null) {
729
+ // The interval could not be parsed from the command line. Claude Code's
730
+ // /loop is a skill, so the model reads the input and works the schedule
731
+ // out itself; the rules below let it do the same here. No chain is
732
+ // created up front: the model picks cron_create (fixed interval) or
733
+ // schedule_wakeup (open ended), and schedule_wakeup creates the chain.
734
+ let rules = LOOP_SCHEDULE_RULES.replace("{prompt}", finalPrompt).replace(
735
+ "{quoted}",
736
+ `"${finalPrompt}"`,
737
+ );
738
+ if (durable) {
739
+ rules +=
740
+ "\n- The user asked for the job to survive a restart: create it with `durable: true`.";
741
+ }
742
+ if (maxFires !== undefined) {
743
+ rules +=
744
+ `\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
745
+ }
746
+ pendingScheduleRules = rules;
747
+ runNow(ctx, finalPrompt);
748
+ return;
749
+ }
750
+
751
+ const spec = intervalToCron(intervalSeconds);
752
+ if (!spec) {
753
+ notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
754
+ return;
755
+ }
756
+ if (!parseCron(spec.cron)) {
757
+ notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
758
+ return;
759
+ }
760
+ if (scheduler.size >= MAX_JOBS) {
761
+ notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
762
+ return;
763
+ }
764
+
765
+ const job = scheduler.add({
766
+ id: generateId(),
767
+ kind: "cron",
768
+ cron: spec.cron,
769
+ prompt: finalPrompt,
770
+ recurring: true,
771
+ createdAt: Date.now(),
772
+ durable,
773
+ maxFires,
774
+ label: humanSchedule(spec.cron),
775
+ source: "loop_command",
776
+ });
777
+
778
+ const expiryDays = Math.round(maxAgeMs() / 86_400_000);
779
+ notify(
780
+ ctx,
781
+ `Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)}) · ${spec.cron} · expires in ${expiryDays}d · cancel with /loop stop ${job.id}` +
782
+ (spec.note ? ` · ${spec.note}` : ""),
783
+ "info",
784
+ );
785
+ scheduleNote(
786
+ `A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
787
+ `next run ${formatClock(job.nextFireAt)}, auto-expires after ${expiryDays} days. ` +
788
+ `The user cancels it with \`/loop stop ${job.id}\`.` +
789
+ (spec.note ? ` The requested interval was adjusted: ${spec.note}.` : ""),
790
+ );
791
+
792
+ runNow(ctx, finalPrompt, job);
793
+ }
794
+
795
+ async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
796
+ if (target) {
797
+ const job = scheduler.get(target);
798
+ if (!job) {
799
+ notify(ctx, `No loop with id ${target}`, "warning");
800
+ return;
801
+ }
802
+ if (job.kind === "wakeup") {
803
+ retireChain(job, "stopped by the user");
804
+ } else {
805
+ scheduler.remove(target);
806
+ notify(ctx, `Cancelled loop ${target}`, "info");
807
+ }
808
+ return;
809
+ }
810
+ const jobs = scheduler.list();
811
+ if (!jobs.length) {
812
+ notify(ctx, "No active loops", "info");
813
+ return;
814
+ }
815
+ for (const job of jobs) {
816
+ if (job.kind === "wakeup") {
817
+ // Retiring also tells pi-cc-monitor to stop the monitors that
818
+ // belong to this chain.
819
+ retireChain(job, "stopped by the user");
820
+ } else {
821
+ scheduler.remove(job.id);
822
+ }
823
+ }
824
+ notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
825
+ }
826
+
827
+ function showStatus(ctx: ExtensionContext): void {
828
+ const jobs = scheduler.list();
829
+ if (!jobs.length) {
830
+ notify(ctx, "No active loops", "info");
831
+ return;
832
+ }
833
+ const lines = jobs.map((job) => {
834
+ if (job.kind === "cron") {
835
+ const cap = job.maxFires !== undefined ? ` · ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
836
+ return `${job.id} cron ${job.cron} (${job.label ?? describeCron(job.cron ?? "")}) next ${formatClock(job.nextFireAt)}${cap} ${truncate(job.prompt, 60)}`;
837
+ }
838
+ const at = job.scheduledFor;
839
+ const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
840
+ return `${job.id} self-paced ${inText} ${truncate(job.prompt, 60)}`;
841
+ });
842
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
843
+ }
844
+
845
+ function showHelp(ctx: ExtensionContext): void {
846
+ const help = [
847
+ "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
848
+ "/loop <prompt> self-paced: the agent picks the next delay",
849
+ "/loop use .pi/loop.md, or the built-in maintenance prompt",
850
+ "/loop stop [id] cancel one loop, or all of them",
851
+ "/loop status list the active loops",
852
+ "/loop widget on|off show or hide the status line above the editor",
853
+ "/loop settings show UI settings",
854
+ "/loop settings messages on|off compact two-line markers",
855
+ "/loop settings tools on|off one-line tool results",
856
+ "",
857
+ "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
858
+ " --max N stop the loop after N fires.",
859
+ ].join("\n");
860
+ if (ctx.hasUI) ctx.ui.notify(help, "info");
861
+ }
862
+
863
+ function showSettings(ctx: ExtensionContext): void {
864
+ const config = loadConfig();
865
+ const lines = [
866
+ `pi-cc settings · ${configPath()}`,
867
+ ` messages: ${config.compactMessages ? "compact" : "card"}`,
868
+ ` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
869
+ "",
870
+ "Usage: /loop settings messages on|off",
871
+ " /loop settings tools on|off",
872
+ ];
873
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
874
+ }
875
+
876
+ function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
877
+ const value = (raw ?? "").toLowerCase();
878
+ const on = value === "on" || value === "true" || value === "1";
879
+ const off = value === "off" || value === "false" || value === "0";
880
+ if (!on && !off) {
881
+ showSettings(ctx);
882
+ return;
883
+ }
884
+ const patch: Partial<PiCcConfig> =
885
+ key === "messages" ? { compactMessages: on } : { compactToolRows: on };
886
+ const next = saveConfig(patch);
887
+ let compatChanged = false;
888
+ if (key === "tools") {
889
+ compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
890
+ }
891
+ try {
892
+ pi.events.emit(CONFIG_CHANNEL, next);
893
+ } catch {
894
+ // another extension must not break the command
895
+ }
896
+ notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
897
+ if (compatChanged) {
898
+ notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
899
+ }
900
+ }
901
+
902
+ // ------------------------------------------------------------------ tools
903
+
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({
915
+ description:
916
+ 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
917
+ }),
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
+ }
953
+
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
+ });
968
+
969
+ 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
+ },
986
+ };
987
+ },
988
+ });
989
+
990
+ pi.registerTool({
991
+ name: "cron_delete",
992
+ label: "Cancel Scheduled Job",
993
+ description: "Cancel a scheduled job by its id. Use it to stop a recurring /loop.",
994
+ parameters: Type.Object({
995
+ id: Type.String({ description: "The job id returned by cron_create." }),
996
+ }),
997
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
998
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
999
+ lastCtx = ctx;
1000
+ const job = scheduler.get(params.id);
1001
+ if (!job) {
1002
+ return {
1003
+ content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
1004
+ details: { cancelled: false, id: params.id },
1005
+ };
1006
+ }
1007
+ scheduler.remove(params.id);
1008
+ return {
1009
+ content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
1010
+ details: { cancelled: true, id: params.id },
1011
+ };
1012
+ },
1013
+ });
1014
+
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");
1037
+ return {
1038
+ content: [{ type: "text" as const, text }],
1039
+ details: { jobs },
1040
+ };
1041
+ },
1042
+ });
1043
+
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
+ }
1083
+ return {
1084
+ content: [
1085
+ {
1086
+ type: "text" as const,
1087
+ text: chains.length
1088
+ ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1089
+ : "Loop ended. Nothing was pending.",
1090
+ },
1091
+ ],
1092
+ details: { stopped: true, cancelledWakeups: chains.length },
1093
+ };
1094
+ }
1095
+
1096
+ if (typeof params.delaySeconds !== "number" || !Number.isFinite(params.delaySeconds)) {
1097
+ throw new Error("delaySeconds is required unless stop is true.");
1098
+ }
1099
+ const prompt = params.prompt?.trim();
1100
+ if (!prompt) {
1101
+ throw new Error("prompt is required unless stop is true.");
1102
+ }
1103
+
1104
+ const requested = params.delaySeconds;
1105
+ const clamped = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, requested));
1106
+ const wasClamped = clamped !== requested;
1107
+
1108
+ let chain = findChain(prompt);
1109
+ if (!chain) {
1110
+ if (stoppedPrompts.has(prompt)) {
1111
+ throw new Error(
1112
+ `The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
1113
+ "Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
1114
+ );
1115
+ }
1116
+ chain = scheduler.add({
1117
+ id: generateId(),
1118
+ kind: "wakeup",
1119
+ prompt,
1120
+ recurring: false,
1121
+ createdAt: Date.now(),
1122
+ chainStartedAt: Date.now(),
1123
+ durable: false,
1124
+ source: "schedule_wakeup",
1125
+ });
1126
+ }
1127
+
1128
+ const scheduledFor = Date.now() + clamped * 1000;
1129
+ scheduler.update(chain.id, {
1130
+ scheduledFor,
1131
+ keepaliveUsed: false,
1132
+ keepaliveTurn: false,
1133
+ awaitingReschedule: false,
1134
+ reason: params.reason,
1135
+ });
1136
+
1137
+ return {
1138
+ content: [
1139
+ {
1140
+ type: "text" as const,
1141
+ text:
1142
+ `Next iteration scheduled in ${clamped}s` +
1143
+ (wasClamped ? ` (clamped from ${requested}s)` : "") +
1144
+ ` for loop ${chain.id}.`,
1145
+ },
1146
+ ],
1147
+ details: {
1148
+ id: chain.id,
1149
+ scheduledFor,
1150
+ clampedDelaySeconds: clamped,
1151
+ wasClamped,
1152
+ stopped: false,
1153
+ },
1154
+ };
1155
+ },
1156
+ });
1157
+ }
1158
+
1159
+ registerTools();
1160
+
1161
+ // ---------------------------------------------------------------- rendering
1162
+
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
+
1307
+ pi.registerToolRenderer((toolName, next) => {
1308
+ if (!loadConfig().compactToolRows) return next();
1309
+ return renderers[toolName] ?? next();
1310
+ });
1311
+ }
1312
+
1313
+ registerToolRenderers();
1314
+
1315
+ // ----------------------------------------------------------------- events
1316
+
1317
+ /**
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.
1367
+ *
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.
1372
+ */
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
+ pi.on("before_agent_start", (event, ctx) => {
1382
+ 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
+ }
1402
+ }
1403
+
1404
+ if (pendingScheduleRules) {
1405
+ notes.push(pendingScheduleRules);
1406
+ pendingScheduleRules = undefined;
1407
+ if (monitorToolAvailable()) {
1408
+ notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"the /loop prompt"`));
1409
+ }
1410
+ }
1411
+
1412
+ if (!notes.length) return;
1413
+ const options = event.systemPromptOptions;
1414
+ options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
1415
+ });
1416
+
1417
+ pi.on("agent_start", async (_event, ctx) => {
1418
+ lastCtx = ctx;
1419
+ scheduler.noteTurnStarted();
1420
+ });
1421
+
1422
+ pi.on("agent_end", async (_event, ctx) => {
1423
+ 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
+ }
1465
+ });
1466
+
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();
1471
+ });
1472
+
1473
+ pi.on("session_start", async (_event, ctx) => {
1474
+ lastCtx = ctx;
1475
+ restoreState(ctx);
1476
+ expireOldJobs(ctx);
1477
+ scheduler.start();
1478
+ if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
1479
+ notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
1480
+ }
1481
+ });
1482
+
1483
+ pi.on("session_before_switch", async () => {
1484
+ for (const chain of scheduler.byKind("wakeup")) {
1485
+ retireChain(chain, "the session was switched");
1486
+ }
1487
+ scheduler.clear(false);
1488
+ });
1489
+
1490
+ pi.on("session_shutdown", async () => {
1491
+ scheduler.stop();
1492
+ scheduler.clear(false);
1493
+ });
1494
+
1495
+ // -------------------------------------------------------------- restoring
1496
+
1497
+ function restoreState(ctx: ExtensionContext): void {
1498
+ scheduler.clear(false);
1499
+
1500
+ const entries = ctx.sessionManager.getEntries();
1501
+ for (let i = entries.length - 1; i >= 0; i--) {
1502
+ const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
1503
+ 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
+ }
1516
+ break;
1517
+ }
1518
+ }
1519
+
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
+ }
1534
+ }
1535
+ }
1536
+ } catch {
1537
+ // ignore a malformed file
1538
+ }
1539
+
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",
1546
+ );
1547
+ }
1548
+ refreshWidget(scheduler.list());
1549
+ }
1550
+ }