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/loopfile.ts CHANGED
@@ -72,17 +72,13 @@ export class LoopFileDelivery {
72
72
 
73
73
  /** The text of one tick for the loop.md sentinel. */
74
74
  resolve(cwd: string, mode: TickMode): string {
75
- const cronNote =
76
- mode === "cron"
77
- ? "\nThe recurring job fires the next tick on its own: do not call schedule_wakeup from this tick."
78
- : "";
79
75
  const file = readLoopFile(cwd);
80
76
  if (file) {
81
77
  if (this.lastDelivered === file.content) {
82
78
  return (
83
79
  "# /loop tick \u2014 loop.md tasks\n" +
84
80
  "Work the tasks from the loop.md contents established earlier in this conversation. " +
85
- `If you cannot find them, treat this as a no-op tick.${cronNote}`
81
+ "If you cannot find them, treat this as a no-op tick."
86
82
  );
87
83
  }
88
84
  this.lastDelivered = file.content;
@@ -92,16 +88,16 @@ export class LoopFileDelivery {
92
88
  "instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).",
93
89
  "",
94
90
  file.content,
95
- ].join("\n") + cronNote;
91
+ ].join("\n");
96
92
  }
97
93
  if (this.lastDelivered === MAINTENANCE_MARKER) {
98
94
  return (
99
95
  "# /loop tick \u2014 maintenance pass\n" +
100
96
  "Run the maintenance pass using the instructions established earlier in this conversation. " +
101
- `If you cannot find them, treat this as a no-op tick.${cronNote}`
97
+ "If you cannot find them, treat this as a no-op tick."
102
98
  );
103
99
  }
104
100
  this.lastDelivered = MAINTENANCE_MARKER;
105
- return MAINTENANCE_PROMPT + cronNote;
101
+ return MAINTENANCE_PROMPT;
106
102
  }
107
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
5
5
  "author": "tarnish233",
6
6
  "type": "module",
package/prompts.ts CHANGED
@@ -23,6 +23,20 @@ export const MAINTENANCE_PROMPT = [
23
23
  ].join("\n");
24
24
 
25
25
  export const PROVENANCE = "[loop tick #{n} \u00b7 scheduled by /loop, not typed by the user]";
26
+ /**
27
+ * A fixed-interval fire is framed as what it is - a scheduled task, not a
28
+ * loop iteration. Claude Code does the same: "This turn was started
29
+ * automatically by a schedule, not typed live by the user. The content below is
30
+ * the stored prompt of a scheduled task ... Treat it as this session's assigned
31
+ * task and carry it out." It never calls a cron fire a "loop tick"; that word
32
+ * belongs to dynamic pacing, where the model has to keep the loop alive. Naming
33
+ * it "loop" here is what made the model try to reschedule.
34
+ */
35
+ export const SCHEDULED_TASK_PROVENANCE =
36
+ "[scheduled task {id} \u00b7 {schedule} \u00b7 started by the schedule, not typed by the user; it fires the next tick on its own, so do not call schedule_wakeup; to cancel it, call cron_delete with id {id}]";
37
+
38
+ /** The first line of the hidden rules message a /loop request or a self-paced tick carries. */
39
+ export const RULES_PROVENANCE = "[/loop instructions for this run \u00b7 added by pi-cc-loop, not typed by the user]";
26
40
 
27
41
  const DELAY_ADVICE = [
28
42
  "- Pick the delay from what you are waiting for. For idle ticks with nothing specific to watch, use 1200-1800s.",
@@ -36,32 +50,49 @@ const DELAY_ADVICE = [
36
50
  * parse. Claude Code's /loop is a skill: the model reads the input and works
37
51
  * the schedule out itself, so an interval written in any language works.
38
52
  */
39
- const LOOP_SCHEDULE_RULES = [
40
- "This is a `/loop` request. Its prompt is: {prompt}",
53
+ /**
54
+ * The instructions for one `/loop` request. Claude Code's `/loop` is a skill:
55
+ * the command turns into a user message carrying these rules, and the model
56
+ * drives CronCreate / CronList / CronDelete / ScheduleWakeup itself. Nothing is
57
+ * created by code, so the conversation shows what was asked and what was done.
58
+ */
59
+ const LOOP_FILE_SENTINEL_PLACEHOLDER = "__loop_md_sentinel__";
60
+
61
+ const LOOP_COMMAND_GUIDELINES = [
62
+ "This is a `/loop` request from the user: {raw}",
41
63
  "",
42
- "Decide how it should repeat, then set it up.",
64
+ "## Read the input",
65
+ "- `[interval] <task>`: an interval was given (\"5m\", \"2h\", \"every 20m\", \"每2分钟\", \"每隔2小时\"). Use the fixed-interval mode below.",
66
+ "- `<task>` alone: no interval. Use the self-paced mode below and choose each delay yourself.",
67
+ "- An interval alone, or a bare `/loop`: the tasks come from loop.md (see the last section).",
68
+ "- `stop [id]` or `status`: see those sections.",
43
69
  "",
44
- "**If the prompt asks for a fixed interval** (\"every 30 seconds\", \"each 5 minutes\", \"every hour\",",
45
- "\"\u6bcf30\u79d2\"): create a recurring job with the `cron_create` tool.",
46
- "- `prompt`: the task without the interval wording, so every run does the task instead of scheduling it again.",
47
- "- Convert the interval to a 5-field cron expression in local time (\"M H DoM Mon DoW\").",
48
- " `s` rounds up to the nearest minute (the scheduler's minimum granularity is 1 minute),",
49
- " `m` are minutes, `h` are hours, `d` are days. Examples: `5m` -> `*/5 * * * *`,",
50
- " `1h` -> `0 * * * *`, `1d` -> `0 0 * * *`. If the interval does not cleanly divide its",
51
- " unit, round to the nearest clean interval and tell the user what you rounded to.",
52
- "- Then confirm to the user: what is scheduled, the cron expression, the human-readable",
53
- " cadence, the next run time from the tool result, that recurring jobs auto-expire after",
54
- " 7 days (they run one final time, then are deleted), and the job id they can cancel with",
55
- " `cron_delete`.",
70
+ "## Fixed interval (an interval was given)",
71
+ "1. Call `cron_create` **once**, `recurring: true`, with a 5-field cron expression in local time and the task **without** the interval wording, so every run does the task instead of scheduling it again.",
72
+ " - under a minute rounds up to `* * * * *`; 5 minutes is `*/5 * * * *`; every hour is `0 * * * *`; every day is `0 0 * * *`.",
73
+ " - if the interval does not divide its unit cleanly, round to the nearest clean interval and say so.",
74
+ "2. **Then do the task now** \u2014 do not wait for the first fire.",
75
+ "3. Confirm briefly: what is scheduled, the cron expression, the cadence, the next run from the tool result, that a recurring job expires after 7 days (it fires once more, then is deleted), the job id, and that `/loop stop <id>` cancels it.",
76
+ "4. Never call `schedule_wakeup` for this loop: the cron fires its own ticks. Scheduling one would run the task twice.",
56
77
  "",
57
- "**Otherwise the interval is open-ended**: you pace the loop yourself.",
58
- "- Do the task now, then write your update for the user.",
59
- "- Then call the `schedule_wakeup` tool with `delaySeconds` (60-3600), a one-sentence `reason`,",
60
- " and `prompt` set to the same /loop prompt verbatim: {quoted}",
61
- " The turn ends as soon as `schedule_wakeup` returns, so call it last.",
62
- DELAY_ADVICE,
63
- "- If the task is complete or can no longer make progress, call `schedule_wakeup` with",
64
- " `stop: true` instead of scheduling another iteration.",
78
+ "## Self-paced (no interval)",
79
+ "1. Do the task now, then write your update for the user.",
80
+ "2. Then call `schedule_wakeup` with `delaySeconds` (60-3600), a one-sentence `reason`, and `prompt` set to the same `/loop` input verbatim ({quoted}). The turn ends as soon as it returns, so call it last.",
81
+ "{delayAdvice}",
82
+ "3. If the next iteration waits on an external event and no monitor is running for it, arm `Monitor` once with `loopPrompt` set to {quoted}; keep `delaySeconds` at 1200-1800 as the fallback heartbeat.",
83
+ "4. If the task is complete or cannot progress without the user, call `schedule_wakeup` with `stop: true` and say why.",
84
+ "",
85
+ "## status and stop",
86
+ "- `status`: call `cron_list` and report each job's id, cadence, next run and prompt, plus whether it is session-only or durable.",
87
+ "- `stop`: if the id is unknown, call `cron_list` first; then `cron_delete` for a scheduled job. A self-paced loop is ended with `schedule_wakeup` and `stop: true`.",
88
+ "",
89
+ "## Flags",
90
+ "- `--durable`: pass `durable: true` to `cron_create` so the job survives restarts.",
91
+ "- `--max N`: pass `maxFires: N`.",
92
+ "",
93
+ "## loop.md",
94
+ "When the request has no task of its own (bare `/loop`, or an interval alone), pass the literal sentinel " + LOOP_FILE_SENTINEL_PLACEHOLDER + " as the prompt: it expands at fire time to `.pi/loop.md`, or to the built-in maintenance pass when there is no loop.md.",
95
+ "For the run you do right now, read `.pi/loop.md` (or `~/.pi/loop.md`) yourself and do those tasks; the sentinel only expands on later fires.",
65
96
  ].join("\n");
66
97
 
67
98
  const LOOP_GUIDELINES = [
@@ -118,8 +149,18 @@ export function provenance(tick: number): string {
118
149
  return fill(PROVENANCE, { n: String(tick) });
119
150
  }
120
151
 
121
- export function scheduleRules(prompt: string): string {
122
- return fill(LOOP_SCHEDULE_RULES, { prompt: describePrompt(prompt), quoted: quote(prompt) });
152
+ /** The header of a fixed-interval fire: a scheduled task, not a loop tick. */
153
+ export function scheduledTaskProvenance(id: string, schedule: string): string {
154
+ return fill(SCHEDULED_TASK_PROVENANCE, { id, schedule });
155
+ }
156
+
157
+ export function loopCommandGuidelines(raw: string): string {
158
+ const quoted = quote(raw || LOOP_FILE_SENTINEL);
159
+ return fill(LOOP_COMMAND_GUIDELINES, {
160
+ raw: raw ? `${quote(raw)}` : "(empty: the loop.md tasks)",
161
+ quoted,
162
+ delayAdvice: DELAY_ADVICE,
163
+ }).replace(LOOP_FILE_SENTINEL_PLACEHOLDER, quote(LOOP_FILE_SENTINEL));
123
164
  }
124
165
 
125
166
  export function loopGuidelines(prompt: string): string {
@@ -172,10 +213,23 @@ By default (durable: false) the job belongs to this Pi session: it is restored i
172
213
  cron_create re-runs a prompt at fixed wall-clock intervals. To watch a log file, process, or command output and be told the moment something changes, use the Monitor tool instead when it is available \u2014 it streams events as they happen; cron polls on a schedule.
173
214
 
174
215
  ## Runtime behavior
175
- Jobs only fire while the agent is idle (not mid-turn). The scheduler shifts each job by a fixed per-job offset on top of whatever you pick: a recurring job runs up to half its period late (max 30 min), a "*/5 * * * *" job runs every 4m45s so the 5-minute prompt cache stays warm, and a one-shot job landing on :00 or :30 runs up to 90 s early. The result of this tool and of cron_list gives each job's actual next run time: when you tell the user when a job will run, give that time, not the minute in the cron expression.
216
+ Jobs only fire while the agent is idle (not mid-turn). The scheduler shifts each job by a fixed per-job offset on top of whatever you pick: a recurring job runs up to half its period late (max 30 min), a "*/5 * * * *" job keeps its 5 minute cadence, late by that offset rather than early, and a one-shot job landing on :00 or :30 runs up to 90 s early. The result of this tool and of cron_list gives each job's actual next run time: when you tell the user when a job will run, give that time, not the minute in the cron expression.
176
217
  Recurring jobs auto-expire after 7 days \u2014 they fire one final time, then are deleted. Tell the user about the 7-day limit when scheduling recurring jobs.
177
218
  Returns a job ID you can pass to cron_delete.`;
178
219
 
220
+ /**
221
+ * Appended to the result of a recurring `cron_create` in a plain turn (not a
222
+ * /loop request, not a scheduled tick). Claude Code's /loop skill says "Then
223
+ * immediately run it now ... Don't wait for the first cron fire", and its model
224
+ * reaches that skill for "every 5 minutes ..." typed without /loop: the skill is
225
+ * listed every turn and CronCreate is a deferred tool. Pi has no deferred tools,
226
+ * so the model calls cron_create directly and the result says it instead. A
227
+ * tool result is appended at the end of the transcript; the cache is untouched.
228
+ */
229
+ export const RUN_NOW_HINT =
230
+ 'If the user asked for this to repeat on an interval ("every 5 minutes"), do the task once now as well \u2014 ' +
231
+ 'don\'t wait for the first fire, as /loop does. A job at set times ("every day at 9am") only runs at those times.';
232
+
179
233
  export const CRON_DELETE_DESCRIPTION =
180
234
  "Cancel a job scheduled with cron_create, or end a self-paced /loop, by its id. Removes it from .pi/scheduled_tasks.json (durable jobs) or from this session (session-only jobs).";
181
235
 
@@ -189,7 +243,10 @@ Pass the same /loop prompt back via \`prompt\` each turn so the next firing repe
189
243
  Do not schedule a short wakeup to poll for work Pi will tell you about anyway (a monitor event, a command you are running): schedule a long fallback (1200s+) so the loop survives if that never arrives. For external work nothing notifies you about (a CI run, a deploy, a remote queue), pick a delay matched to how fast that state actually changes.
190
244
 
191
245
  ## Picking delaySeconds
192
- Most model providers cache the conversation prefix for about 5 minutes. Under 300s (60-270s) the next wakeup reads the context cached; past that it is read uncached \u2014 slower and more expensive. When actively polling, prefer 270s over 300s (300s pays the cache miss without amortizing it), and commit to 1200s+ rather than repeated ~300s waits. For idle ticks with no specific signal to watch, default to 1200-1800s (20-30 min). The runtime clamps to [60, 3600].
246
+ Pick the delay from what you are waiting for. When actively polling state nothing notifies you about, match how fast it changes and commit to it instead of stacking short waits. For idle ticks with no specific signal to watch, default to 1200-1800s (20-30 min). The runtime clamps to [60, 3600].
247
+
248
+ ## Only for self-paced loops
249
+ A /loop started with an interval (for example /loop 5m ...) is a cron job: it fires its own ticks, and calling this from one of those ticks would run the prompt twice. Cancel that kind of loop with \`cron_delete\` instead.
193
250
 
194
251
  ## The reason field
195
252
  One short sentence on what you chose and why. It is shown to the user: "watching CI run" beats "waiting".`;
package/render.ts CHANGED
@@ -41,6 +41,22 @@ export function stripProvenance(text: string, pattern: RegExp): string {
41
41
  return text.replace(pattern, "");
42
42
  }
43
43
 
44
+ /** Cap a string for a display line, with an ellipsis. */
45
+ export function truncate(text: string, max: number): string {
46
+ const flat = text.replace(/\s+/g, " ").trim();
47
+ return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}\u2026`;
48
+ }
49
+
50
+ /** "900ms", "5s", "1m 30s": a duration for a status line. */
51
+ export function formatDuration(ms: number): string {
52
+ if (ms < 1000) return `${Math.round(ms)}ms`;
53
+ const seconds = Math.round(ms / 1000);
54
+ if (seconds < 60) return `${seconds}s`;
55
+ const minutes = Math.floor(seconds / 60);
56
+ const rest = seconds % 60;
57
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
58
+ }
59
+
44
60
  /** The first non-empty line of a text block. */
45
61
  export function firstLine(text: string): string {
46
62
  for (const line of text.split("\n")) {
@@ -66,7 +82,11 @@ export function formatStamp(epochMs: number | undefined): string {
66
82
  const date = new Date(epochMs ?? Date.now());
67
83
  if (Number.isNaN(date.getTime())) return "";
68
84
  const now = new Date();
69
- const time = date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
85
+ // Claude Code's clock style: "6:16pm", lowercase, no space.
86
+ const hour = date.getHours();
87
+ const time = `${hour % 12 === 0 ? 12 : hour % 12}:${String(date.getMinutes()).padStart(2, "0")}${
88
+ hour < 12 ? "am" : "pm"
89
+ }`;
70
90
  if (date.toDateString() === now.toDateString()) return time;
71
91
  const day = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
72
92
  return `${day} ${time}`;
package/renderers.ts CHANGED
@@ -1,137 +1,90 @@
1
1
  /**
2
- * Rendering for pi-cc-loop: the compact tool rows and the loop tick message.
2
+ * The display helpers pi-cc-loop still needs: clock times, the coarse countdown
3
+ * for the footer, and the block a scheduled fire draws in the transcript. The
4
+ * /loop request itself is an ordinary user message, rendered by Pi.
3
5
  */
4
6
 
5
- import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { bodyLines, contentText, formatStamp, renderBlock, renderCard, stripProvenance } from "./render.ts";
6
9
  import { loadConfig } from "./config.ts";
7
10
  import { humanSchedule } from "./cron.ts";
8
11
  import { displayPrompt } from "./loopfile.ts";
9
- import {
10
- bodyLines,
11
- contentText,
12
- firstLine,
13
- formatStamp,
14
- renderBlock,
15
- renderCard,
16
- stripProvenance,
17
- } from "./render.ts";
18
- import type { LoopJob } from "./schedule.ts";
19
- import { compactRenderers, truncate } from "./tool-rows.ts";
20
12
 
13
+
14
+ /**
15
+ * A clock time the way Claude Code writes it: "6:16pm". Formatted here rather
16
+ * than through the locale, so the transcript reads the same everywhere.
17
+ */
21
18
  export function formatClock(epochMs: number | undefined): string {
22
19
  if (!epochMs) return "unknown";
23
- return new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
24
- }
25
-
26
- export function formatDelay(seconds: number | undefined): string {
27
- if (seconds === undefined) return "unknown";
28
- if (seconds < 60) return `${seconds}s`;
29
- const minutes = Math.floor(seconds / 60);
30
- const rest = Math.round(seconds % 60);
31
- return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
20
+ const date = new Date(epochMs);
21
+ const hour = date.getHours();
22
+ const suffix = hour < 12 ? "am" : "pm";
23
+ const twelve = hour % 12 === 0 ? 12 : hour % 12;
24
+ return `${twelve}:${String(date.getMinutes()).padStart(2, "0")}${suffix}`;
32
25
  }
33
26
 
34
- export function jobSummary(job: LoopJob): string {
35
- if (job.kind === "cron" && job.cron) {
36
- return `${job.id} ${humanSchedule(job.cron)} \u00b7 next ${formatClock(job.nextFireAt)}`;
37
- }
38
- return `${job.id} self-paced \u00b7 ${job.scheduledFor ? `next ${formatClock(job.scheduledFor)}` : "unscheduled"}`;
27
+ /**
28
+ * A countdown for the footer: "3m", "<1m", "2h 15m". Rounded up and coarse on
29
+ * purpose, so the shared footer line is repainted at most once a minute.
30
+ */
31
+ export function formatEta(ms: number): string {
32
+ const seconds = ms / 1000;
33
+ if (seconds < 60) return "<1m";
34
+ const minutes = Math.ceil(seconds / 60);
35
+ if (minutes < 60) return `${minutes}m`;
36
+ const hours = Math.floor(minutes / 60);
37
+ const rest = minutes % 60;
38
+ return rest ? `${hours}h ${rest}m` : `${hours}h`;
39
39
  }
40
40
 
41
- /** One-line rows for the four loop tools. */
42
- export function loopToolRenderers(): Record<string, ToolRenderers> {
43
- return {
44
- cron_create: compactRenderers({
45
- call: (args) => ({
46
- label: "Schedule",
47
- detail: `${args.cron ?? ""} \u00b7 ${truncate(displayPrompt(args.prompt ?? ""), 40)}`,
48
- }),
49
- summary: (d) =>
50
- `Scheduled ${d.id ?? "?"} \u00b7 ${humanSchedule(d.cron ?? "")} \u00b7 next ${formatClock(d.nextFireAt)}` +
51
- (d.recurring === false ? " \u00b7 one-shot" : "") +
52
- (d.durable ? " \u00b7 durable" : "") +
53
- (d.runsHere === false ? " \u00b7 runs in another session" : ""),
54
- detail: (d) => (d.prompt ? displayPrompt(d.prompt) : undefined),
55
- }),
56
- cron_delete: compactRenderers({
57
- call: (args) => ({ label: "Cancel", detail: args.id ?? "" }),
58
- summary: (d) => (d.cancelled ? `Cancelled ${d.id ?? "?"}` : `No job ${d.id ?? "?"}`),
59
- }),
60
- cron_list: compactRenderers({
61
- call: () => ({ label: "List scheduled jobs" }),
62
- summary: (d) => {
63
- const jobs: LoopJob[] = d.jobs ?? [];
64
- if (!jobs.length) return "No scheduled jobs";
65
- if (jobs.length === 1) return `1 job \u00b7 ${jobSummary(jobs[0])}`;
66
- const shown = jobs.slice(0, 2).map(jobSummary).join(" \u00b7 ");
67
- return `${jobs.length} jobs \u00b7 ${shown}${jobs.length > 2 ? " \u2026" : ""}`;
68
- },
69
- detail: (d) =>
70
- ((d.jobs ?? []) as LoopJob[])
71
- .map((job) => `${jobSummary(job)} \u00b7 ${truncate(displayPrompt(job.prompt), 60)}`)
72
- .join("\n") || undefined,
73
- }),
74
- schedule_wakeup: compactRenderers({
75
- call: (args) =>
76
- args.stop === true
77
- ? { label: "Stop loop" }
78
- : {
79
- label: "Wakeup",
80
- detail: `${formatDelay(args.delaySeconds)}${args.reason ? ` \u00b7 ${truncate(args.reason, 40)}` : ""}`,
81
- },
82
- summary: (d) => {
83
- if (d.agedOut) return "Loop ended \u00b7 7-day limit reached";
84
- if (d.stopped) {
85
- return `Stopped \u00b7 ${d.cancelledWakeups ?? 0} wakeup${d.cancelledWakeups === 1 ? "" : "s"} cancelled`;
86
- }
87
- const adjusted = d.wasClamped ? " (clamped)" : "";
88
- return `Wakeup in ${formatDelay(d.clampedDelaySeconds)} \u00b7 loop ${d.id ?? "?"}${adjusted}`;
89
- },
90
- detail: (d, args) =>
91
- [args.reason, d.scheduledFor ? `at ${formatClock(d.scheduledFor)}` : undefined]
92
- .filter(Boolean)
93
- .join(" \u00b7 ") || undefined,
94
- }),
95
- };
41
+ /** What a tick carries for the renderer; the model reads `content`. */
42
+ export interface ScheduledDetails {
43
+ kind?: "cron" | "wakeup";
44
+ tickCount?: number;
45
+ jobId?: string;
46
+ cron?: string;
47
+ prompt?: string;
48
+ reason?: string;
49
+ missed?: boolean;
96
50
  }
97
51
 
98
52
  /**
99
- * Draw a tick as a quiet block instead of Pi's default `[pi-cc-loop-tick]`
100
- * title. The provenance line is for the model, not the reader.
53
+ * Draw a scheduled fire the way Claude Code does: `✻ Running scheduled task ·
54
+ * 6:16pm`, then the task. The provenance line in `content` is addressed to the
55
+ * model ("started by the schedule...") and must not reach the reader.
101
56
  */
102
- export function renderTickMessage(message: any, options: any, theme: Theme): any {
103
- const details = (message.details ?? {}) as {
104
- tickCount?: number;
105
- jobId?: string;
106
- cron?: string;
107
- immediate?: boolean;
108
- missed?: boolean;
109
- };
110
- const text = contentText(message.content);
111
- const prompt = stripProvenance(text, /^\[[^\]]*\]\n?/);
57
+ export function renderScheduledMessage(message: any, options: any, theme: Theme): any {
58
+ const details = (message.details ?? {}) as ScheduledDetails;
59
+ const prompt = details.prompt ?? stripProvenance(contentText(message.content), /^\[[^\]]*\]\n?/);
60
+ // A fixed interval fires a scheduled task; only a self-paced tick is a loop.
112
61
  const label = details.missed
113
- ? "loop \u00b7 missed jobs"
114
- : details.tickCount !== undefined
115
- ? `loop #${details.tickCount}`
116
- : "loop";
62
+ ? "\u273b loop \u00b7 missed jobs"
63
+ : details.kind === "cron"
64
+ ? "\u273b Running scheduled task"
65
+ : details.tickCount !== undefined
66
+ ? `\u273b loop #${details.tickCount}`
67
+ : "\u273b loop";
117
68
 
118
69
  if (!loadConfig().compactMessages) {
119
- const summary = firstLine(prompt);
120
- const header = details.missed
121
- ? label
122
- : `loop tick #${details.tickCount ?? "?"}${summary ? ` \u00b7 ${summary}` : ""}`;
70
+ // Same naming as the block: a cron fire is a scheduled task, only a
71
+ // self-paced tick is a loop.
72
+ const header = details.missed ? label : `${label.replace("\u273b ", "")} \u00b7 ${displayPrompt(prompt)}`;
123
73
  return renderCard({ header, body: prompt, expanded: options.expanded, outputPad: options.outputPad, theme });
124
74
  }
125
75
 
126
76
  const stamp = formatStamp(message.timestamp);
127
- const header = stamp
128
- ? `${theme.fg("accent", theme.bold(label))}${theme.fg("dim", " \u00b7 ")}${theme.fg("muted", stamp)}`
129
- : theme.fg("accent", theme.bold(label));
77
+ // The reason the model gave for this delay, in the header so the reader
78
+ // knows why the loop woke up without expanding anything.
79
+ const why = details.kind === "wakeup" && details.reason ? ` \u00b7 ${details.reason}` : "";
80
+ const header =
81
+ (stamp
82
+ ? `${theme.fg("accent", theme.bold(label))}${theme.fg("dim", " \u00b7 ")}${theme.fg("muted", stamp)}`
83
+ : theme.fg("accent", theme.bold(label))) + (why ? theme.fg("dim", why) : "");
130
84
 
131
85
  const meta: string[] = [];
132
86
  if (details.jobId) meta.push(`job ${details.jobId}`);
133
- if (details.cron) meta.push(`cron ${details.cron}`);
134
- else if (details.immediate) meta.push("immediate run");
87
+ if (details.kind === "cron" && details.cron) meta.push(humanSchedule(details.cron));
135
88
  else if (!details.missed) meta.push("self-paced");
136
89
  meta.push("scheduled by /loop");
137
90
 
package/schedule.ts CHANGED
@@ -159,6 +159,8 @@ export interface SchedulerHooks {
159
159
  onExpire?: (job: LoopJob) => void;
160
160
  /** Runs at the start of every scan (housekeeping such as durable jobs). */
161
161
  onScan?: (now: number) => void;
162
+ /** A delivery threw (Pi's API is stale once the session is replaced); the tick is dropped. */
163
+ onDispatchError?: (job: LoopJob, error: Error) => void;
162
164
  }
163
165
 
164
166
  /** Claude Code checks for due tasks once a second. */
@@ -277,7 +279,12 @@ export class LoopScheduler {
277
279
 
278
280
  /** Run one scan: housekeeping, mark due jobs pending, dispatch the oldest when idle. */
279
281
  tick(now = Date.now()): void {
280
- this.hooks.onScan?.(now);
282
+ try {
283
+ this.hooks.onScan?.(now);
284
+ } catch {
285
+ // Housekeeping (the durable file, the footer) is best effort. This runs
286
+ // from a timer, where an exception would take Pi down with it.
287
+ }
281
288
  this.markDue(now);
282
289
  this.tryDispatch(now);
283
290
  }
@@ -336,7 +343,14 @@ export class LoopScheduler {
336
343
  finished = next === null;
337
344
  }
338
345
 
339
- this.hooks.dispatch(job);
346
+ try {
347
+ this.hooks.dispatch(job);
348
+ } catch (error) {
349
+ // Dropped, like any tick that fails to start; the job keeps its next
350
+ // run. Not rethrown: this runs from a timer, where an exception would
351
+ // take Pi down with it.
352
+ this.hooks.onDispatchError?.(job, error instanceof Error ? error : new Error(String(error)));
353
+ }
340
354
  if (finished) this.jobs.delete(job.id);
341
355
  this.hooks.afterDispatch?.(job, finished);
342
356
  if (expired) this.hooks.onExpire?.(job);
package/tool-rows.ts DELETED
@@ -1,79 +0,0 @@
1
- /**
2
- * Compact one-line tool rows and small formatting helpers shared by the pi-cc
3
- * extensions.
4
- *
5
- * This file is identical in pi-cc-loop and pi-cc-monitor; tests/shared.test.mjs
6
- * fails when the two copies drift apart.
7
- */
8
-
9
- import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
10
- import { Container, Text } from "@earendil-works/pi-tui";
11
-
12
- /** Collapse whitespace and cut the text to `max` characters. */
13
- export function truncate(text: string, max: number): string {
14
- const clean = text.replace(/\s+/g, " ").trim();
15
- return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
16
- }
17
-
18
- /** "20s", "5m", "1m 30s", "1h", "1h 5m", "2d 3h". */
19
- export function formatDuration(ms: number): string {
20
- if (!Number.isFinite(ms)) return "unknown";
21
- const seconds = Math.max(0, Math.round(ms / 1000));
22
- if (seconds < 60) return `${seconds}s`;
23
- const minutes = Math.floor(seconds / 60);
24
- const restSeconds = seconds % 60;
25
- if (minutes < 60) return restSeconds ? `${minutes}m ${restSeconds}s` : `${minutes}m`;
26
- const hours = Math.floor(minutes / 60);
27
- const restMinutes = minutes % 60;
28
- if (hours < 24) return restMinutes ? `${hours}h ${restMinutes}m` : `${hours}h`;
29
- const days = Math.floor(hours / 24);
30
- const restHours = hours % 24;
31
- return restHours ? `${days}d ${restHours}h` : `${days}d`;
32
- }
33
-
34
- /** The first text line of a tool result. */
35
- export function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
36
- for (const block of result.content ?? []) {
37
- if (block?.type === "text" && typeof block.text === "string") {
38
- return block.text.split("\n")[0] ?? "";
39
- }
40
- }
41
- return "";
42
- }
43
-
44
- export interface CompactRow {
45
- /** The call row, shown while the call runs. It disappears once the result arrives. */
46
- call: (args: any) => { label: string; detail?: string };
47
- /** The one-line summary of a successful result. */
48
- summary: (details: any, args: any) => string;
49
- /** Extra lines shown when the row is expanded. */
50
- detail?: (details: any, args: any) => string | undefined;
51
- }
52
-
53
- /**
54
- * Renderers that draw a tool call as a single result line, for example
55
- * `✓ Scheduled hu2389lz · Every minute · next 04:55 PM`. Errors show the first
56
- * line of the error text.
57
- */
58
- export function compactRenderers(row: CompactRow): ToolRenderers {
59
- return {
60
- renderCall(args: any, theme: Theme, context: any) {
61
- if (!context?.isPartial && context?.executionStarted) return new Container();
62
- const { label, detail } = row.call(args ?? {});
63
- const title = theme.fg("toolTitle", theme.bold(label));
64
- return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
65
- },
66
- renderResult(result: any, options: any, theme: Theme, context: any) {
67
- if (options?.isPartial) return new Text(theme.fg("warning", "Working\u2026"), 0, 0);
68
- if (context?.isError) {
69
- return new Text(`${theme.fg("error", "\u2717 ")}${theme.fg("error", resultText(result) || "Failed")}`, 0, 0);
70
- }
71
- const details = result?.details ?? {};
72
- const args = context?.args ?? {};
73
- const summary = row.summary(details, args) || resultText(result);
74
- const line = `${theme.fg("success", "\u2713 ")}${theme.fg("toolOutput", summary)}`;
75
- const detail = options?.expanded ? row.detail?.(details, args) : undefined;
76
- return new Text(detail ? `${line}\n${theme.fg("dim", detail)}` : line, 0, 0);
77
- },
78
- };
79
- }