pi-cc-loop 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- package/cron.ts +62 -51
- package/durable.ts +447 -0
- package/index.ts +794 -1027
- package/loopfile.ts +107 -0
- package/package.json +2 -6
- package/prompts.ts +200 -0
- package/render.ts +11 -8
- package/renderers.ts +147 -0
- package/schedule.ts +147 -169
- package/tool-rows.ts +79 -0
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,27 +15,21 @@
|
|
|
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, loadConfig, saveConfig, syncPiCcExtensionsExcludes, type PiCcConfig } from "./config.ts";
|
|
40
33
|
import {
|
|
41
34
|
describeCron,
|
|
42
35
|
humanSchedule,
|
|
@@ -46,37 +39,45 @@ import {
|
|
|
46
39
|
parseEveryClause,
|
|
47
40
|
parseIntervalToken,
|
|
48
41
|
} from "./cron.ts";
|
|
42
|
+
import { DurableRuntime, type Placement } from "./durable.ts";
|
|
43
|
+
import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
|
|
49
44
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
CRON_CREATE_DESCRIPTION,
|
|
46
|
+
CRON_CREATE_SNIPPET,
|
|
47
|
+
CRON_DELETE_DESCRIPTION,
|
|
48
|
+
CRON_DELETE_SNIPPET,
|
|
49
|
+
CRON_LIST_DESCRIPTION,
|
|
50
|
+
CRON_LIST_SNIPPET,
|
|
51
|
+
LOOP_FILE_SENTINEL,
|
|
52
|
+
loopGuidelines,
|
|
53
|
+
missedJobsMessage,
|
|
54
|
+
monitorGuidelines,
|
|
55
|
+
provenance,
|
|
56
|
+
SCHEDULE_WAKEUP_DESCRIPTION,
|
|
57
|
+
SCHEDULE_WAKEUP_SNIPPET,
|
|
58
|
+
scheduleRules,
|
|
59
|
+
} from "./prompts.ts";
|
|
60
|
+
import { formatClock, loopToolRenderers, renderTickMessage } from "./renderers.ts";
|
|
57
61
|
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} from "./
|
|
62
|
+
DEFAULT_TIMING,
|
|
63
|
+
generateId,
|
|
64
|
+
LoopScheduler,
|
|
65
|
+
nextRunTime,
|
|
66
|
+
storedJob,
|
|
67
|
+
type LoopJob,
|
|
68
|
+
type TimingConfig,
|
|
69
|
+
} from "./schedule.ts";
|
|
70
|
+
import { truncate } from "./tool-rows.ts";
|
|
66
71
|
|
|
67
72
|
const STATE_ENTRY = "pi-cc-loop-state";
|
|
68
|
-
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
73
|
+
export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
74
|
+
/** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
|
|
75
|
+
const MONITOR_EVENT_TYPE = "pi-cc-monitor-event";
|
|
69
76
|
const WIDGET_KEY = "pi-cc-loop";
|
|
70
77
|
const CONFIG_CHANNEL = "pi-cc:config";
|
|
71
78
|
const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
|
|
72
79
|
const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
|
|
73
80
|
|
|
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
|
-
|
|
80
81
|
/**
|
|
81
82
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
82
83
|
* listens to it and stops any monitor that belongs to the chain, the way
|
|
@@ -88,76 +89,15 @@ const LOOP_STOPPED_CHANNEL = "loop:stopped";
|
|
|
88
89
|
const MAX_JOBS = 50;
|
|
89
90
|
const MIN_DELAY_SECONDS = 60;
|
|
90
91
|
const MAX_DELAY_SECONDS = 3600;
|
|
92
|
+
/** A wakeup just under the 5 minute prompt cache lifetime is pulled inside it. */
|
|
91
93
|
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]";
|
|
94
|
+
const DEFAULT_MAX_AGE_DAYS = 7;
|
|
95
|
+
/** How many stopped prompts are remembered (and persisted). */
|
|
96
|
+
const MAX_STOPPED_PROMPTS = 20;
|
|
158
97
|
|
|
159
98
|
interface PersistedState {
|
|
160
99
|
version: 1;
|
|
100
|
+
/** Session-only jobs; durable jobs live in .pi/scheduled_tasks.json. */
|
|
161
101
|
jobs: LoopJob[];
|
|
162
102
|
/** Prompts whose chain was stopped and must not be rescheduled. */
|
|
163
103
|
stopped?: string[];
|
|
@@ -172,13 +112,12 @@ function envNumber(name: string, fallback: number): number {
|
|
|
172
112
|
return Number.isFinite(value) ? value : fallback;
|
|
173
113
|
}
|
|
174
114
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
return Math.max(0, days) * 86_400_000;
|
|
115
|
+
function maxAgeDays(): number {
|
|
116
|
+
return Math.max(0, envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS));
|
|
178
117
|
}
|
|
179
118
|
|
|
180
|
-
function
|
|
181
|
-
return
|
|
119
|
+
function maxAgeMs(): number {
|
|
120
|
+
return maxAgeDays() * 86_400_000;
|
|
182
121
|
}
|
|
183
122
|
|
|
184
123
|
/**
|
|
@@ -190,92 +129,23 @@ function keepaliveSeconds(): number {
|
|
|
190
129
|
return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
|
|
191
130
|
}
|
|
192
131
|
|
|
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();
|
|
132
|
+
/** Claude Code's offsets; `PI_LOOP_JITTER=0` runs jobs exactly on their cron minute. */
|
|
133
|
+
function timingConfig(): TimingConfig {
|
|
134
|
+
const fraction = Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", DEFAULT_TIMING.recurringFrac)));
|
|
135
|
+
return {
|
|
136
|
+
...DEFAULT_TIMING,
|
|
137
|
+
recurringFrac: fraction,
|
|
138
|
+
oneShotMaxMs: fraction > 0 ? DEFAULT_TIMING.oneShotMaxMs : 0,
|
|
139
|
+
};
|
|
241
140
|
}
|
|
242
141
|
|
|
243
|
-
|
|
244
|
-
|
|
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 "";
|
|
142
|
+
function durableSyncMs(): number {
|
|
143
|
+
return Math.max(0, envNumber("PI_LOOP_DURABLE_SYNC_MS", 5000));
|
|
251
144
|
}
|
|
252
145
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return `${Math.round(seconds / 60)}m`;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function jobSummary(job: LoopJob): string {
|
|
260
|
-
if (job.kind === "cron" && job.cron) {
|
|
261
|
-
return `${job.id} ${humanSchedule(job.cron)} · next ${formatClock(job.nextFireAt)}`;
|
|
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;
|
|
146
|
+
/** Models used to Claude Code may echo the prompt with its `/loop ` prefix. */
|
|
147
|
+
function normalizePrompt(prompt: string): string {
|
|
148
|
+
return prompt.trim().replace(/^\/loop\s+/i, "").trim();
|
|
279
149
|
}
|
|
280
150
|
|
|
281
151
|
/** The live scheduler, for tests. */
|
|
@@ -321,17 +191,44 @@ function extractFlags(raw: string): { body: string; durable: boolean; maxFires?:
|
|
|
321
191
|
}
|
|
322
192
|
|
|
323
193
|
export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
194
|
+
/** Cached context for UI helpers. */
|
|
195
|
+
let lastCtx: ExtensionContext | undefined;
|
|
196
|
+
/**
|
|
197
|
+
* Prompts whose chain was stopped. A stopped chain must not be resurrected
|
|
198
|
+
* by a late `schedule_wakeup` call from a turn that a still-running monitor
|
|
199
|
+
* woke up.
|
|
200
|
+
*/
|
|
201
|
+
const stoppedPrompts = new Set<string>();
|
|
202
|
+
/**
|
|
203
|
+
* Whether the status widget is shown. Claude Code's main session shows no
|
|
204
|
+
* loop status at all, so it is off by default; `/loop widget on` turns it on.
|
|
205
|
+
*/
|
|
206
|
+
let widgetEnabled = process.env.PI_LOOP_WIDGET === "1";
|
|
207
|
+
/** Signature of the last persisted state, to avoid redundant session entries. */
|
|
208
|
+
let lastPersistedSignature = "";
|
|
209
|
+
/**
|
|
210
|
+
* How many times each prompt has been run as a loop tick. The immediate run
|
|
211
|
+
* of `/loop` and the fires of the job created afterwards share a counter.
|
|
212
|
+
*/
|
|
213
|
+
const tickCounters = new Map<string, number>();
|
|
214
|
+
const loopFile = new LoopFileDelivery();
|
|
215
|
+
let durable: DurableRuntime | undefined;
|
|
216
|
+
/**
|
|
217
|
+
* What the current run belongs to, derived from its starting message:
|
|
218
|
+
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
219
|
+
* `armed` the chains it armed, `tick` the tick number of a /loop run.
|
|
220
|
+
*/
|
|
221
|
+
let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
|
|
222
|
+
|
|
324
223
|
const scheduler = new LoopScheduler({
|
|
325
|
-
dispatch: (job) =>
|
|
326
|
-
|
|
224
|
+
dispatch: (job) => deliverTick(job),
|
|
225
|
+
afterDispatch: (job, finished) => {
|
|
226
|
+
if (job.durable) durable?.afterFire(job, finished);
|
|
327
227
|
},
|
|
328
228
|
onChange: (jobs) => {
|
|
329
229
|
persist(jobs);
|
|
330
230
|
refreshWidget(jobs);
|
|
331
231
|
},
|
|
332
|
-
maxAgeMs,
|
|
333
|
-
jitterFraction,
|
|
334
|
-
jitterCapMs: () => 30 * 60 * 1000,
|
|
335
232
|
canDispatch: () => {
|
|
336
233
|
const ctx = lastCtx;
|
|
337
234
|
if (!ctx) return false;
|
|
@@ -341,104 +238,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
341
238
|
return false;
|
|
342
239
|
}
|
|
343
240
|
},
|
|
241
|
+
timing: timingConfig,
|
|
242
|
+
maxAgeMs,
|
|
344
243
|
onExpire: (job) => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
// cannot come back and the monitor stops with it.
|
|
348
|
-
stoppedPrompts.add(job.prompt);
|
|
349
|
-
try {
|
|
350
|
-
pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: job.prompt, reason: "expired" });
|
|
351
|
-
} catch {
|
|
352
|
-
// ignore
|
|
353
|
-
}
|
|
354
|
-
}
|
|
244
|
+
const ctx = lastCtx;
|
|
245
|
+
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
355
246
|
},
|
|
247
|
+
onScan: (now) => durable?.maintain(now),
|
|
356
248
|
});
|
|
357
249
|
activeScheduler = scheduler;
|
|
358
250
|
|
|
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
251
|
// ---------------------------------------------------------------- helpers
|
|
390
252
|
|
|
253
|
+
/** What a job's persisted signature depends on; per-fire bookkeeping is left out. */
|
|
254
|
+
function signatureOf(job: LoopJob): unknown[] {
|
|
255
|
+
if (job.kind === "wakeup") {
|
|
256
|
+
return [job.id, job.kind, job.prompt, job.scheduledFor ?? null, job.keepaliveUsed ?? false, job.chainStartedAt ?? null];
|
|
257
|
+
}
|
|
258
|
+
return [
|
|
259
|
+
job.id,
|
|
260
|
+
job.kind,
|
|
261
|
+
job.cron,
|
|
262
|
+
job.prompt,
|
|
263
|
+
job.recurring,
|
|
264
|
+
job.createdAt,
|
|
265
|
+
job.maxFires ?? null,
|
|
266
|
+
job.maxFires !== undefined ? (job.fireCount ?? 0) : null,
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Save the session's jobs as a session entry. Durable jobs are left out:
|
|
272
|
+
* they live in .pi/scheduled_tasks.json, and restoring them from a session
|
|
273
|
+
* would run them twice. A new entry is only written when something that
|
|
274
|
+
* matters for a restore changed, not on every fire.
|
|
275
|
+
*/
|
|
391
276
|
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
|
-
);
|
|
277
|
+
const kept = jobs.filter((job) => !job.durable).map(storedJob);
|
|
278
|
+
const stopped = [...stoppedPrompts];
|
|
279
|
+
const signature = JSON.stringify([kept.map(signatureOf), stopped, widgetEnabled]);
|
|
409
280
|
if (signature === lastPersistedSignature) return;
|
|
410
281
|
lastPersistedSignature = signature;
|
|
411
|
-
|
|
412
|
-
const durable = jobs.filter((job) => job.durable);
|
|
413
282
|
try {
|
|
414
|
-
pi.appendEntry(STATE_ENTRY, {
|
|
415
|
-
version: 1,
|
|
416
|
-
jobs,
|
|
417
|
-
stopped: [...stoppedPrompts],
|
|
418
|
-
widget: widgetEnabled,
|
|
419
|
-
} satisfies PersistedState);
|
|
283
|
+
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped, widget: widgetEnabled } satisfies PersistedState);
|
|
420
284
|
} catch {
|
|
421
285
|
// session persistence is best effort
|
|
422
286
|
}
|
|
423
|
-
try {
|
|
424
|
-
// Use the session cwd, not process.cwd(): the user can move the session
|
|
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");
|
|
435
|
-
} catch {
|
|
436
|
-
// durable persistence is best effort
|
|
437
|
-
}
|
|
438
287
|
}
|
|
439
288
|
|
|
440
289
|
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
441
|
-
|
|
290
|
+
try {
|
|
291
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
292
|
+
} catch {
|
|
293
|
+
// a stale context must never break the loop
|
|
294
|
+
}
|
|
442
295
|
}
|
|
443
296
|
|
|
444
297
|
function refreshWidget(jobs: LoopJob[]): void {
|
|
@@ -448,44 +301,35 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
448
301
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
449
302
|
return;
|
|
450
303
|
}
|
|
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
|
-
}
|
|
469
|
-
}
|
|
304
|
+
// Absolute times only: the widget is not redrawn every second.
|
|
305
|
+
const lines = jobs.map((job) => {
|
|
306
|
+
const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
|
|
307
|
+
const when = at ? `next ${formatClock(at)}` : job.kind === "wakeup" ? "awaiting reschedule" : "pending";
|
|
308
|
+
const kind = job.kind === "cron" ? `fixed ${job.cron}` : "self-paced";
|
|
309
|
+
return ctx.ui.theme.fg("accent", `loop ${job.id} \u00b7 ${kind} \u00b7 ${when} \u00b7 ${truncate(displayPrompt(job.prompt), 28)}`);
|
|
310
|
+
});
|
|
470
311
|
ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
|
|
471
312
|
}
|
|
472
313
|
|
|
473
|
-
function
|
|
474
|
-
|
|
314
|
+
function rememberStopped(prompt: string): void {
|
|
315
|
+
stoppedPrompts.delete(prompt);
|
|
316
|
+
stoppedPrompts.add(prompt);
|
|
317
|
+
while (stoppedPrompts.size > MAX_STOPPED_PROMPTS) {
|
|
318
|
+
const oldest = stoppedPrompts.values().next().value;
|
|
319
|
+
if (oldest === undefined) break;
|
|
320
|
+
stoppedPrompts.delete(oldest);
|
|
321
|
+
}
|
|
475
322
|
}
|
|
476
323
|
|
|
477
324
|
/**
|
|
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
|
|
325
|
+
* End a self-paced chain for good: the job is removed, the prompt is
|
|
326
|
+
* remembered so a late `schedule_wakeup` cannot resurrect it, and
|
|
327
|
+
* pi-cc-monitor is told to stop the monitors that belong to it. Without the
|
|
328
|
+
* last part a monitor that keeps firing wakes the agent, the agent
|
|
485
329
|
* reschedules, and the loop the user just stopped comes back.
|
|
486
330
|
*/
|
|
487
|
-
function retireChain(chain: LoopJob, reason: string): void {
|
|
488
|
-
|
|
331
|
+
function retireChain(chain: LoopJob, reason: string, options: { silent?: boolean } = {}): void {
|
|
332
|
+
rememberStopped(chain.prompt);
|
|
489
333
|
scheduler.remove(chain.id);
|
|
490
334
|
try {
|
|
491
335
|
pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
|
|
@@ -493,103 +337,140 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
493
337
|
// a listener must never break the loop
|
|
494
338
|
}
|
|
495
339
|
const ctx = lastCtx;
|
|
496
|
-
if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
|
|
340
|
+
if (ctx && !options.silent) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
|
|
497
341
|
}
|
|
498
342
|
|
|
499
|
-
function
|
|
500
|
-
|
|
343
|
+
function ensureDurable(): DurableRuntime {
|
|
344
|
+
durable ??= createDurable(lastCtx?.cwd ?? process.cwd());
|
|
345
|
+
return durable;
|
|
501
346
|
}
|
|
502
347
|
|
|
503
|
-
function
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
348
|
+
function createDurable(cwd: string): DurableRuntime {
|
|
349
|
+
return new DurableRuntime({
|
|
350
|
+
cwd,
|
|
351
|
+
scheduler,
|
|
352
|
+
timing: timingConfig,
|
|
353
|
+
maxAgeMs,
|
|
354
|
+
syncMs: durableSyncMs,
|
|
355
|
+
onMissed: reportMissed,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Add a job; a durable job is saved to disk and may run in another session. */
|
|
360
|
+
function addJob(job: LoopJob): Placement {
|
|
361
|
+
if (job.durable) return ensureDurable().add(job);
|
|
362
|
+
scheduler.add(job);
|
|
363
|
+
return { runsHere: true };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function removeJob(job: LoopJob): void {
|
|
367
|
+
scheduler.remove(job.id);
|
|
368
|
+
if (job.durable) durable?.remove(job.id);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** This session's jobs plus the durable jobs it can see but does not run. */
|
|
372
|
+
function listJobs(): Array<LoopJob & { foreignReason?: "other-session" | "other-folder" }> {
|
|
373
|
+
return [...scheduler.list(), ...(durable?.foreignJobs() ?? [])];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function foreignNote(job: { foreignReason?: string }): string {
|
|
377
|
+
if (job.foreignReason === "other-folder") return " (copied from another folder: listed, not run here)";
|
|
378
|
+
if (job.foreignReason === "other-session") return " (run by another Pi session in this project)";
|
|
379
|
+
return "";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Durable one-shots that came due while no session ran them: ask before running. */
|
|
383
|
+
function reportMissed(jobs: LoopJob[]): void {
|
|
384
|
+
const lines = jobs.map((job) => {
|
|
385
|
+
const due = nextRunTime(job, job.createdAt, timingConfig());
|
|
386
|
+
const when = due
|
|
387
|
+
? ` (was due ${new Date(due).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })})`
|
|
388
|
+
: "";
|
|
389
|
+
return `- job ${job.id}${when}: ${displayPrompt(job.prompt)}`;
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
// Attached to the next turn rather than starting one at startup.
|
|
393
|
+
pi.sendMessage(
|
|
394
|
+
{
|
|
395
|
+
customType: TICK_MESSAGE_TYPE,
|
|
396
|
+
content: missedJobsMessage(lines),
|
|
397
|
+
display: true,
|
|
398
|
+
details: { missed: true, jobIds: jobs.map((job) => job.id) },
|
|
399
|
+
},
|
|
400
|
+
{ deliverAs: "nextTurn" },
|
|
401
|
+
);
|
|
402
|
+
} catch {
|
|
403
|
+
// best effort
|
|
404
|
+
}
|
|
405
|
+
const ctx = lastCtx;
|
|
406
|
+
if (ctx) {
|
|
407
|
+
notify(
|
|
408
|
+
ctx,
|
|
409
|
+
`${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"}`,
|
|
410
|
+
"warning",
|
|
411
|
+
);
|
|
515
412
|
}
|
|
516
413
|
}
|
|
517
414
|
|
|
415
|
+
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
416
|
+
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
417
|
+
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
418
|
+
}
|
|
419
|
+
|
|
518
420
|
/**
|
|
519
|
-
* Deliver a scheduled tick.
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* waits until the current turn ends instead of cutting into it. That is
|
|
523
|
-
* Claude Code's "A scheduled prompt fires between your turns, not while
|
|
524
|
-
* Claude is mid-response."
|
|
421
|
+
* Deliver a scheduled tick. `followUp` is the low-priority lane: Claude
|
|
422
|
+
* Code's "A scheduled prompt fires between your turns, not while Claude is
|
|
423
|
+
* mid-response."
|
|
525
424
|
*/
|
|
526
425
|
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
426
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
530
427
|
tickCounters.set(job.prompt, tick);
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
runChainId = job.kind === "wakeup" ? job.id : undefined;
|
|
428
|
+
job.tickCount = tick;
|
|
429
|
+
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
430
|
+
// before_agent_start finds the job through `details.jobId` and gives a
|
|
431
|
+
// self-paced tick its chain guidelines.
|
|
536
432
|
pi.sendMessage(
|
|
537
433
|
{
|
|
538
434
|
customType: TICK_MESSAGE_TYPE,
|
|
539
|
-
content
|
|
435
|
+
content: `${provenance(tick)}\n${body}`,
|
|
540
436
|
display: true,
|
|
541
437
|
details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
|
|
542
438
|
},
|
|
543
439
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
544
440
|
);
|
|
545
441
|
|
|
546
|
-
|
|
547
|
-
if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
|
|
442
|
+
if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
|
|
548
443
|
const ctx = lastCtx;
|
|
549
|
-
if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${
|
|
444
|
+
if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${job.maxFires} fires`, "info");
|
|
550
445
|
}
|
|
551
446
|
}
|
|
552
447
|
|
|
553
448
|
/**
|
|
554
|
-
* Run the prompt right away, the way Claude Code's skill does
|
|
555
|
-
*
|
|
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.
|
|
449
|
+
* Run the prompt right away, the way Claude Code's skill does ("Then
|
|
450
|
+
* immediately execute the parsed prompt now").
|
|
563
451
|
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
452
|
+
* `guidelines` are the instructions for this one run. They travel on the
|
|
453
|
+
* message (`details` never reaches the model) and before_agent_start adds
|
|
454
|
+
* them to the run the message starts, so they cannot leak into another run.
|
|
455
|
+
* The caller waits until the agent is idle, so the message starts its own run.
|
|
566
456
|
*/
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
|
|
457
|
+
function runNow(ctx: ExtensionContext, prompt: string, job: LoopJob | undefined, guidelines: string[]): void {
|
|
570
458
|
const tick = (tickCounters.get(prompt) ?? 0) + 1;
|
|
571
459
|
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
|
-
});
|
|
460
|
+
if (job && scheduler.get(job.id)) {
|
|
461
|
+
scheduler.update(job.id, { tickCount: tick, fireCount: (job.fireCount ?? 0) + 1, lastFiredAt: Date.now() });
|
|
578
462
|
}
|
|
579
|
-
|
|
463
|
+
const body = resolvePrompt(prompt, job?.kind === "cron" ? "cron" : "wakeup");
|
|
580
464
|
pi.sendMessage(
|
|
581
465
|
{
|
|
582
466
|
customType: TICK_MESSAGE_TYPE,
|
|
583
|
-
content: `${
|
|
467
|
+
content: `${provenance(tick)}\n${body}`,
|
|
584
468
|
display: true,
|
|
585
|
-
details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
|
|
469
|
+
details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now(), guidelines },
|
|
586
470
|
},
|
|
587
471
|
{ deliverAs: "steer", triggerTurn: true },
|
|
588
472
|
);
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
function scheduleNote(text: string): void {
|
|
592
|
-
pendingNote = text;
|
|
473
|
+
lastCtx = ctx;
|
|
593
474
|
}
|
|
594
475
|
|
|
595
476
|
/** True when the pi-cc-monitor extension registered its Monitor tool. */
|
|
@@ -606,22 +487,76 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
606
487
|
return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
|
|
607
488
|
}
|
|
608
489
|
|
|
490
|
+
/**
|
|
491
|
+
* A run finished without an abort: self-paced chains whose tick the model
|
|
492
|
+
* did not reschedule get their one keepalive, or end.
|
|
493
|
+
*/
|
|
494
|
+
function settleChains(ctx: ExtensionContext): void {
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
for (const chain of scheduler.byKind("wakeup")) {
|
|
497
|
+
// The keepalive turn is the model's last chance to reschedule.
|
|
498
|
+
if (chain.keepaliveTurn) {
|
|
499
|
+
retireChain(chain, "keepalive budget exhausted (the model did not reschedule twice)");
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (!chain.awaitingReschedule) continue;
|
|
503
|
+
|
|
504
|
+
const limit = maxAgeMs();
|
|
505
|
+
if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
|
|
506
|
+
retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (chain.keepaliveUsed) continue;
|
|
510
|
+
|
|
511
|
+
const fallback = keepaliveSeconds();
|
|
512
|
+
if (fallback <= 0) {
|
|
513
|
+
retireChain(chain, "the model did not reschedule (keepalive disabled)");
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
scheduler.update(chain.id, {
|
|
517
|
+
scheduledFor: now + fallback * 1000,
|
|
518
|
+
keepaliveUsed: true,
|
|
519
|
+
awaitingReschedule: false,
|
|
520
|
+
});
|
|
521
|
+
notify(
|
|
522
|
+
ctx,
|
|
523
|
+
`loop ${chain.id}: the model did not reschedule \u2014 keepalive armed (${Math.round(fallback / 60)} min)`,
|
|
524
|
+
"warning",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Esc interrupted a turn that belongs to a self-paced loop (its tick, the
|
|
531
|
+
* /loop run that armed it, or a turn its monitor woke): cancel that loop,
|
|
532
|
+
* with Claude Code's notice ("A `Pending wakeup cancelled` notice confirms
|
|
533
|
+
* that the loop won't fire again").
|
|
534
|
+
*
|
|
535
|
+
* Claude Code cancels every self-paced loop on any Esc, idle or not. Here
|
|
536
|
+
* an interrupted unrelated turn leaves the loops alone; `/loop stop` stops
|
|
537
|
+
* them. Fixed-interval jobs keep running either way, as in Claude Code.
|
|
538
|
+
*/
|
|
539
|
+
function cancelCurrentLoop(ctx: ExtensionContext): void {
|
|
540
|
+
const ids = new Set([...(currentRun.chainId ? [currentRun.chainId] : []), ...(currentRun.armed ?? [])]);
|
|
541
|
+
const chains = [...ids]
|
|
542
|
+
.map((id) => scheduler.get(id))
|
|
543
|
+
.filter((chain): chain is LoopJob => chain?.kind === "wakeup");
|
|
544
|
+
if (!chains.length) return;
|
|
545
|
+
for (const chain of chains) retireChain(chain, "pending wakeup cancelled (Esc)", { silent: true });
|
|
546
|
+
notify(ctx, `Pending wakeup cancelled \u00b7 loop ${chains.map((chain) => chain.id).join(", ")} ended`, "info");
|
|
547
|
+
}
|
|
548
|
+
|
|
609
549
|
// ---------------------------------------------------------------- command
|
|
610
550
|
|
|
611
551
|
pi.registerCommand("loop", {
|
|
612
552
|
description:
|
|
613
|
-
"Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
|
|
553
|
+
"Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop [interval] | /loop stop [id] | /loop status | /loop settings",
|
|
614
554
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
615
555
|
lastCtx = ctx;
|
|
616
556
|
const raw = args.trim();
|
|
617
557
|
|
|
618
|
-
if (!raw) {
|
|
619
|
-
await startLoop(ctx, null, "", false, undefined);
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
558
|
const [head, ...rest] = raw.split(/\s+/);
|
|
624
|
-
switch (head.toLowerCase()) {
|
|
559
|
+
switch ((head ?? "").toLowerCase()) {
|
|
625
560
|
case "stop":
|
|
626
561
|
await stopLoops(ctx, rest.join(" "));
|
|
627
562
|
return;
|
|
@@ -678,26 +613,32 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
678
613
|
let intervalSeconds: number | null = null;
|
|
679
614
|
let prompt = body;
|
|
680
615
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
616
|
+
const intervalOnly = parseIntervalToken(body);
|
|
617
|
+
if (intervalOnly !== null) {
|
|
618
|
+
// "/loop 15m": the loop.md tasks (or maintenance) on a fixed schedule.
|
|
619
|
+
intervalSeconds = intervalOnly;
|
|
620
|
+
prompt = "";
|
|
621
|
+
} else {
|
|
622
|
+
// Rule 1: leading interval token, e.g. "5m check the deploy"
|
|
623
|
+
const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
|
|
624
|
+
if (leading) {
|
|
625
|
+
const parsed = parseIntervalToken(leading[1]);
|
|
626
|
+
if (parsed !== null) {
|
|
627
|
+
intervalSeconds = parsed;
|
|
628
|
+
prompt = leading[2].trim();
|
|
629
|
+
}
|
|
688
630
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
prompt = every.prompt;
|
|
631
|
+
// Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
|
|
632
|
+
if (intervalSeconds === null) {
|
|
633
|
+
const every = parseEveryClause(body);
|
|
634
|
+
if (every) {
|
|
635
|
+
intervalSeconds = every.interval;
|
|
636
|
+
prompt = every.prompt;
|
|
637
|
+
}
|
|
697
638
|
}
|
|
698
639
|
}
|
|
699
640
|
|
|
700
|
-
// Rule 3: no interval -> self-paced
|
|
641
|
+
// Rule 3: no interval -> the model decides (self-paced unless it asks for one)
|
|
701
642
|
await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
|
|
702
643
|
},
|
|
703
644
|
});
|
|
@@ -706,105 +647,115 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
706
647
|
ctx: ExtensionCommandContext,
|
|
707
648
|
intervalSeconds: number | null,
|
|
708
649
|
prompt: string,
|
|
709
|
-
|
|
650
|
+
durableFlag: boolean,
|
|
710
651
|
maxFires: number | undefined,
|
|
711
652
|
): Promise<void> {
|
|
712
|
-
|
|
653
|
+
// Claude Code queues a /loop typed while it is busy. Wait for the current
|
|
654
|
+
// turn rather than steering into it: the immediate run has to start its
|
|
655
|
+
// own turn, or its instructions would never reach the model.
|
|
656
|
+
if (!ctx.isIdle()) {
|
|
657
|
+
notify(ctx, "/loop starts when the current turn finishes", "info");
|
|
658
|
+
await ctx.waitForIdle();
|
|
659
|
+
}
|
|
713
660
|
|
|
661
|
+
// Without a prompt the loop runs the loop.md tasks, resolved at each fire.
|
|
662
|
+
const finalPrompt = prompt || LOOP_FILE_SENTINEL;
|
|
714
663
|
// Starting a chain again clears the stop marker: the user asked for it.
|
|
715
|
-
stoppedPrompts.delete(
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
}
|
|
664
|
+
stoppedPrompts.delete(finalPrompt);
|
|
665
|
+
// A new loop starts by sending the full task list again.
|
|
666
|
+
if (isLoopFileSentinel(finalPrompt)) loopFile.reset();
|
|
727
667
|
|
|
728
668
|
if (intervalSeconds === null) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
"\n- The user asked for the job to survive a restart: create it with `durable: true`.";
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
669
|
+
let guidelines: string[];
|
|
670
|
+
if (!prompt) {
|
|
671
|
+
// A bare /loop is self-paced, like Claude Code's.
|
|
672
|
+
guidelines = [loopGuidelines(finalPrompt)];
|
|
673
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(finalPrompt));
|
|
674
|
+
} else {
|
|
675
|
+
// The interval could not be parsed from the command line. Claude
|
|
676
|
+
// Code's /loop is a skill, so the model reads the input and works
|
|
677
|
+
// the schedule out itself; the rules below let it do the same here.
|
|
678
|
+
let rules = scheduleRules(finalPrompt);
|
|
679
|
+
if (durableFlag) {
|
|
680
|
+
rules += "\n- The user asked for the job to survive a restart: create it with `durable: true`.";
|
|
681
|
+
}
|
|
682
|
+
if (maxFires !== undefined) {
|
|
683
|
+
rules += `\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
|
|
684
|
+
}
|
|
685
|
+
guidelines = [rules];
|
|
686
|
+
if (monitorToolAvailable()) {
|
|
687
|
+
guidelines.push(`If you choose the open-ended mode:\n${monitorGuidelines(finalPrompt)}`);
|
|
688
|
+
}
|
|
745
689
|
}
|
|
746
|
-
|
|
747
|
-
runNow(ctx, finalPrompt);
|
|
690
|
+
runNow(ctx, finalPrompt, undefined, guidelines);
|
|
748
691
|
return;
|
|
749
692
|
}
|
|
750
693
|
|
|
751
694
|
const spec = intervalToCron(intervalSeconds);
|
|
752
|
-
if (!spec) {
|
|
695
|
+
if (!spec || !parseCron(spec.cron)) {
|
|
753
696
|
notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
|
|
754
697
|
return;
|
|
755
698
|
}
|
|
756
|
-
if (!parseCron(spec.cron)) {
|
|
757
|
-
notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
699
|
if (scheduler.size >= MAX_JOBS) {
|
|
761
700
|
notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
|
|
762
701
|
return;
|
|
763
702
|
}
|
|
764
703
|
|
|
765
|
-
const job =
|
|
704
|
+
const job: LoopJob = {
|
|
766
705
|
id: generateId(),
|
|
767
706
|
kind: "cron",
|
|
768
707
|
cron: spec.cron,
|
|
769
708
|
prompt: finalPrompt,
|
|
770
709
|
recurring: true,
|
|
771
710
|
createdAt: Date.now(),
|
|
772
|
-
durable,
|
|
711
|
+
durable: durableFlag,
|
|
773
712
|
maxFires,
|
|
774
713
|
label: humanSchedule(spec.cron),
|
|
775
714
|
source: "loop_command",
|
|
776
|
-
}
|
|
715
|
+
};
|
|
716
|
+
let placement: Placement;
|
|
717
|
+
try {
|
|
718
|
+
placement = addJob(job);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
|
|
777
724
|
|
|
778
|
-
const
|
|
725
|
+
const days = maxAgeDays();
|
|
779
726
|
notify(
|
|
780
727
|
ctx,
|
|
781
|
-
`Scheduled ${job.id} (${job.label}, next run ${formatClock(
|
|
782
|
-
(spec.note ? `
|
|
728
|
+
`Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)}) \u00b7 ${spec.cron} \u00b7 expires in ${days}d \u00b7 cancel with /loop stop ${job.id}` +
|
|
729
|
+
(spec.note ? ` \u00b7 ${spec.note}` : "") +
|
|
730
|
+
(placement.runsHere ? "" : ` \u00b7 runs in the Pi session holding the project lock (pid ${placement.holderPid ?? "?"})`),
|
|
783
731
|
"info",
|
|
784
732
|
);
|
|
785
|
-
|
|
733
|
+
const note =
|
|
786
734
|
`A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
);
|
|
735
|
+
`next run ${formatClock(nextAt)}, auto-expires after ${days} days (it runs one final time, then it is deleted). ` +
|
|
736
|
+
`The user cancels it with \`/loop stop ${job.id}\`.` +
|
|
737
|
+
(spec.note ? ` The requested interval was adjusted: ${spec.note}.` : "");
|
|
791
738
|
|
|
792
|
-
runNow(ctx, finalPrompt, job);
|
|
739
|
+
runNow(ctx, finalPrompt, job, [note]);
|
|
793
740
|
}
|
|
794
741
|
|
|
795
742
|
async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
|
|
796
743
|
if (target) {
|
|
797
744
|
const job = scheduler.get(target);
|
|
798
|
-
if (
|
|
799
|
-
|
|
745
|
+
if (job) {
|
|
746
|
+
if (job.kind === "wakeup") {
|
|
747
|
+
retireChain(job, "stopped by the user");
|
|
748
|
+
} else {
|
|
749
|
+
removeJob(job);
|
|
750
|
+
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
751
|
+
}
|
|
800
752
|
return;
|
|
801
753
|
}
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
scheduler.remove(target);
|
|
806
|
-
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
754
|
+
if (durable?.remove(target)) {
|
|
755
|
+
notify(ctx, `Cancelled durable job ${target}`, "info");
|
|
756
|
+
return;
|
|
807
757
|
}
|
|
758
|
+
notify(ctx, `No loop with id ${target}`, "warning");
|
|
808
759
|
return;
|
|
809
760
|
}
|
|
810
761
|
const jobs = scheduler.list();
|
|
@@ -813,40 +764,38 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
813
764
|
return;
|
|
814
765
|
}
|
|
815
766
|
for (const job of jobs) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
retireChain(job, "stopped by the user");
|
|
820
|
-
} else {
|
|
821
|
-
scheduler.remove(job.id);
|
|
822
|
-
}
|
|
767
|
+
// Retiring also tells pi-cc-monitor to stop the chain's monitors.
|
|
768
|
+
if (job.kind === "wakeup") retireChain(job, "stopped by the user");
|
|
769
|
+
else removeJob(job);
|
|
823
770
|
}
|
|
824
771
|
notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
|
|
825
772
|
}
|
|
826
773
|
|
|
827
774
|
function showStatus(ctx: ExtensionContext): void {
|
|
828
|
-
const jobs =
|
|
775
|
+
const jobs = listJobs();
|
|
829
776
|
if (!jobs.length) {
|
|
830
777
|
notify(ctx, "No active loops", "info");
|
|
831
778
|
return;
|
|
832
779
|
}
|
|
833
780
|
const lines = jobs.map((job) => {
|
|
781
|
+
const prompt = truncate(displayPrompt(job.prompt), 60);
|
|
834
782
|
if (job.kind === "cron") {
|
|
835
|
-
const cap = job.maxFires !== undefined ? `
|
|
836
|
-
|
|
783
|
+
const cap = job.maxFires !== undefined ? ` \u00b7 ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
|
|
784
|
+
const label = job.label ?? describeCron(job.cron ?? "");
|
|
785
|
+
return `${job.id} cron ${job.cron} (${label}) next ${formatClock(job.nextFireAt)}${cap}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
|
|
837
786
|
}
|
|
838
787
|
const at = job.scheduledFor;
|
|
839
|
-
const
|
|
840
|
-
return `${job.id} self-paced ${
|
|
788
|
+
const when = at && at > Date.now() ? `next ${formatClock(at)}` : "awaiting reschedule";
|
|
789
|
+
return `${job.id} self-paced ${when} ${prompt}`;
|
|
841
790
|
});
|
|
842
|
-
|
|
791
|
+
notify(ctx, lines.join("\n"), "info");
|
|
843
792
|
}
|
|
844
793
|
|
|
845
794
|
function showHelp(ctx: ExtensionContext): void {
|
|
846
795
|
const help = [
|
|
847
796
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
848
797
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
849
|
-
"/loop
|
|
798
|
+
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
850
799
|
"/loop stop [id] cancel one loop, or all of them",
|
|
851
800
|
"/loop status list the active loops",
|
|
852
801
|
"/loop widget on|off show or hide the status line above the editor",
|
|
@@ -854,23 +803,24 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
854
803
|
"/loop settings messages on|off compact two-line markers",
|
|
855
804
|
"/loop settings tools on|off one-line tool results",
|
|
856
805
|
"",
|
|
806
|
+
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
857
807
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
858
808
|
" --max N stop the loop after N fires.",
|
|
859
809
|
].join("\n");
|
|
860
|
-
|
|
810
|
+
notify(ctx, help, "info");
|
|
861
811
|
}
|
|
862
812
|
|
|
863
813
|
function showSettings(ctx: ExtensionContext): void {
|
|
864
814
|
const config = loadConfig();
|
|
865
815
|
const lines = [
|
|
866
|
-
`pi-cc settings
|
|
816
|
+
`pi-cc settings \u00b7 ${configPath()}`,
|
|
867
817
|
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
868
818
|
` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
|
|
869
819
|
"",
|
|
870
820
|
"Usage: /loop settings messages on|off",
|
|
871
821
|
" /loop settings tools on|off",
|
|
872
822
|
];
|
|
873
|
-
|
|
823
|
+
notify(ctx, lines.join("\n"), "info");
|
|
874
824
|
}
|
|
875
825
|
|
|
876
826
|
function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
|
|
@@ -881,8 +831,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
881
831
|
showSettings(ctx);
|
|
882
832
|
return;
|
|
883
833
|
}
|
|
884
|
-
const patch: Partial<PiCcConfig> =
|
|
885
|
-
key === "messages" ? { compactMessages: on } : { compactToolRows: on };
|
|
834
|
+
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { compactToolRows: on };
|
|
886
835
|
const next = saveConfig(patch);
|
|
887
836
|
let compatChanged = false;
|
|
888
837
|
if (key === "tools") {
|
|
@@ -895,518 +844,355 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
895
844
|
}
|
|
896
845
|
notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
|
|
897
846
|
if (compatChanged) {
|
|
898
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated
|
|
847
|
+
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
899
848
|
}
|
|
900
849
|
}
|
|
901
850
|
|
|
902
851
|
// ------------------------------------------------------------------ tools
|
|
903
852
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
853
|
+
pi.registerTool({
|
|
854
|
+
name: "cron_create",
|
|
855
|
+
label: "Create Scheduled Job",
|
|
856
|
+
description: CRON_CREATE_DESCRIPTION,
|
|
857
|
+
promptSnippet: CRON_CREATE_SNIPPET,
|
|
858
|
+
parameters: Type.Object({
|
|
859
|
+
cron: Type.String({
|
|
860
|
+
description:
|
|
861
|
+
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
|
|
862
|
+
}),
|
|
863
|
+
prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
|
|
864
|
+
recurring: Type.Optional(
|
|
865
|
+
Type.Boolean({
|
|
915
866
|
description:
|
|
916
|
-
|
|
867
|
+
"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
868
|
}),
|
|
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
|
-
}
|
|
869
|
+
),
|
|
870
|
+
durable: Type.Optional(
|
|
871
|
+
Type.Boolean({
|
|
872
|
+
description:
|
|
873
|
+
"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.",
|
|
874
|
+
}),
|
|
875
|
+
),
|
|
876
|
+
maxFires: Type.Optional(
|
|
877
|
+
Type.Integer({
|
|
878
|
+
minimum: 1,
|
|
879
|
+
description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
|
|
880
|
+
}),
|
|
881
|
+
),
|
|
882
|
+
}),
|
|
883
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
884
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
885
|
+
lastCtx = ctx;
|
|
886
|
+
if (!parseCron(params.cron)) {
|
|
887
|
+
throw new Error(`Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`);
|
|
888
|
+
}
|
|
889
|
+
if (isUnreachable(params.cron)) {
|
|
890
|
+
throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
|
|
891
|
+
}
|
|
892
|
+
if (scheduler.size >= MAX_JOBS) {
|
|
893
|
+
throw new Error(`Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`);
|
|
894
|
+
}
|
|
895
|
+
if (!params.prompt?.trim()) {
|
|
896
|
+
throw new Error("prompt must not be empty.");
|
|
897
|
+
}
|
|
953
898
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
899
|
+
const recurring = params.recurring ?? true;
|
|
900
|
+
const job: LoopJob = {
|
|
901
|
+
id: generateId(),
|
|
902
|
+
kind: "cron",
|
|
903
|
+
cron: params.cron.trim(),
|
|
904
|
+
prompt: params.prompt,
|
|
905
|
+
recurring,
|
|
906
|
+
createdAt: Date.now(),
|
|
907
|
+
durable: params.durable ?? false,
|
|
908
|
+
maxFires: params.maxFires,
|
|
909
|
+
// Continue the tick numbering of the /loop run that led here.
|
|
910
|
+
tickCount: tickCounters.get(params.prompt) ?? currentRun.tick ?? 0,
|
|
911
|
+
label: humanSchedule(params.cron),
|
|
912
|
+
source: "cron_create",
|
|
913
|
+
};
|
|
914
|
+
const placement = addJob(job);
|
|
915
|
+
const nextAt = job.nextFireAt ?? nextRunTime(job, job.createdAt, timingConfig()) ?? undefined;
|
|
916
|
+
|
|
917
|
+
const lines = [`Scheduled ${job.id} (${job.label}, next run ${formatClock(nextAt)})`];
|
|
918
|
+
if (job.durable) {
|
|
919
|
+
lines.push(
|
|
920
|
+
placement.runsHere
|
|
921
|
+
? "Durable: saved to .pi/scheduled_tasks.json, survives restarts."
|
|
922
|
+
: `Durable: saved to .pi/scheduled_tasks.json. Another Pi session in this project (pid ${placement.holderPid ?? "?"}) holds the scheduler lock and runs it.`,
|
|
923
|
+
);
|
|
924
|
+
} else {
|
|
925
|
+
lines.push("Session-only: restored if this session is resumed, gone otherwise.");
|
|
926
|
+
}
|
|
927
|
+
if (recurring) {
|
|
928
|
+
lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
|
|
929
|
+
}
|
|
930
|
+
lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
934
|
+
details: {
|
|
935
|
+
id: job.id,
|
|
936
|
+
cron: job.cron,
|
|
937
|
+
recurring: job.recurring,
|
|
938
|
+
durable: job.durable,
|
|
939
|
+
nextFireAt: nextAt,
|
|
940
|
+
prompt: job.prompt,
|
|
941
|
+
runsHere: placement.runsHere,
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
},
|
|
945
|
+
});
|
|
968
946
|
|
|
947
|
+
pi.registerTool({
|
|
948
|
+
name: "cron_delete",
|
|
949
|
+
label: "Cancel Scheduled Job",
|
|
950
|
+
description: CRON_DELETE_DESCRIPTION,
|
|
951
|
+
promptSnippet: CRON_DELETE_SNIPPET,
|
|
952
|
+
parameters: Type.Object({
|
|
953
|
+
id: Type.String({ description: "The job id returned by cron_create, or the id of a self-paced loop." }),
|
|
954
|
+
}),
|
|
955
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
956
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
957
|
+
lastCtx = ctx;
|
|
958
|
+
const job = scheduler.get(params.id);
|
|
959
|
+
if (job) {
|
|
960
|
+
// A self-paced chain ends the same way /loop stop ends it.
|
|
961
|
+
if (job.kind === "wakeup") retireChain(job, "cancelled with cron_delete");
|
|
962
|
+
else removeJob(job);
|
|
963
|
+
} else if (!durable?.remove(params.id)) {
|
|
969
964
|
return {
|
|
970
|
-
content: [
|
|
971
|
-
|
|
972
|
-
type: "text",
|
|
973
|
-
text:
|
|
974
|
-
`Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)})\n` +
|
|
975
|
-
`Cancel it with cron_delete { "id": "${job.id}" }.`,
|
|
976
|
-
},
|
|
977
|
-
],
|
|
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
|
-
},
|
|
965
|
+
content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
|
|
966
|
+
details: { cancelled: false, id: params.id },
|
|
986
967
|
};
|
|
987
|
-
}
|
|
988
|
-
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
|
|
971
|
+
details: { cancelled: true, id: params.id },
|
|
972
|
+
};
|
|
973
|
+
},
|
|
974
|
+
});
|
|
989
975
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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);
|
|
976
|
+
pi.registerTool({
|
|
977
|
+
name: "cron_list",
|
|
978
|
+
label: "List Scheduled Jobs",
|
|
979
|
+
description: CRON_LIST_DESCRIPTION,
|
|
980
|
+
promptSnippet: CRON_LIST_SNIPPET,
|
|
981
|
+
parameters: Type.Object({}),
|
|
982
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
983
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
984
|
+
lastCtx = ctx;
|
|
985
|
+
const jobs = listJobs();
|
|
986
|
+
if (!jobs.length) {
|
|
1008
987
|
return {
|
|
1009
|
-
content: [{ type: "text" as const, text:
|
|
1010
|
-
details: {
|
|
988
|
+
content: [{ type: "text" as const, text: "No scheduled jobs." }],
|
|
989
|
+
details: { jobs: [] },
|
|
1011
990
|
};
|
|
1012
|
-
}
|
|
1013
|
-
|
|
991
|
+
}
|
|
992
|
+
const text = jobs
|
|
993
|
+
.map((job) => {
|
|
994
|
+
const prompt = truncate(displayPrompt(job.prompt), 80);
|
|
995
|
+
if (job.kind === "cron") {
|
|
996
|
+
return `${job.id} cron ${job.cron} ${humanSchedule(job.cron ?? "")} recurring=${job.recurring} next ${formatClock(job.nextFireAt)}${job.durable ? " durable" : ""}${foreignNote(job)} ${prompt}`;
|
|
997
|
+
}
|
|
998
|
+
return `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${prompt}`;
|
|
999
|
+
})
|
|
1000
|
+
.join("\n");
|
|
1001
|
+
return {
|
|
1002
|
+
content: [{ type: "text" as const, text }],
|
|
1003
|
+
details: { jobs: jobs.map((job) => storedJob(job)) },
|
|
1004
|
+
};
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1014
1007
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
.
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
.
|
|
1008
|
+
pi.registerTool({
|
|
1009
|
+
name: "schedule_wakeup",
|
|
1010
|
+
label: "Schedule Next Loop Iteration",
|
|
1011
|
+
description: SCHEDULE_WAKEUP_DESCRIPTION,
|
|
1012
|
+
promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
|
|
1013
|
+
parameters: Type.Object({
|
|
1014
|
+
delaySeconds: Type.Optional(
|
|
1015
|
+
Type.Number({
|
|
1016
|
+
description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
|
|
1017
|
+
}),
|
|
1018
|
+
),
|
|
1019
|
+
reason: Type.Optional(
|
|
1020
|
+
Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
|
|
1021
|
+
),
|
|
1022
|
+
prompt: Type.Optional(
|
|
1023
|
+
Type.String({
|
|
1024
|
+
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}".`,
|
|
1025
|
+
}),
|
|
1026
|
+
),
|
|
1027
|
+
stop: Type.Optional(
|
|
1028
|
+
Type.Boolean({
|
|
1029
|
+
description: "Set to true to end the self-paced loop immediately. Omit every other field.",
|
|
1030
|
+
}),
|
|
1031
|
+
),
|
|
1032
|
+
}),
|
|
1033
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1034
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1035
|
+
lastCtx = ctx;
|
|
1036
|
+
|
|
1037
|
+
if (params.stop === true) {
|
|
1038
|
+
// Stop the loop this turn belongs to; otherwise the named one, or all.
|
|
1039
|
+
const current = currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined;
|
|
1040
|
+
const named = params.prompt ? scheduler.findChain(normalizePrompt(params.prompt)) : undefined;
|
|
1041
|
+
const chains = current ? [current] : named ? [named] : scheduler.byKind("wakeup");
|
|
1042
|
+
for (const chain of chains) retireChain(chain, "stopped by the agent");
|
|
1037
1043
|
return {
|
|
1038
|
-
content: [
|
|
1039
|
-
|
|
1044
|
+
content: [
|
|
1045
|
+
{
|
|
1046
|
+
type: "text" as const,
|
|
1047
|
+
text: chains.length
|
|
1048
|
+
? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
|
|
1049
|
+
: "Loop ended. Nothing was pending.",
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
details: { stopped: true, cancelledWakeups: chains.length },
|
|
1040
1053
|
};
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
1054
|
+
}
|
|
1043
1055
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
prompt
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
}
|
|
1056
|
+
if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
|
|
1057
|
+
throw new Error("delaySeconds is required unless stop is true.");
|
|
1058
|
+
}
|
|
1059
|
+
const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
|
|
1060
|
+
if (!prompt) {
|
|
1061
|
+
throw new Error("prompt is required unless stop is true.");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Claude Code rounds and clamps; only a change after rounding counts as clamped.
|
|
1065
|
+
const requested = params.delaySeconds;
|
|
1066
|
+
const rounded = Number.isFinite(requested) ? Math.round(requested) : requested > 0 ? MAX_DELAY_SECONDS : MIN_DELAY_SECONDS;
|
|
1067
|
+
let delay = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, rounded));
|
|
1068
|
+
const wasClamped = !Number.isFinite(requested) || delay !== rounded;
|
|
1069
|
+
|
|
1070
|
+
const now = Date.now();
|
|
1071
|
+
// A tick of a chain re-arms that chain even if the model reworded the prompt.
|
|
1072
|
+
let chain =
|
|
1073
|
+
scheduler.findChain(prompt) ?? (currentRun.chainId ? scheduler.get(currentRun.chainId) : undefined);
|
|
1074
|
+
if (chain) {
|
|
1075
|
+
const limit = maxAgeMs();
|
|
1076
|
+
if (limit > 0 && now - (chain.chainStartedAt ?? chain.createdAt) >= limit) {
|
|
1077
|
+
retireChain(chain, `reached its ${maxAgeDays()}-day limit`);
|
|
1083
1078
|
return {
|
|
1084
1079
|
content: [
|
|
1085
1080
|
{
|
|
1086
1081
|
type: "text" as const,
|
|
1087
|
-
text:
|
|
1088
|
-
|
|
1089
|
-
|
|
1082
|
+
text:
|
|
1083
|
+
`The /loop has reached its ${maxAgeDays()}-day limit and has ended. Do not reschedule it; ` +
|
|
1084
|
+
"tell the user they can start a new /loop.",
|
|
1090
1085
|
},
|
|
1091
1086
|
],
|
|
1092
|
-
details: { stopped: true,
|
|
1087
|
+
details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
|
|
1093
1088
|
};
|
|
1094
1089
|
}
|
|
1095
|
-
|
|
1096
|
-
if (
|
|
1097
|
-
throw new Error(
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
throw new Error("prompt is required unless stop is true.");
|
|
1090
|
+
} else {
|
|
1091
|
+
if (stoppedPrompts.has(prompt)) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
`The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
|
|
1094
|
+
"Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
|
|
1095
|
+
);
|
|
1102
1096
|
}
|
|
1097
|
+
chain = {
|
|
1098
|
+
id: generateId(),
|
|
1099
|
+
kind: "wakeup",
|
|
1100
|
+
prompt,
|
|
1101
|
+
recurring: false,
|
|
1102
|
+
createdAt: now,
|
|
1103
|
+
chainStartedAt: now,
|
|
1104
|
+
durable: false,
|
|
1105
|
+
tickCount: tickCounters.get(prompt) ?? currentRun.tick ?? 0,
|
|
1106
|
+
source: "schedule_wakeup",
|
|
1107
|
+
};
|
|
1108
|
+
addJob(chain);
|
|
1109
|
+
}
|
|
1103
1110
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
});
|
|
1126
|
-
}
|
|
1111
|
+
// The run that arms a chain belongs to it: Esc in this run cancels it.
|
|
1112
|
+
(currentRun.armed ??= new Set()).add(chain.id);
|
|
1113
|
+
const scheduledFor = now + delay * 1000;
|
|
1114
|
+
scheduler.update(chain.id, {
|
|
1115
|
+
scheduledFor,
|
|
1116
|
+
keepaliveUsed: false,
|
|
1117
|
+
keepaliveTurn: false,
|
|
1118
|
+
awaitingReschedule: false,
|
|
1119
|
+
reason: params.reason,
|
|
1120
|
+
});
|
|
1127
1121
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1122
|
+
const note = wasClamped ? ` (clamped from ${requested}s)` : "";
|
|
1123
|
+
return {
|
|
1124
|
+
content: [{ type: "text" as const, text: `Next iteration scheduled in ${delay}s${note} for loop ${chain.id}.` }],
|
|
1125
|
+
details: {
|
|
1126
|
+
id: chain.id,
|
|
1130
1127
|
scheduledFor,
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
},
|
|
1154
|
-
};
|
|
1155
|
-
},
|
|
1156
|
-
});
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
registerTools();
|
|
1128
|
+
clampedDelaySeconds: delay,
|
|
1129
|
+
wasClamped,
|
|
1130
|
+
stopped: false,
|
|
1131
|
+
},
|
|
1132
|
+
// Claude Code ends the turn when ScheduleWakeup returns (on models
|
|
1133
|
+
// that support it); the guidelines ask for the update first. Pi only
|
|
1134
|
+
// ends the turn when every tool in the batch agrees.
|
|
1135
|
+
terminate: true,
|
|
1136
|
+
};
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1160
1139
|
|
|
1161
1140
|
// ---------------------------------------------------------------- rendering
|
|
1162
1141
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
* `compactToolRows` to false falls back to Pi's default rendering without
|
|
1168
|
-
* re-registering the tool or reloading the extension.
|
|
1169
|
-
*/
|
|
1170
|
-
function registerToolRenderers(): void {
|
|
1171
|
-
if (typeof pi.registerToolRenderer !== "function") return;
|
|
1172
|
-
const renderers: Record<string, ToolRenderers> = {
|
|
1173
|
-
cron_create: {
|
|
1174
|
-
renderCall(args: any, theme: Theme, context: any) {
|
|
1175
|
-
if (!context.isPartial && context.executionStarted) return completedCall();
|
|
1176
|
-
return toolCall("Schedule", `${args.cron ?? ""} · ${truncate(args.prompt ?? "", 40)}`, theme);
|
|
1177
|
-
},
|
|
1178
|
-
renderResult(result: any, options: any, theme: Theme, context: any) {
|
|
1179
|
-
if (context.isError) {
|
|
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
|
-
);
|
|
1303
|
-
},
|
|
1304
|
-
},
|
|
1305
|
-
};
|
|
1306
|
-
|
|
1142
|
+
if (typeof pi.registerToolRenderer === "function") {
|
|
1143
|
+
const renderers = loopToolRenderers();
|
|
1144
|
+
// The resolver runs every time a row renders, so the setting applies
|
|
1145
|
+
// without re-registering anything.
|
|
1307
1146
|
pi.registerToolRenderer((toolName, next) => {
|
|
1308
1147
|
if (!loadConfig().compactToolRows) return next();
|
|
1309
1148
|
return renderers[toolName] ?? next();
|
|
1310
1149
|
});
|
|
1311
1150
|
}
|
|
1312
1151
|
|
|
1313
|
-
|
|
1152
|
+
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderTickMessage);
|
|
1314
1153
|
|
|
1315
1154
|
// ----------------------------------------------------------------- events
|
|
1316
1155
|
|
|
1317
1156
|
/**
|
|
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.
|
|
1157
|
+
* Give a run the instructions of the loop message that started it, and
|
|
1158
|
+
* remember which chain (if any) the run belongs to.
|
|
1367
1159
|
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
* arm a keepalive on top of the event that just arrived.
|
|
1160
|
+
* A monitor event does not touch the chain: like Claude Code, the pending
|
|
1161
|
+
* wakeup stays armed as the fallback heartbeat, and the model re-arms it if
|
|
1162
|
+
* it wants a different time.
|
|
1372
1163
|
*/
|
|
1373
|
-
pi.events.on(MONITOR_WAKE_CHANNEL, (data) => {
|
|
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
|
-
});
|
|
1380
|
-
|
|
1381
1164
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1382
1165
|
lastCtx = ctx;
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
if (chainId) {
|
|
1393
|
-
const chain = scheduler.get(chainId);
|
|
1394
|
-
if (chain) {
|
|
1395
|
-
notes.push(LOOP_GUIDELINES.replace("{prompt}", chain.prompt).replace("{quoted}", `"${chain.prompt}"`));
|
|
1396
|
-
// Self-paced chains can be woken by an event instead of the timer.
|
|
1397
|
-
// Only mention the Monitor tool when it is actually installed.
|
|
1398
|
-
if (chain.kind === "wakeup" && monitorToolAvailable()) {
|
|
1399
|
-
notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"${chain.prompt}"`));
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1166
|
+
currentRun = {};
|
|
1167
|
+
if (event.messageType !== "custom") return;
|
|
1168
|
+
|
|
1169
|
+
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
|
1170
|
+
// A monitor bound to a loop woke the agent: the run belongs to that loop.
|
|
1171
|
+
const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
|
|
1172
|
+
const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
|
|
1173
|
+
if (chain) currentRun.chainId = chain.id;
|
|
1174
|
+
return;
|
|
1402
1175
|
}
|
|
1176
|
+
if (event.message.customType !== TICK_MESSAGE_TYPE) return;
|
|
1403
1177
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1178
|
+
const details = (event.message.details ?? {}) as {
|
|
1179
|
+
jobId?: string;
|
|
1180
|
+
guidelines?: unknown;
|
|
1181
|
+
immediate?: boolean;
|
|
1182
|
+
tickCount?: number;
|
|
1183
|
+
};
|
|
1184
|
+
const notes = Array.isArray(details.guidelines)
|
|
1185
|
+
? details.guidelines.filter((note): note is string => typeof note === "string")
|
|
1186
|
+
: [];
|
|
1187
|
+
if (details.immediate && typeof details.tickCount === "number") currentRun.tick = details.tickCount;
|
|
1188
|
+
|
|
1189
|
+
// A self-paced tick has to schedule the next iteration.
|
|
1190
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1191
|
+
if (chain?.kind === "wakeup") {
|
|
1192
|
+
currentRun.chainId = chain.id;
|
|
1193
|
+
notes.push(loopGuidelines(chain.prompt));
|
|
1194
|
+
// Only mention the Monitor tool when it is actually installed.
|
|
1195
|
+
if (monitorToolAvailable()) notes.push(monitorGuidelines(chain.prompt));
|
|
1410
1196
|
}
|
|
1411
1197
|
|
|
1412
1198
|
if (!notes.length) return;
|
|
@@ -1414,137 +1200,118 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1414
1200
|
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
|
|
1415
1201
|
});
|
|
1416
1202
|
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
pi.on("
|
|
1203
|
+
/**
|
|
1204
|
+
* The run is over and Pi will not continue on its own. Esc on a loop's own
|
|
1205
|
+
* turn cancels that loop; chains whose tick was not rescheduled get their
|
|
1206
|
+
* keepalive. Then a pending tick may go out.
|
|
1207
|
+
*/
|
|
1208
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
1423
1209
|
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
|
-
}
|
|
1210
|
+
if ((event as { aborted?: boolean } | undefined)?.aborted) cancelCurrentLoop(ctx);
|
|
1211
|
+
settleChains(ctx);
|
|
1212
|
+
currentRun = {};
|
|
1213
|
+
scheduler.notifyIdle();
|
|
1465
1214
|
});
|
|
1466
1215
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
scheduler.notifyIdle();
|
|
1216
|
+
/** After a compaction, send the full loop.md tasks again on the next tick. */
|
|
1217
|
+
pi.on("session_compact", async () => {
|
|
1218
|
+
loopFile.reset();
|
|
1471
1219
|
});
|
|
1472
1220
|
|
|
1473
|
-
pi.on("session_start", async (
|
|
1221
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1474
1222
|
lastCtx = ctx;
|
|
1475
|
-
|
|
1476
|
-
|
|
1223
|
+
durable?.stop();
|
|
1224
|
+
restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
|
|
1225
|
+
durable = createDurable(ctx.cwd);
|
|
1226
|
+
durable.start();
|
|
1477
1227
|
scheduler.start();
|
|
1478
1228
|
if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
|
|
1479
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated
|
|
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");
|
|
1229
|
+
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
1486
1230
|
}
|
|
1487
|
-
scheduler.clear(false);
|
|
1488
1231
|
});
|
|
1489
1232
|
|
|
1490
1233
|
pi.on("session_shutdown", async () => {
|
|
1491
1234
|
scheduler.stop();
|
|
1235
|
+
durable?.stop();
|
|
1236
|
+
durable = undefined;
|
|
1492
1237
|
scheduler.clear(false);
|
|
1493
1238
|
});
|
|
1494
1239
|
|
|
1495
1240
|
// -------------------------------------------------------------- restoring
|
|
1496
1241
|
|
|
1497
|
-
|
|
1242
|
+
/**
|
|
1243
|
+
* Restore the session's jobs from its last state entry, following Claude
|
|
1244
|
+
* Code's resume rules: "restores the tasks scheduled with CronCreate,
|
|
1245
|
+
* except recurring tasks that have expired and one-shot tasks whose
|
|
1246
|
+
* scheduled time has passed. A self-paced /loop isn't restored". A restored
|
|
1247
|
+
* recurring job does not catch up on missed runs.
|
|
1248
|
+
*
|
|
1249
|
+
* `/reload` is different: the process keeps running, so a self-paced
|
|
1250
|
+
* wakeup that is still in the future is kept (Claude Code re-arms a pending
|
|
1251
|
+
* wakeup when its process restarts in time).
|
|
1252
|
+
*/
|
|
1253
|
+
function restoreState(ctx: ExtensionContext, reason: string | undefined): void {
|
|
1498
1254
|
scheduler.clear(false);
|
|
1255
|
+
lastPersistedSignature = "";
|
|
1499
1256
|
|
|
1500
1257
|
const entries = ctx.sessionManager.getEntries();
|
|
1258
|
+
let data: PersistedState | undefined;
|
|
1501
1259
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1502
1260
|
const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
|
|
1503
1261
|
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
|
-
}
|
|
1262
|
+
data = entry.data;
|
|
1516
1263
|
break;
|
|
1517
1264
|
}
|
|
1518
1265
|
}
|
|
1266
|
+
if (!data) {
|
|
1267
|
+
refreshWidget(scheduler.list());
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1519
1270
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1271
|
+
for (const prompt of data.stopped ?? []) rememberStopped(prompt);
|
|
1272
|
+
if (typeof data.widget === "boolean") widgetEnabled = data.widget;
|
|
1273
|
+
|
|
1274
|
+
const now = Date.now();
|
|
1275
|
+
const limit = maxAgeMs();
|
|
1276
|
+
let droppedChains = 0;
|
|
1277
|
+
let droppedOneShots = 0;
|
|
1278
|
+
for (const saved of data.jobs) {
|
|
1279
|
+
// Durable jobs come from .pi/scheduled_tasks.json, never from a session.
|
|
1280
|
+
if (saved.durable) continue;
|
|
1281
|
+
const job = storedJob(saved);
|
|
1282
|
+
if (job.kind === "wakeup") {
|
|
1283
|
+
if (reason === "reload" && job.scheduledFor !== undefined && job.scheduledFor > now) {
|
|
1284
|
+
scheduler.restore(job);
|
|
1285
|
+
} else {
|
|
1286
|
+
droppedChains++;
|
|
1534
1287
|
}
|
|
1288
|
+
continue;
|
|
1535
1289
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1290
|
+
if (!job.recurring) {
|
|
1291
|
+
const due = nextRunTime(job, job.createdAt, timingConfig());
|
|
1292
|
+
if (due === null || due <= now) {
|
|
1293
|
+
droppedOneShots++;
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
} else if (limit > 0 && now - job.createdAt >= limit) {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
scheduler.restore(job);
|
|
1300
|
+
scheduler.arm(job, now);
|
|
1538
1301
|
}
|
|
1539
1302
|
|
|
1540
|
-
const
|
|
1541
|
-
if (
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1303
|
+
const notes: string[] = [];
|
|
1304
|
+
if (droppedChains) {
|
|
1305
|
+
notes.push(
|
|
1306
|
+
`${droppedChains} self-paced loop${droppedChains === 1 ? " was" : "s were"} not restored: run /loop again to restart ${droppedChains === 1 ? "it" : "them"}`,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
if (droppedOneShots) {
|
|
1310
|
+
notes.push(
|
|
1311
|
+
`${droppedOneShots} one-shot job${droppedOneShots === 1 ? "" : "s"} dropped: the scheduled time passed while the session was closed`,
|
|
1546
1312
|
);
|
|
1547
1313
|
}
|
|
1548
|
-
|
|
1314
|
+
if (notes.length) notify(ctx, notes.join(" \u00b7 "), "warning");
|
|
1315
|
+
scheduler.refresh();
|
|
1549
1316
|
}
|
|
1550
1317
|
}
|