pi-cc-loop 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -29,15 +29,11 @@
29
29
 
30
30
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
31
31
  import { Type } from "typebox";
32
- import { configPath, loadConfig, saveConfig, syncPiCcExtensionsExcludes, type PiCcConfig } from "./config.ts";
32
+ import { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
33
33
  import {
34
- describeCron,
35
34
  humanSchedule,
36
- intervalToCron,
37
35
  isUnreachable,
38
36
  parseCron,
39
- parseEveryClause,
40
- parseIntervalToken,
41
37
  } from "./cron.ts";
42
38
  import { DurableRuntime, type Placement } from "./durable.ts";
43
39
  import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
@@ -48,16 +44,18 @@ import {
48
44
  CRON_DELETE_SNIPPET,
49
45
  CRON_LIST_DESCRIPTION,
50
46
  CRON_LIST_SNIPPET,
47
+ cronTickGuidelines,
51
48
  LOOP_FILE_SENTINEL,
52
49
  loopGuidelines,
53
50
  missedJobsMessage,
54
51
  monitorGuidelines,
55
52
  provenance,
56
53
  SCHEDULE_WAKEUP_DESCRIPTION,
54
+ scheduledTaskProvenance,
57
55
  SCHEDULE_WAKEUP_SNIPPET,
58
- scheduleRules,
56
+ loopCommandGuidelines,
59
57
  } from "./prompts.ts";
60
- import { formatClock, loopToolRenderers, renderTickMessage } from "./renderers.ts";
58
+ import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
61
59
  import {
62
60
  DEFAULT_TIMING,
63
61
  generateId,
@@ -67,16 +65,14 @@ import {
67
65
  type LoopJob,
68
66
  type TimingConfig,
69
67
  } from "./schedule.ts";
70
- import { truncate } from "./tool-rows.ts";
68
+ import { truncate } from "./render.ts";
71
69
 
72
70
  const STATE_ENTRY = "pi-cc-loop-state";
73
- export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
74
71
  /** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
75
- const MONITOR_EVENT_TYPE = "pi-cc-monitor-event";
76
- const WIDGET_KEY = "pi-cc-loop";
72
+ const MONITOR_EVENT_TYPE = "Monitor";
73
+ /** Footer status key, shared with every other extension's status line. */
74
+ const STATUS_KEY = "loop";
77
75
  const CONFIG_CHANNEL = "pi-cc:config";
78
- const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
79
- const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
80
76
 
81
77
  /**
82
78
  * The event channel this extension publishes on when a chain ends. pi-cc-monitor
@@ -101,8 +97,6 @@ interface PersistedState {
101
97
  jobs: LoopJob[];
102
98
  /** Prompts whose chain was stopped and must not be rescheduled. */
103
99
  stopped?: string[];
104
- /** Whether the status widget is shown. Claude Code has no such widget. */
105
- widget?: boolean;
106
100
  }
107
101
 
108
102
  function envNumber(name: string, fallback: number): number {
@@ -155,41 +149,6 @@ export function getScheduler(): LoopScheduler | null {
155
149
  return activeScheduler;
156
150
  }
157
151
 
158
- /**
159
- * Pull the `--durable` and `--max N` flags out of the command line.
160
- * Flags may appear anywhere; the remaining tokens are the prompt.
161
- */
162
- function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
163
- const tokens = raw.split(/\s+/).filter(Boolean);
164
- const kept: string[] = [];
165
- let durable = false;
166
- let maxFires: number | undefined;
167
-
168
- for (let i = 0; i < tokens.length; i++) {
169
- const token = tokens[i];
170
- if (token.toLowerCase() === "--durable") {
171
- durable = true;
172
- continue;
173
- }
174
- const inline = /^--max=(\d+)$/i.exec(token);
175
- if (inline) {
176
- maxFires = Math.max(1, Number(inline[1]));
177
- continue;
178
- }
179
- if (/^--max$/i.test(token)) {
180
- const next = tokens[i + 1];
181
- if (next && /^\d+$/.test(next)) {
182
- maxFires = Math.max(1, Number(next));
183
- i++;
184
- }
185
- continue;
186
- }
187
- kept.push(token);
188
- }
189
-
190
- return { body: kept.join(" "), durable, maxFires };
191
- }
192
-
193
152
  export default function piCcLoopExtension(pi: ExtensionAPI): void {
194
153
  /** Cached context for UI helpers. */
195
154
  let lastCtx: ExtensionContext | undefined;
@@ -199,11 +158,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
199
158
  * woke up.
200
159
  */
201
160
  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
161
  /** Signature of the last persisted state, to avoid redundant session entries. */
208
162
  let lastPersistedSignature = "";
209
163
  /**
@@ -216,9 +170,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
216
170
  /**
217
171
  * What the current run belongs to, derived from its starting message:
218
172
  * `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.
173
+ * `cronId` the fixed-interval job whose tick started it, `armed` the chains
174
+ * it armed, `tick` the tick number of a /loop run.
220
175
  */
221
- let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
176
+ let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
222
177
 
223
178
  const scheduler = new LoopScheduler({
224
179
  dispatch: (job) => deliverTick(job),
@@ -227,7 +182,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
227
182
  },
228
183
  onChange: (jobs) => {
229
184
  persist(jobs);
230
- refreshWidget(jobs);
185
+ refreshStatus(jobs);
231
186
  },
232
187
  canDispatch: () => {
233
188
  const ctx = lastCtx;
@@ -244,7 +199,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
244
199
  const ctx = lastCtx;
245
200
  if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
246
201
  },
247
- onScan: (now) => durable?.maintain(now),
202
+ onScan: (now) => {
203
+ durable?.maintain(now);
204
+ // The countdown is coarse, so this is a string compare most seconds.
205
+ refreshStatus();
206
+ },
248
207
  });
249
208
  activeScheduler = scheduler;
250
209
 
@@ -276,11 +235,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
276
235
  function persist(jobs: LoopJob[]): void {
277
236
  const kept = jobs.filter((job) => !job.durable).map(storedJob);
278
237
  const stopped = [...stoppedPrompts];
279
- const signature = JSON.stringify([kept.map(signatureOf), stopped, widgetEnabled]);
238
+ const signature = JSON.stringify([kept.map(signatureOf), stopped]);
280
239
  if (signature === lastPersistedSignature) return;
281
240
  lastPersistedSignature = signature;
282
241
  try {
283
- pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped, widget: widgetEnabled } satisfies PersistedState);
242
+ pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
284
243
  } catch {
285
244
  // session persistence is best effort
286
245
  }
@@ -294,21 +253,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
294
253
  }
295
254
  }
296
255
 
297
- function refreshWidget(jobs: LoopJob[]): void {
256
+ /**
257
+ * Rules for a message we are about to send, keyed by its text. A user
258
+ * message carries no details, so the run's guidelines and the loop it
259
+ * belongs to wait here for before_agent_start to pick them up.
260
+ */
261
+ const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
262
+ [];
263
+
264
+ function queueRun(run: (typeof queuedRuns)[number]): void {
265
+ queuedRuns.push(run);
266
+ if (queuedRuns.length > 8) queuedRuns.shift();
267
+ }
268
+
269
+ function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
270
+ const index = queuedRuns.findIndex((run) => run.content === content);
271
+ return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
272
+ }
273
+
274
+ /**
275
+ * One line in Pi's footer, the same place other extensions show status.
276
+ * Claude Code has no loop status in its footer; a one-line summary is enough
277
+ * to know that something is armed and when it next fires.
278
+ */
279
+ function statusText(jobs: LoopJob[]): string | undefined {
280
+ if (!jobs.length) return undefined;
281
+ const now = Date.now();
282
+ const next = Math.min(
283
+ ...jobs.map((job) => {
284
+ if (job.pending) return now;
285
+ const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
286
+ return at ?? now;
287
+ }),
288
+ );
289
+ return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
290
+ }
291
+
292
+ let lastStatusText: string | undefined;
293
+ let statusSet = false;
294
+
295
+ function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
298
296
  const ctx = lastCtx;
299
297
  if (!ctx || !ctx.hasUI) return;
300
- if (!widgetEnabled) {
301
- ctx.ui.setWidget(WIDGET_KEY, undefined);
302
- return;
298
+ try {
299
+ // Older Pi versions have no footer status API.
300
+ if (typeof ctx.ui.setStatus !== "function") return;
301
+ const text = loadConfig().footer ? statusText(jobs) : undefined;
302
+ // Every call repaints, even with the same text, so speak only on change.
303
+ if (text === lastStatusText && (text !== undefined || !statusSet)) return;
304
+ lastStatusText = text;
305
+ statusSet = text !== undefined;
306
+ ctx.ui.setStatus(STATUS_KEY, text);
307
+ } catch {
308
+ // a stale context must never break the loop
303
309
  }
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
- });
311
- ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
312
310
  }
313
311
 
314
312
  function rememberStopped(prompt: string): void {
@@ -395,9 +393,9 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
395
393
  customType: TICK_MESSAGE_TYPE,
396
394
  content: missedJobsMessage(lines),
397
395
  display: true,
398
- details: { missed: true, jobIds: jobs.map((job) => job.id) },
396
+ details: { missed: true, prompt: lines.join("\n") },
399
397
  },
400
- { deliverAs: "nextTurn" },
398
+ { deliverAs: "followUp" },
401
399
  );
402
400
  } catch {
403
401
  // best effort
@@ -412,6 +410,13 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
412
410
  }
413
411
  }
414
412
 
413
+ /**
414
+ * A scheduled fire is a custom message, not a user message: the provenance
415
+ * line in its content is addressed to the model, and only a custom message
416
+ * can draw something else for the reader (`renderScheduledMessage`).
417
+ */
418
+ const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
419
+
415
420
  /** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
416
421
  function resolvePrompt(prompt: string, mode: TickMode): string {
417
422
  return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
@@ -427,15 +432,27 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
427
432
  tickCounters.set(job.prompt, tick);
428
433
  job.tickCount = tick;
429
434
  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.
435
+ // A fixed-interval tick is what it is - a scheduled task - and a
436
+ // self-paced tick keeps the loop framing. The rules for the run are
437
+ // rebuilt from `details` in before_agent_start.
438
+ const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
439
+ const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
432
440
  pi.sendMessage(
433
441
  {
434
442
  customType: TICK_MESSAGE_TYPE,
435
- content: `${provenance(tick)}\n${body}`,
443
+ content: `${stamp}\n${body}`,
436
444
  display: true,
437
- details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
445
+ details: {
446
+ kind: job.kind,
447
+ tickCount: tick,
448
+ jobId: job.id,
449
+ cron: job.cron,
450
+ prompt: body,
451
+ reason: job.reason,
452
+ },
438
453
  },
454
+ // `triggerTurn` is what starts the run: without it the message is
455
+ // only recorded and nothing ever happens.
439
456
  { deliverAs: "followUp", triggerTurn: true },
440
457
  );
441
458
 
@@ -445,34 +462,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
445
462
  }
446
463
  }
447
464
 
448
- /**
449
- * Run the prompt right away, the way Claude Code's skill does ("Then
450
- * immediately execute the parsed prompt now").
451
- *
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.
456
- */
457
- function runNow(ctx: ExtensionContext, prompt: string, job: LoopJob | undefined, guidelines: string[]): void {
458
- const tick = (tickCounters.get(prompt) ?? 0) + 1;
459
- tickCounters.set(prompt, tick);
460
- if (job && scheduler.get(job.id)) {
461
- scheduler.update(job.id, { tickCount: tick, fireCount: (job.fireCount ?? 0) + 1, lastFiredAt: Date.now() });
462
- }
463
- const body = resolvePrompt(prompt, job?.kind === "cron" ? "cron" : "wakeup");
464
- pi.sendMessage(
465
- {
466
- customType: TICK_MESSAGE_TYPE,
467
- content: `${provenance(tick)}\n${body}`,
468
- display: true,
469
- details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now(), guidelines },
470
- },
471
- { deliverAs: "steer", triggerTurn: true },
472
- );
473
- lastCtx = ctx;
474
- }
475
-
476
465
  /** True when the pi-cc-monitor extension registered its Monitor tool. */
477
466
  function monitorToolAvailable(): boolean {
478
467
  try {
@@ -483,10 +472,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
483
472
  }
484
473
 
485
474
  /** The tool names to exclude from pi-cc-extensions' global styling. */
486
- function piCcToolNames(): string[] {
487
- return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
488
- }
489
-
490
475
  /**
491
476
  * A run finished without an abort: self-paced chains whose tick the model
492
477
  * did not reschedule get their one keepalive, or end.
@@ -549,32 +534,22 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
549
534
  // ---------------------------------------------------------------- command
550
535
 
551
536
  pi.registerCommand("loop", {
552
- description:
553
- "Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop [interval] | /loop stop [id] | /loop status | /loop settings",
537
+ description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
554
538
  handler: async (args: string, ctx: ExtensionCommandContext) => {
555
539
  lastCtx = ctx;
556
540
  const raw = args.trim();
557
-
558
541
  const [head, ...rest] = raw.split(/\s+/);
542
+
543
+ // Settings configure the extension, not a loop: they stay in code.
559
544
  switch ((head ?? "").toLowerCase()) {
560
- case "stop":
561
- await stopLoops(ctx, rest.join(" "));
562
- return;
563
- case "status":
564
- case "list":
565
- case "jobs":
566
- showStatus(ctx);
567
- return;
568
- case "widget": {
545
+ case "footer":
546
+ case "statusline": {
569
547
  const arg = (rest[0] ?? "").toLowerCase();
570
- if (arg !== "on" && arg !== "off") {
571
- notify(ctx, `Usage: /loop widget on|off (currently ${widgetEnabled ? "on" : "off"})`, "info");
548
+ if (!arg) {
549
+ notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
572
550
  return;
573
551
  }
574
- widgetEnabled = arg === "on";
575
- persist(scheduler.list());
576
- refreshWidget(scheduler.list());
577
- notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
552
+ setSetting(ctx, "footer", arg);
578
553
  return;
579
554
  }
580
555
  case "settings":
@@ -589,11 +564,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
589
564
  setSetting(ctx, "messages", value);
590
565
  return;
591
566
  }
592
- if (name === "tools" || name === "tool" || name === "rows") {
593
- setSetting(ctx, "tools", value);
567
+ if (name === "footer" || name === "status") {
568
+ setSetting(ctx, "footer", value);
594
569
  return;
595
570
  }
596
- notify(ctx, "Usage: /loop settings [messages|tools] [on|off]", "info");
571
+ notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
597
572
  return;
598
573
  }
599
574
  case "help":
@@ -605,203 +580,30 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
605
580
  break;
606
581
  }
607
582
 
608
- // Flags are stripped before parsing so they never interfere with the
609
- // interval token.
610
- const flags = extractFlags(raw);
611
- const body = flags.body;
612
-
613
- let intervalSeconds: number | null = null;
614
- let prompt = body;
615
-
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
- }
630
- }
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
- }
638
- }
639
- }
640
-
641
- // Rule 3: no interval -> the model decides (self-paced unless it asks for one)
642
- await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
583
+ // Everything else becomes a normal user message, the way Claude Code's
584
+ // /loop skill does: the conversation keeps the request, and the model
585
+ // drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
586
+ // The per-run rules travel with the run through before_agent_start.
587
+ const text = raw ? `/loop ${raw}` : "/loop";
588
+ queueRun({ content: text, guidelines: [loopCommandGuidelines(raw)] });
589
+ // Pi runs an extension command even while a turn is streaming, and its
590
+ // sendUserMessage throws unless a lane is named: "Agent is already
591
+ // processing. Specify streamingBehavior". `followUp` is that lane - the
592
+ // request waits for the current turn to end, which is where a /loop
593
+ // belongs anyway. When idle the option is ignored and the turn starts.
594
+ pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
643
595
  },
644
596
  });
645
597
 
646
- async function startLoop(
647
- ctx: ExtensionCommandContext,
648
- intervalSeconds: number | null,
649
- prompt: string,
650
- durableFlag: boolean,
651
- maxFires: number | undefined,
652
- ): Promise<void> {
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
- }
660
-
661
- // Without a prompt the loop runs the loop.md tasks, resolved at each fire.
662
- const finalPrompt = prompt || LOOP_FILE_SENTINEL;
663
- // Starting a chain again clears the stop marker: the user asked for it.
664
- stoppedPrompts.delete(finalPrompt);
665
- // A new loop starts by sending the full task list again.
666
- if (isLoopFileSentinel(finalPrompt)) loopFile.reset();
667
-
668
- if (intervalSeconds === null) {
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
- }
689
- }
690
- runNow(ctx, finalPrompt, undefined, guidelines);
691
- return;
692
- }
693
-
694
- const spec = intervalToCron(intervalSeconds);
695
- if (!spec || !parseCron(spec.cron)) {
696
- notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
697
- return;
698
- }
699
- if (scheduler.size >= MAX_JOBS) {
700
- notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
701
- return;
702
- }
703
-
704
- const job: LoopJob = {
705
- id: generateId(),
706
- kind: "cron",
707
- cron: spec.cron,
708
- prompt: finalPrompt,
709
- recurring: true,
710
- createdAt: Date.now(),
711
- durable: durableFlag,
712
- maxFires,
713
- label: humanSchedule(spec.cron),
714
- source: "loop_command",
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;
724
-
725
- const days = maxAgeDays();
726
- notify(
727
- ctx,
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 ?? "?"})`),
731
- "info",
732
- );
733
- const note =
734
- `A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
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}.` : "");
738
-
739
- runNow(ctx, finalPrompt, job, [note]);
740
- }
741
-
742
- async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
743
- if (target) {
744
- const job = scheduler.get(target);
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
- }
752
- return;
753
- }
754
- if (durable?.remove(target)) {
755
- notify(ctx, `Cancelled durable job ${target}`, "info");
756
- return;
757
- }
758
- notify(ctx, `No loop with id ${target}`, "warning");
759
- return;
760
- }
761
- const jobs = scheduler.list();
762
- if (!jobs.length) {
763
- notify(ctx, "No active loops", "info");
764
- return;
765
- }
766
- for (const job of jobs) {
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);
770
- }
771
- notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
772
- }
773
-
774
- function showStatus(ctx: ExtensionContext): void {
775
- const jobs = listJobs();
776
- if (!jobs.length) {
777
- notify(ctx, "No active loops", "info");
778
- return;
779
- }
780
- const lines = jobs.map((job) => {
781
- const prompt = truncate(displayPrompt(job.prompt), 60);
782
- if (job.kind === "cron") {
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}`;
786
- }
787
- const at = job.scheduledFor;
788
- const when = at && at > Date.now() ? `next ${formatClock(at)}` : "awaiting reschedule";
789
- return `${job.id} self-paced ${when} ${prompt}`;
790
- });
791
- notify(ctx, lines.join("\n"), "info");
792
- }
793
-
794
598
  function showHelp(ctx: ExtensionContext): void {
795
599
  const help = [
796
600
  "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
797
601
  "/loop <prompt> self-paced: the agent picks the next delay",
798
602
  "/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
799
- "/loop stop [id] cancel one loop, or all of them",
800
- "/loop status list the active loops",
801
- "/loop widget on|off show or hide the status line above the editor",
603
+ "/loop footer on|off show or hide the one-line summary in the footer",
802
604
  "/loop settings show UI settings",
803
605
  "/loop settings messages on|off compact two-line markers",
804
- "/loop settings tools on|off one-line tool results",
606
+ "/loop settings footer on|off the one-line footer summary",
805
607
  "",
806
608
  "Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
807
609
  "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
@@ -815,15 +617,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
815
617
  const lines = [
816
618
  `pi-cc settings \u00b7 ${configPath()}`,
817
619
  ` messages: ${config.compactMessages ? "compact" : "card"}`,
818
- ` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
620
+ ` footer: ${config.footer ? "summary" : "hidden"}`,
819
621
  "",
820
622
  "Usage: /loop settings messages on|off",
821
- " /loop settings tools on|off",
623
+ " /loop settings footer on|off",
822
624
  ];
823
625
  notify(ctx, lines.join("\n"), "info");
824
626
  }
825
627
 
826
- function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
628
+ function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
827
629
  const value = (raw ?? "").toLowerCase();
828
630
  const on = value === "on" || value === "true" || value === "1";
829
631
  const off = value === "off" || value === "false" || value === "0";
@@ -831,28 +633,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
831
633
  showSettings(ctx);
832
634
  return;
833
635
  }
834
- const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { compactToolRows: on };
636
+ const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
835
637
  const next = saveConfig(patch);
836
- let compatChanged = false;
837
- if (key === "tools") {
838
- compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
839
- }
840
638
  try {
841
639
  pi.events.emit(CONFIG_CHANNEL, next);
842
640
  } catch {
843
641
  // another extension must not break the command
844
642
  }
845
- notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
846
- if (compatChanged) {
847
- notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
848
- }
643
+ notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
644
+ if (key === "footer") refreshStatus();
849
645
  }
850
646
 
851
647
  // ------------------------------------------------------------------ tools
852
648
 
853
649
  pi.registerTool({
854
650
  name: "cron_create",
855
- label: "Create Scheduled Job",
651
+ // Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
652
+ label: "CronCreate",
856
653
  description: CRON_CREATE_DESCRIPTION,
857
654
  promptSnippet: CRON_CREATE_SNIPPET,
858
655
  parameters: Type.Object({
@@ -946,7 +743,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
946
743
 
947
744
  pi.registerTool({
948
745
  name: "cron_delete",
949
- label: "Cancel Scheduled Job",
746
+ label: "CronDelete",
950
747
  description: CRON_DELETE_DESCRIPTION,
951
748
  promptSnippet: CRON_DELETE_SNIPPET,
952
749
  parameters: Type.Object({
@@ -975,7 +772,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
975
772
 
976
773
  pi.registerTool({
977
774
  name: "cron_list",
978
- label: "List Scheduled Jobs",
775
+ label: "CronList",
979
776
  description: CRON_LIST_DESCRIPTION,
980
777
  promptSnippet: CRON_LIST_SNIPPET,
981
778
  parameters: Type.Object({}),
@@ -1007,7 +804,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1007
804
 
1008
805
  pi.registerTool({
1009
806
  name: "schedule_wakeup",
1010
- label: "Schedule Next Loop Iteration",
807
+ label: "ScheduleWakeup",
1011
808
  description: SCHEDULE_WAKEUP_DESCRIPTION,
1012
809
  promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
1013
810
  parameters: Type.Object({
@@ -1017,7 +814,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1017
814
  }),
1018
815
  ),
1019
816
  reason: Type.Optional(
1020
- Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
817
+ Type.String({
818
+ description:
819
+ "One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
820
+ }),
1021
821
  ),
1022
822
  prompt: Type.Optional(
1023
823
  Type.String({
@@ -1045,8 +845,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1045
845
  {
1046
846
  type: "text" as const,
1047
847
  text: chains.length
1048
- ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1049
- : "Loop ended. Nothing was pending.",
848
+ ? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
849
+ "Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
850
+ : "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
851
+ "If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
1050
852
  },
1051
853
  ],
1052
854
  details: { stopped: true, cancelledWakeups: chains.length },
@@ -1054,11 +856,33 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1054
856
  }
1055
857
 
1056
858
  if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
1057
- throw new Error("delaySeconds is required unless stop is true.");
859
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
860
+ }
861
+ if (!params.reason || !params.reason.trim()) {
862
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
1058
863
  }
1059
864
  const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
1060
865
  if (!prompt) {
1061
- throw new Error("prompt is required unless stop is true.");
866
+ throw new Error("`prompt` is required when `stop` is not true.");
867
+ }
868
+
869
+ if (currentRun.cronId) {
870
+ // Not an error: the request is already satisfied by the schedule.
871
+ // Returning it as a result (instead of throwing) keeps the transcript
872
+ // calm and tells the model nothing is missing.
873
+ const cronJob = scheduler.get(currentRun.cronId);
874
+ return {
875
+ content: [
876
+ {
877
+ type: "text" as const,
878
+ text:
879
+ `No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
880
+ `${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
881
+ `Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
882
+ },
883
+ ],
884
+ details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
885
+ };
1062
886
  }
1063
887
 
1064
888
  // Claude Code rounds and clamps; only a change after rounding counts as clamped.
@@ -1080,8 +904,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1080
904
  {
1081
905
  type: "text" as const,
1082
906
  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.",
907
+ `Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
908
+ "do not re-issue. Tell the user they can start a new /loop.",
1085
909
  },
1086
910
  ],
1087
911
  details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
@@ -1119,9 +943,17 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1119
943
  reason: params.reason,
1120
944
  });
1121
945
 
1122
- const note = wasClamped ? ` (clamped from ${requested}s)` : "";
946
+ const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
947
+ const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
1123
948
  return {
1124
- content: [{ type: "text" as const, text: `Next iteration scheduled in ${delay}s${note} for loop ${chain.id}.` }],
949
+ content: [
950
+ {
951
+ type: "text" as const,
952
+ text:
953
+ `Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
954
+ "This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
955
+ },
956
+ ],
1125
957
  details: {
1126
958
  id: chain.id,
1127
959
  scheduledFor,
@@ -1137,19 +969,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1137
969
  },
1138
970
  });
1139
971
 
1140
- // ---------------------------------------------------------------- rendering
1141
-
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.
1146
- pi.registerToolRenderer((toolName, next) => {
1147
- if (!loadConfig().compactToolRows) return next();
1148
- return renderers[toolName] ?? next();
1149
- });
1150
- }
1151
-
1152
- pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderTickMessage);
1153
972
 
1154
973
  // ----------------------------------------------------------------- events
1155
974
 
@@ -1161,11 +980,54 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1161
980
  * wakeup stays armed as the fallback heartbeat, and the model re-arms it if
1162
981
  * it wants a different time.
1163
982
  */
983
+ pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
984
+
1164
985
  pi.on("before_agent_start", (event, ctx) => {
1165
986
  lastCtx = ctx;
1166
987
  currentRun = {};
988
+
989
+ // Our own messages are ordinary user messages now, so the instructions
990
+ // for the run and the loop it belongs to travel with the queued entry.
991
+ if (event.messageType === "user") {
992
+ const queued = takeQueuedRun(event.prompt);
993
+ if (!queued) return;
994
+ currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick };
995
+ if (!queued.guidelines.length) return;
996
+ const options = event.systemPromptOptions;
997
+ options.promptGuidelines = [...(options.promptGuidelines ?? []), ...queued.guidelines];
998
+ return;
999
+ }
1167
1000
  if (event.messageType !== "custom") return;
1168
1001
 
1002
+ if (event.message.customType === TICK_MESSAGE_TYPE) {
1003
+ const details = (event.message.details ?? {}) as {
1004
+ kind?: "cron" | "wakeup";
1005
+ jobId?: string;
1006
+ cron?: string;
1007
+ tickCount?: number;
1008
+ };
1009
+ currentRun = {
1010
+ chainId: details.kind === "wakeup" ? details.jobId : undefined,
1011
+ cronId: details.kind === "cron" ? details.jobId : undefined,
1012
+ tick: details.tickCount,
1013
+ };
1014
+ const guidelines =
1015
+ details.kind === "cron" && details.jobId
1016
+ ? [cronTickGuidelines(details.jobId, humanSchedule(details.cron ?? "* * * * *"))]
1017
+ : [];
1018
+ if (details.kind === "wakeup") {
1019
+ const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
1020
+ if (chain) {
1021
+ guidelines.push(loopGuidelines(chain.prompt));
1022
+ if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
1023
+ }
1024
+ }
1025
+ if (!guidelines.length) return;
1026
+ const options = event.systemPromptOptions;
1027
+ options.promptGuidelines = [...(options.promptGuidelines ?? []), ...guidelines];
1028
+ return;
1029
+ }
1030
+
1169
1031
  if (event.message.customType === MONITOR_EVENT_TYPE) {
1170
1032
  // A monitor bound to a loop woke the agent: the run belongs to that loop.
1171
1033
  const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
@@ -1173,31 +1035,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1173
1035
  if (chain) currentRun.chainId = chain.id;
1174
1036
  return;
1175
1037
  }
1176
- if (event.message.customType !== TICK_MESSAGE_TYPE) return;
1177
-
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));
1196
- }
1197
-
1198
- if (!notes.length) return;
1199
- const options = event.systemPromptOptions;
1200
- options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
1201
1038
  });
1202
1039
 
1203
1040
  /**
@@ -1220,17 +1057,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1220
1057
 
1221
1058
  pi.on("session_start", async (event, ctx) => {
1222
1059
  lastCtx = ctx;
1060
+ // Another process may have edited the shared settings file.
1061
+ invalidateConfig();
1223
1062
  durable?.stop();
1224
1063
  restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1225
1064
  durable = createDurable(ctx.cwd);
1226
1065
  durable.start();
1227
1066
  scheduler.start();
1228
- if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
1229
- notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
1230
- }
1231
1067
  });
1232
1068
 
1233
1069
  pi.on("session_shutdown", async () => {
1070
+ // Pi clears extension statuses when it unloads, but a reload should not
1071
+ // leave a stale count behind until then.
1072
+ try {
1073
+ lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
1074
+ } catch {
1075
+ // the context may already be invalid
1076
+ }
1234
1077
  scheduler.stop();
1235
1078
  durable?.stop();
1236
1079
  durable = undefined;
@@ -1264,12 +1107,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1264
1107
  }
1265
1108
  }
1266
1109
  if (!data) {
1267
- refreshWidget(scheduler.list());
1268
1110
  return;
1269
1111
  }
1270
1112
 
1271
1113
  for (const prompt of data.stopped ?? []) rememberStopped(prompt);
1272
- if (typeof data.widget === "boolean") widgetEnabled = data.widget;
1273
1114
 
1274
1115
  const now = Date.now();
1275
1116
  const limit = maxAgeMs();