pi-cc-loop 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -73
- package/config.ts +15 -86
- package/cron.ts +1 -122
- package/index.ts +273 -388
- package/loopfile.ts +4 -8
- package/package.json +1 -1
- package/prompts.ts +84 -27
- package/render.ts +21 -1
- package/renderers.ts +60 -107
- package/schedule.ts +16 -2
- package/tool-rows.ts +0 -79
package/index.ts
CHANGED
|
@@ -29,15 +29,11 @@
|
|
|
29
29
|
|
|
30
30
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { Type } from "typebox";
|
|
32
|
-
import { configPath, loadConfig, saveConfig,
|
|
32
|
+
import { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
|
|
33
33
|
import {
|
|
34
|
-
describeCron,
|
|
35
34
|
humanSchedule,
|
|
36
|
-
intervalToCron,
|
|
37
35
|
isUnreachable,
|
|
38
36
|
parseCron,
|
|
39
|
-
parseEveryClause,
|
|
40
|
-
parseIntervalToken,
|
|
41
37
|
} from "./cron.ts";
|
|
42
38
|
import { DurableRuntime, type Placement } from "./durable.ts";
|
|
43
39
|
import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
|
|
@@ -53,11 +49,14 @@ import {
|
|
|
53
49
|
missedJobsMessage,
|
|
54
50
|
monitorGuidelines,
|
|
55
51
|
provenance,
|
|
52
|
+
RULES_PROVENANCE,
|
|
53
|
+
RUN_NOW_HINT,
|
|
56
54
|
SCHEDULE_WAKEUP_DESCRIPTION,
|
|
55
|
+
scheduledTaskProvenance,
|
|
57
56
|
SCHEDULE_WAKEUP_SNIPPET,
|
|
58
|
-
|
|
57
|
+
loopCommandGuidelines,
|
|
59
58
|
} from "./prompts.ts";
|
|
60
|
-
import { formatClock,
|
|
59
|
+
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
61
60
|
import {
|
|
62
61
|
DEFAULT_TIMING,
|
|
63
62
|
generateId,
|
|
@@ -67,16 +66,14 @@ import {
|
|
|
67
66
|
type LoopJob,
|
|
68
67
|
type TimingConfig,
|
|
69
68
|
} from "./schedule.ts";
|
|
70
|
-
import { truncate } from "./
|
|
69
|
+
import { truncate } from "./render.ts";
|
|
71
70
|
|
|
72
71
|
const STATE_ENTRY = "pi-cc-loop-state";
|
|
73
|
-
export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
74
72
|
/** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
|
|
75
|
-
const MONITOR_EVENT_TYPE = "
|
|
76
|
-
|
|
73
|
+
const MONITOR_EVENT_TYPE = "Monitor";
|
|
74
|
+
/** Footer status key, shared with every other extension's status line. */
|
|
75
|
+
const STATUS_KEY = "loop";
|
|
77
76
|
const CONFIG_CHANNEL = "pi-cc:config";
|
|
78
|
-
const LOOP_TOOL_NAMES = ["cron_create", "cron_delete", "cron_list", "schedule_wakeup"];
|
|
79
|
-
const ALL_PI_CC_TOOLS = [...LOOP_TOOL_NAMES, "Monitor", "monitor_stop", "monitor_list"];
|
|
80
77
|
|
|
81
78
|
/**
|
|
82
79
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
@@ -101,8 +98,6 @@ interface PersistedState {
|
|
|
101
98
|
jobs: LoopJob[];
|
|
102
99
|
/** Prompts whose chain was stopped and must not be rescheduled. */
|
|
103
100
|
stopped?: string[];
|
|
104
|
-
/** Whether the status widget is shown. Claude Code has no such widget. */
|
|
105
|
-
widget?: boolean;
|
|
106
101
|
}
|
|
107
102
|
|
|
108
103
|
function envNumber(name: string, fallback: number): number {
|
|
@@ -155,41 +150,6 @@ export function getScheduler(): LoopScheduler | null {
|
|
|
155
150
|
return activeScheduler;
|
|
156
151
|
}
|
|
157
152
|
|
|
158
|
-
/**
|
|
159
|
-
* Pull the `--durable` and `--max N` flags out of the command line.
|
|
160
|
-
* Flags may appear anywhere; the remaining tokens are the prompt.
|
|
161
|
-
*/
|
|
162
|
-
function extractFlags(raw: string): { body: string; durable: boolean; maxFires?: number } {
|
|
163
|
-
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
164
|
-
const kept: string[] = [];
|
|
165
|
-
let durable = false;
|
|
166
|
-
let maxFires: number | undefined;
|
|
167
|
-
|
|
168
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
169
|
-
const token = tokens[i];
|
|
170
|
-
if (token.toLowerCase() === "--durable") {
|
|
171
|
-
durable = true;
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const inline = /^--max=(\d+)$/i.exec(token);
|
|
175
|
-
if (inline) {
|
|
176
|
-
maxFires = Math.max(1, Number(inline[1]));
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (/^--max$/i.test(token)) {
|
|
180
|
-
const next = tokens[i + 1];
|
|
181
|
-
if (next && /^\d+$/.test(next)) {
|
|
182
|
-
maxFires = Math.max(1, Number(next));
|
|
183
|
-
i++;
|
|
184
|
-
}
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
kept.push(token);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return { body: kept.join(" "), durable, maxFires };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
153
|
export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
194
154
|
/** Cached context for UI helpers. */
|
|
195
155
|
let lastCtx: ExtensionContext | undefined;
|
|
@@ -199,11 +159,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
199
159
|
* woke up.
|
|
200
160
|
*/
|
|
201
161
|
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
162
|
/** Signature of the last persisted state, to avoid redundant session entries. */
|
|
208
163
|
let lastPersistedSignature = "";
|
|
209
164
|
/**
|
|
@@ -216,9 +171,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
216
171
|
/**
|
|
217
172
|
* What the current run belongs to, derived from its starting message:
|
|
218
173
|
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
219
|
-
* `
|
|
174
|
+
* `cronId` the fixed-interval job whose tick started it, `armed` the chains
|
|
175
|
+
* it armed, `tick` the tick number of a /loop run, `loopRequest` set when a
|
|
176
|
+
* typed /loop started it (its rules already say to do the task now).
|
|
220
177
|
*/
|
|
221
|
-
let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
|
|
178
|
+
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number; loopRequest?: boolean } = {};
|
|
222
179
|
|
|
223
180
|
const scheduler = new LoopScheduler({
|
|
224
181
|
dispatch: (job) => deliverTick(job),
|
|
@@ -227,7 +184,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
227
184
|
},
|
|
228
185
|
onChange: (jobs) => {
|
|
229
186
|
persist(jobs);
|
|
230
|
-
|
|
187
|
+
refreshStatus(jobs);
|
|
231
188
|
},
|
|
232
189
|
canDispatch: () => {
|
|
233
190
|
const ctx = lastCtx;
|
|
@@ -244,7 +201,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
244
201
|
const ctx = lastCtx;
|
|
245
202
|
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
246
203
|
},
|
|
247
|
-
|
|
204
|
+
onDispatchError: (job, error) => {
|
|
205
|
+
const ctx = lastCtx;
|
|
206
|
+
if (ctx) notify(ctx, `loop ${job.id}: this tick could not be delivered and was skipped (${error.message})`, "warning");
|
|
207
|
+
},
|
|
208
|
+
onScan: (now) => {
|
|
209
|
+
durable?.maintain(now);
|
|
210
|
+
// The countdown is coarse, so this is a string compare most seconds.
|
|
211
|
+
refreshStatus();
|
|
212
|
+
},
|
|
248
213
|
});
|
|
249
214
|
activeScheduler = scheduler;
|
|
250
215
|
|
|
@@ -276,11 +241,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
276
241
|
function persist(jobs: LoopJob[]): void {
|
|
277
242
|
const kept = jobs.filter((job) => !job.durable).map(storedJob);
|
|
278
243
|
const stopped = [...stoppedPrompts];
|
|
279
|
-
const signature = JSON.stringify([kept.map(signatureOf), stopped
|
|
244
|
+
const signature = JSON.stringify([kept.map(signatureOf), stopped]);
|
|
280
245
|
if (signature === lastPersistedSignature) return;
|
|
281
246
|
lastPersistedSignature = signature;
|
|
282
247
|
try {
|
|
283
|
-
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped
|
|
248
|
+
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
|
|
284
249
|
} catch {
|
|
285
250
|
// session persistence is best effort
|
|
286
251
|
}
|
|
@@ -294,21 +259,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
294
259
|
}
|
|
295
260
|
}
|
|
296
261
|
|
|
297
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Rules for a message we are about to send, keyed by its text. A user
|
|
264
|
+
* message carries no details, so the run's guidelines and the loop it
|
|
265
|
+
* belongs to wait here for before_agent_start to pick them up.
|
|
266
|
+
*/
|
|
267
|
+
const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
|
|
268
|
+
[];
|
|
269
|
+
|
|
270
|
+
function queueRun(run: (typeof queuedRuns)[number]): void {
|
|
271
|
+
queuedRuns.push(run);
|
|
272
|
+
if (queuedRuns.length > 8) queuedRuns.shift();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
|
|
276
|
+
const index = queuedRuns.findIndex((run) => run.content === content);
|
|
277
|
+
return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One line in Pi's footer, the same place other extensions show status.
|
|
282
|
+
* Claude Code has no loop status in its footer; a one-line summary is enough
|
|
283
|
+
* to know that something is armed and when it next fires.
|
|
284
|
+
*/
|
|
285
|
+
function statusText(jobs: LoopJob[]): string | undefined {
|
|
286
|
+
if (!jobs.length) return undefined;
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const next = Math.min(
|
|
289
|
+
...jobs.map((job) => {
|
|
290
|
+
if (job.pending) return now;
|
|
291
|
+
const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
|
|
292
|
+
return at ?? now;
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let lastStatusText: string | undefined;
|
|
299
|
+
let statusSet = false;
|
|
300
|
+
|
|
301
|
+
function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
|
|
298
302
|
const ctx = lastCtx;
|
|
299
303
|
if (!ctx || !ctx.hasUI) return;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return;
|
|
304
|
+
try {
|
|
305
|
+
// Older Pi versions have no footer status API.
|
|
306
|
+
if (typeof ctx.ui.setStatus !== "function") return;
|
|
307
|
+
const text = loadConfig().footer ? statusText(jobs) : undefined;
|
|
308
|
+
// Every call repaints, even with the same text, so speak only on change.
|
|
309
|
+
if (text === lastStatusText && (text !== undefined || !statusSet)) return;
|
|
310
|
+
lastStatusText = text;
|
|
311
|
+
statusSet = text !== undefined;
|
|
312
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
313
|
+
} catch {
|
|
314
|
+
// a stale context must never break the loop
|
|
303
315
|
}
|
|
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
|
-
});
|
|
311
|
-
ctx.ui.setWidget(WIDGET_KEY, lines.length ? lines : undefined);
|
|
312
316
|
}
|
|
313
317
|
|
|
314
318
|
function rememberStopped(prompt: string): void {
|
|
@@ -395,9 +399,9 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
395
399
|
customType: TICK_MESSAGE_TYPE,
|
|
396
400
|
content: missedJobsMessage(lines),
|
|
397
401
|
display: true,
|
|
398
|
-
details: { missed: true,
|
|
402
|
+
details: { missed: true, prompt: lines.join("\n") },
|
|
399
403
|
},
|
|
400
|
-
{ deliverAs: "
|
|
404
|
+
{ deliverAs: "followUp" },
|
|
401
405
|
);
|
|
402
406
|
} catch {
|
|
403
407
|
// best effort
|
|
@@ -412,6 +416,34 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
412
416
|
}
|
|
413
417
|
}
|
|
414
418
|
|
|
419
|
+
/**
|
|
420
|
+
* A scheduled fire is a custom message, not a user message: the provenance
|
|
421
|
+
* line in its content is addressed to the model, and only a custom message
|
|
422
|
+
* can draw something else for the reader (`renderScheduledMessage`).
|
|
423
|
+
*/
|
|
424
|
+
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The per-run rules, appended to the run as a hidden message instead of
|
|
428
|
+
* being written into the system prompt. Providers that cannot carry a system
|
|
429
|
+
* message mid-conversation get the leading system prompt rebuilt from every
|
|
430
|
+
* change, so a rule that comes and goes with each tick would invalidate the
|
|
431
|
+
* whole cached prefix on the way into the run and again on the way out.
|
|
432
|
+
* Claude Code does the same: its /loop instructions are part of the message.
|
|
433
|
+
*/
|
|
434
|
+
const RULES_MESSAGE_TYPE = "pi-cc-loop-rules";
|
|
435
|
+
|
|
436
|
+
function rulesFor(guidelines: string[]) {
|
|
437
|
+
if (!guidelines.length) return undefined;
|
|
438
|
+
return {
|
|
439
|
+
message: {
|
|
440
|
+
customType: RULES_MESSAGE_TYPE,
|
|
441
|
+
content: [RULES_PROVENANCE, ...guidelines].join("\n\n"),
|
|
442
|
+
display: false,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
415
447
|
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
416
448
|
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
417
449
|
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
@@ -424,20 +456,34 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
424
456
|
*/
|
|
425
457
|
function deliverTick(job: LoopJob): void {
|
|
426
458
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
427
|
-
tickCounters.set(job.prompt, tick);
|
|
428
|
-
job.tickCount = tick;
|
|
429
459
|
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
430
|
-
//
|
|
431
|
-
// self-paced tick
|
|
460
|
+
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
461
|
+
// self-paced tick keeps the loop framing. The rules for the run are
|
|
462
|
+
// rebuilt from `details` in before_agent_start.
|
|
463
|
+
const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
|
|
464
|
+
const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
|
|
432
465
|
pi.sendMessage(
|
|
433
466
|
{
|
|
434
467
|
customType: TICK_MESSAGE_TYPE,
|
|
435
|
-
content: `${
|
|
468
|
+
content: `${stamp}\n${body}`,
|
|
436
469
|
display: true,
|
|
437
|
-
details: {
|
|
470
|
+
details: {
|
|
471
|
+
kind: job.kind,
|
|
472
|
+
tickCount: tick,
|
|
473
|
+
jobId: job.id,
|
|
474
|
+
cron: job.cron,
|
|
475
|
+
prompt: body,
|
|
476
|
+
reason: job.reason,
|
|
477
|
+
},
|
|
438
478
|
},
|
|
479
|
+
// `triggerTurn` is what starts the run: without it the message is
|
|
480
|
+
// only recorded and nothing ever happens.
|
|
439
481
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
440
482
|
);
|
|
483
|
+
// Counted only once the message is in: a throw here means the tick never
|
|
484
|
+
// happened, so it must not use up a number.
|
|
485
|
+
tickCounters.set(job.prompt, tick);
|
|
486
|
+
job.tickCount = tick;
|
|
441
487
|
|
|
442
488
|
if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
|
|
443
489
|
const ctx = lastCtx;
|
|
@@ -445,34 +491,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
445
491
|
}
|
|
446
492
|
}
|
|
447
493
|
|
|
448
|
-
/**
|
|
449
|
-
* Run the prompt right away, the way Claude Code's skill does ("Then
|
|
450
|
-
* immediately execute the parsed prompt now").
|
|
451
|
-
*
|
|
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.
|
|
456
|
-
*/
|
|
457
|
-
function runNow(ctx: ExtensionContext, prompt: string, job: LoopJob | undefined, guidelines: string[]): void {
|
|
458
|
-
const tick = (tickCounters.get(prompt) ?? 0) + 1;
|
|
459
|
-
tickCounters.set(prompt, tick);
|
|
460
|
-
if (job && scheduler.get(job.id)) {
|
|
461
|
-
scheduler.update(job.id, { tickCount: tick, fireCount: (job.fireCount ?? 0) + 1, lastFiredAt: Date.now() });
|
|
462
|
-
}
|
|
463
|
-
const body = resolvePrompt(prompt, job?.kind === "cron" ? "cron" : "wakeup");
|
|
464
|
-
pi.sendMessage(
|
|
465
|
-
{
|
|
466
|
-
customType: TICK_MESSAGE_TYPE,
|
|
467
|
-
content: `${provenance(tick)}\n${body}`,
|
|
468
|
-
display: true,
|
|
469
|
-
details: { jobId: job?.id, tickCount: tick, immediate: true, scheduledAt: Date.now(), guidelines },
|
|
470
|
-
},
|
|
471
|
-
{ deliverAs: "steer", triggerTurn: true },
|
|
472
|
-
);
|
|
473
|
-
lastCtx = ctx;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
494
|
/** True when the pi-cc-monitor extension registered its Monitor tool. */
|
|
477
495
|
function monitorToolAvailable(): boolean {
|
|
478
496
|
try {
|
|
@@ -482,11 +500,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
482
500
|
}
|
|
483
501
|
}
|
|
484
502
|
|
|
485
|
-
/** The tool names to exclude from pi-cc-extensions' global styling. */
|
|
486
|
-
function piCcToolNames(): string[] {
|
|
487
|
-
return monitorToolAvailable() ? ALL_PI_CC_TOOLS : LOOP_TOOL_NAMES;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
503
|
/**
|
|
491
504
|
* A run finished without an abort: self-paced chains whose tick the model
|
|
492
505
|
* did not reschedule get their one keepalive, or end.
|
|
@@ -549,32 +562,22 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
549
562
|
// ---------------------------------------------------------------- command
|
|
550
563
|
|
|
551
564
|
pi.registerCommand("loop", {
|
|
552
|
-
description:
|
|
553
|
-
"Run a prompt repeatedly. Usage: /loop [interval] <prompt> | /loop <prompt> | /loop [interval] | /loop stop [id] | /loop status | /loop settings",
|
|
565
|
+
description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
|
|
554
566
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
555
567
|
lastCtx = ctx;
|
|
556
568
|
const raw = args.trim();
|
|
557
|
-
|
|
558
569
|
const [head, ...rest] = raw.split(/\s+/);
|
|
570
|
+
|
|
571
|
+
// Settings configure the extension, not a loop: they stay in code.
|
|
559
572
|
switch ((head ?? "").toLowerCase()) {
|
|
560
|
-
case "
|
|
561
|
-
|
|
562
|
-
return;
|
|
563
|
-
case "status":
|
|
564
|
-
case "list":
|
|
565
|
-
case "jobs":
|
|
566
|
-
showStatus(ctx);
|
|
567
|
-
return;
|
|
568
|
-
case "widget": {
|
|
573
|
+
case "footer":
|
|
574
|
+
case "statusline": {
|
|
569
575
|
const arg = (rest[0] ?? "").toLowerCase();
|
|
570
|
-
if (arg
|
|
571
|
-
notify(ctx, `Usage: /loop
|
|
576
|
+
if (!arg) {
|
|
577
|
+
notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
|
|
572
578
|
return;
|
|
573
579
|
}
|
|
574
|
-
|
|
575
|
-
persist(scheduler.list());
|
|
576
|
-
refreshWidget(scheduler.list());
|
|
577
|
-
notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
|
|
580
|
+
setSetting(ctx, "footer", arg);
|
|
578
581
|
return;
|
|
579
582
|
}
|
|
580
583
|
case "settings":
|
|
@@ -589,11 +592,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
589
592
|
setSetting(ctx, "messages", value);
|
|
590
593
|
return;
|
|
591
594
|
}
|
|
592
|
-
if (name === "
|
|
593
|
-
setSetting(ctx, "
|
|
595
|
+
if (name === "footer" || name === "status") {
|
|
596
|
+
setSetting(ctx, "footer", value);
|
|
594
597
|
return;
|
|
595
598
|
}
|
|
596
|
-
notify(ctx, "Usage: /loop settings [messages|
|
|
599
|
+
notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
|
|
597
600
|
return;
|
|
598
601
|
}
|
|
599
602
|
case "help":
|
|
@@ -605,203 +608,52 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
605
608
|
break;
|
|
606
609
|
}
|
|
607
610
|
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (leading) {
|
|
625
|
-
const parsed = parseIntervalToken(leading[1]);
|
|
626
|
-
if (parsed !== null) {
|
|
627
|
-
intervalSeconds = parsed;
|
|
628
|
-
prompt = leading[2].trim();
|
|
629
|
-
}
|
|
630
|
-
}
|
|
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
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Rule 3: no interval -> the model decides (self-paced unless it asks for one)
|
|
642
|
-
await startLoop(ctx, intervalSeconds, prompt, flags.durable, flags.maxFires);
|
|
643
|
-
},
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
async function startLoop(
|
|
647
|
-
ctx: ExtensionCommandContext,
|
|
648
|
-
intervalSeconds: number | null,
|
|
649
|
-
prompt: string,
|
|
650
|
-
durableFlag: boolean,
|
|
651
|
-
maxFires: number | undefined,
|
|
652
|
-
): Promise<void> {
|
|
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
|
-
}
|
|
660
|
-
|
|
661
|
-
// Without a prompt the loop runs the loop.md tasks, resolved at each fire.
|
|
662
|
-
const finalPrompt = prompt || LOOP_FILE_SENTINEL;
|
|
663
|
-
// Starting a chain again clears the stop marker: the user asked for it.
|
|
664
|
-
stoppedPrompts.delete(finalPrompt);
|
|
665
|
-
// A new loop starts by sending the full task list again.
|
|
666
|
-
if (isLoopFileSentinel(finalPrompt)) loopFile.reset();
|
|
667
|
-
|
|
668
|
-
if (intervalSeconds === null) {
|
|
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)}`);
|
|
611
|
+
// Everything else becomes a normal user message, the way Claude Code's
|
|
612
|
+
// /loop skill does: the conversation keeps the request, and the model
|
|
613
|
+
// drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
|
|
614
|
+
// The per-run rules travel with the run through before_agent_start.
|
|
615
|
+
const text = raw ? `/loop ${raw}` : "/loop";
|
|
616
|
+
const guidelines = [loopCommandGuidelines(raw)];
|
|
617
|
+
const send = () => {
|
|
618
|
+
// The user asking for this loop again is a new loop: the mark that
|
|
619
|
+
// keeps a late schedule_wakeup from reviving a stopped chain must
|
|
620
|
+
// not block it.
|
|
621
|
+
stoppedPrompts.delete(normalizePrompt(raw) || LOOP_FILE_SENTINEL);
|
|
622
|
+
queueRun({ content: text, guidelines });
|
|
623
|
+
try {
|
|
624
|
+
pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
|
|
625
|
+
} catch {
|
|
626
|
+
// The session was replaced while this request waited.
|
|
688
627
|
}
|
|
689
|
-
}
|
|
690
|
-
runNow(ctx, finalPrompt, undefined, guidelines);
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const spec = intervalToCron(intervalSeconds);
|
|
695
|
-
if (!spec || !parseCron(spec.cron)) {
|
|
696
|
-
notify(ctx, `Invalid interval: ${intervalSeconds}s`, "error");
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
if (scheduler.size >= MAX_JOBS) {
|
|
700
|
-
notify(ctx, `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`, "error");
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const job: LoopJob = {
|
|
705
|
-
id: generateId(),
|
|
706
|
-
kind: "cron",
|
|
707
|
-
cron: spec.cron,
|
|
708
|
-
prompt: finalPrompt,
|
|
709
|
-
recurring: true,
|
|
710
|
-
createdAt: Date.now(),
|
|
711
|
-
durable: durableFlag,
|
|
712
|
-
maxFires,
|
|
713
|
-
label: humanSchedule(spec.cron),
|
|
714
|
-
source: "loop_command",
|
|
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;
|
|
724
|
-
|
|
725
|
-
const days = maxAgeDays();
|
|
726
|
-
notify(
|
|
727
|
-
ctx,
|
|
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 ?? "?"})`),
|
|
731
|
-
"info",
|
|
732
|
-
);
|
|
733
|
-
const note =
|
|
734
|
-
`A recurring /loop job was created: id ${job.id}, cron \`${spec.cron}\` (${spec.human}), ` +
|
|
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}.` : "");
|
|
738
|
-
|
|
739
|
-
runNow(ctx, finalPrompt, job, [note]);
|
|
740
|
-
}
|
|
628
|
+
};
|
|
741
629
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
751
|
-
}
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
if (durable?.remove(target)) {
|
|
755
|
-
notify(ctx, `Cancelled durable job ${target}`, "info");
|
|
630
|
+
// A run that is already streaming has passed before_agent_start, so
|
|
631
|
+
// sending now would split the request in two: the text would be queued
|
|
632
|
+
// into that run without its rules, and the model would work through it
|
|
633
|
+
// twice. Wait for the run to end and start a clean one instead - the
|
|
634
|
+
// same wait a follow-up lane would impose, without losing the rules.
|
|
635
|
+
if (!ctx.isIdle() && typeof ctx.waitForIdle === "function") {
|
|
636
|
+
notify(ctx, `${text} will start when the current turn ends`, "info");
|
|
637
|
+
void ctx.waitForIdle().then(send, () => {});
|
|
756
638
|
return;
|
|
757
639
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
for (const job of jobs) {
|
|
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);
|
|
770
|
-
}
|
|
771
|
-
notify(ctx, `Cancelled ${jobs.length} loop${jobs.length === 1 ? "" : "s"}`, "info");
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function showStatus(ctx: ExtensionContext): void {
|
|
775
|
-
const jobs = listJobs();
|
|
776
|
-
if (!jobs.length) {
|
|
777
|
-
notify(ctx, "No active loops", "info");
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
const lines = jobs.map((job) => {
|
|
781
|
-
const prompt = truncate(displayPrompt(job.prompt), 60);
|
|
782
|
-
if (job.kind === "cron") {
|
|
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}`;
|
|
786
|
-
}
|
|
787
|
-
const at = job.scheduledFor;
|
|
788
|
-
const when = at && at > Date.now() ? `next ${formatClock(at)}` : "awaiting reschedule";
|
|
789
|
-
return `${job.id} self-paced ${when} ${prompt}`;
|
|
790
|
-
});
|
|
791
|
-
notify(ctx, lines.join("\n"), "info");
|
|
792
|
-
}
|
|
640
|
+
// The followUp lane is the fallback for a Pi without waitForIdle: a
|
|
641
|
+
// sendUserMessage from a streaming turn throws without one ("Agent is
|
|
642
|
+
// already processing. Specify streamingBehavior"). When idle the option
|
|
643
|
+
// is ignored and the turn starts.
|
|
644
|
+
send();
|
|
645
|
+
},
|
|
646
|
+
});
|
|
793
647
|
|
|
794
648
|
function showHelp(ctx: ExtensionContext): void {
|
|
795
649
|
const help = [
|
|
796
650
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
797
651
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
798
652
|
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
799
|
-
"/loop
|
|
800
|
-
"/loop status list the active loops",
|
|
801
|
-
"/loop widget on|off show or hide the status line above the editor",
|
|
653
|
+
"/loop footer on|off show or hide the one-line summary in the footer",
|
|
802
654
|
"/loop settings show UI settings",
|
|
803
655
|
"/loop settings messages on|off compact two-line markers",
|
|
804
|
-
"/loop settings
|
|
656
|
+
"/loop settings footer on|off the one-line footer summary",
|
|
805
657
|
"",
|
|
806
658
|
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
807
659
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
@@ -815,15 +667,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
815
667
|
const lines = [
|
|
816
668
|
`pi-cc settings \u00b7 ${configPath()}`,
|
|
817
669
|
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
818
|
-
`
|
|
670
|
+
` footer: ${config.footer ? "summary" : "hidden"}`,
|
|
819
671
|
"",
|
|
820
672
|
"Usage: /loop settings messages on|off",
|
|
821
|
-
" /loop settings
|
|
673
|
+
" /loop settings footer on|off",
|
|
822
674
|
];
|
|
823
675
|
notify(ctx, lines.join("\n"), "info");
|
|
824
676
|
}
|
|
825
677
|
|
|
826
|
-
function setSetting(ctx: ExtensionContext, key: "messages" | "
|
|
678
|
+
function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
|
|
827
679
|
const value = (raw ?? "").toLowerCase();
|
|
828
680
|
const on = value === "on" || value === "true" || value === "1";
|
|
829
681
|
const off = value === "off" || value === "false" || value === "0";
|
|
@@ -831,28 +683,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
831
683
|
showSettings(ctx);
|
|
832
684
|
return;
|
|
833
685
|
}
|
|
834
|
-
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : {
|
|
686
|
+
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
|
|
835
687
|
const next = saveConfig(patch);
|
|
836
|
-
let compatChanged = false;
|
|
837
|
-
if (key === "tools") {
|
|
838
|
-
compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
|
|
839
|
-
}
|
|
840
688
|
try {
|
|
841
689
|
pi.events.emit(CONFIG_CHANNEL, next);
|
|
842
690
|
} catch {
|
|
843
691
|
// another extension must not break the command
|
|
844
692
|
}
|
|
845
|
-
notify(ctx, `${key === "messages" ? "Compact message style" : "
|
|
846
|
-
if (
|
|
847
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
848
|
-
}
|
|
693
|
+
notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
|
|
694
|
+
if (key === "footer") refreshStatus();
|
|
849
695
|
}
|
|
850
696
|
|
|
851
697
|
// ------------------------------------------------------------------ tools
|
|
852
698
|
|
|
853
699
|
pi.registerTool({
|
|
854
700
|
name: "cron_create",
|
|
855
|
-
|
|
701
|
+
// Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
|
|
702
|
+
label: "CronCreate",
|
|
856
703
|
description: CRON_CREATE_DESCRIPTION,
|
|
857
704
|
promptSnippet: CRON_CREATE_SNIPPET,
|
|
858
705
|
parameters: Type.Object({
|
|
@@ -928,6 +775,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
928
775
|
lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
|
|
929
776
|
}
|
|
930
777
|
lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
|
|
778
|
+
// Asked for in plain words, not through /loop: do it once now, as /loop would.
|
|
779
|
+
if (recurring && !currentRun.loopRequest && !currentRun.cronId && !currentRun.chainId) lines.push(RUN_NOW_HINT);
|
|
931
780
|
|
|
932
781
|
return {
|
|
933
782
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
@@ -946,7 +795,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
946
795
|
|
|
947
796
|
pi.registerTool({
|
|
948
797
|
name: "cron_delete",
|
|
949
|
-
label: "
|
|
798
|
+
label: "CronDelete",
|
|
950
799
|
description: CRON_DELETE_DESCRIPTION,
|
|
951
800
|
promptSnippet: CRON_DELETE_SNIPPET,
|
|
952
801
|
parameters: Type.Object({
|
|
@@ -975,7 +824,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
975
824
|
|
|
976
825
|
pi.registerTool({
|
|
977
826
|
name: "cron_list",
|
|
978
|
-
label: "
|
|
827
|
+
label: "CronList",
|
|
979
828
|
description: CRON_LIST_DESCRIPTION,
|
|
980
829
|
promptSnippet: CRON_LIST_SNIPPET,
|
|
981
830
|
parameters: Type.Object({}),
|
|
@@ -1007,7 +856,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1007
856
|
|
|
1008
857
|
pi.registerTool({
|
|
1009
858
|
name: "schedule_wakeup",
|
|
1010
|
-
label: "
|
|
859
|
+
label: "ScheduleWakeup",
|
|
1011
860
|
description: SCHEDULE_WAKEUP_DESCRIPTION,
|
|
1012
861
|
promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
|
|
1013
862
|
parameters: Type.Object({
|
|
@@ -1017,7 +866,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1017
866
|
}),
|
|
1018
867
|
),
|
|
1019
868
|
reason: Type.Optional(
|
|
1020
|
-
Type.String({
|
|
869
|
+
Type.String({
|
|
870
|
+
description:
|
|
871
|
+
"One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
|
|
872
|
+
}),
|
|
1021
873
|
),
|
|
1022
874
|
prompt: Type.Optional(
|
|
1023
875
|
Type.String({
|
|
@@ -1045,8 +897,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1045
897
|
{
|
|
1046
898
|
type: "text" as const,
|
|
1047
899
|
text: chains.length
|
|
1048
|
-
? `Loop
|
|
1049
|
-
|
|
900
|
+
? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
|
|
901
|
+
"Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
|
|
902
|
+
: "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
|
|
903
|
+
"If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
|
|
1050
904
|
},
|
|
1051
905
|
],
|
|
1052
906
|
details: { stopped: true, cancelledWakeups: chains.length },
|
|
@@ -1054,11 +908,33 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1054
908
|
}
|
|
1055
909
|
|
|
1056
910
|
if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
|
|
1057
|
-
throw new Error("delaySeconds
|
|
911
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
912
|
+
}
|
|
913
|
+
if (!params.reason || !params.reason.trim()) {
|
|
914
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
1058
915
|
}
|
|
1059
916
|
const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
|
|
1060
917
|
if (!prompt) {
|
|
1061
|
-
throw new Error("prompt is required
|
|
918
|
+
throw new Error("`prompt` is required when `stop` is not true.");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (currentRun.cronId) {
|
|
922
|
+
// Not an error: the request is already satisfied by the schedule.
|
|
923
|
+
// Returning it as a result (instead of throwing) keeps the transcript
|
|
924
|
+
// calm and tells the model nothing is missing.
|
|
925
|
+
const cronJob = scheduler.get(currentRun.cronId);
|
|
926
|
+
return {
|
|
927
|
+
content: [
|
|
928
|
+
{
|
|
929
|
+
type: "text" as const,
|
|
930
|
+
text:
|
|
931
|
+
`No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
|
|
932
|
+
`${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
|
|
933
|
+
`Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
|
|
934
|
+
},
|
|
935
|
+
],
|
|
936
|
+
details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
|
|
937
|
+
};
|
|
1062
938
|
}
|
|
1063
939
|
|
|
1064
940
|
// Claude Code rounds and clamps; only a change after rounding counts as clamped.
|
|
@@ -1080,8 +956,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1080
956
|
{
|
|
1081
957
|
type: "text" as const,
|
|
1082
958
|
text:
|
|
1083
|
-
`The
|
|
1084
|
-
"
|
|
959
|
+
`Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
|
|
960
|
+
"do not re-issue. Tell the user they can start a new /loop.",
|
|
1085
961
|
},
|
|
1086
962
|
],
|
|
1087
963
|
details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
|
|
@@ -1119,9 +995,17 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1119
995
|
reason: params.reason,
|
|
1120
996
|
});
|
|
1121
997
|
|
|
1122
|
-
const note = wasClamped ? ` (clamped
|
|
998
|
+
const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
|
|
999
|
+
const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
|
|
1123
1000
|
return {
|
|
1124
|
-
content: [
|
|
1001
|
+
content: [
|
|
1002
|
+
{
|
|
1003
|
+
type: "text" as const,
|
|
1004
|
+
text:
|
|
1005
|
+
`Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
|
|
1006
|
+
"This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
|
|
1007
|
+
},
|
|
1008
|
+
],
|
|
1125
1009
|
details: {
|
|
1126
1010
|
id: chain.id,
|
|
1127
1011
|
scheduledFor,
|
|
@@ -1137,19 +1021,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1137
1021
|
},
|
|
1138
1022
|
});
|
|
1139
1023
|
|
|
1140
|
-
// ---------------------------------------------------------------- rendering
|
|
1141
|
-
|
|
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.
|
|
1146
|
-
pi.registerToolRenderer((toolName, next) => {
|
|
1147
|
-
if (!loadConfig().compactToolRows) return next();
|
|
1148
|
-
return renderers[toolName] ?? next();
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderTickMessage);
|
|
1153
1024
|
|
|
1154
1025
|
// ----------------------------------------------------------------- events
|
|
1155
1026
|
|
|
@@ -1161,11 +1032,46 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1161
1032
|
* wakeup stays armed as the fallback heartbeat, and the model re-arms it if
|
|
1162
1033
|
* it wants a different time.
|
|
1163
1034
|
*/
|
|
1035
|
+
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
|
|
1036
|
+
|
|
1164
1037
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1165
1038
|
lastCtx = ctx;
|
|
1166
1039
|
currentRun = {};
|
|
1040
|
+
|
|
1041
|
+
// Our own messages are ordinary user messages now, so the instructions
|
|
1042
|
+
// for the run and the loop it belongs to travel with the queued entry.
|
|
1043
|
+
if (event.messageType === "user") {
|
|
1044
|
+
const queued = takeQueuedRun(event.prompt);
|
|
1045
|
+
if (!queued) return;
|
|
1046
|
+
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick, loopRequest: true };
|
|
1047
|
+
return rulesFor(queued.guidelines);
|
|
1048
|
+
}
|
|
1167
1049
|
if (event.messageType !== "custom") return;
|
|
1168
1050
|
|
|
1051
|
+
if (event.message.customType === TICK_MESSAGE_TYPE) {
|
|
1052
|
+
const details = (event.message.details ?? {}) as {
|
|
1053
|
+
kind?: "cron" | "wakeup";
|
|
1054
|
+
jobId?: string;
|
|
1055
|
+
cron?: string;
|
|
1056
|
+
tickCount?: number;
|
|
1057
|
+
};
|
|
1058
|
+
currentRun = {
|
|
1059
|
+
chainId: details.kind === "wakeup" ? details.jobId : undefined,
|
|
1060
|
+
cronId: details.kind === "cron" ? details.jobId : undefined,
|
|
1061
|
+
tick: details.tickCount,
|
|
1062
|
+
};
|
|
1063
|
+
// A cron fire needs nothing beyond its envelope ("it fires the next tick
|
|
1064
|
+
// on its own, so do not call schedule_wakeup; to cancel it, call
|
|
1065
|
+
// cron_delete"): an extra message every minute would only grow the
|
|
1066
|
+
// context. A self-paced tick carries the rules that keep the loop alive.
|
|
1067
|
+
if (details.kind !== "wakeup") return;
|
|
1068
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1069
|
+
if (!chain) return;
|
|
1070
|
+
const guidelines = [loopGuidelines(chain.prompt)];
|
|
1071
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
|
|
1072
|
+
return rulesFor(guidelines);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1169
1075
|
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
|
1170
1076
|
// A monitor bound to a loop woke the agent: the run belongs to that loop.
|
|
1171
1077
|
const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
|
|
@@ -1173,31 +1079,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1173
1079
|
if (chain) currentRun.chainId = chain.id;
|
|
1174
1080
|
return;
|
|
1175
1081
|
}
|
|
1176
|
-
if (event.message.customType !== TICK_MESSAGE_TYPE) return;
|
|
1177
|
-
|
|
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));
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
if (!notes.length) return;
|
|
1199
|
-
const options = event.systemPromptOptions;
|
|
1200
|
-
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...notes];
|
|
1201
1082
|
});
|
|
1202
1083
|
|
|
1203
1084
|
/**
|
|
@@ -1220,17 +1101,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1220
1101
|
|
|
1221
1102
|
pi.on("session_start", async (event, ctx) => {
|
|
1222
1103
|
lastCtx = ctx;
|
|
1104
|
+
// Another process may have edited the shared settings file.
|
|
1105
|
+
invalidateConfig();
|
|
1223
1106
|
durable?.stop();
|
|
1224
1107
|
restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
|
|
1225
1108
|
durable = createDurable(ctx.cwd);
|
|
1226
1109
|
durable.start();
|
|
1227
1110
|
scheduler.start();
|
|
1228
|
-
if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
|
|
1229
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
1230
|
-
}
|
|
1231
1111
|
});
|
|
1232
1112
|
|
|
1233
1113
|
pi.on("session_shutdown", async () => {
|
|
1114
|
+
// Pi clears extension statuses when it unloads, but a reload should not
|
|
1115
|
+
// leave a stale count behind until then.
|
|
1116
|
+
try {
|
|
1117
|
+
lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
|
|
1118
|
+
} catch {
|
|
1119
|
+
// the context may already be invalid
|
|
1120
|
+
}
|
|
1234
1121
|
scheduler.stop();
|
|
1235
1122
|
durable?.stop();
|
|
1236
1123
|
durable = undefined;
|
|
@@ -1264,12 +1151,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1264
1151
|
}
|
|
1265
1152
|
}
|
|
1266
1153
|
if (!data) {
|
|
1267
|
-
refreshWidget(scheduler.list());
|
|
1268
1154
|
return;
|
|
1269
1155
|
}
|
|
1270
1156
|
|
|
1271
1157
|
for (const prompt of data.stopped ?? []) rememberStopped(prompt);
|
|
1272
|
-
if (typeof data.widget === "boolean") widgetEnabled = data.widget;
|
|
1273
1158
|
|
|
1274
1159
|
const now = Date.now();
|
|
1275
1160
|
const limit = maxAgeMs();
|