pi-cc-loop 0.2.0 → 0.4.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";
@@ -53,11 +49,14 @@ import {
53
49
  missedJobsMessage,
54
50
  monitorGuidelines,
55
51
  provenance,
52
+ RULES_PROVENANCE,
53
+ RUN_NOW_HINT,
56
54
  SCHEDULE_WAKEUP_DESCRIPTION,
55
+ scheduledTaskProvenance,
57
56
  SCHEDULE_WAKEUP_SNIPPET,
58
- scheduleRules,
57
+ loopCommandGuidelines,
59
58
  } from "./prompts.ts";
60
- import { formatClock, loopToolRenderers, renderTickMessage } from "./renderers.ts";
59
+ import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
61
60
  import {
62
61
  DEFAULT_TIMING,
63
62
  generateId,
@@ -67,16 +66,14 @@ import {
67
66
  type LoopJob,
68
67
  type TimingConfig,
69
68
  } from "./schedule.ts";
70
- import { truncate } from "./tool-rows.ts";
69
+ import { truncate } from "./render.ts";
71
70
 
72
71
  const STATE_ENTRY = "pi-cc-loop-state";
73
- export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
74
72
  /** 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";
73
+ const MONITOR_EVENT_TYPE = "Monitor";
74
+ /** Footer status key, shared with every other extension's status line. */
75
+ const STATUS_KEY = "loop";
77
76
  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
77
 
81
78
  /**
82
79
  * The event channel this extension publishes on when a chain ends. pi-cc-monitor
@@ -101,8 +98,6 @@ interface PersistedState {
101
98
  jobs: LoopJob[];
102
99
  /** Prompts whose chain was stopped and must not be rescheduled. */
103
100
  stopped?: string[];
104
- /** Whether the status widget is shown. Claude Code has no such widget. */
105
- widget?: boolean;
106
101
  }
107
102
 
108
103
  function envNumber(name: string, fallback: number): number {
@@ -155,41 +150,6 @@ export function getScheduler(): LoopScheduler | null {
155
150
  return activeScheduler;
156
151
  }
157
152
 
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
153
  export default function piCcLoopExtension(pi: ExtensionAPI): void {
194
154
  /** Cached context for UI helpers. */
195
155
  let lastCtx: ExtensionContext | undefined;
@@ -199,11 +159,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
199
159
  * woke up.
200
160
  */
201
161
  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
162
  /** Signature of the last persisted state, to avoid redundant session entries. */
208
163
  let lastPersistedSignature = "";
209
164
  /**
@@ -216,9 +171,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
216
171
  /**
217
172
  * What the current run belongs to, derived from its starting message:
218
173
  * `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.
174
+ * `cronId` the fixed-interval job whose tick started it, `armed` the chains
175
+ * it armed, `tick` the tick number of a /loop run, `loopRequest` set when a
176
+ * typed /loop started it (its rules already say to do the task now).
220
177
  */
221
- let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
178
+ let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number; loopRequest?: boolean } = {};
222
179
 
223
180
  const scheduler = new LoopScheduler({
224
181
  dispatch: (job) => deliverTick(job),
@@ -227,7 +184,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
227
184
  },
228
185
  onChange: (jobs) => {
229
186
  persist(jobs);
230
- refreshWidget(jobs);
187
+ refreshStatus(jobs);
231
188
  },
232
189
  canDispatch: () => {
233
190
  const ctx = lastCtx;
@@ -244,7 +201,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
244
201
  const ctx = lastCtx;
245
202
  if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
246
203
  },
247
- onScan: (now) => durable?.maintain(now),
204
+ onDispatchError: (job, error) => {
205
+ const ctx = lastCtx;
206
+ if (ctx) notify(ctx, `loop ${job.id}: this tick could not be delivered and was skipped (${error.message})`, "warning");
207
+ },
208
+ onScan: (now) => {
209
+ durable?.maintain(now);
210
+ // The countdown is coarse, so this is a string compare most seconds.
211
+ refreshStatus();
212
+ },
248
213
  });
249
214
  activeScheduler = scheduler;
250
215
 
@@ -276,11 +241,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
276
241
  function persist(jobs: LoopJob[]): void {
277
242
  const kept = jobs.filter((job) => !job.durable).map(storedJob);
278
243
  const stopped = [...stoppedPrompts];
279
- const signature = JSON.stringify([kept.map(signatureOf), stopped, widgetEnabled]);
244
+ const signature = JSON.stringify([kept.map(signatureOf), stopped]);
280
245
  if (signature === lastPersistedSignature) return;
281
246
  lastPersistedSignature = signature;
282
247
  try {
283
- pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped, widget: widgetEnabled } satisfies PersistedState);
248
+ pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
284
249
  } catch {
285
250
  // session persistence is best effort
286
251
  }
@@ -294,21 +259,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
294
259
  }
295
260
  }
296
261
 
297
- function refreshWidget(jobs: LoopJob[]): void {
262
+ /**
263
+ * Rules for a message we are about to send, keyed by its text. A user
264
+ * message carries no details, so the run's guidelines and the loop it
265
+ * belongs to wait here for before_agent_start to pick them up.
266
+ */
267
+ const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
268
+ [];
269
+
270
+ function queueRun(run: (typeof queuedRuns)[number]): void {
271
+ queuedRuns.push(run);
272
+ if (queuedRuns.length > 8) queuedRuns.shift();
273
+ }
274
+
275
+ function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
276
+ const index = queuedRuns.findIndex((run) => run.content === content);
277
+ return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
278
+ }
279
+
280
+ /**
281
+ * One line in Pi's footer, the same place other extensions show status.
282
+ * Claude Code has no loop status in its footer; a one-line summary is enough
283
+ * to know that something is armed and when it next fires.
284
+ */
285
+ function statusText(jobs: LoopJob[]): string | undefined {
286
+ if (!jobs.length) return undefined;
287
+ const now = Date.now();
288
+ const next = Math.min(
289
+ ...jobs.map((job) => {
290
+ if (job.pending) return now;
291
+ const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
292
+ return at ?? now;
293
+ }),
294
+ );
295
+ return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
296
+ }
297
+
298
+ let lastStatusText: string | undefined;
299
+ let statusSet = false;
300
+
301
+ function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
298
302
  const ctx = lastCtx;
299
303
  if (!ctx || !ctx.hasUI) return;
300
- if (!widgetEnabled) {
301
- ctx.ui.setWidget(WIDGET_KEY, undefined);
302
- return;
304
+ try {
305
+ // Older Pi versions have no footer status API.
306
+ if (typeof ctx.ui.setStatus !== "function") return;
307
+ const text = loadConfig().footer ? statusText(jobs) : undefined;
308
+ // Every call repaints, even with the same text, so speak only on change.
309
+ if (text === lastStatusText && (text !== undefined || !statusSet)) return;
310
+ lastStatusText = text;
311
+ statusSet = text !== undefined;
312
+ ctx.ui.setStatus(STATUS_KEY, text);
313
+ } catch {
314
+ // a stale context must never break the loop
303
315
  }
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
316
  }
313
317
 
314
318
  function rememberStopped(prompt: string): void {
@@ -395,9 +399,9 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
395
399
  customType: TICK_MESSAGE_TYPE,
396
400
  content: missedJobsMessage(lines),
397
401
  display: true,
398
- details: { missed: true, jobIds: jobs.map((job) => job.id) },
402
+ details: { missed: true, prompt: lines.join("\n") },
399
403
  },
400
- { deliverAs: "nextTurn" },
404
+ { deliverAs: "followUp" },
401
405
  );
402
406
  } catch {
403
407
  // best effort
@@ -412,6 +416,34 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
412
416
  }
413
417
  }
414
418
 
419
+ /**
420
+ * A scheduled fire is a custom message, not a user message: the provenance
421
+ * line in its content is addressed to the model, and only a custom message
422
+ * can draw something else for the reader (`renderScheduledMessage`).
423
+ */
424
+ const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
425
+
426
+ /**
427
+ * The per-run rules, appended to the run as a hidden message instead of
428
+ * being written into the system prompt. Providers that cannot carry a system
429
+ * message mid-conversation get the leading system prompt rebuilt from every
430
+ * change, so a rule that comes and goes with each tick would invalidate the
431
+ * whole cached prefix on the way into the run and again on the way out.
432
+ * Claude Code does the same: its /loop instructions are part of the message.
433
+ */
434
+ const RULES_MESSAGE_TYPE = "pi-cc-loop-rules";
435
+
436
+ function rulesFor(guidelines: string[]) {
437
+ if (!guidelines.length) return undefined;
438
+ return {
439
+ message: {
440
+ customType: RULES_MESSAGE_TYPE,
441
+ content: [RULES_PROVENANCE, ...guidelines].join("\n\n"),
442
+ display: false,
443
+ },
444
+ };
445
+ }
446
+
415
447
  /** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
416
448
  function resolvePrompt(prompt: string, mode: TickMode): string {
417
449
  return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
@@ -424,20 +456,34 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
424
456
  */
425
457
  function deliverTick(job: LoopJob): void {
426
458
  const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
427
- tickCounters.set(job.prompt, tick);
428
- job.tickCount = tick;
429
459
  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.
460
+ // A fixed-interval tick is what it is - a scheduled task - and a
461
+ // self-paced tick keeps the loop framing. The rules for the run are
462
+ // rebuilt from `details` in before_agent_start.
463
+ const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
464
+ const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
432
465
  pi.sendMessage(
433
466
  {
434
467
  customType: TICK_MESSAGE_TYPE,
435
- content: `${provenance(tick)}\n${body}`,
468
+ content: `${stamp}\n${body}`,
436
469
  display: true,
437
- details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
470
+ details: {
471
+ kind: job.kind,
472
+ tickCount: tick,
473
+ jobId: job.id,
474
+ cron: job.cron,
475
+ prompt: body,
476
+ reason: job.reason,
477
+ },
438
478
  },
479
+ // `triggerTurn` is what starts the run: without it the message is
480
+ // only recorded and nothing ever happens.
439
481
  { deliverAs: "followUp", triggerTurn: true },
440
482
  );
483
+ // Counted only once the message is in: a throw here means the tick never
484
+ // happened, so it must not use up a number.
485
+ tickCounters.set(job.prompt, tick);
486
+ job.tickCount = tick;
441
487
 
442
488
  if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
443
489
  const ctx = lastCtx;
@@ -445,34 +491,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
445
491
  }
446
492
  }
447
493
 
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
494
  /** True when the pi-cc-monitor extension registered its Monitor tool. */
477
495
  function monitorToolAvailable(): boolean {
478
496
  try {
@@ -482,11 +500,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
482
500
  }
483
501
  }
484
502
 
485
- /** 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
503
  /**
491
504
  * A run finished without an abort: self-paced chains whose tick the model
492
505
  * did not reschedule get their one keepalive, or end.
@@ -549,32 +562,22 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
549
562
  // ---------------------------------------------------------------- command
550
563
 
551
564
  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",
565
+ description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
554
566
  handler: async (args: string, ctx: ExtensionCommandContext) => {
555
567
  lastCtx = ctx;
556
568
  const raw = args.trim();
557
-
558
569
  const [head, ...rest] = raw.split(/\s+/);
570
+
571
+ // Settings configure the extension, not a loop: they stay in code.
559
572
  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": {
573
+ case "footer":
574
+ case "statusline": {
569
575
  const arg = (rest[0] ?? "").toLowerCase();
570
- if (arg !== "on" && arg !== "off") {
571
- notify(ctx, `Usage: /loop widget on|off (currently ${widgetEnabled ? "on" : "off"})`, "info");
576
+ if (!arg) {
577
+ notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
572
578
  return;
573
579
  }
574
- widgetEnabled = arg === "on";
575
- persist(scheduler.list());
576
- refreshWidget(scheduler.list());
577
- notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
580
+ setSetting(ctx, "footer", arg);
578
581
  return;
579
582
  }
580
583
  case "settings":
@@ -589,11 +592,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
589
592
  setSetting(ctx, "messages", value);
590
593
  return;
591
594
  }
592
- if (name === "tools" || name === "tool" || name === "rows") {
593
- setSetting(ctx, "tools", value);
595
+ if (name === "footer" || name === "status") {
596
+ setSetting(ctx, "footer", value);
594
597
  return;
595
598
  }
596
- notify(ctx, "Usage: /loop settings [messages|tools] [on|off]", "info");
599
+ notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
597
600
  return;
598
601
  }
599
602
  case "help":
@@ -605,203 +608,52 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
605
608
  break;
606
609
  }
607
610
 
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);
643
- },
644
- });
645
-
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)}`);
611
+ // Everything else becomes a normal user message, the way Claude Code's
612
+ // /loop skill does: the conversation keeps the request, and the model
613
+ // drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
614
+ // The per-run rules travel with the run through before_agent_start.
615
+ const text = raw ? `/loop ${raw}` : "/loop";
616
+ const guidelines = [loopCommandGuidelines(raw)];
617
+ const send = () => {
618
+ // The user asking for this loop again is a new loop: the mark that
619
+ // keeps a late schedule_wakeup from reviving a stopped chain must
620
+ // not block it.
621
+ stoppedPrompts.delete(normalizePrompt(raw) || LOOP_FILE_SENTINEL);
622
+ queueRun({ content: text, guidelines });
623
+ try {
624
+ pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
625
+ } catch {
626
+ // The session was replaced while this request waited.
688
627
  }
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
- }
628
+ };
741
629
 
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");
630
+ // A run that is already streaming has passed before_agent_start, so
631
+ // sending now would split the request in two: the text would be queued
632
+ // into that run without its rules, and the model would work through it
633
+ // twice. Wait for the run to end and start a clean one instead - the
634
+ // same wait a follow-up lane would impose, without losing the rules.
635
+ if (!ctx.isIdle() && typeof ctx.waitForIdle === "function") {
636
+ notify(ctx, `${text} will start when the current turn ends`, "info");
637
+ void ctx.waitForIdle().then(send, () => {});
756
638
  return;
757
639
  }
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
- }
640
+ // The followUp lane is the fallback for a Pi without waitForIdle: a
641
+ // sendUserMessage from a streaming turn throws without one ("Agent is
642
+ // already processing. Specify streamingBehavior"). When idle the option
643
+ // is ignored and the turn starts.
644
+ send();
645
+ },
646
+ });
793
647
 
794
648
  function showHelp(ctx: ExtensionContext): void {
795
649
  const help = [
796
650
  "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
797
651
  "/loop <prompt> self-paced: the agent picks the next delay",
798
652
  "/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",
653
+ "/loop footer on|off show or hide the one-line summary in the footer",
802
654
  "/loop settings show UI settings",
803
655
  "/loop settings messages on|off compact two-line markers",
804
- "/loop settings tools on|off one-line tool results",
656
+ "/loop settings footer on|off the one-line footer summary",
805
657
  "",
806
658
  "Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
807
659
  "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
@@ -815,15 +667,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
815
667
  const lines = [
816
668
  `pi-cc settings \u00b7 ${configPath()}`,
817
669
  ` messages: ${config.compactMessages ? "compact" : "card"}`,
818
- ` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
670
+ ` footer: ${config.footer ? "summary" : "hidden"}`,
819
671
  "",
820
672
  "Usage: /loop settings messages on|off",
821
- " /loop settings tools on|off",
673
+ " /loop settings footer on|off",
822
674
  ];
823
675
  notify(ctx, lines.join("\n"), "info");
824
676
  }
825
677
 
826
- function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
678
+ function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
827
679
  const value = (raw ?? "").toLowerCase();
828
680
  const on = value === "on" || value === "true" || value === "1";
829
681
  const off = value === "off" || value === "false" || value === "0";
@@ -831,28 +683,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
831
683
  showSettings(ctx);
832
684
  return;
833
685
  }
834
- const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { compactToolRows: on };
686
+ const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
835
687
  const next = saveConfig(patch);
836
- let compatChanged = false;
837
- if (key === "tools") {
838
- compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
839
- }
840
688
  try {
841
689
  pi.events.emit(CONFIG_CHANNEL, next);
842
690
  } catch {
843
691
  // another extension must not break the command
844
692
  }
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
- }
693
+ notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
694
+ if (key === "footer") refreshStatus();
849
695
  }
850
696
 
851
697
  // ------------------------------------------------------------------ tools
852
698
 
853
699
  pi.registerTool({
854
700
  name: "cron_create",
855
- label: "Create Scheduled Job",
701
+ // Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
702
+ label: "CronCreate",
856
703
  description: CRON_CREATE_DESCRIPTION,
857
704
  promptSnippet: CRON_CREATE_SNIPPET,
858
705
  parameters: Type.Object({
@@ -928,6 +775,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
928
775
  lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
929
776
  }
930
777
  lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
778
+ // Asked for in plain words, not through /loop: do it once now, as /loop would.
779
+ if (recurring && !currentRun.loopRequest && !currentRun.cronId && !currentRun.chainId) lines.push(RUN_NOW_HINT);
931
780
 
932
781
  return {
933
782
  content: [{ type: "text", text: lines.join("\n") }],
@@ -946,7 +795,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
946
795
 
947
796
  pi.registerTool({
948
797
  name: "cron_delete",
949
- label: "Cancel Scheduled Job",
798
+ label: "CronDelete",
950
799
  description: CRON_DELETE_DESCRIPTION,
951
800
  promptSnippet: CRON_DELETE_SNIPPET,
952
801
  parameters: Type.Object({
@@ -975,7 +824,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
975
824
 
976
825
  pi.registerTool({
977
826
  name: "cron_list",
978
- label: "List Scheduled Jobs",
827
+ label: "CronList",
979
828
  description: CRON_LIST_DESCRIPTION,
980
829
  promptSnippet: CRON_LIST_SNIPPET,
981
830
  parameters: Type.Object({}),
@@ -1007,7 +856,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1007
856
 
1008
857
  pi.registerTool({
1009
858
  name: "schedule_wakeup",
1010
- label: "Schedule Next Loop Iteration",
859
+ label: "ScheduleWakeup",
1011
860
  description: SCHEDULE_WAKEUP_DESCRIPTION,
1012
861
  promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
1013
862
  parameters: Type.Object({
@@ -1017,7 +866,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1017
866
  }),
1018
867
  ),
1019
868
  reason: Type.Optional(
1020
- Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
869
+ Type.String({
870
+ description:
871
+ "One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
872
+ }),
1021
873
  ),
1022
874
  prompt: Type.Optional(
1023
875
  Type.String({
@@ -1045,8 +897,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1045
897
  {
1046
898
  type: "text" as const,
1047
899
  text: chains.length
1048
- ? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
1049
- : "Loop ended. Nothing was pending.",
900
+ ? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
901
+ "Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
902
+ : "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
903
+ "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
904
  },
1051
905
  ],
1052
906
  details: { stopped: true, cancelledWakeups: chains.length },
@@ -1054,11 +908,33 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1054
908
  }
1055
909
 
1056
910
  if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
1057
- throw new Error("delaySeconds is required unless stop is true.");
911
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
912
+ }
913
+ if (!params.reason || !params.reason.trim()) {
914
+ throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
1058
915
  }
1059
916
  const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
1060
917
  if (!prompt) {
1061
- throw new Error("prompt is required unless stop is true.");
918
+ throw new Error("`prompt` is required when `stop` is not true.");
919
+ }
920
+
921
+ if (currentRun.cronId) {
922
+ // Not an error: the request is already satisfied by the schedule.
923
+ // Returning it as a result (instead of throwing) keeps the transcript
924
+ // calm and tells the model nothing is missing.
925
+ const cronJob = scheduler.get(currentRun.cronId);
926
+ return {
927
+ content: [
928
+ {
929
+ type: "text" as const,
930
+ text:
931
+ `No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
932
+ `${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
933
+ `Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
934
+ },
935
+ ],
936
+ details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
937
+ };
1062
938
  }
1063
939
 
1064
940
  // Claude Code rounds and clamps; only a change after rounding counts as clamped.
@@ -1080,8 +956,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1080
956
  {
1081
957
  type: "text" as const,
1082
958
  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.",
959
+ `Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
960
+ "do not re-issue. Tell the user they can start a new /loop.",
1085
961
  },
1086
962
  ],
1087
963
  details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
@@ -1119,9 +995,17 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1119
995
  reason: params.reason,
1120
996
  });
1121
997
 
1122
- const note = wasClamped ? ` (clamped from ${requested}s)` : "";
998
+ const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
999
+ const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
1123
1000
  return {
1124
- content: [{ type: "text" as const, text: `Next iteration scheduled in ${delay}s${note} for loop ${chain.id}.` }],
1001
+ content: [
1002
+ {
1003
+ type: "text" as const,
1004
+ text:
1005
+ `Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
1006
+ "This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
1007
+ },
1008
+ ],
1125
1009
  details: {
1126
1010
  id: chain.id,
1127
1011
  scheduledFor,
@@ -1137,19 +1021,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1137
1021
  },
1138
1022
  });
1139
1023
 
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
1024
 
1154
1025
  // ----------------------------------------------------------------- events
1155
1026
 
@@ -1161,11 +1032,46 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1161
1032
  * wakeup stays armed as the fallback heartbeat, and the model re-arms it if
1162
1033
  * it wants a different time.
1163
1034
  */
1035
+ pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
1036
+
1164
1037
  pi.on("before_agent_start", (event, ctx) => {
1165
1038
  lastCtx = ctx;
1166
1039
  currentRun = {};
1040
+
1041
+ // Our own messages are ordinary user messages now, so the instructions
1042
+ // for the run and the loop it belongs to travel with the queued entry.
1043
+ if (event.messageType === "user") {
1044
+ const queued = takeQueuedRun(event.prompt);
1045
+ if (!queued) return;
1046
+ currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick, loopRequest: true };
1047
+ return rulesFor(queued.guidelines);
1048
+ }
1167
1049
  if (event.messageType !== "custom") return;
1168
1050
 
1051
+ if (event.message.customType === TICK_MESSAGE_TYPE) {
1052
+ const details = (event.message.details ?? {}) as {
1053
+ kind?: "cron" | "wakeup";
1054
+ jobId?: string;
1055
+ cron?: string;
1056
+ tickCount?: number;
1057
+ };
1058
+ currentRun = {
1059
+ chainId: details.kind === "wakeup" ? details.jobId : undefined,
1060
+ cronId: details.kind === "cron" ? details.jobId : undefined,
1061
+ tick: details.tickCount,
1062
+ };
1063
+ // A cron fire needs nothing beyond its envelope ("it fires the next tick
1064
+ // on its own, so do not call schedule_wakeup; to cancel it, call
1065
+ // cron_delete"): an extra message every minute would only grow the
1066
+ // context. A self-paced tick carries the rules that keep the loop alive.
1067
+ if (details.kind !== "wakeup") return;
1068
+ const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
1069
+ if (!chain) return;
1070
+ const guidelines = [loopGuidelines(chain.prompt)];
1071
+ if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
1072
+ return rulesFor(guidelines);
1073
+ }
1074
+
1169
1075
  if (event.message.customType === MONITOR_EVENT_TYPE) {
1170
1076
  // A monitor bound to a loop woke the agent: the run belongs to that loop.
1171
1077
  const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
@@ -1173,31 +1079,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1173
1079
  if (chain) currentRun.chainId = chain.id;
1174
1080
  return;
1175
1081
  }
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
1082
  });
1202
1083
 
1203
1084
  /**
@@ -1220,17 +1101,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1220
1101
 
1221
1102
  pi.on("session_start", async (event, ctx) => {
1222
1103
  lastCtx = ctx;
1104
+ // Another process may have edited the shared settings file.
1105
+ invalidateConfig();
1223
1106
  durable?.stop();
1224
1107
  restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1225
1108
  durable = createDurable(ctx.cwd);
1226
1109
  durable.start();
1227
1110
  scheduler.start();
1228
- if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
1229
- notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
1230
- }
1231
1111
  });
1232
1112
 
1233
1113
  pi.on("session_shutdown", async () => {
1114
+ // Pi clears extension statuses when it unloads, but a reload should not
1115
+ // leave a stale count behind until then.
1116
+ try {
1117
+ lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
1118
+ } catch {
1119
+ // the context may already be invalid
1120
+ }
1234
1121
  scheduler.stop();
1235
1122
  durable?.stop();
1236
1123
  durable = undefined;
@@ -1264,12 +1151,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1264
1151
  }
1265
1152
  }
1266
1153
  if (!data) {
1267
- refreshWidget(scheduler.list());
1268
1154
  return;
1269
1155
  }
1270
1156
 
1271
1157
  for (const prompt of data.stopped ?? []) rememberStopped(prompt);
1272
- if (typeof data.widget === "boolean") widgetEnabled = data.widget;
1273
1158
 
1274
1159
  const now = Date.now();
1275
1160
  const limit = maxAgeMs();