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/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
- "name": "pi-cc-loop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "pi-cc-loop",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
5
+ "author": "tarnish233",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "pi-package",
10
+ "extension",
11
+ "loop",
12
+ "cron",
13
+ "scheduler",
14
+ "claude-code"
15
+ ],
16
+ "homepage": "https://github.com/tarnish233/pi-cc-loop#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/tarnish233/pi-cc-loop.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/tarnish233/pi-cc-loop/issues"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "cron.ts",
27
+ "schedule.ts",
28
+ "render.ts",
29
+ "config.ts",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "*",
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*",
42
+ "typebox": "*"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.19.0"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test tests/*.test.mjs",
49
+ "typecheck": "tsc --noEmit -p tsconfig.json"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/render.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Rendering helpers for the pi-cc-loop custom messages.
3
+ *
4
+ * Pi renders a custom message with the raw `customType` as its title
5
+ * (`[pi-cc-loop-tick]`), which reads like a machine name. A custom renderer
6
+ * replaces that with either a quiet two-line block (the default) or the
7
+ * legacy padded card, depending on `compactMessages`.
8
+ *
9
+ * The quiet block is modelled on Claude Code's `scheduled_task_fire` marker:
10
+ * a timestamped header plus the prompt. No icon and no background; the
11
+ * accent-coloured label carries the meaning, and the timestamp makes a
12
+ * minute-by-minute loop easy to scan.
13
+ *
14
+ * The renderer only affects the display. The message content the model sees is
15
+ * unchanged.
16
+ */
17
+
18
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
19
+ import { Box, Container, Text } from "@earendil-works/pi-tui";
20
+
21
+ /** Flatten a custom message body to plain text. */
22
+ export function contentText(content: unknown): string {
23
+ if (typeof content === "string") return content;
24
+ if (Array.isArray(content)) {
25
+ return content
26
+ .map((part) => {
27
+ if (!part || typeof part !== "object") return "";
28
+ const text = (part as { text?: unknown }).text;
29
+ return typeof text === "string" ? text : "";
30
+ })
31
+ .join("\n");
32
+ }
33
+ return "";
34
+ }
35
+
36
+ /** Remove the provenance line the model needs but the reader does not. */
37
+ export function stripProvenance(text: string, pattern: RegExp): string {
38
+ return text.replace(pattern, "");
39
+ }
40
+
41
+ /** The first non-empty line of a text block. */
42
+ export function firstLine(text: string): string {
43
+ for (const line of text.split("\n")) {
44
+ const trimmed = line.trim();
45
+ if (trimmed) return trimmed;
46
+ }
47
+ return "";
48
+ }
49
+
50
+ /** Split a text block into displayable lines, dropping blank ones. */
51
+ export function bodyLines(text: string): string[] {
52
+ return text
53
+ .split("\n")
54
+ .map((line) => line.replace(/\r$/, ""))
55
+ .filter((line) => line.trim().length > 0);
56
+ }
57
+
58
+ /**
59
+ * Format a timestamp the way Claude Code does: just the time for today,
60
+ * "Oct 21 4:13 PM" for an older message.
61
+ */
62
+ export function formatStamp(epochMs: number | undefined): string {
63
+ const date = new Date(epochMs ?? Date.now());
64
+ if (Number.isNaN(date.getTime())) return "";
65
+ const now = new Date();
66
+ const time = date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
67
+ if (date.toDateString() === now.toDateString()) return time;
68
+ const day = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
69
+ return `${day} ${time}`;
70
+ }
71
+
72
+ /**
73
+ * Draw the legacy padded card used when `compactMessages` is off.
74
+ *
75
+ * `header` is the one-line summary, `body` the full text shown when the
76
+ * message is expanded.
77
+ */
78
+ export function renderCard(options: {
79
+ header: string;
80
+ body?: string;
81
+ expanded: boolean;
82
+ outputPad: number;
83
+ theme: Theme;
84
+ }): Box {
85
+ const { header, body, expanded, outputPad, theme } = options;
86
+ const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
87
+ box.addChild(new Text(theme.fg("customMessageLabel", header), 0, 0));
88
+ if (expanded && body) {
89
+ box.addChild(new Text(theme.fg("customMessageText", `\n${body}`), 0, 0));
90
+ } else if (!expanded) {
91
+ box.addChild(new Text(theme.fg("dim", ` ${expandHint()}`), 0, 0));
92
+ }
93
+ return box;
94
+ }
95
+
96
+ /**
97
+ * The hint for the expand shortcut. The keybinding is configurable and the
98
+ * theme has to be initialised, so fall back to the default when the hint
99
+ * would come out empty.
100
+ */
101
+ function expandHint(): string {
102
+ try {
103
+ const hint = keyHint("app.tools.expand", "to expand");
104
+ const plain = hint.replace(/\x1b\[[0-9;]*m/g, "");
105
+ if (!plain.trim() || plain.trim().startsWith("to expand")) {
106
+ return "(Ctrl+O to expand)";
107
+ }
108
+ return hint;
109
+ } catch {
110
+ return "(Ctrl+O to expand)";
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Draw a quiet event block: one coloured header line, then an indented body.
116
+ *
117
+ * Collapsed shows at most `maxBodyLines` body lines (plus a "+N more" note);
118
+ * expanded shows the whole body and the metadata line.
119
+ */
120
+ export function renderBlock(options: {
121
+ header: string;
122
+ body: string[];
123
+ meta?: string[];
124
+ expanded: boolean;
125
+ outputPad: number;
126
+ theme: Theme;
127
+ maxBodyLines?: number;
128
+ }): Container {
129
+ const { header, body, meta, expanded, outputPad, theme } = options;
130
+ const limit = options.maxBodyLines ?? 3;
131
+ const container = new Container();
132
+ container.addChild(new Text(header, outputPad, 0));
133
+
134
+ const indent = outputPad + 2;
135
+ const shown = expanded ? body : body.slice(0, limit);
136
+ for (const line of shown) {
137
+ container.addChild(new Text(theme.fg("customMessageText", line), indent, 0));
138
+ }
139
+ if (!expanded && body.length > shown.length) {
140
+ container.addChild(new Text(theme.fg("dim", `+${body.length - shown.length} more lines`), indent, 0));
141
+ }
142
+ if (expanded && meta?.length) {
143
+ container.addChild(new Text(theme.fg("dim", meta.join(" · ")), indent, 0));
144
+ }
145
+ return container;
146
+ }
package/schedule.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Job store and dispatch scheduler for the pi-cc-loop extension.
3
+ *
4
+ * Modelled on Claude Code's scheduler (v2.1.306):
5
+ *
6
+ * - "The scheduler checks every second for due tasks and enqueues them at low
7
+ * priority." -> one scan per second, not one timer per job.
8
+ * - "A scheduled prompt fires between your turns, not while Claude is
9
+ * mid-response." -> a due job waits until the agent is idle.
10
+ * - "If a task's scheduled time passes while Claude is busy on a long-running
11
+ * request, it fires once when Claude becomes idle, not once per missed
12
+ * interval." -> at most one pending tick per job, no backlog.
13
+ */
14
+
15
+ import { nextFireAfter, parseCron, type CronFields } from "./cron.ts";
16
+
17
+ export type JobKind = "cron" | "wakeup";
18
+
19
+ export interface LoopJob {
20
+ id: string;
21
+ kind: JobKind;
22
+ /** 5-field cron expression, for `cron` jobs. */
23
+ cron?: string;
24
+ /** The prompt that is sent to the model on every fire. */
25
+ prompt: string;
26
+ recurring: boolean;
27
+ createdAt: number;
28
+ /** Epoch ms of the next cron slot. */
29
+ nextFireAt?: number;
30
+ /** Epoch ms of the next dynamic wakeup. */
31
+ scheduledFor?: number;
32
+ /** Epoch ms when the dynamic chain started; used for the age-out check. */
33
+ chainStartedAt?: number;
34
+ /** True once the keepalive fallback has been used for this chain. */
35
+ keepaliveUsed?: boolean;
36
+ /** True while the current turn is the keepalive turn (the model's last chance). */
37
+ keepaliveTurn?: boolean;
38
+ /** True after a tick was dispatched, until the model reschedules. */
39
+ awaitingReschedule?: boolean;
40
+ /** True when the job is due and waiting for the agent to go idle. */
41
+ pending?: boolean;
42
+ /** Epoch ms when the job became pending. */
43
+ dueAt?: number;
44
+ /** Epoch ms when the job was last dispatched. */
45
+ dispatchedAt?: number;
46
+ /** Turn sequence number at dispatch; set once a turn confirms delivery. */
47
+ dispatchedTurnSeq?: number;
48
+ /** How many times the job has fired. */
49
+ fireCount?: number;
50
+ /** How many ticks the model has seen. */
51
+ tickCount?: number;
52
+ /** Epoch ms of the last fire. */
53
+ lastTickAt?: number;
54
+ /** Optional cap on the number of fires. */
55
+ maxFires?: number;
56
+ /** True when the job is written to disk and survives a restart. */
57
+ durable: boolean;
58
+ /** Free-form label shown in `/loop status`. */
59
+ label?: string;
60
+ /** Why the delay was chosen (dynamic jobs). */
61
+ reason?: string;
62
+ /** Where the job came from: `loop_command`, `cron_create`, `schedule_wakeup`. */
63
+ source: string;
64
+ }
65
+
66
+ export interface SchedulerHooks {
67
+ /** Deliver the job's prompt to the agent. */
68
+ dispatch: (job: LoopJob) => void;
69
+ /** Called whenever the job set changes, so state can be persisted. */
70
+ onChange?: (jobs: LoopJob[]) => void;
71
+ /** Max age of a recurring job in ms; 0 disables the check. */
72
+ maxAgeMs?: () => number;
73
+ /** Fraction of its period a recurring job may be delayed by; 0 disables jitter. */
74
+ jitterFraction?: () => number;
75
+ /** Upper bound for the jitter delay in ms. */
76
+ jitterCapMs?: () => number;
77
+ /** True when the agent is idle and has nothing queued: a tick may go out. */
78
+ canDispatch?: () => boolean;
79
+ /** Called when a job passes its maximum age and is dropped. */
80
+ onExpire?: (job: LoopJob) => void;
81
+ }
82
+
83
+ /** Claude Code checks for due tasks once a second. */
84
+ export const SCAN_INTERVAL_MS = 1000;
85
+
86
+ /**
87
+ * The scan interval can be raised for tests, where the scan is driven by hand.
88
+ */
89
+ export function scanIntervalMs(): number {
90
+ const raw = process.env.PI_LOOP_SCAN_MS;
91
+ if (raw === undefined) return SCAN_INTERVAL_MS;
92
+ const value = Number(raw);
93
+ return Number.isFinite(value) && value > 0 ? value : SCAN_INTERVAL_MS;
94
+ }
95
+
96
+ /**
97
+ * How long a dispatched tick may sit without a turn starting before it is
98
+ * treated as undelivered and requeued.
99
+ */
100
+ export const DISPATCH_CONFIRM_MS = 30_000;
101
+
102
+ const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
103
+
104
+ export function generateId(length = 8): string {
105
+ let out = "";
106
+ for (let i = 0; i < length; i++) {
107
+ out += ID_ALPHABET[Math.floor(Math.random() * ID_ALPHABET.length)];
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export class LoopScheduler {
113
+ private jobs = new Map<string, LoopJob>();
114
+ private scanTimer: ReturnType<typeof setInterval> | null = null;
115
+ private hooks: SchedulerHooks;
116
+ private inFlightId: string | null = null;
117
+ private turnSeq = 0;
118
+
119
+ constructor(hooks: SchedulerHooks) {
120
+ this.hooks = hooks;
121
+ }
122
+
123
+ get size(): number {
124
+ return this.jobs.size;
125
+ }
126
+
127
+ list(): LoopJob[] {
128
+ return [...this.jobs.values()].sort((a, b) => a.createdAt - b.createdAt);
129
+ }
130
+
131
+ get(id: string): LoopJob | undefined {
132
+ return this.jobs.get(id);
133
+ }
134
+
135
+ byKind(kind: JobKind): LoopJob[] {
136
+ return this.list().filter((job) => job.kind === kind);
137
+ }
138
+
139
+ /** The dynamic chain that owns the given wakeup prompt, if any. */
140
+ findChain(prompt: string): LoopJob | undefined {
141
+ return this.list().find((job) => job.kind === "wakeup" && job.prompt === prompt);
142
+ }
143
+
144
+ add(job: LoopJob): LoopJob {
145
+ this.jobs.set(job.id, job);
146
+ this.arm(job);
147
+ this.changed();
148
+ return job;
149
+ }
150
+
151
+ update(id: string, patch: Partial<LoopJob>): LoopJob | undefined {
152
+ const job = this.jobs.get(id);
153
+ if (!job) return undefined;
154
+ const updated = { ...job, ...patch };
155
+ this.jobs.set(id, updated);
156
+ this.changed();
157
+ return updated;
158
+ }
159
+
160
+ remove(id: string): boolean {
161
+ const existed = this.jobs.delete(id);
162
+ if (existed && this.inFlightId === id) this.inFlightId = null;
163
+ if (existed) this.changed();
164
+ return existed;
165
+ }
166
+
167
+ /** Remove every job of a kind (used by `/loop stop`). */
168
+ removeKind(kind: JobKind): number {
169
+ const ids = this.byKind(kind).map((job) => job.id);
170
+ for (const id of ids) this.jobs.delete(id);
171
+ if (ids.length) this.changed();
172
+ return ids.length;
173
+ }
174
+
175
+ clear(notify = true): void {
176
+ this.jobs.clear();
177
+ this.inFlightId = null;
178
+ if (notify) this.changed();
179
+ }
180
+
181
+ /** Restore a job without arming it (used while loading persisted state). */
182
+ restore(job: LoopJob): void {
183
+ this.jobs.set(job.id, job);
184
+ }
185
+
186
+ // ------------------------------------------------------------- lifecycle
187
+
188
+ /** Start the once-per-second scan. */
189
+ start(): void {
190
+ if (this.scanTimer) return;
191
+ this.scanTimer = setInterval(() => this.tick(), scanIntervalMs());
192
+ // Never keep the process alive just for a pending tick.
193
+ if (typeof this.scanTimer.unref === "function") this.scanTimer.unref();
194
+ }
195
+
196
+ stop(): void {
197
+ if (this.scanTimer) {
198
+ clearInterval(this.scanTimer);
199
+ this.scanTimer = null;
200
+ }
201
+ }
202
+
203
+ /** Compute the first fire time of a cron job. */
204
+ arm(job: LoopJob, from = Date.now()): void {
205
+ if (job.kind !== "cron" || !job.cron) return;
206
+ const fields = parseCron(job.cron);
207
+ if (!fields) return;
208
+ const next = nextFireAfter(fields, new Date(from));
209
+ if (!next) return;
210
+ job.nextFireAt = next.getTime() + this.jitterFor(job, fields, next);
211
+ }
212
+
213
+ // ------------------------------------------------------------- dispatch
214
+
215
+ /**
216
+ * Run one scan: expire old jobs, requeue unconfirmed ticks, mark due jobs
217
+ * pending, and dispatch the oldest pending job when the agent is idle.
218
+ */
219
+ tick(now = Date.now()): void {
220
+ this.expire(now);
221
+ this.retryUnconfirmed(now);
222
+ this.markDue(now);
223
+ this.tryDispatch(now);
224
+ }
225
+
226
+ /**
227
+ * The agent settled: a dispatched tick has finished its turn, so the next
228
+ * pending job may go out.
229
+ */
230
+ notifyIdle(): void {
231
+ this.releaseInFlight();
232
+ this.tick();
233
+ }
234
+
235
+ /** A turn started: any dispatched tick is confirmed delivered. */
236
+ noteTurnStarted(): void {
237
+ this.turnSeq += 1;
238
+ if (!this.inFlightId) return;
239
+ const job = this.jobs.get(this.inFlightId);
240
+ if (job) job.dispatchedTurnSeq = this.turnSeq;
241
+ }
242
+
243
+ /**
244
+ * Mark jobs whose time has come as pending. A job is pending at most once:
245
+ * missed intervals coalesce into a single tick, like Claude Code.
246
+ */
247
+ private markDue(now: number): void {
248
+ let changed = false;
249
+ for (const job of this.jobs.values()) {
250
+ if (job.pending) continue;
251
+ const at = job.kind === "cron" ? job.nextFireAt : job.scheduledFor;
252
+ if (at === undefined || at > now) continue;
253
+ job.pending = true;
254
+ job.dueAt = now;
255
+ if (job.kind === "wakeup") {
256
+ // The wakeup has been consumed; the model has to arm the next one.
257
+ job.scheduledFor = undefined;
258
+ }
259
+ changed = true;
260
+ }
261
+ if (changed) this.changed();
262
+ }
263
+
264
+ /** Dispatch the oldest pending job when nothing is in flight and the agent is idle. */
265
+ private tryDispatch(now: number): boolean {
266
+ if (this.inFlightId) return false;
267
+ const job = this.oldestPending();
268
+ if (!job) return false;
269
+ if (this.hooks.canDispatch && !this.hooks.canDispatch()) return false;
270
+
271
+ job.pending = false;
272
+ job.dueAt = undefined;
273
+ job.dispatchedAt = now;
274
+ job.dispatchedTurnSeq = undefined;
275
+ job.fireCount = (job.fireCount ?? 0) + 1;
276
+ job.lastTickAt = now;
277
+
278
+ this.inFlightId = job.id;
279
+
280
+ if (job.kind === "wakeup") {
281
+ // A wakeup that fired from the keepalive is the model's last chance.
282
+ if (job.keepaliveUsed) job.keepaliveTurn = true;
283
+ job.awaitingReschedule = true;
284
+ } else {
285
+ this.advanceCron(job, now);
286
+ }
287
+ this.changed();
288
+
289
+ this.hooks.dispatch(job);
290
+
291
+ // A one-shot cron job and a capped cron job are done after their last
292
+ // fire. A wakeup chain is never deleted here: the model re-arms it.
293
+ if (job.kind === "cron" && (!job.recurring || (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires))) {
294
+ this.jobs.delete(job.id);
295
+ this.inFlightId = null;
296
+ this.changed();
297
+ }
298
+ return true;
299
+ }
300
+
301
+ /** Recompute the next cron slot after a fire. */
302
+ private advanceCron(job: LoopJob, now: number): void {
303
+ if (!job.cron) return;
304
+ const fields = parseCron(job.cron);
305
+ if (!fields) return;
306
+ const next = nextFireAfter(fields, new Date(now));
307
+ if (!next) return;
308
+ job.nextFireAt = next.getTime() + this.jitterFor(job, fields, next);
309
+ }
310
+
311
+ /**
312
+ * A tick that was dispatched but never started a turn is treated as
313
+ * undelivered: the job goes back to pending so it fires again.
314
+ */
315
+ private retryUnconfirmed(now: number): void {
316
+ if (!this.inFlightId) return;
317
+ const job = this.jobs.get(this.inFlightId);
318
+ if (!job) {
319
+ this.inFlightId = null;
320
+ return;
321
+ }
322
+ if (job.dispatchedTurnSeq !== undefined) return;
323
+ if (now - (job.dispatchedAt ?? 0) < DISPATCH_CONFIRM_MS) return;
324
+ job.pending = true;
325
+ job.dueAt = now;
326
+ this.inFlightId = null;
327
+ this.changed();
328
+ }
329
+
330
+ /** Called when the agent settles: the in-flight tick has finished its turn. */
331
+ releaseInFlight(): void {
332
+ this.inFlightId = null;
333
+ }
334
+
335
+ private oldestPending(): LoopJob | undefined {
336
+ return this.list()
337
+ .filter((job) => job.pending)
338
+ .sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
339
+ }
340
+
341
+ // --------------------------------------------------------------- expiry
342
+
343
+ private expire(now: number): void {
344
+ const limit = this.hooks.maxAgeMs?.() ?? 0;
345
+ if (limit <= 0) return;
346
+ let changed = false;
347
+ for (const job of this.list()) {
348
+ if (now - job.createdAt < limit) continue;
349
+ this.jobs.delete(job.id);
350
+ if (this.inFlightId === job.id) this.inFlightId = null;
351
+ changed = true;
352
+ this.hooks.onExpire?.(job);
353
+ }
354
+ if (changed) this.changed();
355
+ }
356
+
357
+ /**
358
+ * Randomised delay for recurring jobs, so many sessions polling the same
359
+ * target do not all fire on the same second.
360
+ */
361
+ private jitterFor(job: LoopJob, fields: CronFields, next: Date): number {
362
+ if (!job.recurring) return 0;
363
+ const fraction = this.hooks.jitterFraction?.() ?? 0;
364
+ if (!(fraction > 0)) return 0;
365
+ const following = nextFireAfter(fields, next);
366
+ if (!following) return 0;
367
+ const period = following.getTime() - next.getTime();
368
+ const cap = this.hooks.jitterCapMs?.() ?? 30 * 60 * 1000;
369
+ return Math.round(Math.random() * Math.min(fraction * period, cap));
370
+ }
371
+
372
+ private changed(): void {
373
+ this.hooks.onChange?.(this.list());
374
+ }
375
+ }