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/README.md +100 -73
- package/config.ts +15 -86
- package/cron.ts +1 -122
- package/index.ts +273 -388
- package/loopfile.ts +4 -8
- package/package.json +1 -1
- package/prompts.ts +84 -27
- package/render.ts +21 -1
- package/renderers.ts +60 -107
- package/schedule.ts +16 -2
- package/tool-rows.ts +0 -79
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"-
|
|
47
|
-
"-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
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
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
/**
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
100
|
-
*
|
|
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
|
|
103
|
-
const details = (message.details ?? {}) as
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
115
|
-
?
|
|
116
|
-
:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|