pi-cc-loop 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/loopfile.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * loop.md support, resolved at fire time like Claude Code's `<<loop.md>>`
3
+ * sentinel.
4
+ *
5
+ * Claude Code docs: "Edits to loop.md take effect on the next iteration, so
6
+ * you can refine the instructions while a loop is running. When no loop.md
7
+ * exists in either location, the loop falls back to the built-in maintenance
8
+ * prompt. Keep the file concise: content beyond 25,000 bytes is truncated."
9
+ *
10
+ * A bare `/loop` stores the sentinel as its prompt instead of the file's text,
11
+ * so the model does not have to echo a long prompt back to `schedule_wakeup`,
12
+ * and each tick reads the file again. The full text goes out on the first
13
+ * delivery and whenever the file changed; otherwise a short reminder points
14
+ * back to it.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { LOOP_FILE_SENTINEL, MAINTENANCE_PROMPT } from "./prompts.ts";
21
+
22
+ /** Claude Code truncates loop.md beyond this length. */
23
+ export const LOOP_FILE_MAX_CHARS = 25_000;
24
+
25
+ const MAINTENANCE_MARKER = "\u0000maintenance";
26
+
27
+ export function isLoopFileSentinel(prompt: string | undefined): boolean {
28
+ return prompt?.trim() === LOOP_FILE_SENTINEL;
29
+ }
30
+
31
+ /** How a job's prompt is shown to the user. */
32
+ export function displayPrompt(prompt: string): string {
33
+ return isLoopFileSentinel(prompt) ? "/loop (loop.md)" : prompt;
34
+ }
35
+
36
+ /** Project first, then user level, like Claude Code's .claude/loop.md and ~/.claude/loop.md. */
37
+ export function loopFileCandidates(cwd: string): string[] {
38
+ return [path.join(cwd, ".pi", "loop.md"), path.join(os.homedir(), ".pi", "loop.md")];
39
+ }
40
+
41
+ /** The first non-empty loop file, truncated like Claude Code's. */
42
+ export function readLoopFile(cwd: string): { path: string; content: string } | null {
43
+ for (const candidate of loopFileCandidates(cwd)) {
44
+ let raw: string;
45
+ try {
46
+ raw = fs.readFileSync(candidate, "utf8");
47
+ } catch {
48
+ continue;
49
+ }
50
+ const content = raw.trim();
51
+ if (content) return { path: candidate, content: truncate(content) };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function truncate(content: string): string {
57
+ if (content.length <= LOOP_FILE_MAX_CHARS) return content;
58
+ const cut = content.lastIndexOf("\n", LOOP_FILE_MAX_CHARS);
59
+ return `${content.slice(0, cut > 0 ? cut : LOOP_FILE_MAX_CHARS)}\n> WARNING: loop.md was truncated to ${LOOP_FILE_MAX_CHARS} bytes. Keep the task list concise.`;
60
+ }
61
+
62
+ export type TickMode = "cron" | "wakeup";
63
+
64
+ /** Remembers what a session was last given, so unchanged tasks are not resent. */
65
+ export class LoopFileDelivery {
66
+ private lastDelivered: string | null = null;
67
+
68
+ /** Send the full text on the next tick (a new /loop, or after compaction). */
69
+ reset(): void {
70
+ this.lastDelivered = null;
71
+ }
72
+
73
+ /** The text of one tick for the loop.md sentinel. */
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
+ const file = readLoopFile(cwd);
80
+ if (file) {
81
+ if (this.lastDelivered === file.content) {
82
+ return (
83
+ "# /loop tick \u2014 loop.md tasks\n" +
84
+ "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}`
86
+ );
87
+ }
88
+ this.lastDelivered = file.content;
89
+ return [
90
+ `# /loop tick \u2014 tasks from ${file.path}`,
91
+ "The user configured a loop-tasks file. Work through the tasks defined below; these are the " +
92
+ "instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).",
93
+ "",
94
+ file.content,
95
+ ].join("\n") + cronNote;
96
+ }
97
+ if (this.lastDelivered === MAINTENANCE_MARKER) {
98
+ return (
99
+ "# /loop tick \u2014 maintenance pass\n" +
100
+ "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}`
102
+ );
103
+ }
104
+ this.lastDelivered = MAINTENANCE_MARKER;
105
+ return MAINTENANCE_PROMPT + cronNote;
106
+ }
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -22,11 +22,7 @@
22
22
  "url": "https://github.com/tarnish233/pi-cc-loop/issues"
23
23
  },
24
24
  "files": [
25
- "index.ts",
26
- "cron.ts",
27
- "schedule.ts",
28
- "render.ts",
29
- "config.ts",
25
+ "*.ts",
30
26
  "README.md",
31
27
  "LICENSE"
32
28
  ],
package/prompts.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Model-facing text for pi-cc-loop: the per-run guidelines, the built-in
3
+ * maintenance prompt and the tool descriptions.
4
+ *
5
+ * The tool descriptions follow Claude Code's (v2.1.302) closely. The
6
+ * differences are Pi's: lowercase tool names, `.pi/` instead of `.claude/`,
7
+ * and a provider-neutral note about prompt caching.
8
+ */
9
+
10
+ /** A bare `/loop` stores this as its prompt; it expands to loop.md (or the maintenance pass) at fire time. */
11
+ export const LOOP_FILE_SENTINEL = "<<loop.md>>";
12
+
13
+ /** Claude Code's built-in maintenance prompt, used when there is no loop.md. */
14
+ export const MAINTENANCE_PROMPT = [
15
+ "You are running a scheduled maintenance pass.",
16
+ "Work through the following, in order:",
17
+ "1. Continue any unfinished work from the conversation.",
18
+ "2. Tend to the current branch's pull request: review comments, failed CI runs, merge conflicts.",
19
+ "3. Run cleanup passes such as bug hunts or simplification when nothing else is pending.",
20
+ "Do not start new initiatives outside that scope, and only perform irreversible actions",
21
+ "(push, delete) that continue something the transcript already authorized.",
22
+ "If nothing needs doing, say so in one line.",
23
+ ].join("\n");
24
+
25
+ export const PROVENANCE = "[loop tick #{n} \u00b7 scheduled by /loop, not typed by the user]";
26
+
27
+ const DELAY_ADVICE = [
28
+ "- Pick the delay from what you are waiting for. For idle ticks with nothing specific to watch, use 1200-1800s.",
29
+ " When actively polling external state nothing notifies you about (a CI run, a deploy), match how fast it changes.",
30
+ "- Do not poll for work Pi will report anyway (a monitor event, a command you are running): use a long",
31
+ " fallback (1200s+) instead.",
32
+ ].join("\n");
33
+
34
+ /**
35
+ * The rules for a `/loop <prompt>` whose interval the command layer could not
36
+ * parse. Claude Code's /loop is a skill: the model reads the input and works
37
+ * the schedule out itself, so an interval written in any language works.
38
+ */
39
+ const LOOP_SCHEDULE_RULES = [
40
+ "This is a `/loop` request. Its prompt is: {prompt}",
41
+ "",
42
+ "Decide how it should repeat, then set it up.",
43
+ "",
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`.",
56
+ "",
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.",
65
+ ].join("\n");
66
+
67
+ const LOOP_GUIDELINES = [
68
+ "A self-paced `/loop` is running. Its prompt is: {prompt}",
69
+ "After this iteration, decide whether the loop continues:",
70
+ "- To continue, call the `schedule_wakeup` tool with `delaySeconds` (60-3600), a one-sentence `reason`,",
71
+ " and `prompt` set to the same /loop prompt verbatim: {quoted}",
72
+ "- Write your update for the user before you call `schedule_wakeup`: the turn ends as soon as it returns.",
73
+ DELAY_ADVICE,
74
+ "- If the task is complete, or the loop cannot move further without the user, call `schedule_wakeup`",
75
+ " with `stop: true` instead and say why.",
76
+ ].join("\n");
77
+
78
+ /**
79
+ * How a self-paced loop uses the Monitor tool, after Claude Code's /loop
80
+ * skill: arm a monitor only when the next iteration waits on an event, arm it
81
+ * once, and keep the wakeup as a long fallback heartbeat. A monitor event
82
+ * does not cancel that heartbeat.
83
+ */
84
+ const MONITOR_GUIDELINES = [
85
+ "If the next iteration is gated on an external event (a CI run finishing, a PR comment, a log line) and no",
86
+ "monitor is already running for it, arm one with the `Monitor` tool and set `loopPrompt` to {quoted}, so the",
87
+ "event wakes you as soon as it happens. Arm it once: on later iterations call `monitor_list` first and skip",
88
+ "this step while a monitor for it is still running.",
89
+ "While a monitor is armed, keep `delaySeconds` at 1200-1800: the monitor is the wake signal and the",
90
+ "`schedule_wakeup` delay is only the fallback heartbeat. When a monitor event wakes you, handle the event,",
91
+ "then decide whether the loop continues.",
92
+ ].join("\n");
93
+
94
+ /**
95
+ * Fill `{name}` placeholders. A replacer function is used so that `$&` or
96
+ * `$1` in a prompt are inserted literally.
97
+ */
98
+ export function fill(template: string, values: Record<string, string>): string {
99
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
100
+ }
101
+
102
+ /** The prompt as a JSON string: unambiguous even with quotes or newlines. */
103
+ export function quote(prompt: string): string {
104
+ return JSON.stringify(prompt);
105
+ }
106
+
107
+ /** How a prompt is described to the model; the loop.md sentinel gets an explanation. */
108
+ function describePrompt(prompt: string): string {
109
+ if (prompt !== LOOP_FILE_SENTINEL) return prompt;
110
+ return (
111
+ "the loop tasks in the message above (pass the literal sentinel " +
112
+ `${quote(LOOP_FILE_SENTINEL)} as \`prompt\`: it expands to the current loop.md tasks, or the built-in ` +
113
+ "maintenance pass when there is no loop.md, every time it fires)"
114
+ );
115
+ }
116
+
117
+ export function provenance(tick: number): string {
118
+ return fill(PROVENANCE, { n: String(tick) });
119
+ }
120
+
121
+ export function scheduleRules(prompt: string): string {
122
+ return fill(LOOP_SCHEDULE_RULES, { prompt: describePrompt(prompt), quoted: quote(prompt) });
123
+ }
124
+
125
+ export function loopGuidelines(prompt: string): string {
126
+ return fill(LOOP_GUIDELINES, { prompt: describePrompt(prompt), quoted: quote(prompt) });
127
+ }
128
+
129
+ export function monitorGuidelines(prompt: string): string {
130
+ return fill(MONITOR_GUIDELINES, { quoted: quote(prompt) });
131
+ }
132
+
133
+ /** Claude Code's notice for durable one-shot jobs that came due while nobody was running them. */
134
+ export function missedJobsMessage(lines: string[]): string {
135
+ const one = lines.length === 1;
136
+ return [
137
+ "[missed scheduled jobs \u00b7 reported by /loop, not typed by the user]",
138
+ `The following one-shot scheduled task${one ? " was" : "s were"} missed while Pi was not running. ` +
139
+ `${one ? "It has" : "They have"} already been removed from .pi/scheduled_tasks.json.`,
140
+ `Do NOT execute ${one ? "this prompt" : "these prompts"} yet. First ask the user whether to run ` +
141
+ `${one ? "it" : "each one"} now. Only execute if the user confirms.`,
142
+ ...lines,
143
+ ].join("\n");
144
+ }
145
+
146
+ // ---------------------------------------------------------------- tools
147
+
148
+ export const CRON_CREATE_DESCRIPTION = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.
149
+ Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local \u2014 no timezone conversion needed.
150
+
151
+ ## One-shot tasks (recurring: false)
152
+ For "remind me at X" or "at <time>, do Y" requests \u2014 fire once then auto-delete.
153
+ Pin minute/hour/day-of-month/month to specific values:
154
+ "remind me at 2:30pm today to check the deploy" \u2192 cron: "30 14 <today_dom> <today_month> *", recurring: false
155
+ "tomorrow morning, run the smoke test" \u2192 cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false
156
+
157
+ ## Recurring jobs (recurring: true, the default)
158
+ For "every N minutes" / "every hour" / "weekdays at 9am" requests:
159
+ "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)
160
+
161
+ ## Avoid the :00 and :30 minute marks when the task allows it
162
+ When the user's request is approximate, pick a minute that is NOT 0 or 30:
163
+ "every morning around 9" \u2192 "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")
164
+ "hourly" \u2192 "7 * * * *" (not "0 * * * *")
165
+ "in an hour or so, remind me to..." \u2192 pick whatever minute you land on, don't round
166
+ Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past").
167
+
168
+ ## Durability
169
+ By default (durable: false) the job belongs to this Pi session: it is restored if the session is resumed and gone otherwise. Pass durable: true to write it to .pi/scheduled_tasks.json so it survives restarts; one Pi session per project runs the durable jobs. Only use durable: true when the user explicitly asks for the task to persist ("keep doing this every day", "set this up permanently").
170
+
171
+ ## Not for live watching
172
+ 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
+
174
+ ## 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.
176
+ 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
+ Returns a job ID you can pass to cron_delete.`;
178
+
179
+ export const CRON_DELETE_DESCRIPTION =
180
+ "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
+
182
+ export const CRON_LIST_DESCRIPTION =
183
+ "List the scheduled jobs, both durable (.pi/scheduled_tasks.json) and session-only, with their ids, schedules, actual next run times and prompts.";
184
+
185
+ export const SCHEDULE_WAKEUP_DESCRIPTION = `Schedule when to resume work in a self-paced /loop \u2014 the user invoked /loop without an interval, asking you to self-pace iterations of a task. Call it as the last thing in the turn to keep the loop alive: the turn ends as soon as it returns, so write your update for the user first. To end the loop, call it with stop: true (omit every other field).
186
+
187
+ Pass the same /loop prompt back via \`prompt\` each turn so the next firing repeats the task. For a bare /loop (no prompt), pass the literal sentinel "${LOOP_FILE_SENTINEL}": it expands at fire time to the current loop.md tasks, or to the built-in maintenance pass when there is no loop.md.
188
+
189
+ 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
+
191
+ ## 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].
193
+
194
+ ## The reason field
195
+ One short sentence on what you chose and why. It is shown to the user: "watching CI run" beats "waiting".`;
196
+
197
+ export const CRON_CREATE_SNIPPET = "Schedule a prompt on a 5-field cron expression, recurring or one-shot";
198
+ export const CRON_DELETE_SNIPPET = "Cancel a scheduled job or a self-paced /loop by id";
199
+ export const CRON_LIST_SNIPPET = "List scheduled jobs with their actual next run times";
200
+ export const SCHEDULE_WAKEUP_SNIPPET = "Pick when a self-paced /loop runs next, or stop it";
package/render.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Rendering helpers for the pi-cc-loop custom messages.
2
+ * Rendering helpers for the pi-cc custom messages (loop ticks, monitor events).
3
3
  *
4
- * Pi renders a custom message with the raw `customType` as its title
5
- * (`[pi-cc-loop-tick]`), which reads like a machine name. A custom renderer
6
- * replaces that with either a quiet two-line block (the default) or the
7
- * legacy padded card, depending on `compactMessages`.
4
+ * Pi renders a custom message with its raw `customType` as the title, which
5
+ * reads like a machine name. A custom renderer replaces that with either a
6
+ * quiet block (the default) or the legacy padded card, depending on
7
+ * `compactMessages`.
8
8
  *
9
9
  * The quiet block is modelled on Claude Code's `scheduled_task_fire` marker:
10
- * a timestamped header plus the prompt. No icon and no background; the
11
- * accent-coloured label carries the meaning, and the timestamp makes a
12
- * minute-by-minute loop easy to scan.
10
+ * a timestamped header plus the text, with no icon and no background. Long
11
+ * text is capped to a few lines while collapsed; expanding shows all of it and
12
+ * the metadata.
13
13
  *
14
14
  * The renderer only affects the display. The message content the model sees is
15
15
  * unchanged.
16
+ *
17
+ * This file is identical in pi-cc-loop and pi-cc-monitor; tests/shared.test.mjs
18
+ * fails when the two copies drift apart.
16
19
  */
17
20
 
18
21
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
package/renderers.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Rendering for pi-cc-loop: the compact tool rows and the loop tick message.
3
+ */
4
+
5
+ import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
6
+ import { loadConfig } from "./config.ts";
7
+ import { humanSchedule } from "./cron.ts";
8
+ 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
+
21
+ export function formatClock(epochMs: number | undefined): string {
22
+ 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`;
32
+ }
33
+
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"}`;
39
+ }
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
+ };
96
+ }
97
+
98
+ /**
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.
101
+ */
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?/);
112
+ const label = details.missed
113
+ ? "loop \u00b7 missed jobs"
114
+ : details.tickCount !== undefined
115
+ ? `loop #${details.tickCount}`
116
+ : "loop";
117
+
118
+ if (!loadConfig().compactMessages) {
119
+ const summary = firstLine(prompt);
120
+ const header = details.missed
121
+ ? label
122
+ : `loop tick #${details.tickCount ?? "?"}${summary ? ` \u00b7 ${summary}` : ""}`;
123
+ return renderCard({ header, body: prompt, expanded: options.expanded, outputPad: options.outputPad, theme });
124
+ }
125
+
126
+ 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));
130
+
131
+ const meta: string[] = [];
132
+ 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");
135
+ else if (!details.missed) meta.push("self-paced");
136
+ meta.push("scheduled by /loop");
137
+
138
+ return renderBlock({
139
+ header,
140
+ body: bodyLines(prompt),
141
+ meta,
142
+ expanded: options.expanded,
143
+ outputPad: options.outputPad,
144
+ theme,
145
+ maxBodyLines: 3,
146
+ });
147
+ }