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/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-cc-loop — a Claude Code compatible `/loop` for the Pi coding agent.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors Claude Code v2.1.306:
|
|
5
|
-
*
|
|
6
4
|
* /loop 5m check the deploy fixed interval, driven by a 5-field cron
|
|
7
5
|
* /loop check the deploy self-paced: the model picks the next delay
|
|
8
|
-
* /loop
|
|
6
|
+
* /loop 15m | /loop the loop.md tasks (or the built-in maintenance
|
|
7
|
+
* prompt), on a fixed interval or self-paced
|
|
9
8
|
* /loop stop | status | list manage the jobs
|
|
10
9
|
*
|
|
11
10
|
* Two mechanisms, same as Claude Code:
|
|
@@ -16,66 +15,64 @@
|
|
|
16
15
|
* self-paced `schedule_wakeup` lets the model choose the next delay
|
|
17
16
|
* (60-3600s). If the model forgets to reschedule, the runtime
|
|
18
17
|
* arms one 20 minute keepalive; if it still forgets, the
|
|
19
|
-
* chain ends.
|
|
18
|
+
* chain ends. Esc on one of its turns cancels it.
|
|
20
19
|
*
|
|
21
20
|
* The tools are named after their Claude Code counterparts:
|
|
22
|
-
* CronCreate
|
|
23
|
-
* CronDelete -> cron_delete
|
|
24
|
-
* CronList -> cron_list
|
|
21
|
+
* CronCreate -> cron_create, CronDelete -> cron_delete, CronList -> cron_list,
|
|
25
22
|
* ScheduleWakeup -> schedule_wakeup
|
|
23
|
+
*
|
|
24
|
+
* Module map: schedule.ts (job store, dispatch, run times), cron.ts (cron
|
|
25
|
+
* syntax, intervals, labels), prompts.ts (model-facing text), loopfile.ts
|
|
26
|
+
* (loop.md), durable.ts (.pi/scheduled_tasks.json and its lock), renderers.ts
|
|
27
|
+
* (tool rows, tick message).
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
|
-
import
|
|
29
|
-
import os from "node:os";
|
|
30
|
-
import path from "node:path";
|
|
31
|
-
import type {
|
|
32
|
-
ExtensionAPI,
|
|
33
|
-
ExtensionCommandContext,
|
|
34
|
-
ExtensionContext,
|
|
35
|
-
Theme,
|
|
36
|
-
ToolRenderers,
|
|
37
|
-
} from "@earendil-works/pi-coding-agent";
|
|
38
|
-
import { Container, Text } from "@earendil-works/pi-tui";
|
|
30
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
39
31
|
import { Type } from "typebox";
|
|
32
|
+
import { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
|
|
40
33
|
import {
|
|
41
|
-
describeCron,
|
|
42
34
|
humanSchedule,
|
|
43
|
-
intervalToCron,
|
|
44
35
|
isUnreachable,
|
|
45
36
|
parseCron,
|
|
46
|
-
parseEveryClause,
|
|
47
|
-
parseIntervalToken,
|
|
48
37
|
} from "./cron.ts";
|
|
38
|
+
import { DurableRuntime, type Placement } from "./durable.ts";
|
|
39
|
+
import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
|
|
49
40
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
CRON_CREATE_DESCRIPTION,
|
|
42
|
+
CRON_CREATE_SNIPPET,
|
|
43
|
+
CRON_DELETE_DESCRIPTION,
|
|
44
|
+
CRON_DELETE_SNIPPET,
|
|
45
|
+
CRON_LIST_DESCRIPTION,
|
|
46
|
+
CRON_LIST_SNIPPET,
|
|
47
|
+
cronTickGuidelines,
|
|
48
|
+
LOOP_FILE_SENTINEL,
|
|
49
|
+
loopGuidelines,
|
|
50
|
+
missedJobsMessage,
|
|
51
|
+
monitorGuidelines,
|
|
52
|
+
provenance,
|
|
53
|
+
SCHEDULE_WAKEUP_DESCRIPTION,
|
|
54
|
+
scheduledTaskProvenance,
|
|
55
|
+
SCHEDULE_WAKEUP_SNIPPET,
|
|
56
|
+
loopCommandGuidelines,
|
|
57
|
+
} from "./prompts.ts";
|
|
58
|
+
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
57
59
|
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} from "./
|
|
60
|
+
DEFAULT_TIMING,
|
|
61
|
+
generateId,
|
|
62
|
+
LoopScheduler,
|
|
63
|
+
nextRunTime,
|
|
64
|
+
storedJob,
|
|
65
|
+
type LoopJob,
|
|
66
|
+
type TimingConfig,
|
|
67
|
+
} from "./schedule.ts";
|
|
68
|
+
import { truncate } from "./render.ts";
|
|
66
69
|
|
|
67
70
|
const STATE_ENTRY = "pi-cc-loop-state";
|
|
68
|
-
|
|
69
|
-
const
|
|
71
|
+
/** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
|
|
72
|
+
const MONITOR_EVENT_TYPE = "Monitor";
|
|
73
|
+
/** Footer status key, shared with every other extension's status line. */
|
|
74
|
+
const STATUS_KEY = "loop";
|
|
70
75
|
const CONFIG_CHANNEL = "pi-cc:config";
|
|
71
|
-
const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
|
|
72
|
-
const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The event channel pi-cc-monitor publishes on. When a monitor names a loop
|
|
76
|
-
* chain, its events supersede that chain's fallback heartbeat.
|
|
77
|
-
*/
|
|
78
|
-
const MONITOR_WAKE_CHANNEL = "monitor:wake";
|
|
79
76
|
|
|
80
77
|
/**
|
|
81
78
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
@@ -88,81 +85,18 @@ const LOOP_STOPPED_CHANNEL = "loop:stopped";
|
|
|
88
85
|
const MAX_JOBS = 50;
|
|
89
86
|
const MIN_DELAY_SECONDS = 60;
|
|
90
87
|
const MAX_DELAY_SECONDS = 3600;
|
|
88
|
+
/** A wakeup just under the 5 minute prompt cache lifetime is pulled inside it. */
|
|
91
89
|
const DEFAULT_KEEPALIVE_SECONDS = 1200;
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
"You are running a scheduled maintenance pass.",
|
|
96
|
-
"Work through the following, in order:",
|
|
97
|
-
"1. Continue any unfinished work from the conversation.",
|
|
98
|
-
"2. Tend to the current branch's pull request: review comments, failed CI runs, merge conflicts.",
|
|
99
|
-
"3. Run cleanup passes such as bug hunts or simplification when nothing else is pending.",
|
|
100
|
-
"Do not start new initiatives outside that scope, and only perform irreversible actions",
|
|
101
|
-
"(push, delete) that the transcript already authorized.",
|
|
102
|
-
].join("\n");
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The rules for a loop whose interval the command layer could not parse.
|
|
106
|
-
*
|
|
107
|
-
* Claude Code's /loop is a skill: the model reads the user's input and
|
|
108
|
-
* applies the skill's rules itself, so an interval written in any language is
|
|
109
|
-
* understood. The command layer here only recognises the English token forms
|
|
110
|
-
* (`5m`, `every 20m`); when it finds none, these rules take over so the model
|
|
111
|
-
* can work the schedule out itself.
|
|
112
|
-
*/
|
|
113
|
-
const LOOP_SCHEDULE_RULES = [
|
|
114
|
-
"This is a `/loop` request. Its prompt is: {prompt}",
|
|
115
|
-
"",
|
|
116
|
-
"Decide how it should repeat, then set it up.",
|
|
117
|
-
"",
|
|
118
|
-
"**If the prompt asks for a fixed interval** (\"every 30 seconds\", \"each 5 minutes\",",
|
|
119
|
-
"\"every hour\", \"5m\", \"every 5m\"): create a recurring job with the `cron_create` tool.",
|
|
120
|
-
"- Convert the interval to a 5-field cron expression in local time (\"M H DoM Mon DoW\").",
|
|
121
|
-
" `s` rounds up to the nearest minute (the scheduler's minimum granularity is 1 minute),",
|
|
122
|
-
" `m` are minutes, `h` are hours, `d` are days. Examples: `5m` -> `*/5 * * * *`,",
|
|
123
|
-
" `1h` -> `0 * * * *`, `1d` -> `0 0 * * *`. If the interval does not cleanly divide its",
|
|
124
|
-
" unit, round to the nearest clean interval and tell the user what you rounded to.",
|
|
125
|
-
"- Then confirm to the user: what is scheduled, the cron expression, the human-readable",
|
|
126
|
-
" cadence, that recurring jobs auto-expire after 7 days, and the job id they can cancel",
|
|
127
|
-
" with `cron_delete`.",
|
|
128
|
-
"",
|
|
129
|
-
"**Otherwise the interval is open-ended.** After each iteration, call the `schedule_wakeup`",
|
|
130
|
-
"tool with:",
|
|
131
|
-
"- `delaySeconds`: seconds until the next iteration (60-3600). Default to 1200-1800; pick",
|
|
132
|
-
" something shorter only when you observed fast-changing external state.",
|
|
133
|
-
"- `reason`: one short sentence on why you picked that delay.",
|
|
134
|
-
"- `prompt`: the same /loop prompt verbatim: {quoted}",
|
|
135
|
-
"If the task is complete or can no longer make progress, call `schedule_wakeup` with",
|
|
136
|
-
"`stop: true` instead of scheduling another iteration.",
|
|
137
|
-
].join("\n");
|
|
138
|
-
|
|
139
|
-
const LOOP_GUIDELINES = [
|
|
140
|
-
"A `/loop` chain is active. Its prompt is: {prompt}",
|
|
141
|
-
"After this iteration finishes, call the `schedule_wakeup` tool with:",
|
|
142
|
-
"- `delaySeconds`: seconds until the next iteration (60-3600). Default to 1200-1800;",
|
|
143
|
-
" pick something shorter only when you observed fast-changing external state.",
|
|
144
|
-
"- `reason`: one short sentence on why you picked that delay.",
|
|
145
|
-
"- `prompt`: the same /loop prompt verbatim: {quoted}",
|
|
146
|
-
"If the task is complete or can no longer make progress, call `schedule_wakeup` with",
|
|
147
|
-
"`stop: true` instead of scheduling another iteration.",
|
|
148
|
-
].join("\n");
|
|
149
|
-
|
|
150
|
-
const MONITOR_GUIDELINES = [
|
|
151
|
-
"This iteration is waiting on an external event.",
|
|
152
|
-
"Arm the `Monitor` tool with `loopPrompt` set to {quoted} so the event wakes this chain",
|
|
153
|
-
"immediately instead of waiting for the next heartbeat. Keep the `schedule_wakeup` call too:",
|
|
154
|
-
"its delay is then only the fallback heartbeat for the case where no event ever arrives.",
|
|
155
|
-
].join("\n");
|
|
156
|
-
|
|
157
|
-
const PROVENANCE = "[loop tick #{n} · scheduled by /loop, not typed by the user]";
|
|
90
|
+
const DEFAULT_MAX_AGE_DAYS = 7;
|
|
91
|
+
/** How many stopped prompts are remembered (and persisted). */
|
|
92
|
+
const MAX_STOPPED_PROMPTS = 20;
|
|
158
93
|
|
|
159
94
|
interface PersistedState {
|
|
160
95
|
version: 1;
|
|
96
|
+
/** Session-only jobs; durable jobs live in .pi/scheduled_tasks.json. */
|
|
161
97
|
jobs: LoopJob[];
|
|
162
98
|
/** Prompts whose chain was stopped and must not be rescheduled. */
|
|
163
99
|
stopped?: string[];
|
|
164
|
-
/** Whether the status widget is shown. Claude Code has no such widget. */
|
|
165
|
-
widget?: boolean;
|
|
166
100
|
}
|
|
167
101
|
|
|
168
102
|
function envNumber(name: string, fallback: number): number {
|
|
@@ -172,13 +106,12 @@ function envNumber(name: string, fallback: number): number {
|
|
|
172
106
|
return Number.isFinite(value) ? value : fallback;
|
|
173
107
|
}
|
|
174
108
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
return Math.max(0, days) * 86_400_000;
|
|
109
|
+
function maxAgeDays(): number {
|
|
110
|
+
return Math.max(0, envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS));
|
|
178
111
|
}
|
|
179
112
|
|
|
180
|
-
function
|
|
181
|
-
return
|
|
113
|
+
function maxAgeMs(): number {
|
|
114
|
+
return maxAgeDays() * 86_400_000;
|
|
182
115
|
}
|
|
183
116
|
|
|
184
117
|
/**
|
|
@@ -190,92 +123,23 @@ function keepaliveSeconds(): number {
|
|
|
190
123
|
return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
|
|
191
124
|
}
|
|
192
125
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const restMinutes = minutes % 60;
|
|
202
|
-
if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
|
|
203
|
-
const days = Math.floor(hours / 24);
|
|
204
|
-
return `${days}d${hours % 24}h`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function formatClock(epochMs: number | undefined): string {
|
|
208
|
-
if (!epochMs) return "unknown";
|
|
209
|
-
return new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function truncate(text: string, max: number): string {
|
|
213
|
-
const clean = text.replace(/\s+/g, " ").trim();
|
|
214
|
-
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** A compact one-line call row for a tool. */
|
|
218
|
-
function toolCall(label: string, detail: string, theme: Theme): Text {
|
|
219
|
-
const title = theme.fg("toolTitle", theme.bold(label));
|
|
220
|
-
return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** A compact one-line result row for a tool. */
|
|
224
|
-
function toolResult(
|
|
225
|
-
summary: string,
|
|
226
|
-
detail: string | undefined,
|
|
227
|
-
options: { expanded: boolean; isPartial: boolean; isError: boolean },
|
|
228
|
-
theme: Theme,
|
|
229
|
-
): Text {
|
|
230
|
-
if (options.isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
|
|
231
|
-
const prefix = options.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
|
|
232
|
-
const body = options.isError ? theme.fg("error", summary) : theme.fg("toolOutput", summary);
|
|
233
|
-
const text = prefix + body;
|
|
234
|
-
if (options.expanded && detail) return new Text(`${text}\n${theme.fg("dim", detail)}`, 0, 0);
|
|
235
|
-
return new Text(text, 0, 0);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Hide the call row once the result has arrived, leaving one result line. */
|
|
239
|
-
function completedCall(): Container {
|
|
240
|
-
return new Container();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** The first text line of a tool result, used as a fallback summary. */
|
|
244
|
-
function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
|
|
245
|
-
for (const block of result.content ?? []) {
|
|
246
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
247
|
-
return block.text.split("\n")[0] ?? "";
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return "";
|
|
126
|
+
/** Claude Code's offsets; `PI_LOOP_JITTER=0` runs jobs exactly on their cron minute. */
|
|
127
|
+
function timingConfig(): TimingConfig {
|
|
128
|
+
const fraction = Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", DEFAULT_TIMING.recurringFrac)));
|
|
129
|
+
return {
|
|
130
|
+
...DEFAULT_TIMING,
|
|
131
|
+
recurringFrac: fraction,
|
|
132
|
+
oneShotMaxMs: fraction > 0 ? DEFAULT_TIMING.oneShotMaxMs : 0,
|
|
133
|
+
};
|
|
251
134
|
}
|
|
252
135
|
|
|
253
|
-
function
|
|
254
|
-
|
|
255
|
-
if (seconds < 60) return `${seconds}s`;
|
|
256
|
-
return `${Math.round(seconds / 60)}m`;
|
|
136
|
+
function durableSyncMs(): number {
|
|
137
|
+
return Math.max(0, envNumber("PI_LOOP_DURABLE_SYNC_MS", 5000));
|
|
257
138
|
}
|
|
258
139
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
return `${job.id} self-paced · ${job.scheduledFor ? `next ${formatClock(job.scheduledFor)}` : "unscheduled"}`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function resolveLoopFile(cwd: string): { path: string; content: string } | null {
|
|
267
|
-
const candidates = [path.join(cwd, ".pi", "loop.md"), path.join(os.homedir(), ".pi", "loop.md")];
|
|
268
|
-
for (const candidate of candidates) {
|
|
269
|
-
try {
|
|
270
|
-
if (fs.existsSync(candidate)) {
|
|
271
|
-
const content = fs.readFileSync(candidate, "utf8");
|
|
272
|
-
if (content.trim()) return { path: candidate, content };
|
|
273
|
-
}
|
|
274
|
-
} catch {
|
|
275
|
-
// ignore unreadable candidates
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return null;
|
|
140
|
+
/** Models used to Claude Code may echo the prompt with its `/loop ` prefix. */
|
|
141
|
+
function normalizePrompt(prompt: string): string {
|
|
142
|
+
return prompt.trim().replace(/^\/loop\s+/i, "").trim();
|
|
279
143
|
}
|
|
280
144
|
|
|
281
145
|
/** The live scheduler, for tests. */
|
|
@@ -285,53 +149,41 @@ export function getScheduler(): LoopScheduler | null {
|
|
|
285
149
|
return activeScheduler;
|
|
286
150
|
}
|
|
287
151
|
|
|
288
|
-
/**
|
|
289
|
-
* Pull the `--durable` and `--max N` flags out of the command line.
|
|
290
|
-
* Flags may appear anywhere; the remaining tokens are the prompt.
|
|
291
|
-
*/
|
|
292
|
-
function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
|
|
293
|
-
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
294
|
-
const kept: string[] = [];
|
|
295
|
-
let durable = false;
|
|
296
|
-
let maxFires: number | undefined;
|
|
297
|
-
|
|
298
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
299
|
-
const token = tokens[i];
|
|
300
|
-
if (token.toLowerCase() === "--durable") {
|
|
301
|
-
durable = true;
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const inline = /^--max=(\d+)$/i.exec(token);
|
|
305
|
-
if (inline) {
|
|
306
|
-
maxFires = Math.max(1, Number(inline[1]));
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
if (/^--max$/i.test(token)) {
|
|
310
|
-
const next = tokens[i + 1];
|
|
311
|
-
if (next && /^\d+$/.test(next)) {
|
|
312
|
-
maxFires = Math.max(1, Number(next));
|
|
313
|
-
i++;
|
|
314
|
-
}
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
kept.push(token);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return { body: kept.join(" "), durable, maxFires };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
152
|
export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
153
|
+
/** Cached context for UI helpers. */
|
|
154
|
+
let lastCtx: ExtensionContext | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Prompts whose chain was stopped. A stopped chain must not be resurrected
|
|
157
|
+
* by a late `schedule_wakeup` call from a turn that a still-running monitor
|
|
158
|
+
* woke up.
|
|
159
|
+
*/
|
|
160
|
+
const stoppedPrompts = new Set<string>();
|
|
161
|
+
/** Signature of the last persisted state, to avoid redundant session entries. */
|
|
162
|
+
let lastPersistedSignature = "";
|
|
163
|
+
/**
|
|
164
|
+
* How many times each prompt has been run as a loop tick. The immediate run
|
|
165
|
+
* of `/loop` and the fires of the job created afterwards share a counter.
|
|
166
|
+
*/
|
|
167
|
+
const tickCounters = new Map<string, number>();
|
|
168
|
+
const loopFile = new LoopFileDelivery();
|
|
169
|
+
let durable: DurableRuntime | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* What the current run belongs to, derived from its starting message:
|
|
172
|
+
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
173
|
+
* `cronId` the fixed-interval job whose tick started it, `armed` the chains
|
|
174
|
+
* it armed, `tick` the tick number of a /loop run.
|
|
175
|
+
*/
|
|
176
|
+
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
|
|
177
|
+
|
|
324
178
|
const scheduler = new LoopScheduler({
|
|
325
|
-
dispatch: (job) =>
|
|
326
|
-
|
|
179
|
+
dispatch: (job) => deliverTick(job),
|
|
180
|
+
afterDispatch: (job, finished) => {
|
|
181
|
+
if (job.durable) durable?.afterFire(job, finished);
|
|
327
182
|
},
|
|
328
183
|
onChange: (jobs) => {
|
|
329
184
|
persist(jobs);
|
|
330
|
-
|
|
185
|
+
refreshStatus(jobs);
|
|
331
186
|
},
|
|
332
|
-
maxAgeMs,
|
|
333
|
-
jitterFraction,
|
|
334
|
-
jitterCapMs: () => 30 * 60 * 1000,
|
|
335
187
|
canDispatch: () => {
|
|
336
188
|
const ctx = lastCtx;
|
|
337
189
|
if (!ctx) return false;
|
|
@@ -341,151 +193,141 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
341
193
|
return false;
|
|
342
194
|
}
|
|
343
195
|
},
|
|
196
|
+
timing: timingConfig,
|
|
197
|
+
maxAgeMs,
|
|
344
198
|
onExpire: (job) => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// ignore
|
|
353
|
-
}
|
|
354
|
-
}
|
|
199
|
+
const ctx = lastCtx;
|
|
200
|
+
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
201
|
+
},
|
|
202
|
+
onScan: (now) => {
|
|
203
|
+
durable?.maintain(now);
|
|
204
|
+
// The countdown is coarse, so this is a string compare most seconds.
|
|
205
|
+
refreshStatus();
|
|
355
206
|
},
|
|
356
207
|
});
|
|
357
208
|
activeScheduler = scheduler;
|
|
358
209
|
|
|
359
|
-
/** Chain that the current run belongs to, injected as a system guideline. */
|
|
360
|
-
let runChainId: string | undefined;
|
|
361
|
-
/**
|
|
362
|
-
* Rules for a loop whose interval the command line did not contain. The
|
|
363
|
-
* model applies them itself, the way Claude Code's /loop skill does.
|
|
364
|
-
*/
|
|
365
|
-
let pendingScheduleRules: string | undefined;
|
|
366
|
-
/** One-off note attached to the next run. */
|
|
367
|
-
let pendingNote: string | undefined;
|
|
368
|
-
/** Cached context for UI helpers. */
|
|
369
|
-
let lastCtx: ExtensionContext | undefined;
|
|
370
|
-
/**
|
|
371
|
-
* Prompts whose chain was stopped. A stopped chain must not be resurrected
|
|
372
|
-
* by a late `schedule_wakeup` call from a turn that a still-running monitor
|
|
373
|
-
* woke up.
|
|
374
|
-
*/
|
|
375
|
-
const stoppedPrompts = new Set<string>();
|
|
376
|
-
/**
|
|
377
|
-
* Whether the status widget is shown.
|
|
378
|
-
*
|
|
379
|
-
* Claude Code's main session shows no loop status at all: the job id only
|
|
380
|
-
* appears once, in the CronCreate tool result, and the countdown only in
|
|
381
|
-
* agent view. The widget is therefore off by default here too; `/loop
|
|
382
|
-
* widget on` turns it on for people who want the at-a-glance status that
|
|
383
|
-
* agent view provides in Claude Code.
|
|
384
|
-
*/
|
|
385
|
-
let widgetEnabled = process.env.PI_LOOP_WIDGET === "1";
|
|
386
|
-
/** Signature of the last persisted state, to avoid empty session entries. */
|
|
387
|
-
let lastPersistedSignature = "";
|
|
388
|
-
|
|
389
210
|
// ---------------------------------------------------------------- helpers
|
|
390
211
|
|
|
212
|
+
/** What a job's persisted signature depends on; per-fire bookkeeping is left out. */
|
|
213
|
+
function signatureOf(job: LoopJob): unknown[] {
|
|
214
|
+
if (job.kind === "wakeup") {
|
|
215
|
+
return [job.id, job.kind, job.prompt, job.scheduledFor ?? null, job.keepaliveUsed ?? false, job.chainStartedAt ?? null];
|
|
216
|
+
}
|
|
217
|
+
return [
|
|
218
|
+
job.id,
|
|
219
|
+
job.kind,
|
|
220
|
+
job.cron,
|
|
221
|
+
job.prompt,
|
|
222
|
+
job.recurring,
|
|
223
|
+
job.createdAt,
|
|
224
|
+
job.maxFires ?? null,
|
|
225
|
+
job.maxFires !== undefined ? (job.fireCount ?? 0) : null,
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Save the session's jobs as a session entry. Durable jobs are left out:
|
|
231
|
+
* they live in .pi/scheduled_tasks.json, and restoring them from a session
|
|
232
|
+
* would run them twice. A new entry is only written when something that
|
|
233
|
+
* matters for a restore changed, not on every fire.
|
|
234
|
+
*/
|
|
391
235
|
function persist(jobs: LoopJob[]): void {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
job.kind,
|
|
396
|
-
job.cron,
|
|
397
|
-
job.prompt,
|
|
398
|
-
job.recurring,
|
|
399
|
-
job.nextFireAt,
|
|
400
|
-
job.scheduledFor,
|
|
401
|
-
job.keepaliveUsed,
|
|
402
|
-
job.awaitingReschedule,
|
|
403
|
-
job.durable,
|
|
404
|
-
job.reason,
|
|
405
|
-
job.maxFires,
|
|
406
|
-
job.fireCount,
|
|
407
|
-
]),
|
|
408
|
-
);
|
|
236
|
+
const kept = jobs.filter((job) => !job.durable).map(storedJob);
|
|
237
|
+
const stopped = [...stoppedPrompts];
|
|
238
|
+
const signature = JSON.stringify([kept.map(signatureOf), stopped]);
|
|
409
239
|
if (signature === lastPersistedSignature) return;
|
|
410
240
|
lastPersistedSignature = signature;
|
|
411
|
-
|
|
412
|
-
const durable = jobs.filter((job) => job.durable);
|
|
413
241
|
try {
|
|
414
|
-
pi.appendEntry(STATE_ENTRY, {
|
|
415
|
-
version: 1,
|
|
416
|
-
jobs,
|
|
417
|
-
stopped: [...stoppedPrompts],
|
|
418
|
-
widget: widgetEnabled,
|
|
419
|
-
} satisfies PersistedState);
|
|
242
|
+
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
|
|
420
243
|
} catch {
|
|
421
244
|
// session persistence is best effort
|
|
422
245
|
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
423
249
|
try {
|
|
424
|
-
|
|
425
|
-
// with /cd without restarting the process.
|
|
426
|
-
const dir = path.join(lastCtx?.cwd ?? process.cwd(), ".pi");
|
|
427
|
-
const file = path.join(dir, "scheduled_tasks.json");
|
|
428
|
-
if (durable.length === 0) {
|
|
429
|
-
if (fs.existsSync(file)) fs.rmSync(file);
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
433
|
-
const payload: PersistedState = { version: 1, jobs: durable };
|
|
434
|
-
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
250
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
435
251
|
} catch {
|
|
436
|
-
//
|
|
252
|
+
// a stale context must never break the loop
|
|
437
253
|
}
|
|
438
254
|
}
|
|
439
255
|
|
|
440
|
-
|
|
441
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Rules for a message we are about to send, keyed by its text. A user
|
|
258
|
+
* message carries no details, so the run's guidelines and the loop it
|
|
259
|
+
* belongs to wait here for before_agent_start to pick them up.
|
|
260
|
+
*/
|
|
261
|
+
const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
|
|
262
|
+
[];
|
|
263
|
+
|
|
264
|
+
function queueRun(run: (typeof queuedRuns)[number]): void {
|
|
265
|
+
queuedRuns.push(run);
|
|
266
|
+
if (queuedRuns.length > 8) queuedRuns.shift();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
|
|
270
|
+
const index = queuedRuns.findIndex((run) => run.content === content);
|
|
271
|
+
return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One line in Pi's footer, the same place other extensions show status.
|
|
276
|
+
* Claude Code has no loop status in its footer; a one-line summary is enough
|
|
277
|
+
* to know that something is armed and when it next fires.
|
|
278
|
+
*/
|
|
279
|
+
function statusText(jobs: LoopJob[]): string | undefined {
|
|
280
|
+
if (!jobs.length) return undefined;
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const next = Math.min(
|
|
283
|
+
...jobs.map((job) => {
|
|
284
|
+
if (job.pending) return now;
|
|
285
|
+
const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
|
|
286
|
+
return at ?? now;
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
|
|
442
290
|
}
|
|
443
291
|
|
|
444
|
-
|
|
292
|
+
let lastStatusText: string | undefined;
|
|
293
|
+
let statusSet = false;
|
|
294
|
+
|
|
295
|
+
function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
|
|
445
296
|
const ctx = lastCtx;
|
|
446
297
|
if (!ctx || !ctx.hasUI) return;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
"accent",
|
|
459
|
-
`loop ${job.id} · fixed ${job.cron} · next ${formatClock(at)} (${inText}) · ${truncate(job.prompt, 28)}`,
|
|
460
|
-
),
|
|
461
|
-
);
|
|
462
|
-
} else {
|
|
463
|
-
const at = job.scheduledFor;
|
|
464
|
-
const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
|
|
465
|
-
lines.push(
|
|
466
|
-
ctx.ui.theme.fg("accent", `loop ${job.id} · self-paced · ${inText} · ${truncate(job.prompt, 28)}`),
|
|
467
|
-
);
|
|
468
|
-
}
|
|
298
|
+
try {
|
|
299
|
+
// Older Pi versions have no footer status API.
|
|
300
|
+
if (typeof ctx.ui.setStatus !== "function") return;
|
|
301
|
+
const text = loadConfig().footer ? statusText(jobs) : undefined;
|
|
302
|
+
// Every call repaints, even with the same text, so speak only on change.
|
|
303
|
+
if (text === lastStatusText && (text !== undefined || !statusSet)) return;
|
|
304
|
+
lastStatusText = text;
|
|
305
|
+
statusSet = text !== undefined;
|
|
306
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
307
|
+
} catch {
|
|
308
|
+
// a stale context must never break the loop
|
|
469
309
|
}
|
|
470
|
-
ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
|
|
471
310
|
}
|
|
472
311
|
|
|
473
|
-
function
|
|
474
|
-
|
|
312
|
+
function rememberStopped(prompt: string): void {
|
|
313
|
+
stoppedPrompts.delete(prompt);
|
|
314
|
+
stoppedPrompts.add(prompt);
|
|
315
|
+
while (stoppedPrompts.size > MAX_STOPPED_PROMPTS) {
|
|
316
|
+
const oldest = stoppedPrompts.values().next().value;
|
|
317
|
+
if (oldest === undefined) break;
|
|
318
|
+
stoppedPrompts.delete(oldest);
|
|
319
|
+
}
|
|
475
320
|
}
|
|
476
321
|
|
|
477
322
|
/**
|
|
478
|
-
* End a chain for good
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
* stop any monitor that belongs to it. Claude Code requires the last part:
|
|
483
|
-
* "call ScheduleWakeup with stop: true and TaskStop any Monitor you armed".
|
|
484
|
-
* Without it, a monitor that keeps firing wakes the agent, the agent
|
|
323
|
+
* End a self-paced chain for good: the job is removed, the prompt is
|
|
324
|
+
* remembered so a late `schedule_wakeup` cannot resurrect it, and
|
|
325
|
+
* pi-cc-monitor is told to stop the monitors that belong to it. Without the
|
|
326
|
+
* last part a monitor that keeps firing wakes the agent, the agent
|
|
485
327
|
* reschedules, and the loop the user just stopped comes back.
|
|
486
328
|
*/
|
|
487
|
-
function retireChain(chain: LoopJob, reason: string): void {
|
|
488
|
-
|
|
329
|
+
function retireChain(chain: LoopJob, reason: string, options: { silent?: boolean } = {}): void {
|
|
330
|
+
rememberStopped(chain.prompt);
|
|
489
331
|
scheduler.remove(chain.id);
|
|
490
332
|
try {
|
|
491
333
|
pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
|
|
@@ -493,105 +335,133 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
493
335
|
// a listener must never break the loop
|
|
494
336
|
}
|
|
495
337
|
const ctx = lastCtx;
|
|
496
|
-
if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
|
|
338
|
+
if (ctx && !options.silent) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
|
|
497
339
|
}
|
|
498
340
|
|
|
499
|
-
function
|
|
500
|
-
|
|
341
|
+
function ensureDurable(): DurableRuntime {
|
|
342
|
+
durable ??= createDurable(lastCtx?.cwd ?? process.cwd());
|
|
343
|
+
return durable;
|
|
501
344
|
}
|
|
502
345
|
|
|
503
|
-
function
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
346
|
+
function createDurable(cwd: string): DurableRuntime {
|
|
347
|
+
return new DurableRuntime({
|
|
348
|
+
cwd,
|
|
349
|
+
scheduler,
|
|
350
|
+
timing: timingConfig,
|
|
351
|
+
maxAgeMs,
|
|
352
|
+
syncMs: durableSyncMs,
|
|
353
|
+
onMissed: reportMissed,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Add a job; a durable job is saved to disk and may run in another session. */
|
|
358
|
+
function addJob(job: LoopJob): Placement {
|
|
359
|
+
if (job.durable) return ensureDurable().add(job);
|
|
360
|
+
scheduler.add(job);
|
|
361
|
+
return { runsHere: true };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function removeJob(job: LoopJob): void {
|
|
365
|
+
scheduler.remove(job.id);
|
|
366
|
+
if (job.durable) durable?.remove(job.id);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** This session's jobs plus the durable jobs it can see but does not run. */
|
|
370
|
+
function listJobs(): Array<LoopJob & { foreignReason?: "other-session" | "other-folder" }> {
|
|
371
|
+
return [...scheduler.list(), ...(durable?.foreignJobs() ?? [])];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function foreignNote(job: { foreignReason?: string }): string {
|
|
375
|
+
if (job.foreignReason === "other-folder") return " (copied from another folder: listed, not run here)";
|
|
376
|
+
if (job.foreignReason === "other-session") return " (run by another Pi session in this project)";
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Durable one-shots that came due while no session ran them: ask before running. */
|
|
381
|
+
function reportMissed(jobs: LoopJob[]): void {
|
|
382
|
+
const lines = jobs.map((job) => {
|
|
383
|
+
const due = nextRunTime(job, job.createdAt, timingConfig());
|
|
384
|
+
const when = due
|
|
385
|
+
? ` (was due ${new Date(due).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })})`
|
|
386
|
+
: "";
|
|
387
|
+
return `- job ${job.id}${when}: ${displayPrompt(job.prompt)}`;
|
|
388
|
+
});
|
|
389
|
+
try {
|
|
390
|
+
// Attached to the next turn rather than starting one at startup.
|
|
391
|
+
pi.sendMessage(
|
|
392
|
+
{
|
|
393
|
+
customType: TICK_MESSAGE_TYPE,
|
|
394
|
+
content: missedJobsMessage(lines),
|
|
395
|
+
display: true,
|
|
396
|
+
details: { missed: true, prompt: lines.join("\n") },
|
|
397
|
+
},
|
|
398
|
+
{ deliverAs: "followUp" },
|
|
399
|
+
);
|
|
400
|
+
} catch {
|
|
401
|
+
// best effort
|
|
402
|
+
}
|
|
403
|
+
const ctx = lastCtx;
|
|
404
|
+
if (ctx) {
|
|
405
|
+
notify(
|
|
406
|
+
ctx,
|
|
407
|
+
`${jobs.length} durable one-shot job${jobs.length === 1 ? " was" : "s were"} missed while Pi was closed; you will be asked before ${jobs.length === 1 ? "it runs" : "they run"}`,
|
|
408
|
+
"warning",
|
|
409
|
+
);
|
|
515
410
|
}
|
|
516
411
|
}
|
|
517
412
|
|
|
518
413
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
414
|
+
* A scheduled fire is a custom message, not a user message: the provenance
|
|
415
|
+
* line in its content is addressed to the model, and only a custom message
|
|
416
|
+
* can draw something else for the reader (`renderScheduledMessage`).
|
|
417
|
+
*/
|
|
418
|
+
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
419
|
+
|
|
420
|
+
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
421
|
+
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
422
|
+
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Deliver a scheduled tick. `followUp` is the low-priority lane: Claude
|
|
427
|
+
* Code's "A scheduled prompt fires between your turns, not while Claude is
|
|
428
|
+
* mid-response."
|
|
525
429
|
*/
|
|
526
430
|
function deliverTick(job: LoopJob): void {
|
|
527
|
-
// Continue the shared per-prompt counter: the immediate run of /loop and
|
|
528
|
-
// the first fire of the job the model created afterwards are one loop.
|
|
529
431
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
530
432
|
tickCounters.set(job.prompt, tick);
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
// A
|
|
534
|
-
//
|
|
535
|
-
|
|
433
|
+
job.tickCount = tick;
|
|
434
|
+
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
435
|
+
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
436
|
+
// self-paced tick keeps the loop framing. The rules for the run are
|
|
437
|
+
// rebuilt from `details` in before_agent_start.
|
|
438
|
+
const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
|
|
439
|
+
const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
|
|
536
440
|
pi.sendMessage(
|
|
537
441
|
{
|
|
538
442
|
customType: TICK_MESSAGE_TYPE,
|
|
539
|
-
content
|
|
443
|
+
content: `${stamp}\n${body}`,
|
|
540
444
|
display: true,
|
|
541
|
-
details: {
|
|
445
|
+
details: {
|
|
446
|
+
kind: job.kind,
|
|
447
|
+
tickCount: tick,
|
|
448
|
+
jobId: job.id,
|
|
449
|
+
cron: job.cron,
|
|
450
|
+
prompt: body,
|
|
451
|
+
reason: job.reason,
|
|
452
|
+
},
|
|
542
453
|
},
|
|
454
|
+
// `triggerTurn` is what starts the run: without it the message is
|
|
455
|
+
// only recorded and nothing ever happens.
|
|
543
456
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
544
457
|
);
|
|
545
458
|
|
|
546
|
-
|
|
547
|
-
if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
|
|
459
|
+
if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
|
|
548
460
|
const ctx = lastCtx;
|
|
549
|
-
if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${
|
|
461
|
+
if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${job.maxFires} fires`, "info");
|
|
550
462
|
}
|
|
551
463
|
}
|
|
552
464
|
|
|
553
|
-
/**
|
|
554
|
-
* Run the prompt right away, the way Claude Code's skill does
|
|
555
|
-
* ("Then immediately execute the parsed prompt now").
|
|
556
|
-
*
|
|
557
|
-
* `steer` is the right lane here: it lands in the current turn when the
|
|
558
|
-
* agent is already working, which is what executing the prompt inside the
|
|
559
|
-
* skill turn means.
|
|
560
|
-
*/
|
|
561
|
-
/**
|
|
562
|
-
* How many times each prompt has been run as a loop tick.
|
|
563
|
-
*
|
|
564
|
-
* The immediate run of `/loop` and the first fire of the job the model
|
|
565
|
-
* creates afterwards belong to the same loop, so they share one counter.
|
|
566
|
-
*/
|
|
567
|
-
const tickCounters = new Map<string, number>();
|
|
568
|
-
|
|
569
|
-
function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
|
|
570
|
-
const tick = (tickCounters.get(prompt) ?? 0) + 1;
|
|
571
|
-
tickCounters.set(prompt, tick);
|
|
572
|
-
if (job) {
|
|
573
|
-
scheduler.update(job.id, {
|
|
574
|
-
tickCount: tick,
|
|
575
|
-
fireCount: (job.fireCount ?? 0) + 1,
|
|
576
|
-
lastTickAt: Date.now(),
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
runChainId = job?.kind === "wakeup" ? job.id : undefined;
|
|
580
|
-
pi.sendMessage(
|
|
581
|
-
{
|
|
582
|
-
customType: TICK_MESSAGE_TYPE,
|
|
583
|
-
content: `${PROVENANCE.replace("{n}", String(tick))}\n${prompt}`,
|
|
584
|
-
display: true,
|
|
585
|
-
details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
|
|
586
|
-
},
|
|
587
|
-
{ deliverAs: "steer", triggerTurn: true },
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function scheduleNote(text: string): void {
|
|
592
|
-
pendingNote = text;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
465
|
/** True when the pi-cc-monitor extension registered its Monitor tool. */
|
|
596
466
|
function monitorToolAvailable(): boolean {
|
|
597
467
|
try {
|
|
@@ -602,44 +472,84 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
602
472
|
}
|
|
603
473
|
|
|
604
474
|
/** The tool names to exclude from pi-cc-extensions' global styling. */
|
|
605
|
-
|
|
606
|
-
|
|
475
|
+
/**
|
|
476
|
+
* A run finished without an abort: self-paced chains whose tick the model
|
|
477
|
+
* did not reschedule get their one keepalive, or end.
|
|
478
|
+
*/
|
|
479
|
+
function settleChains(ctx: ExtensionContext): void {
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
for (const chain of scheduler.byKind("wakeup")) {
|
|
482
|
+
// The keepalive turn is the model's last chance to reschedule.
|
|
483
|
+
if (chain.keepaliveTurn) {
|
|
484
|
+
retireChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (!chain.awaitingReschedule) continue;
|
|
488
|
+
|
|
489
|
+
const limit = maxAgeMs();
|
|
490
|
+
if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
|
|
491
|
+
retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (chain.keepaliveUsed) continue;
|
|
495
|
+
|
|
496
|
+
const fallback = keepaliveSeconds();
|
|
497
|
+
if (fallback <= 0) {
|
|
498
|
+
retireChain(chain, "the model did not reschedule (keepalive disabled)");
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
scheduler.update(chain.id, {
|
|
502
|
+
scheduledFor: now + fallback * 1000,
|
|
503
|
+
keepaliveUsed: true,
|
|
504
|
+
awaitingReschedule: false,
|
|
505
|
+
});
|
|
506
|
+
notify(
|
|
507
|
+
ctx,
|
|
508
|
+
`loop ${chain.id}: the model did not reschedule \u2014 keepalive armed (${Math.round(fallback / 60)} min)`,
|
|
509
|
+
"warning",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Esc interrupted a turn that belongs to a self-paced loop (its tick, the
|
|
516
|
+
* /loop run that armed it, or a turn its monitor woke): cancel that loop,
|
|
517
|
+
* with Claude Code's notice ("A `Pending wakeup cancelled` notice confirms
|
|
518
|
+
* that the loop won't fire again").
|
|
519
|
+
*
|
|
520
|
+
* Claude Code cancels every self-paced loop on any Esc, idle or not. Here
|
|
521
|
+
* an interrupted unrelated turn leaves the loops alone; `/loop stop` stops
|
|
522
|
+
* them. Fixed-interval jobs keep running either way, as in Claude Code.
|
|
523
|
+
*/
|
|
524
|
+
function cancelCurrentLoop(ctx: ExtensionContext): void {
|
|
525
|
+
const ids = new Set([...(currentRun.chainId ? [currentRun.chainId] : []), ...(currentRun.armed ?? [])]);
|
|
526
|
+
const chains = [...ids]
|
|
527
|
+
.map((id) => scheduler.get(id))
|
|
528
|
+
.filter((chain): chain is LoopJob => chain?.kind === "wakeup");
|
|
529
|
+
if (!chains.length) return;
|
|
530
|
+
for (const chain of chains) retireChain(chain, "pending wakeup cancelled (Esc)", { silent: true });
|
|
531
|
+
notify(ctx, `Pending wakeup cancelled \u00b7 loop ${chains.map((chain) => chain.id).join(", ")} ended`, "info");
|
|
607
532
|
}
|
|
608
533
|
|
|
609
534
|
// ---------------------------------------------------------------- command
|
|
610
535
|
|
|
611
536
|
pi.registerCommand("loop", {
|
|
612
|
-
description:
|
|
613
|
-
"Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
|
|
537
|
+
description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
|
|
614
538
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
615
539
|
lastCtx = ctx;
|
|
616
540
|
const raw = args.trim();
|
|
617
|
-
|
|
618
|
-
if (!raw) {
|
|
619
|
-
await startLoop(ctx, null, "", false, undefined);
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
541
|
const [head, ...rest] = raw.split(/\s+/);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
case "
|
|
629
|
-
case "list":
|
|
630
|
-
case "jobs":
|
|
631
|
-
showStatus(ctx);
|
|
632
|
-
return;
|
|
633
|
-
case "widget": {
|
|
542
|
+
|
|
543
|
+
// Settings configure the extension, not a loop: they stay in code.
|
|
544
|
+
switch ((head ?? "").toLowerCase()) {
|
|
545
|
+
case "footer":
|
|
546
|
+
case "statusline": {
|
|
634
547
|
const arg = (rest[0] ?? "").toLowerCase();
|
|
635
|
-
if (arg
|
|
636
|
-
notify(ctx, `Usage: /loop
|
|
548
|
+
if (!arg) {
|
|
549
|
+
notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
|
|
637
550
|
return;
|
|
638
551
|
}
|
|
639
|
-
|
|
640
|
-
persist(scheduler.list());
|
|
641
|
-
refreshWidget(scheduler.list());
|
|
642
|
-
notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
|
|
552
|
+
setSetting(ctx, "footer", arg);
|
|
643
553
|
return;
|
|
644
554
|
}
|
|
645
555
|
case "settings":
|
|
@@ -654,11 +564,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
654
564
|
setSetting(ctx, "messages", value);
|
|
655
565
|
return;
|
|
656
566
|
}
|
|
657
|
-
if (name === "
|
|
658
|
-
setSetting(ctx, "
|
|
567
|
+
if (name === "footer" || name === "status") {
|
|
568
|
+
setSetting(ctx, "footer", value);
|
|
659
569
|
return;
|
|
660
570
|
}
|
|
661
|
-
notify(ctx, "Usage: /loop settings [messages|
|
|
571
|
+
notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
|
|
662
572
|
return;
|
|
663
573
|
}
|
|
664
574
|
case "help":
|
|
@@ -670,210 +580,52 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
670
580
|
break;
|
|
671
581
|
}
|
|
672
582
|
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
//
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (parsed !== null) {
|
|
686
|
-
intervalSeconds = parsed;
|
|
687
|
-
prompt = leading[2].trim();
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
|
|
692
|
-
if (intervalSeconds === null) {
|
|
693
|
-
const every = parseEveryClause(body);
|
|
694
|
-
if (every) {
|
|
695
|
-
intervalSeconds = every.interval;
|
|
696
|
-
prompt = every.prompt;
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Rule 3: no interval -> self-paced
|
|
701
|
-
await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
|
|
583
|
+
// Everything else becomes a normal user message, the way Claude Code's
|
|
584
|
+
// /loop skill does: the conversation keeps the request, and the model
|
|
585
|
+
// drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
|
|
586
|
+
// The per-run rules travel with the run through before_agent_start.
|
|
587
|
+
const text = raw ? `/loop ${raw}` : "/loop";
|
|
588
|
+
queueRun({ content: text, guidelines: [loopCommandGuidelines(raw)] });
|
|
589
|
+
// Pi runs an extension command even while a turn is streaming, and its
|
|
590
|
+
// sendUserMessage throws unless a lane is named: "Agent is already
|
|
591
|
+
// processing. Specify streamingBehavior". `followUp` is that lane - the
|
|
592
|
+
// request waits for the current turn to end, which is where a /loop
|
|
593
|
+
// belongs anyway. When idle the option is ignored and the turn starts.
|
|
594
|
+
pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
|
|
702
595
|
},
|
|
703
596
|
});
|
|
704
597
|
|
|
705
|
-
async function startLoop(
|
|
706
|
-
ctx: ExtensionCommandContext,
|
|
707
|
-
intervalSeconds: number | null,
|
|
708
|
-
prompt: string,
|
|
709
|
-
durable: boolean,
|
|
710
|
-
maxFires: number | undefined,
|
|
711
|
-
): Promise<void> {
|
|
712
|
-
expireOldJobs(ctx);
|
|
713
|
-
|
|
714
|
-
// Starting a chain again clears the stop marker: the user asked for it.
|
|
715
|
-
stoppedPrompts.delete(prompt);
|
|
716
|
-
|
|
717
|
-
let finalPrompt = prompt;
|
|
718
|
-
|
|
719
|
-
if (!finalPrompt) {
|
|
720
|
-
const loopFile = resolveLoopFile(ctx.cwd);
|
|
721
|
-
if (loopFile) {
|
|
722
|
-
finalPrompt = `Work the tasks from the loop file at ${loopFile.path}:\n\n${loopFile.content}`;
|
|
723
|
-
} else {
|
|
724
|
-
finalPrompt = MAINTENANCE_PROMPT;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (intervalSeconds === null) {
|
|
729
|
-
// The interval could not be parsed from the command line. Claude Code's
|
|
730
|
-
// /loop is a skill, so the model reads the input and works the schedule
|
|
731
|
-
// out itself; the rules below let it do the same here. No chain is
|
|
732
|
-
// created up front: the model picks cron_create (fixed interval) or
|
|
733
|
-
// schedule_wakeup (open ended), and schedule_wakeup creates the chain.
|
|
734
|
-
let rules = LOOP_SCHEDULE_RULES.replace("{prompt}", finalPrompt).replace(
|
|
735
|
-
"{quoted}",
|
|
736
|
-
`"${finalPrompt}"`,
|
|
737
|
-
);
|
|
738
|
-
if (durable) {
|
|
739
|
-
rules +=
|
|
740
|
-
"\n- The user asked for the job to survive a restart: create it with `durable: true`.";
|
|
741
|
-
}
|
|
742
|
-
if (maxFires !== undefined) {
|
|
743
|
-
rules +=
|
|
744
|
-
`\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
|
|
745
|
-
}
|
|
746
|
-
pendingScheduleRules = rules;
|
|
747
|
-
runNow(ctx, finalPrompt);
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
const spec = intervalToCron(intervalSeconds);
|
|
752
|
-
if (!spec) {
|
|
753
|
-
notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
if (!parseCron(spec.cron)) {
|
|
757
|
-
notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
if (scheduler.size >= MAX_JOBS) {
|
|
761
|
-
notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const job = scheduler.add({
|
|
766
|
-
id: generateId(),
|
|
767
|
-
kind: "cron",
|
|
768
|
-
cron: spec.cron,
|
|
769
|
-
prompt: finalPrompt,
|
|
770
|
-
recurring: true,
|
|
771
|
-
createdAt: Date.now(),
|
|
772
|
-
durable,
|
|
773
|
-
maxFires,
|
|
774
|
-
label: humanSchedule(spec.cron),
|
|
775
|
-
source: "loop_command",
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
const expiryDays = Math.round(maxAgeMs() / 86_400_000);
|
|
779
|
-
notify(
|
|
780
|
-
ctx,
|
|
781
|
-
`Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)}) · ${spec.cron} · expires in ${expiryDays}d · cancel with /loop stop ${job.id}` +
|
|
782
|
-
(spec.note ? ` · ${spec.note}` : ""),
|
|
783
|
-
"info",
|
|
784
|
-
);
|
|
785
|
-
scheduleNote(
|
|
786
|
-
`A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
|
|
787
|
-
`next run ${formatClock(job.nextFireAt)}, auto-expires after ${expiryDays} days. ` +
|
|
788
|
-
`The user cancels it with \`/loop stop ${job.id}\`.` +
|
|
789
|
-
(spec.note ? ` The requested interval was adjusted: ${spec.note}.` : ""),
|
|
790
|
-
);
|
|
791
|
-
|
|
792
|
-
runNow(ctx, finalPrompt, job);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
|
|
796
|
-
if (target) {
|
|
797
|
-
const job = scheduler.get(target);
|
|
798
|
-
if (!job) {
|
|
799
|
-
notify(ctx, `No loop with id ${target}`, "warning");
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
if (job.kind === "wakeup") {
|
|
803
|
-
retireChain(job, "stopped by the user");
|
|
804
|
-
} else {
|
|
805
|
-
scheduler.remove(target);
|
|
806
|
-
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
807
|
-
}
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
const jobs = scheduler.list();
|
|
811
|
-
if (!jobs.length) {
|
|
812
|
-
notify(ctx, "No active loops", "info");
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
for (const job of jobs) {
|
|
816
|
-
if (job.kind === "wakeup") {
|
|
817
|
-
// Retiring also tells pi-cc-monitor to stop the monitors that
|
|
818
|
-
// belong to this chain.
|
|
819
|
-
retireChain(job, "stopped by the user");
|
|
820
|
-
} else {
|
|
821
|
-
scheduler.remove(job.id);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
function showStatus(ctx: ExtensionContext): void {
|
|
828
|
-
const jobs = scheduler.list();
|
|
829
|
-
if (!jobs.length) {
|
|
830
|
-
notify(ctx, "No active loops", "info");
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
const lines = jobs.map((job) => {
|
|
834
|
-
if (job.kind === "cron") {
|
|
835
|
-
const cap = job.maxFires !== undefined ? ` · ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
|
|
836
|
-
return `${job.id} cron ${job.cron} (${job.label ?? describeCron(job.cron ?? "")}) next ${formatClock(job.nextFireAt)}${cap} ${truncate(job.prompt, 60)}`;
|
|
837
|
-
}
|
|
838
|
-
const at = job.scheduledFor;
|
|
839
|
-
const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
|
|
840
|
-
return `${job.id} self-paced ${inText} ${truncate(job.prompt, 60)}`;
|
|
841
|
-
});
|
|
842
|
-
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
843
|
-
}
|
|
844
|
-
|
|
845
598
|
function showHelp(ctx: ExtensionContext): void {
|
|
846
599
|
const help = [
|
|
847
600
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
848
601
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
849
|
-
"/loop
|
|
850
|
-
"/loop
|
|
851
|
-
"/loop status list the active loops",
|
|
852
|
-
"/loop widget on|off show or hide the status line above the editor",
|
|
602
|
+
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
603
|
+
"/loop footer on|off show or hide the one-line summary in the footer",
|
|
853
604
|
"/loop settings show UI settings",
|
|
854
605
|
"/loop settings messages on|off compact two-line markers",
|
|
855
|
-
"/loop settings
|
|
606
|
+
"/loop settings footer on|off the one-line footer summary",
|
|
856
607
|
"",
|
|
608
|
+
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
857
609
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
858
610
|
" --max N stop the loop after N fires.",
|
|
859
611
|
].join("\n");
|
|
860
|
-
|
|
612
|
+
notify(ctx, help, "info");
|
|
861
613
|
}
|
|
862
614
|
|
|
863
615
|
function showSettings(ctx: ExtensionContext): void {
|
|
864
616
|
const config = loadConfig();
|
|
865
617
|
const lines = [
|
|
866
|
-
`pi-cc settings
|
|
618
|
+
`pi-cc settings \u00b7 ${configPath()}`,
|
|
867
619
|
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
868
|
-
`
|
|
620
|
+
` footer: ${config.footer ? "summary" : "hidden"}`,
|
|
869
621
|
"",
|
|
870
622
|
"Usage: /loop settings messages on|off",
|
|
871
|
-
" /loop settings
|
|
623
|
+
" /loop settings footer on|off",
|
|
872
624
|
];
|
|
873
|
-
|
|
625
|
+
notify(ctx, lines.join("\n"), "info");
|
|
874
626
|
}
|
|
875
627
|
|
|
876
|
-
function setSetting(ctx: ExtensionContext, key: "messages" | "
|
|
628
|
+
function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
|
|
877
629
|
const value = (raw ?? "").toLowerCase();
|
|
878
630
|
const on = value === "on" || value === "true" || value === "1";
|
|
879
631
|
const off = value === "off" || value === "false" || value === "0";
|
|
@@ -881,670 +633,526 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
881
633
|
showSettings(ctx);
|
|
882
634
|
return;
|
|
883
635
|
}
|
|
884
|
-
const patch: Partial<PiCcConfig> =
|
|
885
|
-
key === "messages" ? { compactMessages: on } : { compactToolRows: on };
|
|
636
|
+
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
|
|
886
637
|
const next = saveConfig(patch);
|
|
887
|
-
let compatChanged = false;
|
|
888
|
-
if (key === "tools") {
|
|
889
|
-
compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
|
|
890
|
-
}
|
|
891
638
|
try {
|
|
892
639
|
pi.events.emit(CONFIG_CHANNEL, next);
|
|
893
640
|
} catch {
|
|
894
641
|
// another extension must not break the command
|
|
895
642
|
}
|
|
896
|
-
notify(ctx, `${key === "messages" ? "Compact message style" : "
|
|
897
|
-
if (
|
|
898
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
|
|
899
|
-
}
|
|
643
|
+
notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
|
|
644
|
+
if (key === "footer") refreshStatus();
|
|
900
645
|
}
|
|
901
646
|
|
|
902
647
|
// ------------------------------------------------------------------ tools
|
|
903
648
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
649
|
+
pi.registerTool({
|
|
650
|
+
name: "cron_create",
|
|
651
|
+
// Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
|
|
652
|
+
label: "CronCreate",
|
|
653
|
+
description: CRON_CREATE_DESCRIPTION,
|
|
654
|
+
promptSnippet: CRON_CREATE_SNIPPET,
|
|
655
|
+
parameters: Type.Object({
|
|
656
|
+
cron: Type.String({
|
|
657
|
+
description:
|
|
658
|
+
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
|
|
659
|
+
}),
|
|
660
|
+
prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
|
|
661
|
+
recurring: Type.Optional(
|
|
662
|
+
Type.Boolean({
|
|
915
663
|
description:
|
|
916
|
-
|
|
664
|
+
"true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete.",
|
|
917
665
|
}),
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (scheduler.size >= MAX_JOBS) {
|
|
948
|
-
throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
|
|
949
|
-
}
|
|
950
|
-
if (!params.prompt?.trim()) {
|
|
951
|
-
throw new Error("prompt must not be empty.");
|
|
952
|
-
}
|
|
666
|
+
),
|
|
667
|
+
durable: Type.Optional(
|
|
668
|
+
Type.Boolean({
|
|
669
|
+
description:
|
|
670
|
+
"true = persist to .pi/scheduled_tasks.json and survive restarts. false (default) = this session only. Use true only when the user asks the task to survive across sessions.",
|
|
671
|
+
}),
|
|
672
|
+
),
|
|
673
|
+
maxFires: Type.Optional(
|
|
674
|
+
Type.Integer({
|
|
675
|
+
minimum: 1,
|
|
676
|
+
description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
|
|
677
|
+
}),
|
|
678
|
+
),
|
|
679
|
+
}),
|
|
680
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
681
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
682
|
+
lastCtx = ctx;
|
|
683
|
+
if (!parseCron(params.cron)) {
|
|
684
|
+
throw new Error(`Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`);
|
|
685
|
+
}
|
|
686
|
+
if (isUnreachable(params.cron)) {
|
|
687
|
+
throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
|
|
688
|
+
}
|
|
689
|
+
if (scheduler.size >= MAX_JOBS) {
|
|
690
|
+
throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
|
|
691
|
+
}
|
|
692
|
+
if (!params.prompt?.trim()) {
|
|
693
|
+
throw new Error("prompt must not be empty.");
|
|
694
|
+
}
|
|
953
695
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
696
|
+
const recurring = params.recurring ?? true;
|
|
697
|
+
const job: LoopJob = {
|
|
698
|
+
id: generateId(),
|
|
699
|
+
kind: "cron",
|
|
700
|
+
cron: params.cron.trim(),
|
|
701
|
+
prompt: params.prompt,
|
|
702
|
+
recurring,
|
|
703
|
+
createdAt: Date.now(),
|
|
704
|
+
durable: params.durable ?? false,
|
|
705
|
+
maxFires: params.maxFires,
|
|
706
|
+
// Continue the tick numbering of the /loop run that led here.
|
|
707
|
+
tickCount: tickCounters.get(params.prompt) ?? currentRun.tick ?? 0,
|
|
708
|
+
label: humanSchedule(params.cron),
|
|
709
|
+
source: "cron_create",
|
|
710
|
+
};
|
|
711
|
+
const placement = addJob(job);
|
|
712
|
+
const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
|
|
713
|
+
|
|
714
|
+
const lines = [`Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)})`];
|
|
715
|
+
if (job.durable) {
|
|
716
|
+
lines.push(
|
|
717
|
+
placement.runsHere
|
|
718
|
+
? "Durable: saved to .pi/scheduled_tasks.json, survives restarts."
|
|
719
|
+
: `Durable: saved to .pi/scheduled_tasks.json. Another Pi session in this project (pid ${placement.holderPid ?? "?"}) holds the scheduler lock and runs it.`,
|
|
720
|
+
);
|
|
721
|
+
} else {
|
|
722
|
+
lines.push("Session-only: restored if this session is resumed, gone otherwise.");
|
|
723
|
+
}
|
|
724
|
+
if (recurring) {
|
|
725
|
+
lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
|
|
726
|
+
}
|
|
727
|
+
lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
731
|
+
details: {
|
|
732
|
+
id: job.id,
|
|
733
|
+
cron: job.cron,
|
|
734
|
+
recurring: job.recurring,
|
|
735
|
+
durable: job.durable,
|
|
736
|
+
nextFireAt: nextAt,
|
|
737
|
+
prompt: job.prompt,
|
|
738
|
+
runsHere: placement.runsHere,
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
});
|
|
968
743
|
|
|
744
|
+
pi.registerTool({
|
|
745
|
+
name: "cron_delete",
|
|
746
|
+
label: "CronDelete",
|
|
747
|
+
description: CRON_DELETE_DESCRIPTION,
|
|
748
|
+
promptSnippet: CRON_DELETE_SNIPPET,
|
|
749
|
+
parameters: Type.Object({
|
|
750
|
+
id: Type.String({ description: "The job id returned by cron_create, or the id of a self-paced loop." }),
|
|
751
|
+
}),
|
|
752
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
753
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
754
|
+
lastCtx = ctx;
|
|
755
|
+
const job = scheduler.get(params.id);
|
|
756
|
+
if (job) {
|
|
757
|
+
// A self-paced chain ends the same way /loop stop ends it.
|
|
758
|
+
if (job.kind === "wakeup") retireChain(job, "cancelled with cron_delete");
|
|
759
|
+
else removeJob(job);
|
|
760
|
+
} else if (!durable?.remove(params.id)) {
|
|
761
|
+
return {
|
|
762
|
+
content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
|
|
763
|
+
details: { cancelled: false, id: params.id },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
|
|
768
|
+
details: { cancelled: true, id: params.id },
|
|
769
|
+
};
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
pi.registerTool({
|
|
774
|
+
name: "cron_list",
|
|
775
|
+
label: "CronList",
|
|
776
|
+
description: CRON_LIST_DESCRIPTION,
|
|
777
|
+
promptSnippet: CRON_LIST_SNIPPET,
|
|
778
|
+
parameters: Type.Object({}),
|
|
779
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
780
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
781
|
+
lastCtx = ctx;
|
|
782
|
+
const jobs = listJobs();
|
|
783
|
+
if (!jobs.length) {
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text" as const, text: "No scheduled jobs." }],
|
|
786
|
+
details: { jobs: [] },
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const text = jobs
|
|
790
|
+
.map((job) => {
|
|
791
|
+
const prompt = truncate(displayPrompt(job.prompt), 80);
|
|
792
|
+
if (job.kind === "cron") {
|
|
793
|
+
return `${job.id} cron ${job.cron} ${humanSchedule(job.cron ?? "")} recurring=${job.recurring} next ${formatClock(job.nextFireAt)}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
|
|
794
|
+
}
|
|
795
|
+
return `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${prompt}`;
|
|
796
|
+
})
|
|
797
|
+
.join("\n");
|
|
798
|
+
return {
|
|
799
|
+
content: [{ type: "text" as const, text }],
|
|
800
|
+
details: { jobs: jobs.map((job) => storedJob(job)) },
|
|
801
|
+
};
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
pi.registerTool({
|
|
806
|
+
name: "schedule_wakeup",
|
|
807
|
+
label: "ScheduleWakeup",
|
|
808
|
+
description: SCHEDULE_WAKEUP_DESCRIPTION,
|
|
809
|
+
promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
|
|
810
|
+
parameters: Type.Object({
|
|
811
|
+
delaySeconds: Type.Optional(
|
|
812
|
+
Type.Number({
|
|
813
|
+
description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
|
|
814
|
+
}),
|
|
815
|
+
),
|
|
816
|
+
reason: Type.Optional(
|
|
817
|
+
Type.String({
|
|
818
|
+
description:
|
|
819
|
+
"One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
|
|
820
|
+
}),
|
|
821
|
+
),
|
|
822
|
+
prompt: Type.Optional(
|
|
823
|
+
Type.String({
|
|
824
|
+
description: `The /loop input to fire on wake-up, verbatim, so the next firing repeats the task. For a bare /loop pass the literal sentinel "${LOOP_FILE_SENTINEL}".`,
|
|
825
|
+
}),
|
|
826
|
+
),
|
|
827
|
+
stop: Type.Optional(
|
|
828
|
+
Type.Boolean({
|
|
829
|
+
description: "Set to true to end the self-paced loop immediately. Omit every other field.",
|
|
830
|
+
}),
|
|
831
|
+
),
|
|
832
|
+
}),
|
|
833
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
834
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
835
|
+
lastCtx = ctx;
|
|
836
|
+
|
|
837
|
+
if (params.stop === true) {
|
|
838
|
+
// Stop the loop this turn belongs to; otherwise the named one, or all.
|
|
839
|
+
const current = currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined;
|
|
840
|
+
const named = params.prompt ? scheduler.findChain(normalizePrompt(params.prompt)) : undefined;
|
|
841
|
+
const chains = current ? [current] : named ? [named] : scheduler.byKind("wakeup");
|
|
842
|
+
for (const chain of chains) retireChain(chain, "stopped by the agent");
|
|
969
843
|
return {
|
|
970
844
|
content: [
|
|
971
845
|
{
|
|
972
|
-
type: "text",
|
|
973
|
-
text:
|
|
974
|
-
`
|
|
975
|
-
|
|
846
|
+
type: "text" as const,
|
|
847
|
+
text: chains.length
|
|
848
|
+
? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
|
|
849
|
+
"Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
|
|
850
|
+
: "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
|
|
851
|
+
"If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
|
|
976
852
|
},
|
|
977
853
|
],
|
|
978
|
-
details: {
|
|
979
|
-
id: job.id,
|
|
980
|
-
cron: job.cron,
|
|
981
|
-
recurring: job.recurring,
|
|
982
|
-
durable: job.durable,
|
|
983
|
-
nextFireAt: job.nextFireAt,
|
|
984
|
-
prompt: job.prompt,
|
|
985
|
-
},
|
|
854
|
+
details: { stopped: true, cancelledWakeups: chains.length },
|
|
986
855
|
};
|
|
987
|
-
}
|
|
988
|
-
});
|
|
856
|
+
}
|
|
989
857
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const job = scheduler.get(params.id);
|
|
1001
|
-
if (!job) {
|
|
1002
|
-
return {
|
|
1003
|
-
content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
|
|
1004
|
-
details: { cancelled: false, id: params.id },
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
scheduler.remove(params.id);
|
|
1008
|
-
return {
|
|
1009
|
-
content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
|
|
1010
|
-
details: { cancelled: true, id: params.id },
|
|
1011
|
-
};
|
|
1012
|
-
},
|
|
1013
|
-
});
|
|
858
|
+
if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
|
|
859
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
860
|
+
}
|
|
861
|
+
if (!params.reason || !params.reason.trim()) {
|
|
862
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
863
|
+
}
|
|
864
|
+
const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
|
|
865
|
+
if (!prompt) {
|
|
866
|
+
throw new Error("`prompt` is required when `stop` is not true.");
|
|
867
|
+
}
|
|
1014
868
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1021
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1022
|
-
lastCtx = ctx;
|
|
1023
|
-
const jobs = scheduler.list();
|
|
1024
|
-
if (!jobs.length) {
|
|
1025
|
-
return {
|
|
1026
|
-
content: [{ type: "text" as const, text: "No scheduled jobs." }],
|
|
1027
|
-
details: { jobs: [] },
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
const text = jobs
|
|
1031
|
-
.map((job) =>
|
|
1032
|
-
job.kind === "cron"
|
|
1033
|
-
? `${job.id} cron ${job.cron} recurring=${job.recurring} next ${formatClock(job.nextFireAt)} ${truncate(job.prompt, 80)}`
|
|
1034
|
-
: `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${truncate(job.prompt, 80)}`,
|
|
1035
|
-
)
|
|
1036
|
-
.join("\n");
|
|
869
|
+
if (currentRun.cronId) {
|
|
870
|
+
// Not an error: the request is already satisfied by the schedule.
|
|
871
|
+
// Returning it as a result (instead of throwing) keeps the transcript
|
|
872
|
+
// calm and tells the model nothing is missing.
|
|
873
|
+
const cronJob = scheduler.get(currentRun.cronId);
|
|
1037
874
|
return {
|
|
1038
|
-
content: [
|
|
1039
|
-
|
|
875
|
+
content: [
|
|
876
|
+
{
|
|
877
|
+
type: "text" as const,
|
|
878
|
+
text:
|
|
879
|
+
`No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
|
|
880
|
+
`${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
|
|
881
|
+
`Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
|
|
1040
885
|
};
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
886
|
+
}
|
|
1043
887
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
reason: Type.Optional(
|
|
1059
|
-
Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
|
|
1060
|
-
),
|
|
1061
|
-
prompt: Type.Optional(
|
|
1062
|
-
Type.String({
|
|
1063
|
-
description:
|
|
1064
|
-
"The /loop input to fire on wake-up. Pass the same /loop input verbatim each turn so the next firing continues the loop.",
|
|
1065
|
-
}),
|
|
1066
|
-
),
|
|
1067
|
-
stop: Type.Optional(
|
|
1068
|
-
Type.Boolean({
|
|
1069
|
-
description: "Set to true to end the self-paced loop immediately. All other fields are ignored.",
|
|
1070
|
-
}),
|
|
1071
|
-
),
|
|
1072
|
-
}),
|
|
1073
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1074
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1075
|
-
lastCtx = ctx;
|
|
1076
|
-
|
|
1077
|
-
if (params.stop === true) {
|
|
1078
|
-
const chains = scheduler.byKind("wakeup");
|
|
1079
|
-
for (const chain of chains) {
|
|
1080
|
-
// Retiring also stops the monitors that belong to this chain.
|
|
1081
|
-
retireChain(chain, "stopped by the agent");
|
|
1082
|
-
}
|
|
888
|
+
// Claude Code rounds and clamps; only a change after rounding counts as clamped.
|
|
889
|
+
const requested = params.delaySeconds;
|
|
890
|
+
const rounded = Number.isFinite(requested) ? Math.round(requested) : requested > 0 ? MAX_DELAY_SECONDS : MIN_DELAY_SECONDS;
|
|
891
|
+
let delay = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, rounded));
|
|
892
|
+
const wasClamped = !Number.isFinite(requested) || delay !== rounded;
|
|
893
|
+
|
|
894
|
+
const now = Date.now();
|
|
895
|
+
// A tick of a chain re-arms that chain even if the model reworded the prompt.
|
|
896
|
+
let chain =
|
|
897
|
+
scheduler.findChain(prompt) ?? (currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined);
|
|
898
|
+
if (chain) {
|
|
899
|
+
const limit = maxAgeMs();
|
|
900
|
+
if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
|
|
901
|
+
retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
|
|
1083
902
|
return {
|
|
1084
903
|
content: [
|
|
1085
904
|
{
|
|
1086
905
|
type: "text" as const,
|
|
1087
|
-
text:
|
|
1088
|
-
|
|
1089
|
-
|
|
906
|
+
text:
|
|
907
|
+
`Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
|
|
908
|
+
"do not re-issue. Tell the user they can start a new /loop.",
|
|
1090
909
|
},
|
|
1091
910
|
],
|
|
1092
|
-
details: { stopped: true,
|
|
911
|
+
details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
|
|
1093
912
|
};
|
|
1094
913
|
}
|
|
1095
|
-
|
|
1096
|
-
if (
|
|
1097
|
-
throw new Error(
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
throw new Error("prompt is required unless stop is true.");
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const requested = params.delaySeconds;
|
|
1105
|
-
const clamped = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, requested));
|
|
1106
|
-
const wasClamped = clamped !== requested;
|
|
1107
|
-
|
|
1108
|
-
let chain = findChain(prompt);
|
|
1109
|
-
if (!chain) {
|
|
1110
|
-
if (stoppedPrompts.has(prompt)) {
|
|
1111
|
-
throw new Error(
|
|
1112
|
-
`The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
|
|
1113
|
-
"Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
|
|
1114
|
-
);
|
|
1115
|
-
}
|
|
1116
|
-
chain = scheduler.add({
|
|
1117
|
-
id: generateId(),
|
|
1118
|
-
kind: "wakeup",
|
|
1119
|
-
prompt,
|
|
1120
|
-
recurring: false,
|
|
1121
|
-
createdAt: Date.now(),
|
|
1122
|
-
chainStartedAt: Date.now(),
|
|
1123
|
-
durable: false,
|
|
1124
|
-
source: "schedule_wakeup",
|
|
1125
|
-
});
|
|
914
|
+
} else {
|
|
915
|
+
if (stoppedPrompts.has(prompt)) {
|
|
916
|
+
throw new Error(
|
|
917
|
+
`The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
|
|
918
|
+
"Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
|
|
919
|
+
);
|
|
1126
920
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
return {
|
|
1138
|
-
content: [
|
|
1139
|
-
{
|
|
1140
|
-
type: "text" as const,
|
|
1141
|
-
text:
|
|
1142
|
-
`Next iteration scheduled in ${clamped}s` +
|
|
1143
|
-
(wasClamped ? ` (clamped from ${requested}s)` : "") +
|
|
1144
|
-
` for loop ${chain.id}.`,
|
|
1145
|
-
},
|
|
1146
|
-
],
|
|
1147
|
-
details: {
|
|
1148
|
-
id: chain.id,
|
|
1149
|
-
scheduledFor,
|
|
1150
|
-
clampedDelaySeconds: clamped,
|
|
1151
|
-
wasClamped,
|
|
1152
|
-
stopped: false,
|
|
1153
|
-
},
|
|
921
|
+
chain = {
|
|
922
|
+
id: generateId(),
|
|
923
|
+
kind: "wakeup",
|
|
924
|
+
prompt,
|
|
925
|
+
recurring: false,
|
|
926
|
+
createdAt: now,
|
|
927
|
+
chainStartedAt: now,
|
|
928
|
+
durable: false,
|
|
929
|
+
tickCount: tickCounters.get(prompt) ?? currentRun.tick ?? 0,
|
|
930
|
+
source: "schedule_wakeup",
|
|
1154
931
|
};
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
registerTools();
|
|
932
|
+
addJob(chain);
|
|
933
|
+
}
|
|
1160
934
|
|
|
1161
|
-
|
|
935
|
+
// The run that arms a chain belongs to it: Esc in this run cancels it.
|
|
936
|
+
(currentRun.armed ??= new Set()).add(chain.id);
|
|
937
|
+
const scheduledFor = now + delay * 1000;
|
|
938
|
+
scheduler.update(chain.id, {
|
|
939
|
+
scheduledFor,
|
|
940
|
+
keepaliveUsed: false,
|
|
941
|
+
keepaliveTurn: false,
|
|
942
|
+
awaitingReschedule: false,
|
|
943
|
+
reason: params.reason,
|
|
944
|
+
});
|
|
1162
945
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
return toolResult(
|
|
1181
|
-
resultText(result) || "Failed",
|
|
1182
|
-
undefined,
|
|
1183
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: true },
|
|
1184
|
-
theme,
|
|
1185
|
-
);
|
|
1186
|
-
}
|
|
1187
|
-
const d = (result.details ?? {}) as {
|
|
1188
|
-
id?: string;
|
|
1189
|
-
cron?: string;
|
|
1190
|
-
nextFireAt?: number;
|
|
1191
|
-
recurring?: boolean;
|
|
1192
|
-
durable?: boolean;
|
|
1193
|
-
prompt?: string;
|
|
1194
|
-
};
|
|
1195
|
-
const summary =
|
|
1196
|
-
`Scheduled ${d.id ?? "?"} · ${humanSchedule(d.cron ?? "")} · next ${formatClock(d.nextFireAt)}` +
|
|
1197
|
-
(d.recurring === false ? " · one-shot" : "") +
|
|
1198
|
-
(d.durable ? " · durable" : "");
|
|
1199
|
-
return toolResult(
|
|
1200
|
-
summary,
|
|
1201
|
-
d.prompt,
|
|
1202
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
|
|
1203
|
-
theme,
|
|
1204
|
-
);
|
|
1205
|
-
},
|
|
1206
|
-
},
|
|
1207
|
-
cron_delete: {
|
|
1208
|
-
renderCall(args: any, theme: Theme, context: any) {
|
|
1209
|
-
if (!context.isPartial && context.executionStarted) return completedCall();
|
|
1210
|
-
return toolCall("Cancel", args.id ?? "", theme);
|
|
1211
|
-
},
|
|
1212
|
-
renderResult(result: any, options: any, theme: Theme, context: any) {
|
|
1213
|
-
if (context.isError) {
|
|
1214
|
-
return toolResult(
|
|
1215
|
-
resultText(result) || "Failed",
|
|
1216
|
-
undefined,
|
|
1217
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: true },
|
|
1218
|
-
theme,
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1221
|
-
const d = (result.details ?? {}) as { cancelled?: boolean; id?: string };
|
|
1222
|
-
const summary = d.cancelled ? `Cancelled ${d.id ?? "?"}` : `No job ${d.id ?? "?"}`;
|
|
1223
|
-
return toolResult(
|
|
1224
|
-
summary,
|
|
1225
|
-
undefined,
|
|
1226
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
|
|
1227
|
-
theme,
|
|
1228
|
-
);
|
|
1229
|
-
},
|
|
1230
|
-
},
|
|
1231
|
-
cron_list: {
|
|
1232
|
-
renderCall(_args: any, theme: Theme, context: any) {
|
|
1233
|
-
if (!context.isPartial && context.executionStarted) return completedCall();
|
|
1234
|
-
return toolCall("List scheduled jobs", "", theme);
|
|
1235
|
-
},
|
|
1236
|
-
renderResult(result: any, options: any, theme: Theme, context: any) {
|
|
1237
|
-
if (context.isError) {
|
|
1238
|
-
return toolResult(
|
|
1239
|
-
resultText(result) || "Failed",
|
|
1240
|
-
undefined,
|
|
1241
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: true },
|
|
1242
|
-
theme,
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
const d = (result.details ?? {}) as { jobs?: LoopJob[] };
|
|
1246
|
-
const jobs = d.jobs ?? [];
|
|
1247
|
-
let summary: string;
|
|
1248
|
-
if (!jobs.length) summary = "No scheduled jobs";
|
|
1249
|
-
else if (jobs.length === 1) summary = `1 job · ${jobSummary(jobs[0])}`;
|
|
1250
|
-
else {
|
|
1251
|
-
const shown = jobs.slice(0, 2).map(jobSummary).join(" · ");
|
|
1252
|
-
summary = `${jobs.length} jobs · ${shown}${jobs.length > 2 ? " …" : ""}`;
|
|
1253
|
-
}
|
|
1254
|
-
const detail = options.expanded
|
|
1255
|
-
? jobs.map((job) => `${jobSummary(job)} · ${truncate(job.prompt, 60)}`).join("\n")
|
|
1256
|
-
: undefined;
|
|
1257
|
-
return toolResult(
|
|
1258
|
-
summary || resultText(result),
|
|
1259
|
-
detail,
|
|
1260
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
|
|
1261
|
-
theme,
|
|
1262
|
-
);
|
|
1263
|
-
},
|
|
1264
|
-
},
|
|
1265
|
-
schedule_wakeup: {
|
|
1266
|
-
renderCall(args: any, theme: Theme, context: any) {
|
|
1267
|
-
if (!context.isPartial && context.executionStarted) return completedCall();
|
|
1268
|
-
if (args.stop === true) return toolCall("Stop loop", "", theme);
|
|
1269
|
-
const reason = args.reason ? ` · ${truncate(args.reason, 40)}` : "";
|
|
1270
|
-
return toolCall("Wakeup", `${formatDelay(args.delaySeconds)}${reason}`, theme);
|
|
1271
|
-
},
|
|
1272
|
-
renderResult(result: any, options: any, theme: Theme, context: any) {
|
|
1273
|
-
if (context.isError) {
|
|
1274
|
-
return toolResult(
|
|
1275
|
-
resultText(result) || "Failed",
|
|
1276
|
-
undefined,
|
|
1277
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: true },
|
|
1278
|
-
theme,
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
const d = (result.details ?? {}) as {
|
|
1282
|
-
id?: string;
|
|
1283
|
-
scheduledFor?: number;
|
|
1284
|
-
clampedDelaySeconds?: number;
|
|
1285
|
-
wasClamped?: boolean;
|
|
1286
|
-
stopped?: boolean;
|
|
1287
|
-
cancelledWakeups?: number;
|
|
1288
|
-
};
|
|
1289
|
-
const summary = d.stopped
|
|
1290
|
-
? `Stopped · ${d.cancelledWakeups ?? 0} wakeup${d.cancelledWakeups === 1 ? "" : "s"} cancelled`
|
|
1291
|
-
: `Wakeup in ${formatDelay(d.clampedDelaySeconds)} · loop ${d.id ?? "?"}${d.wasClamped ? " (clamped)" : ""}`;
|
|
1292
|
-
const detail = options.expanded
|
|
1293
|
-
? [context.args.reason, d.scheduledFor ? `at ${formatClock(d.scheduledFor)}` : undefined]
|
|
1294
|
-
.filter(Boolean)
|
|
1295
|
-
.join(" · ")
|
|
1296
|
-
: undefined;
|
|
1297
|
-
return toolResult(
|
|
1298
|
-
summary,
|
|
1299
|
-
detail || undefined,
|
|
1300
|
-
{ expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
|
|
1301
|
-
theme,
|
|
1302
|
-
);
|
|
946
|
+
const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
|
|
947
|
+
const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
|
|
948
|
+
return {
|
|
949
|
+
content: [
|
|
950
|
+
{
|
|
951
|
+
type: "text" as const,
|
|
952
|
+
text:
|
|
953
|
+
`Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
|
|
954
|
+
"This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
details: {
|
|
958
|
+
id: chain.id,
|
|
959
|
+
scheduledFor,
|
|
960
|
+
clampedDelaySeconds: delay,
|
|
961
|
+
wasClamped,
|
|
962
|
+
stopped: false,
|
|
1303
963
|
},
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
}
|
|
964
|
+
// Claude Code ends the turn when ScheduleWakeup returns (on models
|
|
965
|
+
// that support it); the guidelines ask for the update first. Pi only
|
|
966
|
+
// ends the turn when every tool in the batch agrees.
|
|
967
|
+
terminate: true,
|
|
968
|
+
};
|
|
969
|
+
},
|
|
970
|
+
});
|
|
1312
971
|
|
|
1313
|
-
registerToolRenderers();
|
|
1314
972
|
|
|
1315
973
|
// ----------------------------------------------------------------- events
|
|
1316
974
|
|
|
1317
975
|
/**
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*/
|
|
1321
|
-
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, (message, options, theme) => {
|
|
1322
|
-
const details = (message.details ?? {}) as {
|
|
1323
|
-
tickCount?: number;
|
|
1324
|
-
jobId?: string;
|
|
1325
|
-
cron?: string;
|
|
1326
|
-
immediate?: boolean;
|
|
1327
|
-
};
|
|
1328
|
-
const text = contentText(message.content);
|
|
1329
|
-
const prompt = stripProvenance(text, /^\[loop tick[^\]]*\]\n?/);
|
|
1330
|
-
if (!loadConfig().compactMessages) {
|
|
1331
|
-
const summary = firstLine(prompt);
|
|
1332
|
-
const header = `loop tick #${details.tickCount ?? "?"}${summary ? ` · ${summary}` : ""}`;
|
|
1333
|
-
return renderCard({
|
|
1334
|
-
header,
|
|
1335
|
-
body: prompt,
|
|
1336
|
-
expanded: options.expanded,
|
|
1337
|
-
outputPad: options.outputPad,
|
|
1338
|
-
theme,
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
const label = details.tickCount !== undefined ? `loop #${details.tickCount}` : "loop";
|
|
1342
|
-
const stamp = formatStamp(message.timestamp);
|
|
1343
|
-
const header = stamp
|
|
1344
|
-
? `${theme.fg("accent", theme.bold(label))}${theme.fg("dim", " · ")}${theme.fg("muted", stamp)}`
|
|
1345
|
-
: theme.fg("accent", theme.bold(label));
|
|
1346
|
-
|
|
1347
|
-
const meta: string[] = [];
|
|
1348
|
-
if (details.jobId) meta.push(`job ${details.jobId}`);
|
|
1349
|
-
if (details.cron) meta.push(`cron ${details.cron}`);
|
|
1350
|
-
else if (details.immediate) meta.push("immediate run");
|
|
1351
|
-
else meta.push("self-paced");
|
|
1352
|
-
meta.push("scheduled by /loop");
|
|
1353
|
-
|
|
1354
|
-
return renderBlock({
|
|
1355
|
-
header,
|
|
1356
|
-
body: bodyLines(prompt),
|
|
1357
|
-
meta,
|
|
1358
|
-
expanded: options.expanded,
|
|
1359
|
-
outputPad: options.outputPad,
|
|
1360
|
-
theme,
|
|
1361
|
-
maxBodyLines: 3,
|
|
1362
|
-
});
|
|
1363
|
-
});
|
|
1364
|
-
|
|
1365
|
-
/**
|
|
1366
|
-
* A monitor event supersedes the chain's pending fallback heartbeat.
|
|
976
|
+
* Give a run the instructions of the loop message that started it, and
|
|
977
|
+
* remember which chain (if any) the run belongs to.
|
|
1367
978
|
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
* arm a keepalive on top of the event that just arrived.
|
|
979
|
+
* A monitor event does not touch the chain: like Claude Code, the pending
|
|
980
|
+
* wakeup stays armed as the fallback heartbeat, and the model re-arms it if
|
|
981
|
+
* it wants a different time.
|
|
1372
982
|
*/
|
|
1373
|
-
pi.
|
|
1374
|
-
const wake = data as { loopPrompt?: string } | undefined;
|
|
1375
|
-
if (!wake?.loopPrompt) return;
|
|
1376
|
-
const chain = scheduler.findChain(wake.loopPrompt);
|
|
1377
|
-
if (!chain) return;
|
|
1378
|
-
scheduler.update(chain.id, { scheduledFor: undefined, awaitingReschedule: true });
|
|
1379
|
-
});
|
|
983
|
+
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
|
|
1380
984
|
|
|
1381
985
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1382
986
|
lastCtx = ctx;
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
987
|
+
currentRun = {};
|
|
988
|
+
|
|
989
|
+
// Our own messages are ordinary user messages now, so the instructions
|
|
990
|
+
// for the run and the loop it belongs to travel with the queued entry.
|
|
991
|
+
if (event.messageType === "user") {
|
|
992
|
+
const queued = takeQueuedRun(event.prompt);
|
|
993
|
+
if (!queued) return;
|
|
994
|
+
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick };
|
|
995
|
+
if (!queued.guidelines.length) return;
|
|
996
|
+
const options = event.systemPromptOptions;
|
|
997
|
+
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...queued.guidelines];
|
|
998
|
+
return;
|
|
1388
999
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1000
|
+
if (event.messageType !== "custom") return;
|
|
1001
|
+
|
|
1002
|
+
if (event.message.customType === TICK_MESSAGE_TYPE) {
|
|
1003
|
+
const details = (event.message.details ?? {}) as {
|
|
1004
|
+
kind?: "cron" | "wakeup";
|
|
1005
|
+
jobId?: string;
|
|
1006
|
+
cron?: string;
|
|
1007
|
+
tickCount?: number;
|
|
1008
|
+
};
|
|
1009
|
+
currentRun = {
|
|
1010
|
+
chainId: details.kind === "wakeup" ? details.jobId : undefined,
|
|
1011
|
+
cronId: details.kind === "cron" ? details.jobId : undefined,
|
|
1012
|
+
tick: details.tickCount,
|
|
1013
|
+
};
|
|
1014
|
+
const guidelines =
|
|
1015
|
+
details.kind === "cron" && details.jobId
|
|
1016
|
+
? [cronTickGuidelines(details.jobId, humanSchedule(details.cron ?? "* * * * *"))]
|
|
1017
|
+
: [];
|
|
1018
|
+
if (details.kind === "wakeup") {
|
|
1019
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1020
|
+
if (chain) {
|
|
1021
|
+
guidelines.push(loopGuidelines(chain.prompt));
|
|
1022
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
|
|
1400
1023
|
}
|
|
1401
1024
|
}
|
|
1025
|
+
if (!guidelines.length) return;
|
|
1026
|
+
const options = event.systemPromptOptions;
|
|
1027
|
+
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...guidelines];
|
|
1028
|
+
return;
|
|
1402
1029
|
}
|
|
1403
1030
|
|
|
1404
|
-
if (
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1031
|
+
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
|
1032
|
+
// A monitor bound to a loop woke the agent: the run belongs to that loop.
|
|
1033
|
+
const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
|
|
1034
|
+
const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
|
|
1035
|
+
if (chain) currentRun.chainId = chain.id;
|
|
1036
|
+
return;
|
|
1410
1037
|
}
|
|
1411
|
-
|
|
1412
|
-
if (!notes.length) return;
|
|
1413
|
-
const options = event.systemPromptOptions;
|
|
1414
|
-
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
|
|
1415
|
-
});
|
|
1416
|
-
|
|
1417
|
-
pi.on("agent_start", async (_event, ctx) => {
|
|
1418
|
-
lastCtx = ctx;
|
|
1419
|
-
scheduler.noteTurnStarted();
|
|
1420
1038
|
});
|
|
1421
1039
|
|
|
1422
|
-
|
|
1040
|
+
/**
|
|
1041
|
+
* The run is over and Pi will not continue on its own. Esc on a loop's own
|
|
1042
|
+
* turn cancels that loop; chains whose tick was not rescheduled get their
|
|
1043
|
+
* keepalive. Then a pending tick may go out.
|
|
1044
|
+
*/
|
|
1045
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
1423
1046
|
lastCtx = ctx;
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
// The keepalive turn is the model's last chance to reschedule.
|
|
1429
|
-
if (chain.keepaliveTurn) {
|
|
1430
|
-
scheduler.update(chain.id, { keepaliveTurn: false, awaitingReschedule: false });
|
|
1431
|
-
endChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
|
|
1432
|
-
continue;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// The model rescheduled: it armed the next wakeup itself.
|
|
1436
|
-
if (!chain.awaitingReschedule) continue;
|
|
1437
|
-
|
|
1438
|
-
const limit = maxAgeMs();
|
|
1439
|
-
if (limit > 0 && chain.chainStartedAt !== undefined && now - chain.chainStartedAt >= limit) {
|
|
1440
|
-
scheduler.update(chain.id, { awaitingReschedule: false });
|
|
1441
|
-
endChain(chain, "aged out");
|
|
1442
|
-
continue;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
if (chain.keepaliveUsed) continue;
|
|
1446
|
-
|
|
1447
|
-
const fallback = keepaliveSeconds();
|
|
1448
|
-
if (fallback <= 0) {
|
|
1449
|
-
scheduler.update(chain.id, { awaitingReschedule: false });
|
|
1450
|
-
endChain(chain, "the model did not reschedule (keepalive disabled)");
|
|
1451
|
-
continue;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
scheduler.update(chain.id, {
|
|
1455
|
-
scheduledFor: now + fallback * 1000,
|
|
1456
|
-
keepaliveUsed: true,
|
|
1457
|
-
awaitingReschedule: false,
|
|
1458
|
-
});
|
|
1459
|
-
notify(
|
|
1460
|
-
ctx,
|
|
1461
|
-
`loop ${chain.id}: the model did not reschedule — keepalive armed (${Math.round(fallback / 60)} min)`,
|
|
1462
|
-
"warning",
|
|
1463
|
-
);
|
|
1464
|
-
}
|
|
1047
|
+
if ((event as { aborted?: boolean } | undefined)?.aborted) cancelCurrentLoop(ctx);
|
|
1048
|
+
settleChains(ctx);
|
|
1049
|
+
currentRun = {};
|
|
1050
|
+
scheduler.notifyIdle();
|
|
1465
1051
|
});
|
|
1466
1052
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
scheduler.notifyIdle();
|
|
1053
|
+
/** After a compaction, send the full loop.md tasks again on the next tick. */
|
|
1054
|
+
pi.on("session_compact", async () => {
|
|
1055
|
+
loopFile.reset();
|
|
1471
1056
|
});
|
|
1472
1057
|
|
|
1473
|
-
pi.on("session_start", async (
|
|
1058
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1474
1059
|
lastCtx = ctx;
|
|
1475
|
-
|
|
1476
|
-
|
|
1060
|
+
// Another process may have edited the shared settings file.
|
|
1061
|
+
invalidateConfig();
|
|
1062
|
+
durable?.stop();
|
|
1063
|
+
restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
|
|
1064
|
+
durable = createDurable(ctx.cwd);
|
|
1065
|
+
durable.start();
|
|
1477
1066
|
scheduler.start();
|
|
1478
|
-
if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
|
|
1479
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
|
|
1480
|
-
}
|
|
1481
|
-
});
|
|
1482
|
-
|
|
1483
|
-
pi.on("session_before_switch", async () => {
|
|
1484
|
-
for (const chain of scheduler.byKind("wakeup")) {
|
|
1485
|
-
retireChain(chain, "the session was switched");
|
|
1486
|
-
}
|
|
1487
|
-
scheduler.clear(false);
|
|
1488
1067
|
});
|
|
1489
1068
|
|
|
1490
1069
|
pi.on("session_shutdown", async () => {
|
|
1070
|
+
// Pi clears extension statuses when it unloads, but a reload should not
|
|
1071
|
+
// leave a stale count behind until then.
|
|
1072
|
+
try {
|
|
1073
|
+
lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
|
|
1074
|
+
} catch {
|
|
1075
|
+
// the context may already be invalid
|
|
1076
|
+
}
|
|
1491
1077
|
scheduler.stop();
|
|
1078
|
+
durable?.stop();
|
|
1079
|
+
durable = undefined;
|
|
1492
1080
|
scheduler.clear(false);
|
|
1493
1081
|
});
|
|
1494
1082
|
|
|
1495
1083
|
// -------------------------------------------------------------- restoring
|
|
1496
1084
|
|
|
1497
|
-
|
|
1085
|
+
/**
|
|
1086
|
+
* Restore the session's jobs from its last state entry, following Claude
|
|
1087
|
+
* Code's resume rules: "restores the tasks scheduled with CronCreate,
|
|
1088
|
+
* except recurring tasks that have expired and one-shot tasks whose
|
|
1089
|
+
* scheduled time has passed. A self-paced /loop isn't restored". A restored
|
|
1090
|
+
* recurring job does not catch up on missed runs.
|
|
1091
|
+
*
|
|
1092
|
+
* `/reload` is different: the process keeps running, so a self-paced
|
|
1093
|
+
* wakeup that is still in the future is kept (Claude Code re-arms a pending
|
|
1094
|
+
* wakeup when its process restarts in time).
|
|
1095
|
+
*/
|
|
1096
|
+
function restoreState(ctx: ExtensionContext, reason: string | undefined): void {
|
|
1498
1097
|
scheduler.clear(false);
|
|
1098
|
+
lastPersistedSignature = "";
|
|
1499
1099
|
|
|
1500
1100
|
const entries = ctx.sessionManager.getEntries();
|
|
1101
|
+
let data: PersistedState | undefined;
|
|
1501
1102
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1502
1103
|
const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
|
|
1503
1104
|
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.jobs) {
|
|
1504
|
-
|
|
1505
|
-
if (typeof entry.data.widget === "boolean") widgetEnabled = entry.data.widget;
|
|
1506
|
-
for (const job of entry.data.jobs) {
|
|
1507
|
-
if (job.kind === "wakeup") {
|
|
1508
|
-
// A self-paced chain is not restored: the wakeup that was pending
|
|
1509
|
-
// died with the previous process.
|
|
1510
|
-
scheduler.restore({ ...job, scheduledFor: undefined, keepaliveUsed: true });
|
|
1511
|
-
} else {
|
|
1512
|
-
scheduler.restore(job);
|
|
1513
|
-
scheduler.arm(job);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1105
|
+
data = entry.data;
|
|
1516
1106
|
break;
|
|
1517
1107
|
}
|
|
1518
1108
|
}
|
|
1109
|
+
if (!data) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1519
1112
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1113
|
+
for (const prompt of data.stopped ?? []) rememberStopped(prompt);
|
|
1114
|
+
|
|
1115
|
+
const now = Date.now();
|
|
1116
|
+
const limit = maxAgeMs();
|
|
1117
|
+
let droppedChains = 0;
|
|
1118
|
+
let droppedOneShots = 0;
|
|
1119
|
+
for (const saved of data.jobs) {
|
|
1120
|
+
// Durable jobs come from .pi/scheduled_tasks.json, never from a session.
|
|
1121
|
+
if (saved.durable) continue;
|
|
1122
|
+
const job = storedJob(saved);
|
|
1123
|
+
if (job.kind === "wakeup") {
|
|
1124
|
+
if (reason === "reload" && job.scheduledFor !== undefined && job.scheduledFor > now) {
|
|
1125
|
+
scheduler.restore(job);
|
|
1126
|
+
} else {
|
|
1127
|
+
droppedChains++;
|
|
1534
1128
|
}
|
|
1129
|
+
continue;
|
|
1535
1130
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1131
|
+
if (!job.recurring) {
|
|
1132
|
+
const due = nextRunTime(job, job.createdAt, timingConfig());
|
|
1133
|
+
if (due === null || due <= now) {
|
|
1134
|
+
droppedOneShots++;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
} else if (limit > 0 && now - job.createdAt >= limit) {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
scheduler.restore(job);
|
|
1141
|
+
scheduler.arm(job, now);
|
|
1538
1142
|
}
|
|
1539
1143
|
|
|
1540
|
-
const
|
|
1541
|
-
if (
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1144
|
+
const notes: string[] = [];
|
|
1145
|
+
if (droppedChains) {
|
|
1146
|
+
notes.push(
|
|
1147
|
+
`${droppedChains} self-paced loop${droppedChains === 1 ? " was" : "s were"} not restored: run /loop again to restart ${droppedChains === 1 ? "it" : "them"}`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
if (droppedOneShots) {
|
|
1151
|
+
notes.push(
|
|
1152
|
+
`${droppedOneShots} one-shot job${droppedOneShots === 1 ? "" : "s"} dropped: the scheduled time passed while the session was closed`,
|
|
1546
1153
|
);
|
|
1547
1154
|
}
|
|
1548
|
-
|
|
1155
|
+
if (notes.length) notify(ctx, notes.join(" \u00b7 "), "warning");
|
|
1156
|
+
scheduler.refresh();
|
|
1549
1157
|
}
|
|
1550
1158
|
}
|