pi-cc-loop 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -63
- package/config.ts +15 -86
- package/cron.ts +52 -152
- package/durable.ts +447 -0
- package/index.ts +826 -1218
- package/loopfile.ts +103 -0
- package/package.json +2 -6
- package/prompts.ts +257 -0
- package/render.ts +32 -9
- package/renderers.ts +98 -0
- package/schedule.ts +147 -169
package/loopfile.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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 file = readLoopFile(cwd);
|
|
76
|
+
if (file) {
|
|
77
|
+
if (this.lastDelivered === file.content) {
|
|
78
|
+
return (
|
|
79
|
+
"# /loop tick \u2014 loop.md tasks\n" +
|
|
80
|
+
"Work the tasks from the loop.md contents established earlier in this conversation. " +
|
|
81
|
+
"If you cannot find them, treat this as a no-op tick."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
this.lastDelivered = file.content;
|
|
85
|
+
return [
|
|
86
|
+
`# /loop tick \u2014 tasks from ${file.path}`,
|
|
87
|
+
"The user configured a loop-tasks file. Work through the tasks defined below; these are the " +
|
|
88
|
+
"instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).",
|
|
89
|
+
"",
|
|
90
|
+
file.content,
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
if (this.lastDelivered === MAINTENANCE_MARKER) {
|
|
94
|
+
return (
|
|
95
|
+
"# /loop tick \u2014 maintenance pass\n" +
|
|
96
|
+
"Run the maintenance pass using the instructions established earlier in this conversation. " +
|
|
97
|
+
"If you cannot find them, treat this as a no-op tick."
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
this.lastDelivered = MAINTENANCE_MARKER;
|
|
101
|
+
return MAINTENANCE_PROMPT;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cc-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
"
|
|
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,257 @@
|
|
|
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
|
+
* 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]";
|
|
37
|
+
|
|
38
|
+
const DELAY_ADVICE = [
|
|
39
|
+
"- Pick the delay from what you are waiting for. For idle ticks with nothing specific to watch, use 1200-1800s.",
|
|
40
|
+
" When actively polling external state nothing notifies you about (a CI run, a deploy), match how fast it changes.",
|
|
41
|
+
"- Do not poll for work Pi will report anyway (a monitor event, a command you are running): use a long",
|
|
42
|
+
" fallback (1200s+) instead.",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The rules for a `/loop <prompt>` whose interval the command layer could not
|
|
47
|
+
* parse. Claude Code's /loop is a skill: the model reads the input and works
|
|
48
|
+
* the schedule out itself, so an interval written in any language works.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The instructions for one `/loop` request. Claude Code's `/loop` is a skill:
|
|
52
|
+
* the command turns into a user message carrying these rules, and the model
|
|
53
|
+
* drives CronCreate / CronList / CronDelete / ScheduleWakeup itself. Nothing is
|
|
54
|
+
* created by code, so the conversation shows what was asked and what was done.
|
|
55
|
+
*/
|
|
56
|
+
const LOOP_FILE_SENTINEL_PLACEHOLDER = "__loop_md_sentinel__";
|
|
57
|
+
|
|
58
|
+
const LOOP_COMMAND_GUIDELINES = [
|
|
59
|
+
"This is a `/loop` request from the user: {raw}",
|
|
60
|
+
"",
|
|
61
|
+
"## Read the input",
|
|
62
|
+
"- `[interval] <task>`: an interval was given (\"5m\", \"2h\", \"every 20m\", \"每2分钟\", \"每隔2小时\"). Use the fixed-interval mode below.",
|
|
63
|
+
"- `<task>` alone: no interval. Use the self-paced mode below and choose each delay yourself.",
|
|
64
|
+
"- An interval alone, or a bare `/loop`: the tasks come from loop.md (see the last section).",
|
|
65
|
+
"- `stop [id]` or `status`: see those sections.",
|
|
66
|
+
"",
|
|
67
|
+
"## Fixed interval (an interval was given)",
|
|
68
|
+
"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.",
|
|
69
|
+
" - under a minute rounds up to `* * * * *`; 5 minutes is `*/5 * * * *`; every hour is `0 * * * *`; every day is `0 0 * * *`.",
|
|
70
|
+
" - if the interval does not divide its unit cleanly, round to the nearest clean interval and say so.",
|
|
71
|
+
"2. **Then do the task now** \u2014 do not wait for the first fire.",
|
|
72
|
+
"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.",
|
|
73
|
+
"4. Never call `schedule_wakeup` for this loop: the cron fires its own ticks. Scheduling one would run the task twice.",
|
|
74
|
+
"",
|
|
75
|
+
"## Self-paced (no interval)",
|
|
76
|
+
"1. Do the task now, then write your update for the user.",
|
|
77
|
+
"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.",
|
|
78
|
+
"{delayAdvice}",
|
|
79
|
+
"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.",
|
|
80
|
+
"4. If the task is complete or cannot progress without the user, call `schedule_wakeup` with `stop: true` and say why.",
|
|
81
|
+
"",
|
|
82
|
+
"## status and stop",
|
|
83
|
+
"- `status`: call `cron_list` and report each job's id, cadence, next run and prompt, plus whether it is session-only or durable.",
|
|
84
|
+
"- `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`.",
|
|
85
|
+
"",
|
|
86
|
+
"## Flags",
|
|
87
|
+
"- `--durable`: pass `durable: true` to `cron_create` so the job survives restarts.",
|
|
88
|
+
"- `--max N`: pass `maxFires: N`.",
|
|
89
|
+
"",
|
|
90
|
+
"## loop.md",
|
|
91
|
+
"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.",
|
|
92
|
+
"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.",
|
|
93
|
+
].join("\n");
|
|
94
|
+
|
|
95
|
+
const LOOP_GUIDELINES = [
|
|
96
|
+
"A self-paced `/loop` is running. Its prompt is: {prompt}",
|
|
97
|
+
"After this iteration, decide whether the loop continues:",
|
|
98
|
+
"- To continue, call the `schedule_wakeup` tool with `delaySeconds` (60-3600), a one-sentence `reason`,",
|
|
99
|
+
" and `prompt` set to the same /loop prompt verbatim: {quoted}",
|
|
100
|
+
"- Write your update for the user before you call `schedule_wakeup`: the turn ends as soon as it returns.",
|
|
101
|
+
DELAY_ADVICE,
|
|
102
|
+
"- If the task is complete, or the loop cannot move further without the user, call `schedule_wakeup`",
|
|
103
|
+
" with `stop: true` instead and say why.",
|
|
104
|
+
].join("\n");
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fixed-interval loops fire their own next tick, so a wakeup scheduled from
|
|
108
|
+
* one would run the same prompt twice. Claude Code's tick says the same:
|
|
109
|
+
* "The recurring cron will fire the next tick automatically — do not call
|
|
110
|
+
* ScheduleWakeup from this tick."
|
|
111
|
+
*/
|
|
112
|
+
const CRON_TICK_GUIDELINES = [
|
|
113
|
+
"This is a fixed-interval /loop (job {id}, {schedule}): the schedule fires the next tick on its own.",
|
|
114
|
+
"Do not call `schedule_wakeup` from this tick — it would run this prompt a second time on a self-paced",
|
|
115
|
+
"schedule. To cancel the loop, call `cron_delete` with id {id}.",
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How a self-paced loop uses the Monitor tool, after Claude Code's /loop
|
|
120
|
+
* skill: arm a monitor only when the next iteration waits on an event, arm it
|
|
121
|
+
* once, and keep the wakeup as a long fallback heartbeat. A monitor event
|
|
122
|
+
* does not cancel that heartbeat.
|
|
123
|
+
*/
|
|
124
|
+
const MONITOR_GUIDELINES = [
|
|
125
|
+
"If the next iteration is gated on an external event (a CI run finishing, a PR comment, a log line) and no",
|
|
126
|
+
"monitor is already running for it, arm one with the `Monitor` tool and set `loopPrompt` to {quoted}, so the",
|
|
127
|
+
"event wakes you as soon as it happens. Arm it once: on later iterations call `monitor_list` first and skip",
|
|
128
|
+
"this step while a monitor for it is still running.",
|
|
129
|
+
"While a monitor is armed, keep `delaySeconds` at 1200-1800: the monitor is the wake signal and the",
|
|
130
|
+
"`schedule_wakeup` delay is only the fallback heartbeat. When a monitor event wakes you, handle the event,",
|
|
131
|
+
"then decide whether the loop continues.",
|
|
132
|
+
].join("\n");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fill `{name}` placeholders. A replacer function is used so that `$&` or
|
|
136
|
+
* `$1` in a prompt are inserted literally.
|
|
137
|
+
*/
|
|
138
|
+
export function fill(template: string, values: Record<string, string>): string {
|
|
139
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The prompt as a JSON string: unambiguous even with quotes or newlines. */
|
|
143
|
+
export function quote(prompt: string): string {
|
|
144
|
+
return JSON.stringify(prompt);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** How a prompt is described to the model; the loop.md sentinel gets an explanation. */
|
|
148
|
+
function describePrompt(prompt: string): string {
|
|
149
|
+
if (prompt !== LOOP_FILE_SENTINEL) return prompt;
|
|
150
|
+
return (
|
|
151
|
+
"the loop tasks in the message above (pass the literal sentinel " +
|
|
152
|
+
`${quote(LOOP_FILE_SENTINEL)} as \`prompt\`: it expands to the current loop.md tasks, or the built-in ` +
|
|
153
|
+
"maintenance pass when there is no loop.md, every time it fires)"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function provenance(tick: number): string {
|
|
158
|
+
return fill(PROVENANCE, { n: String(tick) });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The header of a fixed-interval fire: a scheduled task, not a loop tick. */
|
|
162
|
+
export function scheduledTaskProvenance(id: string, schedule: string): string {
|
|
163
|
+
return fill(SCHEDULED_TASK_PROVENANCE, { id, schedule });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loopCommandGuidelines(raw: string): string {
|
|
167
|
+
const quoted = quote(raw || LOOP_FILE_SENTINEL);
|
|
168
|
+
return fill(LOOP_COMMAND_GUIDELINES, {
|
|
169
|
+
raw: raw ? `${quote(raw)}` : "(empty: the loop.md tasks)",
|
|
170
|
+
quoted,
|
|
171
|
+
delayAdvice: DELAY_ADVICE,
|
|
172
|
+
}).replace(LOOP_FILE_SENTINEL_PLACEHOLDER, quote(LOOP_FILE_SENTINEL));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function loopGuidelines(prompt: string): string {
|
|
176
|
+
return fill(LOOP_GUIDELINES, { prompt: describePrompt(prompt), quoted: quote(prompt) });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function cronTickGuidelines(id: string, schedule: string): string {
|
|
180
|
+
return fill(CRON_TICK_GUIDELINES, { id, schedule });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function monitorGuidelines(prompt: string): string {
|
|
184
|
+
return fill(MONITOR_GUIDELINES, { quoted: quote(prompt) });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Claude Code's notice for durable one-shot jobs that came due while nobody was running them. */
|
|
188
|
+
export function missedJobsMessage(lines: string[]): string {
|
|
189
|
+
const one = lines.length === 1;
|
|
190
|
+
return [
|
|
191
|
+
"[missed scheduled jobs \u00b7 reported by /loop, not typed by the user]",
|
|
192
|
+
`The following one-shot scheduled task${one ? " was" : "s were"} missed while Pi was not running. ` +
|
|
193
|
+
`${one ? "It has" : "They have"} already been removed from .pi/scheduled_tasks.json.`,
|
|
194
|
+
`Do NOT execute ${one ? "this prompt" : "these prompts"} yet. First ask the user whether to run ` +
|
|
195
|
+
`${one ? "it" : "each one"} now. Only execute if the user confirms.`,
|
|
196
|
+
...lines,
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------- tools
|
|
201
|
+
|
|
202
|
+
export const CRON_CREATE_DESCRIPTION = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
## One-shot tasks (recurring: false)
|
|
206
|
+
For "remind me at X" or "at <time>, do Y" requests \u2014 fire once then auto-delete.
|
|
207
|
+
Pin minute/hour/day-of-month/month to specific values:
|
|
208
|
+
"remind me at 2:30pm today to check the deploy" \u2192 cron: "30 14 <today_dom> <today_month> *", recurring: false
|
|
209
|
+
"tomorrow morning, run the smoke test" \u2192 cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false
|
|
210
|
+
|
|
211
|
+
## Recurring jobs (recurring: true, the default)
|
|
212
|
+
For "every N minutes" / "every hour" / "weekdays at 9am" requests:
|
|
213
|
+
"*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)
|
|
214
|
+
|
|
215
|
+
## Avoid the :00 and :30 minute marks when the task allows it
|
|
216
|
+
When the user's request is approximate, pick a minute that is NOT 0 or 30:
|
|
217
|
+
"every morning around 9" \u2192 "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")
|
|
218
|
+
"hourly" \u2192 "7 * * * *" (not "0 * * * *")
|
|
219
|
+
"in an hour or so, remind me to..." \u2192 pick whatever minute you land on, don't round
|
|
220
|
+
Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past").
|
|
221
|
+
|
|
222
|
+
## Durability
|
|
223
|
+
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").
|
|
224
|
+
|
|
225
|
+
## Not for live watching
|
|
226
|
+
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.
|
|
227
|
+
|
|
228
|
+
## Runtime behavior
|
|
229
|
+
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.
|
|
230
|
+
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.
|
|
231
|
+
Returns a job ID you can pass to cron_delete.`;
|
|
232
|
+
|
|
233
|
+
export const CRON_DELETE_DESCRIPTION =
|
|
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).";
|
|
235
|
+
|
|
236
|
+
export const CRON_LIST_DESCRIPTION =
|
|
237
|
+
"List the scheduled jobs, both durable (.pi/scheduled_tasks.json) and session-only, with their ids, schedules, actual next run times and prompts.";
|
|
238
|
+
|
|
239
|
+
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).
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
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.
|
|
244
|
+
|
|
245
|
+
## Picking delaySeconds
|
|
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.
|
|
250
|
+
|
|
251
|
+
## The reason field
|
|
252
|
+
One short sentence on what you chose and why. It is shown to the user: "watching CI run" beats "waiting".`;
|
|
253
|
+
|
|
254
|
+
export const CRON_CREATE_SNIPPET = "Schedule a prompt on a 5-field cron expression, recurring or one-shot";
|
|
255
|
+
export const CRON_DELETE_SNIPPET = "Cancel a scheduled job or a self-paced /loop by id";
|
|
256
|
+
export const CRON_LIST_SNIPPET = "List scheduled jobs with their actual next run times";
|
|
257
|
+
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
|
|
2
|
+
* Rendering helpers for the pi-cc custom messages (loop ticks, monitor events).
|
|
3
3
|
*
|
|
4
|
-
* Pi renders a custom message with
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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";
|
|
@@ -38,6 +41,22 @@ export function stripProvenance(text: string, pattern: RegExp): string {
|
|
|
38
41
|
return text.replace(pattern, "");
|
|
39
42
|
}
|
|
40
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
|
+
|
|
41
60
|
/** The first non-empty line of a text block. */
|
|
42
61
|
export function firstLine(text: string): string {
|
|
43
62
|
for (const line of text.split("\n")) {
|
|
@@ -63,7 +82,11 @@ export function formatStamp(epochMs: number | undefined): string {
|
|
|
63
82
|
const date = new Date(epochMs ?? Date.now());
|
|
64
83
|
if (Number.isNaN(date.getTime())) return "";
|
|
65
84
|
const now = new Date();
|
|
66
|
-
|
|
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
|
+
}`;
|
|
67
90
|
if (date.toDateString() === now.toDateString()) return time;
|
|
68
91
|
const day = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
69
92
|
return `${day} ${time}`;
|
package/renderers.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { bodyLines, contentText, formatStamp, renderBlock, renderCard, stripProvenance } from "./render.ts";
|
|
9
|
+
import { loadConfig } from "./config.ts";
|
|
10
|
+
import { humanSchedule } from "./cron.ts";
|
|
11
|
+
import { displayPrompt } from "./loopfile.ts";
|
|
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
|
+
*/
|
|
18
|
+
export function formatClock(epochMs: number | undefined): string {
|
|
19
|
+
if (!epochMs) return "unknown";
|
|
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}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
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
|
+
}
|
|
40
|
+
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
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.
|
|
56
|
+
*/
|
|
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.
|
|
61
|
+
const label = details.missed
|
|
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";
|
|
68
|
+
|
|
69
|
+
if (!loadConfig().compactMessages) {
|
|
70
|
+
const header = details.missed ? label : `loop tick #${details.tickCount ?? "?"} \u00b7 ${displayPrompt(prompt)}`;
|
|
71
|
+
return renderCard({ header, body: prompt, expanded: options.expanded, outputPad: options.outputPad, theme });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stamp = formatStamp(message.timestamp);
|
|
75
|
+
// The reason the model gave for this delay, in the header so the reader
|
|
76
|
+
// knows why the loop woke up without expanding anything.
|
|
77
|
+
const why = details.kind === "wakeup" && details.reason ? ` \u00b7 ${details.reason}` : "";
|
|
78
|
+
const header =
|
|
79
|
+
(stamp
|
|
80
|
+
? `${theme.fg("accent", theme.bold(label))}${theme.fg("dim", " \u00b7 ")}${theme.fg("muted", stamp)}`
|
|
81
|
+
: theme.fg("accent", theme.bold(label))) + (why ? theme.fg("dim", why) : "");
|
|
82
|
+
|
|
83
|
+
const meta: string[] = [];
|
|
84
|
+
if (details.jobId) meta.push(`job ${details.jobId}`);
|
|
85
|
+
if (details.kind === "cron" && details.cron) meta.push(humanSchedule(details.cron));
|
|
86
|
+
else if (!details.missed) meta.push("self-paced");
|
|
87
|
+
meta.push("scheduled by /loop");
|
|
88
|
+
|
|
89
|
+
return renderBlock({
|
|
90
|
+
header,
|
|
91
|
+
body: bodyLines(prompt),
|
|
92
|
+
meta,
|
|
93
|
+
expanded: options.expanded,
|
|
94
|
+
outputPad: options.outputPad,
|
|
95
|
+
theme,
|
|
96
|
+
maxBodyLines: 3,
|
|
97
|
+
});
|
|
98
|
+
}
|