pi-cc-loop 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +125 -2
- package/config.ts +173 -0
- package/cron.ts +359 -0
- package/index.ts +1550 -0
- package/package.json +53 -5
- package/render.ts +146 -0
- package/schedule.ts +375 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-cc-loop — a Claude Code compatible `/loop` for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code v2.1.306:
|
|
5
|
+
*
|
|
6
|
+
* /loop 5m check the deploy fixed interval, driven by a 5-field cron
|
|
7
|
+
* /loop check the deploy self-paced: the model picks the next delay
|
|
8
|
+
* /loop uses .pi/loop.md, or the built-in maintenance prompt
|
|
9
|
+
* /loop stop | status | list manage the jobs
|
|
10
|
+
*
|
|
11
|
+
* Two mechanisms, same as Claude Code:
|
|
12
|
+
*
|
|
13
|
+
* fixed interval a cron job fires on a timer. The scheduler enqueues the
|
|
14
|
+
* prompt at low priority, between turns, and coalesces
|
|
15
|
+
* missed intervals into a single tick.
|
|
16
|
+
* self-paced `schedule_wakeup` lets the model choose the next delay
|
|
17
|
+
* (60-3600s). If the model forgets to reschedule, the runtime
|
|
18
|
+
* arms one 20 minute keepalive; if it still forgets, the
|
|
19
|
+
* chain ends.
|
|
20
|
+
*
|
|
21
|
+
* The tools are named after their Claude Code counterparts:
|
|
22
|
+
* CronCreate -> cron_create
|
|
23
|
+
* CronDelete -> cron_delete
|
|
24
|
+
* CronList -> cron_list
|
|
25
|
+
* ScheduleWakeup -> schedule_wakeup
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs";
|
|
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";
|
|
39
|
+
import { Type } from "typebox";
|
|
40
|
+
import {
|
|
41
|
+
describeCron,
|
|
42
|
+
humanSchedule,
|
|
43
|
+
intervalToCron,
|
|
44
|
+
isUnreachable,
|
|
45
|
+
parseCron,
|
|
46
|
+
parseEveryClause,
|
|
47
|
+
parseIntervalToken,
|
|
48
|
+
} from "./cron.ts";
|
|
49
|
+
import {
|
|
50
|
+
configPath,
|
|
51
|
+
loadConfig,
|
|
52
|
+
saveConfig,
|
|
53
|
+
syncPiCcExtensionsExcludes,
|
|
54
|
+
type PiCcConfig,
|
|
55
|
+
} from "./config.ts";
|
|
56
|
+
import { generateId, LoopScheduler, type LoopJob } from "./schedule.ts";
|
|
57
|
+
import {
|
|
58
|
+
bodyLines,
|
|
59
|
+
contentText,
|
|
60
|
+
firstLine,
|
|
61
|
+
formatStamp,
|
|
62
|
+
renderBlock,
|
|
63
|
+
renderCard,
|
|
64
|
+
stripProvenance,
|
|
65
|
+
} from "./render.ts";
|
|
66
|
+
|
|
67
|
+
const STATE_ENTRY = "pi-cc-loop-state";
|
|
68
|
+
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
69
|
+
const WIDGET_KEY = "pi-cc-loop";
|
|
70
|
+
const CONFIG_CHANNEL = "pi-cc:config";
|
|
71
|
+
const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
|
|
72
|
+
const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The event channel pi-cc-monitor publishes on. When a monitor names a loop
|
|
76
|
+
* chain, its events supersede that chain's fallback heartbeat.
|
|
77
|
+
*/
|
|
78
|
+
const MONITOR_WAKE_CHANNEL = "monitor:wake";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
82
|
+
* listens to it and stops any monitor that belongs to the chain, the way
|
|
83
|
+
* Claude Code requires ("call ScheduleWakeup with stop: true and TaskStop any
|
|
84
|
+
* Monitor you armed").
|
|
85
|
+
*/
|
|
86
|
+
const LOOP_STOPPED_CHANNEL = "loop:stopped";
|
|
87
|
+
|
|
88
|
+
const MAX_JOBS = 50;
|
|
89
|
+
const MIN_DELAY_SECONDS = 60;
|
|
90
|
+
const MAX_DELAY_SECONDS = 3600;
|
|
91
|
+
const DEFAULT_KEEPALIVE_SECONDS = 1200;
|
|
92
|
+
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
93
|
+
|
|
94
|
+
const MAINTENANCE_PROMPT = [
|
|
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]";
|
|
158
|
+
|
|
159
|
+
interface PersistedState {
|
|
160
|
+
version: 1;
|
|
161
|
+
jobs: LoopJob[];
|
|
162
|
+
/** Prompts whose chain was stopped and must not be rescheduled. */
|
|
163
|
+
stopped?: string[];
|
|
164
|
+
/** Whether the status widget is shown. Claude Code has no such widget. */
|
|
165
|
+
widget?: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function envNumber(name: string, fallback: number): number {
|
|
169
|
+
const raw = process.env[name];
|
|
170
|
+
if (raw === undefined) return fallback;
|
|
171
|
+
const value = Number(raw);
|
|
172
|
+
return Number.isFinite(value) ? value : fallback;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function maxAgeMs(): number {
|
|
176
|
+
const days = envNumber("PI_LOOP_MAX_AGE_DAYS", DEFAULT_MAX_AGE_MS / 86_400_000);
|
|
177
|
+
return Math.max(0, days) * 86_400_000;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function jitterFraction(): number {
|
|
181
|
+
return Math.min(1, Math.max(0, envNumber("PI_LOOP_JITTER", 0)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fallback delay used when the model forgot to reschedule a self-paced loop.
|
|
186
|
+
* Set to 0 to disable the fallback: the chain then ends as soon as a turn
|
|
187
|
+
* finishes without a reschedule.
|
|
188
|
+
*/
|
|
189
|
+
function keepaliveSeconds(): number {
|
|
190
|
+
return Math.max(0, envNumber("PI_LOOP_KEEPALIVE_SECONDS", DEFAULT_KEEPALIVE_SECONDS));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatDuration(ms: number): string {
|
|
194
|
+
if (!Number.isFinite(ms) || ms < 0) return "unknown";
|
|
195
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
196
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
197
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
198
|
+
const seconds = totalSeconds % 60;
|
|
199
|
+
if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
|
|
200
|
+
const hours = Math.floor(minutes / 60);
|
|
201
|
+
const restMinutes = minutes % 60;
|
|
202
|
+
if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
|
|
203
|
+
const days = Math.floor(hours / 24);
|
|
204
|
+
return `${days}d${hours % 24}h`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatClock(epochMs: number | undefined): string {
|
|
208
|
+
if (!epochMs) return "unknown";
|
|
209
|
+
return new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function truncate(text: string, max: number): string {
|
|
213
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
214
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A compact one-line call row for a tool. */
|
|
218
|
+
function toolCall(label: string, detail: string, theme: Theme): Text {
|
|
219
|
+
const title = theme.fg("toolTitle", theme.bold(label));
|
|
220
|
+
return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A compact one-line result row for a tool. */
|
|
224
|
+
function toolResult(
|
|
225
|
+
summary: string,
|
|
226
|
+
detail: string | undefined,
|
|
227
|
+
options: { expanded: boolean; isPartial: boolean; isError: boolean },
|
|
228
|
+
theme: Theme,
|
|
229
|
+
): Text {
|
|
230
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
|
|
231
|
+
const prefix = options.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
|
|
232
|
+
const body = options.isError ? theme.fg("error", summary) : theme.fg("toolOutput", summary);
|
|
233
|
+
const text = prefix + body;
|
|
234
|
+
if (options.expanded && detail) return new Text(`${text}\n${theme.fg("dim", detail)}`, 0, 0);
|
|
235
|
+
return new Text(text, 0, 0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Hide the call row once the result has arrived, leaving one result line. */
|
|
239
|
+
function completedCall(): Container {
|
|
240
|
+
return new Container();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The first text line of a tool result, used as a fallback summary. */
|
|
244
|
+
function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
|
|
245
|
+
for (const block of result.content ?? []) {
|
|
246
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
247
|
+
return block.text.split("\n")[0] ?? "";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatDelay(seconds: number | undefined): string {
|
|
254
|
+
if (seconds === undefined) return "unknown";
|
|
255
|
+
if (seconds < 60) return `${seconds}s`;
|
|
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;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The live scheduler, for tests. */
|
|
282
|
+
let activeScheduler: LoopScheduler | null = null;
|
|
283
|
+
|
|
284
|
+
export function getScheduler(): LoopScheduler | null {
|
|
285
|
+
return activeScheduler;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Pull the `--durable` and `--max N` flags out of the command line.
|
|
290
|
+
* Flags may appear anywhere; the remaining tokens are the prompt.
|
|
291
|
+
*/
|
|
292
|
+
function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
|
|
293
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
294
|
+
const kept: string[] = [];
|
|
295
|
+
let durable = false;
|
|
296
|
+
let maxFires: number | undefined;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
299
|
+
const token = tokens[i];
|
|
300
|
+
if (token.toLowerCase() === "--durable") {
|
|
301
|
+
durable = true;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const inline = /^--max=(\d+)$/i.exec(token);
|
|
305
|
+
if (inline) {
|
|
306
|
+
maxFires = Math.max(1, Number(inline[1]));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (/^--max$/i.test(token)) {
|
|
310
|
+
const next = tokens[i + 1];
|
|
311
|
+
if (next && /^\d+$/.test(next)) {
|
|
312
|
+
maxFires = Math.max(1, Number(next));
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
kept.push(token);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { body: kept.join(" "), durable, maxFires };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
324
|
+
const scheduler = new LoopScheduler({
|
|
325
|
+
dispatch: (job) => {
|
|
326
|
+
deliverTick(job);
|
|
327
|
+
},
|
|
328
|
+
onChange: (jobs) => {
|
|
329
|
+
persist(jobs);
|
|
330
|
+
refreshWidget(jobs);
|
|
331
|
+
},
|
|
332
|
+
maxAgeMs,
|
|
333
|
+
jitterFraction,
|
|
334
|
+
jitterCapMs: () => 30 * 60 * 1000,
|
|
335
|
+
canDispatch: () => {
|
|
336
|
+
const ctx = lastCtx;
|
|
337
|
+
if (!ctx) return false;
|
|
338
|
+
try {
|
|
339
|
+
return ctx.isIdle() && !ctx.hasPendingMessages();
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
onExpire: (job) => {
|
|
345
|
+
if (job.kind === "wakeup") {
|
|
346
|
+
// The job is already gone from the store; retire it so the chain
|
|
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
|
+
}
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
activeScheduler = scheduler;
|
|
358
|
+
|
|
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
|
+
// ---------------------------------------------------------------- helpers
|
|
390
|
+
|
|
391
|
+
function persist(jobs: LoopJob[]): void {
|
|
392
|
+
const signature = JSON.stringify(
|
|
393
|
+
jobs.map((job) => [
|
|
394
|
+
job.id,
|
|
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
|
+
);
|
|
409
|
+
if (signature === lastPersistedSignature) return;
|
|
410
|
+
lastPersistedSignature = signature;
|
|
411
|
+
|
|
412
|
+
const durable = jobs.filter((job) => job.durable);
|
|
413
|
+
try {
|
|
414
|
+
pi.appendEntry(STATE_ENTRY, {
|
|
415
|
+
version: 1,
|
|
416
|
+
jobs,
|
|
417
|
+
stopped: [...stoppedPrompts],
|
|
418
|
+
widget: widgetEnabled,
|
|
419
|
+
} satisfies PersistedState);
|
|
420
|
+
} catch {
|
|
421
|
+
// session persistence is best effort
|
|
422
|
+
}
|
|
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
|
+
}
|
|
439
|
+
|
|
440
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
441
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function refreshWidget(jobs: LoopJob[]): void {
|
|
445
|
+
const ctx = lastCtx;
|
|
446
|
+
if (!ctx || !ctx.hasUI) return;
|
|
447
|
+
if (!widgetEnabled) {
|
|
448
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const lines: string[] = [];
|
|
452
|
+
for (const job of jobs) {
|
|
453
|
+
if (job.kind === "cron") {
|
|
454
|
+
const at = job.nextFireAt;
|
|
455
|
+
const inText = at ? `in ${formatDuration(at - Date.now())}` : "pending";
|
|
456
|
+
lines.push(
|
|
457
|
+
ctx.ui.theme.fg(
|
|
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
|
+
}
|
|
470
|
+
ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function findChain(prompt: string): LoopJob | undefined {
|
|
474
|
+
return scheduler.findChain(prompt);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* End a chain for good.
|
|
479
|
+
*
|
|
480
|
+
* Retiring means: the job is removed, the prompt is remembered so a late
|
|
481
|
+
* `schedule_wakeup` cannot resurrect the chain, and pi-cc-monitor is told to
|
|
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
|
|
485
|
+
* reschedules, and the loop the user just stopped comes back.
|
|
486
|
+
*/
|
|
487
|
+
function retireChain(chain: LoopJob, reason: string): void {
|
|
488
|
+
stoppedPrompts.add(chain.prompt);
|
|
489
|
+
scheduler.remove(chain.id);
|
|
490
|
+
try {
|
|
491
|
+
pi.events.emit(LOOP_STOPPED_CHANNEL, { prompt: chain.prompt, reason });
|
|
492
|
+
} catch {
|
|
493
|
+
// a listener must never break the loop
|
|
494
|
+
}
|
|
495
|
+
const ctx = lastCtx;
|
|
496
|
+
if (ctx) notify(ctx, `loop ${chain.id} ended: ${reason}`, "info");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function endChain(chain: LoopJob, reason: string): void {
|
|
500
|
+
retireChain(chain, reason);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function expireOldJobs(ctx: ExtensionContext): void {
|
|
504
|
+
const limit = maxAgeMs();
|
|
505
|
+
if (limit <= 0) return;
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
for (const job of scheduler.list()) {
|
|
508
|
+
if (now - job.createdAt < limit) continue;
|
|
509
|
+
if (job.kind === "wakeup") {
|
|
510
|
+
retireChain(job, "expired");
|
|
511
|
+
} else {
|
|
512
|
+
scheduler.remove(job.id);
|
|
513
|
+
notify(ctx, `loop ${job.id} expired after ${Math.round(limit / 86_400_000)} days`, "info");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Deliver a scheduled tick.
|
|
520
|
+
*
|
|
521
|
+
* `followUp` is the low-priority lane: when the agent is busy the message
|
|
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."
|
|
525
|
+
*/
|
|
526
|
+
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
|
+
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
530
|
+
tickCounters.set(job.prompt, tick);
|
|
531
|
+
scheduler.update(job.id, { tickCount: tick, lastTickAt: Date.now() });
|
|
532
|
+
const content = `${PROVENANCE.replace("{n}", String(tick))}\n${job.prompt}`;
|
|
533
|
+
// A self-paced tick needs the chain guidelines too, so the model knows
|
|
534
|
+
// it has to schedule the next iteration.
|
|
535
|
+
runChainId = job.kind === "wakeup" ? job.id : undefined;
|
|
536
|
+
pi.sendMessage(
|
|
537
|
+
{
|
|
538
|
+
customType: TICK_MESSAGE_TYPE,
|
|
539
|
+
content,
|
|
540
|
+
display: true,
|
|
541
|
+
details: { jobId: job.id, tickCount: tick, cron: job.cron, scheduledAt: Date.now() },
|
|
542
|
+
},
|
|
543
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const cap = job.maxFires;
|
|
547
|
+
if (cap !== undefined && (job.fireCount ?? 0) >= cap) {
|
|
548
|
+
const ctx = lastCtx;
|
|
549
|
+
if (ctx) notify(ctx, `loop ${job.id} reached its cap of ${cap} fires`, "info");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Run the prompt right away, the way Claude Code's skill does
|
|
555
|
+
* ("Then immediately execute the parsed prompt now").
|
|
556
|
+
*
|
|
557
|
+
* `steer` is the right lane here: it lands in the current turn when the
|
|
558
|
+
* agent is already working, which is what executing the prompt inside the
|
|
559
|
+
* skill turn means.
|
|
560
|
+
*/
|
|
561
|
+
/**
|
|
562
|
+
* How many times each prompt has been run as a loop tick.
|
|
563
|
+
*
|
|
564
|
+
* The immediate run of `/loop` and the first fire of the job the model
|
|
565
|
+
* creates afterwards belong to the same loop, so they share one counter.
|
|
566
|
+
*/
|
|
567
|
+
const tickCounters = new Map<string, number>();
|
|
568
|
+
|
|
569
|
+
function runNow(ctx: ExtensionContext, prompt: string, job?: LoopJob): void {
|
|
570
|
+
const tick = (tickCounters.get(prompt) ?? 0) + 1;
|
|
571
|
+
tickCounters.set(prompt, tick);
|
|
572
|
+
if (job) {
|
|
573
|
+
scheduler.update(job.id, {
|
|
574
|
+
tickCount: tick,
|
|
575
|
+
fireCount: (job.fireCount ?? 0) + 1,
|
|
576
|
+
lastTickAt: Date.now(),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
runChainId = job?.kind === "wakeup" ? job.id : undefined;
|
|
580
|
+
pi.sendMessage(
|
|
581
|
+
{
|
|
582
|
+
customType: TICK_MESSAGE_TYPE,
|
|
583
|
+
content: `${PROVENANCE.replace("{n}", String(tick))}\n${prompt}`,
|
|
584
|
+
display: true,
|
|
585
|
+
details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now() },
|
|
586
|
+
},
|
|
587
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function scheduleNote(text: string): void {
|
|
592
|
+
pendingNote = text;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** True when the pi-cc-monitor extension registered its Monitor tool. */
|
|
596
|
+
function monitorToolAvailable(): boolean {
|
|
597
|
+
try {
|
|
598
|
+
return pi.getAllTools().some((tool) => tool.name === "Monitor");
|
|
599
|
+
} catch {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The tool names to exclude from pi-cc-extensions' global styling. */
|
|
605
|
+
function piCcToolNames(): string[] {
|
|
606
|
+
return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------------- command
|
|
610
|
+
|
|
611
|
+
pi.registerCommand("loop", {
|
|
612
|
+
description:
|
|
613
|
+
"Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop stop [id] | /loop status | /loop settings",
|
|
614
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
615
|
+
lastCtx = ctx;
|
|
616
|
+
const raw = args.trim();
|
|
617
|
+
|
|
618
|
+
if (!raw) {
|
|
619
|
+
await startLoop(ctx, null, "", false, undefined);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const [head, ...rest] = raw.split(/\s+/);
|
|
624
|
+
switch (head.toLowerCase()) {
|
|
625
|
+
case "stop":
|
|
626
|
+
await stopLoops(ctx, rest.join(" "));
|
|
627
|
+
return;
|
|
628
|
+
case "status":
|
|
629
|
+
case "list":
|
|
630
|
+
case "jobs":
|
|
631
|
+
showStatus(ctx);
|
|
632
|
+
return;
|
|
633
|
+
case "widget": {
|
|
634
|
+
const arg = (rest[0] ?? "").toLowerCase();
|
|
635
|
+
if (arg !== "on" && arg !== "off") {
|
|
636
|
+
notify(ctx, `Usage: /loop widget on|off (currently ${widgetEnabled ? "on" : "off"})`, "info");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
widgetEnabled = arg === "on";
|
|
640
|
+
persist(scheduler.list());
|
|
641
|
+
refreshWidget(scheduler.list());
|
|
642
|
+
notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
case "settings":
|
|
646
|
+
case "config": {
|
|
647
|
+
const [key, value] = rest;
|
|
648
|
+
if (!key) {
|
|
649
|
+
showSettings(ctx);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const name = key.toLowerCase();
|
|
653
|
+
if (name === "messages" || name === "message") {
|
|
654
|
+
setSetting(ctx, "messages", value);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (name === "tools" || name === "tool" || name === "rows") {
|
|
658
|
+
setSetting(ctx, "tools", value);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
notify(ctx, "Usage: /loop settings [messages|tools] [on|off]", "info");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
case "help":
|
|
665
|
+
case "--help":
|
|
666
|
+
case "-h":
|
|
667
|
+
showHelp(ctx);
|
|
668
|
+
return;
|
|
669
|
+
default:
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Flags are stripped before parsing so they never interfere with the
|
|
674
|
+
// interval token.
|
|
675
|
+
const flags = extractFlags(raw);
|
|
676
|
+
const body = flags.body;
|
|
677
|
+
|
|
678
|
+
let intervalSeconds: number | null = null;
|
|
679
|
+
let prompt = body;
|
|
680
|
+
|
|
681
|
+
// Rule 1: leading interval token, e.g. "5m check the deploy"
|
|
682
|
+
const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
|
|
683
|
+
if (leading) {
|
|
684
|
+
const parsed = parseIntervalToken(leading[1]);
|
|
685
|
+
if (parsed !== null) {
|
|
686
|
+
intervalSeconds = parsed;
|
|
687
|
+
prompt = leading[2].trim();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Rule 2: trailing "every N units", e.g. "check the deploy every 20m"
|
|
692
|
+
if (intervalSeconds === null) {
|
|
693
|
+
const every = parseEveryClause(body);
|
|
694
|
+
if (every) {
|
|
695
|
+
intervalSeconds = every.interval;
|
|
696
|
+
prompt = every.prompt;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Rule 3: no interval -> self-paced
|
|
701
|
+
await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
async function startLoop(
|
|
706
|
+
ctx: ExtensionCommandContext,
|
|
707
|
+
intervalSeconds: number | null,
|
|
708
|
+
prompt: string,
|
|
709
|
+
durable: boolean,
|
|
710
|
+
maxFires: number | undefined,
|
|
711
|
+
): Promise<void> {
|
|
712
|
+
expireOldJobs(ctx);
|
|
713
|
+
|
|
714
|
+
// Starting a chain again clears the stop marker: the user asked for it.
|
|
715
|
+
stoppedPrompts.delete(prompt);
|
|
716
|
+
|
|
717
|
+
let finalPrompt = prompt;
|
|
718
|
+
|
|
719
|
+
if (!finalPrompt) {
|
|
720
|
+
const loopFile = resolveLoopFile(ctx.cwd);
|
|
721
|
+
if (loopFile) {
|
|
722
|
+
finalPrompt = `Work the tasks from the loop file at ${loopFile.path}:\n\n${loopFile.content}`;
|
|
723
|
+
} else {
|
|
724
|
+
finalPrompt = MAINTENANCE_PROMPT;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (intervalSeconds === null) {
|
|
729
|
+
// The interval could not be parsed from the command line. Claude Code's
|
|
730
|
+
// /loop is a skill, so the model reads the input and works the schedule
|
|
731
|
+
// out itself; the rules below let it do the same here. No chain is
|
|
732
|
+
// created up front: the model picks cron_create (fixed interval) or
|
|
733
|
+
// schedule_wakeup (open ended), and schedule_wakeup creates the chain.
|
|
734
|
+
let rules = LOOP_SCHEDULE_RULES.replace("{prompt}", finalPrompt).replace(
|
|
735
|
+
"{quoted}",
|
|
736
|
+
`"${finalPrompt}"`,
|
|
737
|
+
);
|
|
738
|
+
if (durable) {
|
|
739
|
+
rules +=
|
|
740
|
+
"\n- The user asked for the job to survive a restart: create it with `durable: true`.";
|
|
741
|
+
}
|
|
742
|
+
if (maxFires !== undefined) {
|
|
743
|
+
rules +=
|
|
744
|
+
`\n- The user asked for the job to stop after ${maxFires} fires: create it with \`maxFires: ${maxFires}\`.`;
|
|
745
|
+
}
|
|
746
|
+
pendingScheduleRules = rules;
|
|
747
|
+
runNow(ctx, finalPrompt);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const spec = intervalToCron(intervalSeconds);
|
|
752
|
+
if (!spec) {
|
|
753
|
+
notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (!parseCron(spec.cron)) {
|
|
757
|
+
notify(ctx, `Could not build a cron expression for ${intervalSeconds}s`, "error");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (scheduler.size >= MAX_JOBS) {
|
|
761
|
+
notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const job = scheduler.add({
|
|
766
|
+
id: generateId(),
|
|
767
|
+
kind: "cron",
|
|
768
|
+
cron: spec.cron,
|
|
769
|
+
prompt: finalPrompt,
|
|
770
|
+
recurring: true,
|
|
771
|
+
createdAt: Date.now(),
|
|
772
|
+
durable,
|
|
773
|
+
maxFires,
|
|
774
|
+
label: humanSchedule(spec.cron),
|
|
775
|
+
source: "loop_command",
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const expiryDays = Math.round(maxAgeMs() / 86_400_000);
|
|
779
|
+
notify(
|
|
780
|
+
ctx,
|
|
781
|
+
`Scheduled ${job.id} (${job.label}, next run ${formatClock(job.nextFireAt)}) · ${spec.cron} · expires in ${expiryDays}d · cancel with /loop stop ${job.id}` +
|
|
782
|
+
(spec.note ? ` · ${spec.note}` : ""),
|
|
783
|
+
"info",
|
|
784
|
+
);
|
|
785
|
+
scheduleNote(
|
|
786
|
+
`A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
|
|
787
|
+
`next run ${formatClock(job.nextFireAt)}, auto-expires after ${expiryDays} days. ` +
|
|
788
|
+
`The user cancels it with \`/loop stop ${job.id}\`.` +
|
|
789
|
+
(spec.note ? ` The requested interval was adjusted: ${spec.note}.` : ""),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
runNow(ctx, finalPrompt, job);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
|
|
796
|
+
if (target) {
|
|
797
|
+
const job = scheduler.get(target);
|
|
798
|
+
if (!job) {
|
|
799
|
+
notify(ctx, `No loop with id ${target}`, "warning");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (job.kind === "wakeup") {
|
|
803
|
+
retireChain(job, "stopped by the user");
|
|
804
|
+
} else {
|
|
805
|
+
scheduler.remove(target);
|
|
806
|
+
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const jobs = scheduler.list();
|
|
811
|
+
if (!jobs.length) {
|
|
812
|
+
notify(ctx, "No active loops", "info");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
for (const job of jobs) {
|
|
816
|
+
if (job.kind === "wakeup") {
|
|
817
|
+
// Retiring also tells pi-cc-monitor to stop the monitors that
|
|
818
|
+
// belong to this chain.
|
|
819
|
+
retireChain(job, "stopped by the user");
|
|
820
|
+
} else {
|
|
821
|
+
scheduler.remove(job.id);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function showStatus(ctx: ExtensionContext): void {
|
|
828
|
+
const jobs = scheduler.list();
|
|
829
|
+
if (!jobs.length) {
|
|
830
|
+
notify(ctx, "No active loops", "info");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const lines = jobs.map((job) => {
|
|
834
|
+
if (job.kind === "cron") {
|
|
835
|
+
const cap = job.maxFires !== undefined ? ` · ${job.fireCount ?? 0}/${job.maxFires} fires` : "";
|
|
836
|
+
return `${job.id} cron ${job.cron} (${job.label ?? describeCron(job.cron ?? "")}) next ${formatClock(job.nextFireAt)}${cap} ${truncate(job.prompt, 60)}`;
|
|
837
|
+
}
|
|
838
|
+
const at = job.scheduledFor;
|
|
839
|
+
const inText = at && at > Date.now() ? `next in ${formatDuration(at - Date.now())}` : "awaiting reschedule";
|
|
840
|
+
return `${job.id} self-paced ${inText} ${truncate(job.prompt, 60)}`;
|
|
841
|
+
});
|
|
842
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function showHelp(ctx: ExtensionContext): void {
|
|
846
|
+
const help = [
|
|
847
|
+
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
848
|
+
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
849
|
+
"/loop use .pi/loop.md, or the built-in maintenance prompt",
|
|
850
|
+
"/loop stop [id] cancel one loop, or all of them",
|
|
851
|
+
"/loop status list the active loops",
|
|
852
|
+
"/loop widget on|off show or hide the status line above the editor",
|
|
853
|
+
"/loop settings show UI settings",
|
|
854
|
+
"/loop settings messages on|off compact two-line markers",
|
|
855
|
+
"/loop settings tools on|off one-line tool results",
|
|
856
|
+
"",
|
|
857
|
+
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
858
|
+
" --max N stop the loop after N fires.",
|
|
859
|
+
].join("\n");
|
|
860
|
+
if (ctx.hasUI) ctx.ui.notify(help, "info");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function showSettings(ctx: ExtensionContext): void {
|
|
864
|
+
const config = loadConfig();
|
|
865
|
+
const lines = [
|
|
866
|
+
`pi-cc settings · ${configPath()}`,
|
|
867
|
+
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
868
|
+
` tool rows: ${config.compactToolRows ? "compact" : "default"}`,
|
|
869
|
+
"",
|
|
870
|
+
"Usage: /loop settings messages on|off",
|
|
871
|
+
" /loop settings tools on|off",
|
|
872
|
+
];
|
|
873
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function setSetting(ctx: ExtensionContext, key: "messages" | "tools", raw: string | undefined): void {
|
|
877
|
+
const value = (raw ?? "").toLowerCase();
|
|
878
|
+
const on = value === "on" || value === "true" || value === "1";
|
|
879
|
+
const off = value === "off" || value === "false" || value === "0";
|
|
880
|
+
if (!on && !off) {
|
|
881
|
+
showSettings(ctx);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const patch: Partial<PiCcConfig> =
|
|
885
|
+
key === "messages" ? { compactMessages: on } : { compactToolRows: on };
|
|
886
|
+
const next = saveConfig(patch);
|
|
887
|
+
let compatChanged = false;
|
|
888
|
+
if (key === "tools") {
|
|
889
|
+
compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
pi.events.emit(CONFIG_CHANNEL, next);
|
|
893
|
+
} catch {
|
|
894
|
+
// another extension must not break the command
|
|
895
|
+
}
|
|
896
|
+
notify(ctx, `${key === "messages" ? "Compact message style" : "Compact tool rows"} ${on ? "enabled" : "disabled"}`, "info");
|
|
897
|
+
if (compatChanged) {
|
|
898
|
+
notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ------------------------------------------------------------------ tools
|
|
903
|
+
|
|
904
|
+
function registerTools(): void {
|
|
905
|
+
|
|
906
|
+
pi.registerTool({
|
|
907
|
+
name: "cron_create",
|
|
908
|
+
label: "Create Scheduled Job",
|
|
909
|
+
description:
|
|
910
|
+
"Schedule a prompt to run on a 5-field cron expression in local time. Use this for fixed-interval " +
|
|
911
|
+
"work such as polling a deployment or re-running a check. One-shot reminders should set " +
|
|
912
|
+
"recurring to false. The job id can be cancelled with cron_delete.",
|
|
913
|
+
parameters: Type.Object({
|
|
914
|
+
cron: Type.String({
|
|
915
|
+
description:
|
|
916
|
+
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes).',
|
|
917
|
+
}),
|
|
918
|
+
prompt: Type.String({ description: "The prompt to enqueue at each fire time." }),
|
|
919
|
+
recurring: Type.Optional(
|
|
920
|
+
Type.Boolean({
|
|
921
|
+
description:
|
|
922
|
+
"true (default) = fire on every cron match until deleted or auto-expired. false = fire once at the next match, then auto-delete.",
|
|
923
|
+
}),
|
|
924
|
+
),
|
|
925
|
+
durable: Type.Optional(
|
|
926
|
+
Type.Boolean({ description: "Write the job to .pi/scheduled_tasks.json so it survives a restart." }),
|
|
927
|
+
),
|
|
928
|
+
maxFires: Type.Optional(
|
|
929
|
+
Type.Number({
|
|
930
|
+
minimum: 1,
|
|
931
|
+
description: "Stop the job after this many fires. Claude Code has no such cap; it is an addition here.",
|
|
932
|
+
}),
|
|
933
|
+
),
|
|
934
|
+
}),
|
|
935
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
936
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
937
|
+
lastCtx = ctx;
|
|
938
|
+
const fields = parseCron(params.cron);
|
|
939
|
+
if (!fields) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
`Invalid cron expression '${params.cron}'. Expected 5 fields: M H DoM Mon DoW.`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (isUnreachable(params.cron)) {
|
|
945
|
+
throw new Error(`Cron expression '${params.cron}' does not match any date within the next year.`);
|
|
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
|
+
}
|
|
953
|
+
|
|
954
|
+
const job = scheduler.add({
|
|
955
|
+
id: generateId(),
|
|
956
|
+
kind: "cron",
|
|
957
|
+
cron: params.cron.trim(),
|
|
958
|
+
prompt: params.prompt,
|
|
959
|
+
recurring: params.recurring ?? true,
|
|
960
|
+
createdAt: Date.now(),
|
|
961
|
+
durable: params.durable ?? false,
|
|
962
|
+
maxFires: params.maxFires,
|
|
963
|
+
// Continue the tick numbering of the /loop run that led here.
|
|
964
|
+
tickCount: tickCounters.get(params.prompt) ?? 0,
|
|
965
|
+
label: humanSchedule(params.cron),
|
|
966
|
+
source: "cron_create",
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
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
|
+
},
|
|
986
|
+
};
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
pi.registerTool({
|
|
991
|
+
name: "cron_delete",
|
|
992
|
+
label: "Cancel Scheduled Job",
|
|
993
|
+
description: "Cancel a scheduled job by its id. Use it to stop a recurring /loop.",
|
|
994
|
+
parameters: Type.Object({
|
|
995
|
+
id: Type.String({ description: "The job id returned by cron_create." }),
|
|
996
|
+
}),
|
|
997
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
998
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
999
|
+
lastCtx = ctx;
|
|
1000
|
+
const job = scheduler.get(params.id);
|
|
1001
|
+
if (!job) {
|
|
1002
|
+
return {
|
|
1003
|
+
content: [{ type: "text" as const, text: `No scheduled job with id ${params.id}.` }],
|
|
1004
|
+
details: { cancelled: false, id: params.id },
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
scheduler.remove(params.id);
|
|
1008
|
+
return {
|
|
1009
|
+
content: [{ type: "text" as const, text: `Cancelled job ${params.id}.` }],
|
|
1010
|
+
details: { cancelled: true, id: params.id },
|
|
1011
|
+
};
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
pi.registerTool({
|
|
1016
|
+
name: "cron_list",
|
|
1017
|
+
label: "List Scheduled Jobs",
|
|
1018
|
+
description: "List the scheduled jobs of this session with their ids, cron expressions and next run times.",
|
|
1019
|
+
parameters: Type.Object({}),
|
|
1020
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1021
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1022
|
+
lastCtx = ctx;
|
|
1023
|
+
const jobs = scheduler.list();
|
|
1024
|
+
if (!jobs.length) {
|
|
1025
|
+
return {
|
|
1026
|
+
content: [{ type: "text" as const, text: "No scheduled jobs." }],
|
|
1027
|
+
details: { jobs: [] },
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
const text = jobs
|
|
1031
|
+
.map((job) =>
|
|
1032
|
+
job.kind === "cron"
|
|
1033
|
+
? `${job.id} cron ${job.cron} recurring=${job.recurring} next ${formatClock(job.nextFireAt)} ${truncate(job.prompt, 80)}`
|
|
1034
|
+
: `${job.id} self-paced next ${job.scheduledFor ? formatClock(job.scheduledFor) : "unscheduled"} ${truncate(job.prompt, 80)}`,
|
|
1035
|
+
)
|
|
1036
|
+
.join("\n");
|
|
1037
|
+
return {
|
|
1038
|
+
content: [{ type: "text" as const, text }],
|
|
1039
|
+
details: { jobs },
|
|
1040
|
+
};
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
pi.registerTool({
|
|
1045
|
+
name: "schedule_wakeup",
|
|
1046
|
+
label: "Schedule Next Loop Iteration",
|
|
1047
|
+
description:
|
|
1048
|
+
"Schedule the next iteration of a self-paced /loop, or end the loop. " +
|
|
1049
|
+
"Pass delaySeconds (60-3600), a one-sentence reason, and the same /loop prompt verbatim. " +
|
|
1050
|
+
"Pass stop: true when the task is complete or can no longer make progress.",
|
|
1051
|
+
parameters: Type.Object({
|
|
1052
|
+
delaySeconds: Type.Optional(
|
|
1053
|
+
Type.Number({
|
|
1054
|
+
description:
|
|
1055
|
+
"Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless stop is true.",
|
|
1056
|
+
}),
|
|
1057
|
+
),
|
|
1058
|
+
reason: Type.Optional(
|
|
1059
|
+
Type.String({ description: "One short sentence explaining the chosen delay. Shown to the user." }),
|
|
1060
|
+
),
|
|
1061
|
+
prompt: Type.Optional(
|
|
1062
|
+
Type.String({
|
|
1063
|
+
description:
|
|
1064
|
+
"The /loop input to fire on wake-up. Pass the same /loop input verbatim each turn so the next firing continues the loop.",
|
|
1065
|
+
}),
|
|
1066
|
+
),
|
|
1067
|
+
stop: Type.Optional(
|
|
1068
|
+
Type.Boolean({
|
|
1069
|
+
description: "Set to true to end the self-paced loop immediately. All other fields are ignored.",
|
|
1070
|
+
}),
|
|
1071
|
+
),
|
|
1072
|
+
}),
|
|
1073
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1074
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1075
|
+
lastCtx = ctx;
|
|
1076
|
+
|
|
1077
|
+
if (params.stop === true) {
|
|
1078
|
+
const chains = scheduler.byKind("wakeup");
|
|
1079
|
+
for (const chain of chains) {
|
|
1080
|
+
// Retiring also stops the monitors that belong to this chain.
|
|
1081
|
+
retireChain(chain, "stopped by the agent");
|
|
1082
|
+
}
|
|
1083
|
+
return {
|
|
1084
|
+
content: [
|
|
1085
|
+
{
|
|
1086
|
+
type: "text" as const,
|
|
1087
|
+
text: chains.length
|
|
1088
|
+
? `Loop ended. ${chains.length} pending wakeup(s) cancelled.`
|
|
1089
|
+
: "Loop ended. Nothing was pending.",
|
|
1090
|
+
},
|
|
1091
|
+
],
|
|
1092
|
+
details: { stopped: true, cancelledWakeups: chains.length },
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (typeof params.delaySeconds !== "number" || !Number.isFinite(params.delaySeconds)) {
|
|
1097
|
+
throw new Error("delaySeconds is required unless stop is true.");
|
|
1098
|
+
}
|
|
1099
|
+
const prompt = params.prompt?.trim();
|
|
1100
|
+
if (!prompt) {
|
|
1101
|
+
throw new Error("prompt is required unless stop is true.");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const requested = params.delaySeconds;
|
|
1105
|
+
const clamped = Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, requested));
|
|
1106
|
+
const wasClamped = clamped !== requested;
|
|
1107
|
+
|
|
1108
|
+
let chain = findChain(prompt);
|
|
1109
|
+
if (!chain) {
|
|
1110
|
+
if (stoppedPrompts.has(prompt)) {
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`The /loop chain for "${prompt}" was stopped and must not be rescheduled. ` +
|
|
1113
|
+
"Do not call schedule_wakeup for it again. If the work should continue, ask the user to start a new /loop.",
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
chain = scheduler.add({
|
|
1117
|
+
id: generateId(),
|
|
1118
|
+
kind: "wakeup",
|
|
1119
|
+
prompt,
|
|
1120
|
+
recurring: false,
|
|
1121
|
+
createdAt: Date.now(),
|
|
1122
|
+
chainStartedAt: Date.now(),
|
|
1123
|
+
durable: false,
|
|
1124
|
+
source: "schedule_wakeup",
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const scheduledFor = Date.now() + clamped * 1000;
|
|
1129
|
+
scheduler.update(chain.id, {
|
|
1130
|
+
scheduledFor,
|
|
1131
|
+
keepaliveUsed: false,
|
|
1132
|
+
keepaliveTurn: false,
|
|
1133
|
+
awaitingReschedule: false,
|
|
1134
|
+
reason: params.reason,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
return {
|
|
1138
|
+
content: [
|
|
1139
|
+
{
|
|
1140
|
+
type: "text" as const,
|
|
1141
|
+
text:
|
|
1142
|
+
`Next iteration scheduled in ${clamped}s` +
|
|
1143
|
+
(wasClamped ? ` (clamped from ${requested}s)` : "") +
|
|
1144
|
+
` for loop ${chain.id}.`,
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
details: {
|
|
1148
|
+
id: chain.id,
|
|
1149
|
+
scheduledFor,
|
|
1150
|
+
clampedDelaySeconds: clamped,
|
|
1151
|
+
wasClamped,
|
|
1152
|
+
stopped: false,
|
|
1153
|
+
},
|
|
1154
|
+
};
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
registerTools();
|
|
1160
|
+
|
|
1161
|
+
// ---------------------------------------------------------------- rendering
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Compact one-line tool rows.
|
|
1165
|
+
*
|
|
1166
|
+
* The resolver is consulted every time a tool row renders, so setting
|
|
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
|
+
|
|
1307
|
+
pi.registerToolRenderer((toolName, next) => {
|
|
1308
|
+
if (!loadConfig().compactToolRows) return next();
|
|
1309
|
+
return renderers[toolName] ?? next();
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
registerToolRenderers();
|
|
1314
|
+
|
|
1315
|
+
// ----------------------------------------------------------------- events
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Draw the tick as a quiet two-line block instead of Pi's default
|
|
1319
|
+
* `[pi-cc-loop-tick]` title.
|
|
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.
|
|
1367
|
+
*
|
|
1368
|
+
* The handler is deliberately synchronous: pi-cc-monitor emits the bus
|
|
1369
|
+
* event before it sends the notification, so this must land before the
|
|
1370
|
+
* woken turn starts. Otherwise the turn would end, find no reschedule, and
|
|
1371
|
+
* arm a keepalive on top of the event that just arrived.
|
|
1372
|
+
*/
|
|
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
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1382
|
+
lastCtx = ctx;
|
|
1383
|
+
const notes: string[] = [];
|
|
1384
|
+
|
|
1385
|
+
if (pendingNote) {
|
|
1386
|
+
notes.push(pendingNote);
|
|
1387
|
+
pendingNote = undefined;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const chainId = runChainId;
|
|
1391
|
+
runChainId = undefined;
|
|
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
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (pendingScheduleRules) {
|
|
1405
|
+
notes.push(pendingScheduleRules);
|
|
1406
|
+
pendingScheduleRules = undefined;
|
|
1407
|
+
if (monitorToolAvailable()) {
|
|
1408
|
+
notes.push(MONITOR_GUIDELINES.replace("{quoted}", `"the /loop prompt"`));
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (!notes.length) return;
|
|
1413
|
+
const options = event.systemPromptOptions;
|
|
1414
|
+
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
1418
|
+
lastCtx = ctx;
|
|
1419
|
+
scheduler.noteTurnStarted();
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1423
|
+
lastCtx = ctx;
|
|
1424
|
+
expireOldJobs(ctx);
|
|
1425
|
+
|
|
1426
|
+
const now = Date.now();
|
|
1427
|
+
for (const chain of scheduler.byKind("wakeup")) {
|
|
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
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
1468
|
+
lastCtx = ctx;
|
|
1469
|
+
// The agent is idle now: a pending tick may go out.
|
|
1470
|
+
scheduler.notifyIdle();
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1474
|
+
lastCtx = ctx;
|
|
1475
|
+
restoreState(ctx);
|
|
1476
|
+
expireOldJobs(ctx);
|
|
1477
|
+
scheduler.start();
|
|
1478
|
+
if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
|
|
1479
|
+
notify(ctx, "pi-cc-extensions excludeRenderers updated · /reload to apply", "info");
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
pi.on("session_before_switch", async () => {
|
|
1484
|
+
for (const chain of scheduler.byKind("wakeup")) {
|
|
1485
|
+
retireChain(chain, "the session was switched");
|
|
1486
|
+
}
|
|
1487
|
+
scheduler.clear(false);
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
pi.on("session_shutdown", async () => {
|
|
1491
|
+
scheduler.stop();
|
|
1492
|
+
scheduler.clear(false);
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// -------------------------------------------------------------- restoring
|
|
1496
|
+
|
|
1497
|
+
function restoreState(ctx: ExtensionContext): void {
|
|
1498
|
+
scheduler.clear(false);
|
|
1499
|
+
|
|
1500
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1501
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1502
|
+
const entry = entries[i] as { type?: string; customType?: string; data?: PersistedState };
|
|
1503
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.jobs) {
|
|
1504
|
+
for (const prompt of entry.data.stopped ?? []) stoppedPrompts.add(prompt);
|
|
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
|
+
}
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Durable jobs live in .pi/scheduled_tasks.json and are restored even
|
|
1521
|
+
// across a restart.
|
|
1522
|
+
const file = path.join(ctx.cwd, ".pi", "scheduled_tasks.json");
|
|
1523
|
+
try {
|
|
1524
|
+
if (fs.existsSync(file)) {
|
|
1525
|
+
const payload = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedState;
|
|
1526
|
+
for (const job of payload.jobs ?? []) {
|
|
1527
|
+
if (scheduler.get(job.id)) continue;
|
|
1528
|
+
if (job.kind === "wakeup") {
|
|
1529
|
+
scheduler.restore({ ...job, scheduledFor: undefined, keepaliveUsed: true });
|
|
1530
|
+
} else {
|
|
1531
|
+
scheduler.restore(job);
|
|
1532
|
+
scheduler.arm(job);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
} catch {
|
|
1537
|
+
// ignore a malformed file
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const pending = scheduler.byKind("wakeup");
|
|
1541
|
+
if (pending.length) {
|
|
1542
|
+
notify(
|
|
1543
|
+
ctx,
|
|
1544
|
+
`${pending.length} self-paced loop(s) were not restored: their wakeup did not survive the restart. Re-run /loop to restart them.`,
|
|
1545
|
+
"warning",
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
refreshWidget(scheduler.list());
|
|
1549
|
+
}
|
|
1550
|
+
}
|