pi-cc-loop 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -75
- package/config.ts +15 -86
- package/cron.ts +4 -115
- package/index.ts +228 -387
- package/loopfile.ts +4 -8
- package/package.json +1 -1
- package/prompts.ts +84 -27
- package/render.ts +21 -1
- package/renderers.ts +58 -107
- 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";
|
|
@@ -48,16 +44,18 @@ import {
|
|
|
48
44
|
CRON_DELETE_SNIPPET,
|
|
49
45
|
CRON_LIST_DESCRIPTION,
|
|
50
46
|
CRON_LIST_SNIPPET,
|
|
47
|
+
cronTickGuidelines,
|
|
51
48
|
LOOP_FILE_SENTINEL,
|
|
52
49
|
loopGuidelines,
|
|
53
50
|
missedJobsMessage,
|
|
54
51
|
monitorGuidelines,
|
|
55
52
|
provenance,
|
|
56
53
|
SCHEDULE_WAKEUP_DESCRIPTION,
|
|
54
|
+
scheduledTaskProvenance,
|
|
57
55
|
SCHEDULE_WAKEUP_SNIPPET,
|
|
58
|
-
|
|
56
|
+
loopCommandGuidelines,
|
|
59
57
|
} from "./prompts.ts";
|
|
60
|
-
import { formatClock,
|
|
58
|
+
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
61
59
|
import {
|
|
62
60
|
DEFAULT_TIMING,
|
|
63
61
|
generateId,
|
|
@@ -67,16 +65,14 @@ import {
|
|
|
67
65
|
type LoopJob,
|
|
68
66
|
type TimingConfig,
|
|
69
67
|
} from "./schedule.ts";
|
|
70
|
-
import { truncate } from "./
|
|
68
|
+
import { truncate } from "./render.ts";
|
|
71
69
|
|
|
72
70
|
const STATE_ENTRY = "pi-cc-loop-state";
|
|
73
|
-
export const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
74
71
|
/** pi-cc-monitor's notification type; its details name the loop a monitor belongs to. */
|
|
75
|
-
const MONITOR_EVENT_TYPE = "
|
|
76
|
-
|
|
72
|
+
const MONITOR_EVENT_TYPE = "Monitor";
|
|
73
|
+
/** Footer status key, shared with every other extension's status line. */
|
|
74
|
+
const STATUS_KEY = "loop";
|
|
77
75
|
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
76
|
|
|
81
77
|
/**
|
|
82
78
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
@@ -101,8 +97,6 @@ interface PersistedState {
|
|
|
101
97
|
jobs: LoopJob[];
|
|
102
98
|
/** Prompts whose chain was stopped and must not be rescheduled. */
|
|
103
99
|
stopped?: string[];
|
|
104
|
-
/** Whether the status widget is shown. Claude Code has no such widget. */
|
|
105
|
-
widget?: boolean;
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
function envNumber(name: string, fallback: number): number {
|
|
@@ -155,41 +149,6 @@ export function getScheduler(): LoopScheduler | null {
|
|
|
155
149
|
return activeScheduler;
|
|
156
150
|
}
|
|
157
151
|
|
|
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
152
|
export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
194
153
|
/** Cached context for UI helpers. */
|
|
195
154
|
let lastCtx: ExtensionContext | undefined;
|
|
@@ -199,11 +158,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
199
158
|
* woke up.
|
|
200
159
|
*/
|
|
201
160
|
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
161
|
/** Signature of the last persisted state, to avoid redundant session entries. */
|
|
208
162
|
let lastPersistedSignature = "";
|
|
209
163
|
/**
|
|
@@ -216,9 +170,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
216
170
|
/**
|
|
217
171
|
* What the current run belongs to, derived from its starting message:
|
|
218
172
|
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
219
|
-
* `
|
|
173
|
+
* `cronId` the fixed-interval job whose tick started it, `armed` the chains
|
|
174
|
+
* it armed, `tick` the tick number of a /loop run.
|
|
220
175
|
*/
|
|
221
|
-
let currentRun: { chainId?: string; armed?: Set<string>; tick?: number } = {};
|
|
176
|
+
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
|
|
222
177
|
|
|
223
178
|
const scheduler = new LoopScheduler({
|
|
224
179
|
dispatch: (job) => deliverTick(job),
|
|
@@ -227,7 +182,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
227
182
|
},
|
|
228
183
|
onChange: (jobs) => {
|
|
229
184
|
persist(jobs);
|
|
230
|
-
|
|
185
|
+
refreshStatus(jobs);
|
|
231
186
|
},
|
|
232
187
|
canDispatch: () => {
|
|
233
188
|
const ctx = lastCtx;
|
|
@@ -244,7 +199,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
244
199
|
const ctx = lastCtx;
|
|
245
200
|
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
246
201
|
},
|
|
247
|
-
onScan: (now) =>
|
|
202
|
+
onScan: (now) => {
|
|
203
|
+
durable?.maintain(now);
|
|
204
|
+
// The countdown is coarse, so this is a string compare most seconds.
|
|
205
|
+
refreshStatus();
|
|
206
|
+
},
|
|
248
207
|
});
|
|
249
208
|
activeScheduler = scheduler;
|
|
250
209
|
|
|
@@ -276,11 +235,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
276
235
|
function persist(jobs: LoopJob[]): void {
|
|
277
236
|
const kept = jobs.filter((job) => !job.durable).map(storedJob);
|
|
278
237
|
const stopped = [...stoppedPrompts];
|
|
279
|
-
const signature = JSON.stringify([kept.map(signatureOf), stopped
|
|
238
|
+
const signature = JSON.stringify([kept.map(signatureOf), stopped]);
|
|
280
239
|
if (signature === lastPersistedSignature) return;
|
|
281
240
|
lastPersistedSignature = signature;
|
|
282
241
|
try {
|
|
283
|
-
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped
|
|
242
|
+
pi.appendEntry(STATE_ENTRY, { version: 1, jobs: kept, stopped } satisfies PersistedState);
|
|
284
243
|
} catch {
|
|
285
244
|
// session persistence is best effort
|
|
286
245
|
}
|
|
@@ -294,21 +253,60 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
294
253
|
}
|
|
295
254
|
}
|
|
296
255
|
|
|
297
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Rules for a message we are about to send, keyed by its text. A user
|
|
258
|
+
* message carries no details, so the run's guidelines and the loop it
|
|
259
|
+
* belongs to wait here for before_agent_start to pick them up.
|
|
260
|
+
*/
|
|
261
|
+
const queuedRuns: Array<{ content: string; guidelines: string[]; chainId?: string; cronId?: string; tick?: number }> =
|
|
262
|
+
[];
|
|
263
|
+
|
|
264
|
+
function queueRun(run: (typeof queuedRuns)[number]): void {
|
|
265
|
+
queuedRuns.push(run);
|
|
266
|
+
if (queuedRuns.length > 8) queuedRuns.shift();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function takeQueuedRun(content: string): (typeof queuedRuns)[number] | undefined {
|
|
270
|
+
const index = queuedRuns.findIndex((run) => run.content === content);
|
|
271
|
+
return index < 0 ? undefined : queuedRuns.splice(index, 1)[0];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One line in Pi's footer, the same place other extensions show status.
|
|
276
|
+
* Claude Code has no loop status in its footer; a one-line summary is enough
|
|
277
|
+
* to know that something is armed and when it next fires.
|
|
278
|
+
*/
|
|
279
|
+
function statusText(jobs: LoopJob[]): string | undefined {
|
|
280
|
+
if (!jobs.length) return undefined;
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const next = Math.min(
|
|
283
|
+
...jobs.map((job) => {
|
|
284
|
+
if (job.pending) return now;
|
|
285
|
+
const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
|
|
286
|
+
return at ?? now;
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
return `loop ${jobs.length} \u00b7 next ${formatEta(Math.max(0, next - now))}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let lastStatusText: string | undefined;
|
|
293
|
+
let statusSet = false;
|
|
294
|
+
|
|
295
|
+
function refreshStatus(jobs: LoopJob[] = scheduler.list()): void {
|
|
298
296
|
const ctx = lastCtx;
|
|
299
297
|
if (!ctx || !ctx.hasUI) return;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return;
|
|
298
|
+
try {
|
|
299
|
+
// Older Pi versions have no footer status API.
|
|
300
|
+
if (typeof ctx.ui.setStatus !== "function") return;
|
|
301
|
+
const text = loadConfig().footer ? statusText(jobs) : undefined;
|
|
302
|
+
// Every call repaints, even with the same text, so speak only on change.
|
|
303
|
+
if (text === lastStatusText && (text !== undefined || !statusSet)) return;
|
|
304
|
+
lastStatusText = text;
|
|
305
|
+
statusSet = text !== undefined;
|
|
306
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
307
|
+
} catch {
|
|
308
|
+
// a stale context must never break the loop
|
|
303
309
|
}
|
|
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
310
|
}
|
|
313
311
|
|
|
314
312
|
function rememberStopped(prompt: string): void {
|
|
@@ -395,9 +393,9 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
395
393
|
customType: TICK_MESSAGE_TYPE,
|
|
396
394
|
content: missedJobsMessage(lines),
|
|
397
395
|
display: true,
|
|
398
|
-
details: { missed: true,
|
|
396
|
+
details: { missed: true, prompt: lines.join("\n") },
|
|
399
397
|
},
|
|
400
|
-
{ deliverAs: "
|
|
398
|
+
{ deliverAs: "followUp" },
|
|
401
399
|
);
|
|
402
400
|
} catch {
|
|
403
401
|
// best effort
|
|
@@ -412,6 +410,13 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
412
410
|
}
|
|
413
411
|
}
|
|
414
412
|
|
|
413
|
+
/**
|
|
414
|
+
* A scheduled fire is a custom message, not a user message: the provenance
|
|
415
|
+
* line in its content is addressed to the model, and only a custom message
|
|
416
|
+
* can draw something else for the reader (`renderScheduledMessage`).
|
|
417
|
+
*/
|
|
418
|
+
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
419
|
+
|
|
415
420
|
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
416
421
|
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
417
422
|
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
@@ -427,15 +432,27 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
427
432
|
tickCounters.set(job.prompt, tick);
|
|
428
433
|
job.tickCount = tick;
|
|
429
434
|
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
430
|
-
//
|
|
431
|
-
// self-paced tick
|
|
435
|
+
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
436
|
+
// self-paced tick keeps the loop framing. The rules for the run are
|
|
437
|
+
// rebuilt from `details` in before_agent_start.
|
|
438
|
+
const cadence = job.kind === "cron" ? humanSchedule(job.cron ?? "* * * * *") : undefined;
|
|
439
|
+
const stamp = cadence ? scheduledTaskProvenance(job.id, cadence) : provenance(tick);
|
|
432
440
|
pi.sendMessage(
|
|
433
441
|
{
|
|
434
442
|
customType: TICK_MESSAGE_TYPE,
|
|
435
|
-
content: `${
|
|
443
|
+
content: `${stamp}\n${body}`,
|
|
436
444
|
display: true,
|
|
437
|
-
details: {
|
|
445
|
+
details: {
|
|
446
|
+
kind: job.kind,
|
|
447
|
+
tickCount: tick,
|
|
448
|
+
jobId: job.id,
|
|
449
|
+
cron: job.cron,
|
|
450
|
+
prompt: body,
|
|
451
|
+
reason: job.reason,
|
|
452
|
+
},
|
|
438
453
|
},
|
|
454
|
+
// `triggerTurn` is what starts the run: without it the message is
|
|
455
|
+
// only recorded and nothing ever happens.
|
|
439
456
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
440
457
|
);
|
|
441
458
|
|
|
@@ -445,34 +462,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
445
462
|
}
|
|
446
463
|
}
|
|
447
464
|
|
|
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
465
|
/** True when the pi-cc-monitor extension registered its Monitor tool. */
|
|
477
466
|
function monitorToolAvailable(): boolean {
|
|
478
467
|
try {
|
|
@@ -483,10 +472,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
483
472
|
}
|
|
484
473
|
|
|
485
474
|
/** 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
475
|
/**
|
|
491
476
|
* A run finished without an abort: self-paced chains whose tick the model
|
|
492
477
|
* did not reschedule get their one keepalive, or end.
|
|
@@ -549,32 +534,22 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
549
534
|
// ---------------------------------------------------------------- command
|
|
550
535
|
|
|
551
536
|
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",
|
|
537
|
+
description: "Run a prompt repeatedly: /loop [interval] <prompt>, /loop <prompt>, /loop stop, /loop status.",
|
|
554
538
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
555
539
|
lastCtx = ctx;
|
|
556
540
|
const raw = args.trim();
|
|
557
|
-
|
|
558
541
|
const [head, ...rest] = raw.split(/\s+/);
|
|
542
|
+
|
|
543
|
+
// Settings configure the extension, not a loop: they stay in code.
|
|
559
544
|
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": {
|
|
545
|
+
case "footer":
|
|
546
|
+
case "statusline": {
|
|
569
547
|
const arg = (rest[0] ?? "").toLowerCase();
|
|
570
|
-
if (arg
|
|
571
|
-
notify(ctx, `Usage: /loop
|
|
548
|
+
if (!arg) {
|
|
549
|
+
notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
|
|
572
550
|
return;
|
|
573
551
|
}
|
|
574
|
-
|
|
575
|
-
persist(scheduler.list());
|
|
576
|
-
refreshWidget(scheduler.list());
|
|
577
|
-
notify(ctx, `Loop status widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
|
|
552
|
+
setSetting(ctx, "footer", arg);
|
|
578
553
|
return;
|
|
579
554
|
}
|
|
580
555
|
case "settings":
|
|
@@ -589,11 +564,11 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
589
564
|
setSetting(ctx, "messages", value);
|
|
590
565
|
return;
|
|
591
566
|
}
|
|
592
|
-
if (name === "
|
|
593
|
-
setSetting(ctx, "
|
|
567
|
+
if (name === "footer" || name === "status") {
|
|
568
|
+
setSetting(ctx, "footer", value);
|
|
594
569
|
return;
|
|
595
570
|
}
|
|
596
|
-
notify(ctx, "Usage: /loop settings [messages|
|
|
571
|
+
notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
|
|
597
572
|
return;
|
|
598
573
|
}
|
|
599
574
|
case "help":
|
|
@@ -605,203 +580,30 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
605
580
|
break;
|
|
606
581
|
}
|
|
607
582
|
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
prompt = "";
|
|
621
|
-
} else {
|
|
622
|
-
// Rule 1: leading interval token, e.g. "5m check the deploy"
|
|
623
|
-
const leading = /^(\S+)\s+([\s\S]+)$/.exec(body);
|
|
624
|
-
if (leading) {
|
|
625
|
-
const parsed = parseIntervalToken(leading[1]);
|
|
626
|
-
if (parsed !== null) {
|
|
627
|
-
intervalSeconds = parsed;
|
|
628
|
-
prompt = leading[2].trim();
|
|
629
|
-
}
|
|
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);
|
|
583
|
+
// Everything else becomes a normal user message, the way Claude Code's
|
|
584
|
+
// /loop skill does: the conversation keeps the request, and the model
|
|
585
|
+
// drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
|
|
586
|
+
// The per-run rules travel with the run through before_agent_start.
|
|
587
|
+
const text = raw ? `/loop ${raw}` : "/loop";
|
|
588
|
+
queueRun({ content: text, guidelines: [loopCommandGuidelines(raw)] });
|
|
589
|
+
// Pi runs an extension command even while a turn is streaming, and its
|
|
590
|
+
// sendUserMessage throws unless a lane is named: "Agent is already
|
|
591
|
+
// processing. Specify streamingBehavior". `followUp` is that lane - the
|
|
592
|
+
// request waits for the current turn to end, which is where a /loop
|
|
593
|
+
// belongs anyway. When idle the option is ignored and the turn starts.
|
|
594
|
+
pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
|
|
643
595
|
},
|
|
644
596
|
});
|
|
645
597
|
|
|
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)}`);
|
|
688
|
-
}
|
|
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
|
-
}
|
|
741
|
-
|
|
742
|
-
async function stopLoops(ctx: ExtensionContext, target: string): Promise<void> {
|
|
743
|
-
if (target) {
|
|
744
|
-
const job = scheduler.get(target);
|
|
745
|
-
if (job) {
|
|
746
|
-
if (job.kind === "wakeup") {
|
|
747
|
-
retireChain(job, "stopped by the user");
|
|
748
|
-
} else {
|
|
749
|
-
removeJob(job);
|
|
750
|
-
notify(ctx, `Cancelled loop ${target}`, "info");
|
|
751
|
-
}
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
if (durable?.remove(target)) {
|
|
755
|
-
notify(ctx, `Cancelled durable job ${target}`, "info");
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
notify(ctx, `No loop with id ${target}`, "warning");
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
const jobs = scheduler.list();
|
|
762
|
-
if (!jobs.length) {
|
|
763
|
-
notify(ctx, "No active loops", "info");
|
|
764
|
-
return;
|
|
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
|
-
}
|
|
793
|
-
|
|
794
598
|
function showHelp(ctx: ExtensionContext): void {
|
|
795
599
|
const help = [
|
|
796
600
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
797
601
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
798
602
|
"/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",
|
|
603
|
+
"/loop footer on|off show or hide the one-line summary in the footer",
|
|
802
604
|
"/loop settings show UI settings",
|
|
803
605
|
"/loop settings messages on|off compact two-line markers",
|
|
804
|
-
"/loop settings
|
|
606
|
+
"/loop settings footer on|off the one-line footer summary",
|
|
805
607
|
"",
|
|
806
608
|
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
807
609
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
@@ -815,15 +617,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
815
617
|
const lines = [
|
|
816
618
|
`pi-cc settings \u00b7 ${configPath()}`,
|
|
817
619
|
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
818
|
-
`
|
|
620
|
+
` footer: ${config.footer ? "summary" : "hidden"}`,
|
|
819
621
|
"",
|
|
820
622
|
"Usage: /loop settings messages on|off",
|
|
821
|
-
" /loop settings
|
|
623
|
+
" /loop settings footer on|off",
|
|
822
624
|
];
|
|
823
625
|
notify(ctx, lines.join("\n"), "info");
|
|
824
626
|
}
|
|
825
627
|
|
|
826
|
-
function setSetting(ctx: ExtensionContext, key: "messages" | "
|
|
628
|
+
function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
|
|
827
629
|
const value = (raw ?? "").toLowerCase();
|
|
828
630
|
const on = value === "on" || value === "true" || value === "1";
|
|
829
631
|
const off = value === "off" || value === "false" || value === "0";
|
|
@@ -831,28 +633,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
831
633
|
showSettings(ctx);
|
|
832
634
|
return;
|
|
833
635
|
}
|
|
834
|
-
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : {
|
|
636
|
+
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
|
|
835
637
|
const next = saveConfig(patch);
|
|
836
|
-
let compatChanged = false;
|
|
837
|
-
if (key === "tools") {
|
|
838
|
-
compatChanged = syncPiCcExtensionsExcludes(piCcToolNames(), on);
|
|
839
|
-
}
|
|
840
638
|
try {
|
|
841
639
|
pi.events.emit(CONFIG_CHANNEL, next);
|
|
842
640
|
} catch {
|
|
843
641
|
// another extension must not break the command
|
|
844
642
|
}
|
|
845
|
-
notify(ctx, `${key === "messages" ? "Compact message style" : "
|
|
846
|
-
if (
|
|
847
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
848
|
-
}
|
|
643
|
+
notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
|
|
644
|
+
if (key === "footer") refreshStatus();
|
|
849
645
|
}
|
|
850
646
|
|
|
851
647
|
// ------------------------------------------------------------------ tools
|
|
852
648
|
|
|
853
649
|
pi.registerTool({
|
|
854
650
|
name: "cron_create",
|
|
855
|
-
|
|
651
|
+
// Claude Code's tool names: the row reads "CronCreate(*/5 * * * *: ...)".
|
|
652
|
+
label: "CronCreate",
|
|
856
653
|
description: CRON_CREATE_DESCRIPTION,
|
|
857
654
|
promptSnippet: CRON_CREATE_SNIPPET,
|
|
858
655
|
parameters: Type.Object({
|
|
@@ -946,7 +743,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
946
743
|
|
|
947
744
|
pi.registerTool({
|
|
948
745
|
name: "cron_delete",
|
|
949
|
-
label: "
|
|
746
|
+
label: "CronDelete",
|
|
950
747
|
description: CRON_DELETE_DESCRIPTION,
|
|
951
748
|
promptSnippet: CRON_DELETE_SNIPPET,
|
|
952
749
|
parameters: Type.Object({
|
|
@@ -975,7 +772,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
975
772
|
|
|
976
773
|
pi.registerTool({
|
|
977
774
|
name: "cron_list",
|
|
978
|
-
label: "
|
|
775
|
+
label: "CronList",
|
|
979
776
|
description: CRON_LIST_DESCRIPTION,
|
|
980
777
|
promptSnippet: CRON_LIST_SNIPPET,
|
|
981
778
|
parameters: Type.Object({}),
|
|
@@ -1007,7 +804,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1007
804
|
|
|
1008
805
|
pi.registerTool({
|
|
1009
806
|
name: "schedule_wakeup",
|
|
1010
|
-
label: "
|
|
807
|
+
label: "ScheduleWakeup",
|
|
1011
808
|
description: SCHEDULE_WAKEUP_DESCRIPTION,
|
|
1012
809
|
promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
|
|
1013
810
|
parameters: Type.Object({
|
|
@@ -1017,7 +814,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1017
814
|
}),
|
|
1018
815
|
),
|
|
1019
816
|
reason: Type.Optional(
|
|
1020
|
-
Type.String({
|
|
817
|
+
Type.String({
|
|
818
|
+
description:
|
|
819
|
+
"One short sentence explaining the chosen delay. Shown to the user, so be specific. Required unless stop is true.",
|
|
820
|
+
}),
|
|
1021
821
|
),
|
|
1022
822
|
prompt: Type.Optional(
|
|
1023
823
|
Type.String({
|
|
@@ -1045,8 +845,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1045
845
|
{
|
|
1046
846
|
type: "text" as const,
|
|
1047
847
|
text: chains.length
|
|
1048
|
-
? `Loop
|
|
1049
|
-
|
|
848
|
+
? `Loop stopped \u2014 cancelled ${chains.length} pending wakeup(s); no further dynamic-loop wakeups scheduled. ` +
|
|
849
|
+
"Any Monitor armed for this loop was stopped with it. Write the loop's outcome for the user, then end the turn."
|
|
850
|
+
: "Loop stopped \u2014 any dynamic loop in this session is ended; there was no pending wakeup to cancel. " +
|
|
851
|
+
"If you are running a fixed-interval /loop (a recurring cron), it is NOT stopped by this call \u2014 cancel it with cron_delete.",
|
|
1050
852
|
},
|
|
1051
853
|
],
|
|
1052
854
|
details: { stopped: true, cancelledWakeups: chains.length },
|
|
@@ -1054,11 +856,33 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1054
856
|
}
|
|
1055
857
|
|
|
1056
858
|
if (typeof params.delaySeconds !== "number" || Number.isNaN(params.delaySeconds)) {
|
|
1057
|
-
throw new Error("delaySeconds
|
|
859
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
860
|
+
}
|
|
861
|
+
if (!params.reason || !params.reason.trim()) {
|
|
862
|
+
throw new Error("`delaySeconds` and `reason` are required when `stop` is not true.");
|
|
1058
863
|
}
|
|
1059
864
|
const prompt = params.prompt ? normalizePrompt(params.prompt) : "";
|
|
1060
865
|
if (!prompt) {
|
|
1061
|
-
throw new Error("prompt is required
|
|
866
|
+
throw new Error("`prompt` is required when `stop` is not true.");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (currentRun.cronId) {
|
|
870
|
+
// Not an error: the request is already satisfied by the schedule.
|
|
871
|
+
// Returning it as a result (instead of throwing) keeps the transcript
|
|
872
|
+
// calm and tells the model nothing is missing.
|
|
873
|
+
const cronJob = scheduler.get(currentRun.cronId);
|
|
874
|
+
return {
|
|
875
|
+
content: [
|
|
876
|
+
{
|
|
877
|
+
type: "text" as const,
|
|
878
|
+
text:
|
|
879
|
+
`No wakeup needed: this tick belongs to the fixed-interval /loop ${currentRun.cronId}` +
|
|
880
|
+
`${cronJob?.cron ? ` (${humanSchedule(cronJob.cron)})` : ""}, which fires its own next tick. ` +
|
|
881
|
+
`Nothing was scheduled. To end the loop, call cron_delete with id ${currentRun.cronId}.`,
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
details: { suppressed: "fixed-interval", id: currentRun.cronId, stopped: false },
|
|
885
|
+
};
|
|
1062
886
|
}
|
|
1063
887
|
|
|
1064
888
|
// Claude Code rounds and clamps; only a change after rounding counts as clamped.
|
|
@@ -1080,8 +904,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1080
904
|
{
|
|
1081
905
|
type: "text" as const,
|
|
1082
906
|
text:
|
|
1083
|
-
`The
|
|
1084
|
-
"
|
|
907
|
+
`Wakeup not scheduled. The loop reached its maximum duration (${maxAgeDays()} days) \u2014 the loop has ended; ` +
|
|
908
|
+
"do not re-issue. Tell the user they can start a new /loop.",
|
|
1085
909
|
},
|
|
1086
910
|
],
|
|
1087
911
|
details: { stopped: true, agedOut: true, id: chain.id, cancelledWakeups: 1 },
|
|
@@ -1119,9 +943,17 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1119
943
|
reason: params.reason,
|
|
1120
944
|
});
|
|
1121
945
|
|
|
1122
|
-
const note = wasClamped ? ` (clamped
|
|
946
|
+
const note = wasClamped ? ` (clamped to ${delay}s from your requested value)` : "";
|
|
947
|
+
const clock = new Date(scheduledFor).toTimeString().slice(0, 8);
|
|
1123
948
|
return {
|
|
1124
|
-
content: [
|
|
949
|
+
content: [
|
|
950
|
+
{
|
|
951
|
+
type: "text" as const,
|
|
952
|
+
text:
|
|
953
|
+
`Next wakeup scheduled for ${clock} (in ${delay}s)${note}. ` +
|
|
954
|
+
"This turn ends when this call returns \u2014 you are re-invoked when the wakeup fires or a monitor event arrives.",
|
|
955
|
+
},
|
|
956
|
+
],
|
|
1125
957
|
details: {
|
|
1126
958
|
id: chain.id,
|
|
1127
959
|
scheduledFor,
|
|
@@ -1137,19 +969,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1137
969
|
},
|
|
1138
970
|
});
|
|
1139
971
|
|
|
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
972
|
|
|
1154
973
|
// ----------------------------------------------------------------- events
|
|
1155
974
|
|
|
@@ -1161,11 +980,54 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1161
980
|
* wakeup stays armed as the fallback heartbeat, and the model re-arms it if
|
|
1162
981
|
* it wants a different time.
|
|
1163
982
|
*/
|
|
983
|
+
pi.registerMessageRenderer(TICK_MESSAGE_TYPE, renderScheduledMessage);
|
|
984
|
+
|
|
1164
985
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1165
986
|
lastCtx = ctx;
|
|
1166
987
|
currentRun = {};
|
|
988
|
+
|
|
989
|
+
// Our own messages are ordinary user messages now, so the instructions
|
|
990
|
+
// for the run and the loop it belongs to travel with the queued entry.
|
|
991
|
+
if (event.messageType === "user") {
|
|
992
|
+
const queued = takeQueuedRun(event.prompt);
|
|
993
|
+
if (!queued) return;
|
|
994
|
+
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick };
|
|
995
|
+
if (!queued.guidelines.length) return;
|
|
996
|
+
const options = event.systemPromptOptions;
|
|
997
|
+
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...queued.guidelines];
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1167
1000
|
if (event.messageType !== "custom") return;
|
|
1168
1001
|
|
|
1002
|
+
if (event.message.customType === TICK_MESSAGE_TYPE) {
|
|
1003
|
+
const details = (event.message.details ?? {}) as {
|
|
1004
|
+
kind?: "cron" | "wakeup";
|
|
1005
|
+
jobId?: string;
|
|
1006
|
+
cron?: string;
|
|
1007
|
+
tickCount?: number;
|
|
1008
|
+
};
|
|
1009
|
+
currentRun = {
|
|
1010
|
+
chainId: details.kind === "wakeup" ? details.jobId : undefined,
|
|
1011
|
+
cronId: details.kind === "cron" ? details.jobId : undefined,
|
|
1012
|
+
tick: details.tickCount,
|
|
1013
|
+
};
|
|
1014
|
+
const guidelines =
|
|
1015
|
+
details.kind === "cron" && details.jobId
|
|
1016
|
+
? [cronTickGuidelines(details.jobId, humanSchedule(details.cron ?? "* * * * *"))]
|
|
1017
|
+
: [];
|
|
1018
|
+
if (details.kind === "wakeup") {
|
|
1019
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1020
|
+
if (chain) {
|
|
1021
|
+
guidelines.push(loopGuidelines(chain.prompt));
|
|
1022
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (!guidelines.length) return;
|
|
1026
|
+
const options = event.systemPromptOptions;
|
|
1027
|
+
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...guidelines];
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1169
1031
|
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
|
1170
1032
|
// A monitor bound to a loop woke the agent: the run belongs to that loop.
|
|
1171
1033
|
const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
|
|
@@ -1173,31 +1035,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1173
1035
|
if (chain) currentRun.chainId = chain.id;
|
|
1174
1036
|
return;
|
|
1175
1037
|
}
|
|
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
1038
|
});
|
|
1202
1039
|
|
|
1203
1040
|
/**
|
|
@@ -1220,17 +1057,23 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1220
1057
|
|
|
1221
1058
|
pi.on("session_start", async (event, ctx) => {
|
|
1222
1059
|
lastCtx = ctx;
|
|
1060
|
+
// Another process may have edited the shared settings file.
|
|
1061
|
+
invalidateConfig();
|
|
1223
1062
|
durable?.stop();
|
|
1224
1063
|
restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
|
|
1225
1064
|
durable = createDurable(ctx.cwd);
|
|
1226
1065
|
durable.start();
|
|
1227
1066
|
scheduler.start();
|
|
1228
|
-
if (syncPiCcExtensionsExcludes(piCcToolNames(), loadConfig().compactToolRows)) {
|
|
1229
|
-
notify(ctx, "pi-cc-extensions excludeRenderers updated \u00b7 /reload to apply", "info");
|
|
1230
|
-
}
|
|
1231
1067
|
});
|
|
1232
1068
|
|
|
1233
1069
|
pi.on("session_shutdown", async () => {
|
|
1070
|
+
// Pi clears extension statuses when it unloads, but a reload should not
|
|
1071
|
+
// leave a stale count behind until then.
|
|
1072
|
+
try {
|
|
1073
|
+
lastCtx?.ui?.setStatus?.(STATUS_KEY, undefined);
|
|
1074
|
+
} catch {
|
|
1075
|
+
// the context may already be invalid
|
|
1076
|
+
}
|
|
1234
1077
|
scheduler.stop();
|
|
1235
1078
|
durable?.stop();
|
|
1236
1079
|
durable = undefined;
|
|
@@ -1264,12 +1107,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1264
1107
|
}
|
|
1265
1108
|
}
|
|
1266
1109
|
if (!data) {
|
|
1267
|
-
refreshWidget(scheduler.list());
|
|
1268
1110
|
return;
|
|
1269
1111
|
}
|
|
1270
1112
|
|
|
1271
1113
|
for (const prompt of data.stopped ?? []) rememberStopped(prompt);
|
|
1272
|
-
if (typeof data.widget === "boolean") widgetEnabled = data.widget;
|
|
1273
1114
|
|
|
1274
1115
|
const now = Date.now();
|
|
1275
1116
|
const limit = maxAgeMs();
|