pi-cc-loop 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- package/cron.ts +62 -51
- package/durable.ts +447 -0
- package/index.ts +794 -1027
- package/loopfile.ts +107 -0
- package/package.json +2 -6
- package/prompts.ts +200 -0
- package/render.ts +11 -8
- package/renderers.ts +147 -0
- package/schedule.ts +147 -169
- package/tool-rows.ts +79 -0
package/schedule.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Job store and dispatch scheduler for the pi-cc-loop extension.
|
|
3
3
|
*
|
|
4
|
-
* Modelled on Claude Code's scheduler
|
|
4
|
+
* Modelled on Claude Code's scheduler:
|
|
5
5
|
*
|
|
6
6
|
* - "The scheduler checks every second for due tasks and enqueues them at low
|
|
7
7
|
* priority." -> one scan per second, not one timer per job.
|
|
@@ -10,9 +10,18 @@
|
|
|
10
10
|
* - "If a task's scheduled time passes while Claude is busy on a long-running
|
|
11
11
|
* request, it fires once when Claude becomes idle, not once per missed
|
|
12
12
|
* interval." -> at most one pending tick per job, no backlog.
|
|
13
|
+
* - "Recurring tasks automatically expire 7 days after creation. The task
|
|
14
|
+
* fires one final time, then deletes itself." -> a recurring job past its
|
|
15
|
+
* maximum age is deleted right after its next fire. One-shot jobs do not
|
|
16
|
+
* expire.
|
|
17
|
+
* - Run times carry Claude Code's fixed per-job offset (see nextRunTime).
|
|
18
|
+
*
|
|
19
|
+
* There is no delivery confirmation or retry: Pi marks the session busy as
|
|
20
|
+
* soon as a tick starts a turn, so the idle check alone keeps ticks one at a
|
|
21
|
+
* time, and a tick that fails to start is dropped (Claude Code drops it too).
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
|
-
import { nextFireAfter, parseCron
|
|
24
|
+
import { nextFireAfter, parseCron } from "./cron.ts";
|
|
16
25
|
|
|
17
26
|
export type JobKind = "cron" | "wakeup";
|
|
18
27
|
|
|
@@ -25,67 +34,137 @@ export interface LoopJob {
|
|
|
25
34
|
prompt: string;
|
|
26
35
|
recurring: boolean;
|
|
27
36
|
createdAt: number;
|
|
28
|
-
/** Epoch ms of the next cron
|
|
37
|
+
/** Epoch ms of the next cron run, offset included. */
|
|
29
38
|
nextFireAt?: number;
|
|
30
|
-
/** Epoch ms of the next
|
|
39
|
+
/** Epoch ms of the next self-paced wakeup. */
|
|
31
40
|
scheduledFor?: number;
|
|
32
|
-
/** Epoch ms when the
|
|
41
|
+
/** Epoch ms when the self-paced chain started; used for the 7-day limit. */
|
|
33
42
|
chainStartedAt?: number;
|
|
34
43
|
/** True once the keepalive fallback has been used for this chain. */
|
|
35
44
|
keepaliveUsed?: boolean;
|
|
36
45
|
/** True while the current turn is the keepalive turn (the model's last chance). */
|
|
37
46
|
keepaliveTurn?: boolean;
|
|
38
|
-
/** True after a
|
|
47
|
+
/** True after a wakeup fired, until the model reschedules. */
|
|
39
48
|
awaitingReschedule?: boolean;
|
|
40
49
|
/** True when the job is due and waiting for the agent to go idle. */
|
|
41
50
|
pending?: boolean;
|
|
42
51
|
/** Epoch ms when the job became pending. */
|
|
43
52
|
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
53
|
/** How many times the job has fired. */
|
|
49
54
|
fireCount?: number;
|
|
50
55
|
/** How many ticks the model has seen. */
|
|
51
56
|
tickCount?: number;
|
|
52
57
|
/** Epoch ms of the last fire. */
|
|
53
|
-
|
|
58
|
+
lastFiredAt?: number;
|
|
54
59
|
/** Optional cap on the number of fires. */
|
|
55
60
|
maxFires?: number;
|
|
56
|
-
/** True when the job
|
|
61
|
+
/** True when the job lives in .pi/scheduled_tasks.json and survives restarts. */
|
|
57
62
|
durable: boolean;
|
|
63
|
+
/** The project folder a durable job was created in; it only runs there. */
|
|
64
|
+
createdInProject?: string;
|
|
58
65
|
/** Free-form label shown in `/loop status`. */
|
|
59
66
|
label?: string;
|
|
60
|
-
/** Why the delay was chosen (
|
|
67
|
+
/** Why the delay was chosen (self-paced chains). */
|
|
61
68
|
reason?: string;
|
|
62
69
|
/** Where the job came from: `loop_command`, `cron_create`, `schedule_wakeup`. */
|
|
63
70
|
source: string;
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
/** Fields that only mean something while the job lives in this process. */
|
|
74
|
+
const TRANSIENT_FIELDS = ["pending", "dueAt", "awaitingReschedule", "keepaliveTurn"] as const;
|
|
75
|
+
|
|
76
|
+
/** A copy of the job without its in-process state, for persisting. */
|
|
77
|
+
export function storedJob(job: LoopJob): LoopJob {
|
|
78
|
+
const copy = { ...job };
|
|
79
|
+
for (const field of TRANSIENT_FIELDS) delete copy[field];
|
|
80
|
+
return copy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Claude Code's offsets (v2.1.302 defaults, also in its docs): "a recurring
|
|
85
|
+
* task starts each run late by up to half the gap to the next run, and by at
|
|
86
|
+
* most 30 minutes"; "a one-shot task scheduled for :00 or :30 runs up to 90
|
|
87
|
+
* seconds early".
|
|
88
|
+
*
|
|
89
|
+
* Claude Code also starts `*\/5` jobs 15 seconds early, so that consecutive runs
|
|
90
|
+
* stay inside its 5 minute prompt cache. That is dropped here: it makes the
|
|
91
|
+
* interval 4m45s instead of 5m, and caches now last far longer than 5 minutes.
|
|
92
|
+
*/
|
|
93
|
+
export interface TimingConfig {
|
|
94
|
+
recurringFrac: number;
|
|
95
|
+
recurringCapMs: number;
|
|
96
|
+
oneShotMaxMs: number;
|
|
97
|
+
oneShotMinuteMod: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const DEFAULT_TIMING: TimingConfig = {
|
|
101
|
+
recurringFrac: 0.5,
|
|
102
|
+
recurringCapMs: 30 * 60_000,
|
|
103
|
+
oneShotMaxMs: 90_000,
|
|
104
|
+
oneShotMinuteMod: 30,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A fraction in [0, 1) derived from the job id, so a job always gets the same offset. */
|
|
108
|
+
export function idFraction(id: string): number {
|
|
109
|
+
let hash = 0x811c9dc5;
|
|
110
|
+
for (let i = 0; i < id.length; i++) {
|
|
111
|
+
hash ^= id.charCodeAt(i);
|
|
112
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
113
|
+
}
|
|
114
|
+
return hash / 4294967296;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* When a cron job runs next, measured from `from`, with Claude Code's
|
|
119
|
+
* offsets. Recurring jobs measure from their last fire (or creation); a
|
|
120
|
+
* one-shot measures from its creation, so its run time never moves.
|
|
121
|
+
*/
|
|
122
|
+
export function nextRunTime(
|
|
123
|
+
job: Pick<LoopJob, "id" | "cron" | "recurring">,
|
|
124
|
+
from: number,
|
|
125
|
+
timing: TimingConfig = DEFAULT_TIMING,
|
|
126
|
+
): number | null {
|
|
127
|
+
if (!job.cron) return null;
|
|
128
|
+
const fields = parseCron(job.cron);
|
|
129
|
+
if (!fields) return null;
|
|
130
|
+
const first = nextFireAfter(fields, new Date(from));
|
|
131
|
+
if (!first) return null;
|
|
132
|
+
const slot = first.getTime();
|
|
133
|
+
|
|
134
|
+
if (!job.recurring) {
|
|
135
|
+
if (new Date(slot).getMinutes() % timing.oneShotMinuteMod !== 0) return slot;
|
|
136
|
+
return Math.max(slot - idFraction(job.id) * timing.oneShotMaxMs, from);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const second = nextFireAfter(fields, first);
|
|
140
|
+
if (!second) return slot;
|
|
141
|
+
const period = second.getTime() - slot;
|
|
142
|
+
return slot + Math.min(idFraction(job.id) * timing.recurringFrac * period, timing.recurringCapMs);
|
|
143
|
+
}
|
|
144
|
+
|
|
66
145
|
export interface SchedulerHooks {
|
|
67
146
|
/** Deliver the job's prompt to the agent. */
|
|
68
147
|
dispatch: (job: LoopJob) => void;
|
|
148
|
+
/** Called after a fire; `finished` when the job was deleted (one-shot, cap, final run). */
|
|
149
|
+
afterDispatch?: (job: LoopJob, finished: boolean) => void;
|
|
69
150
|
/** Called whenever the job set changes, so state can be persisted. */
|
|
70
151
|
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
152
|
/** True when the agent is idle and has nothing queued: a tick may go out. */
|
|
78
153
|
canDispatch?: () => boolean;
|
|
79
|
-
/**
|
|
154
|
+
/** The offsets to apply to run times. */
|
|
155
|
+
timing?: () => TimingConfig;
|
|
156
|
+
/** Max age of a recurring job in ms; 0 disables the limit. */
|
|
157
|
+
maxAgeMs?: () => number;
|
|
158
|
+
/** A recurring job ran its final time and was deleted. */
|
|
80
159
|
onExpire?: (job: LoopJob) => void;
|
|
160
|
+
/** Runs at the start of every scan (housekeeping such as durable jobs). */
|
|
161
|
+
onScan?: (now: number) => void;
|
|
81
162
|
}
|
|
82
163
|
|
|
83
164
|
/** Claude Code checks for due tasks once a second. */
|
|
84
165
|
export const SCAN_INTERVAL_MS = 1000;
|
|
85
166
|
|
|
86
|
-
/**
|
|
87
|
-
* The scan interval can be raised for tests, where the scan is driven by hand.
|
|
88
|
-
*/
|
|
167
|
+
/** The scan interval can be raised for tests, where the scan is driven by hand. */
|
|
89
168
|
export function scanIntervalMs(): number {
|
|
90
169
|
const raw = process.env.PI_LOOP_SCAN_MS;
|
|
91
170
|
if (raw === undefined) return SCAN_INTERVAL_MS;
|
|
@@ -93,12 +172,6 @@ export function scanIntervalMs(): number {
|
|
|
93
172
|
return Number.isFinite(value) && value > 0 ? value : SCAN_INTERVAL_MS;
|
|
94
173
|
}
|
|
95
174
|
|
|
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
175
|
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
103
176
|
|
|
104
177
|
export function generateId(length = 8): string {
|
|
@@ -112,13 +185,8 @@ export function generateId(length = 8): string {
|
|
|
112
185
|
export class LoopScheduler {
|
|
113
186
|
private jobs = new Map<string, LoopJob>();
|
|
114
187
|
private scanTimer: ReturnType<typeof setInterval> | null = null;
|
|
115
|
-
private hooks: SchedulerHooks;
|
|
116
|
-
private inFlightId: string | null = null;
|
|
117
|
-
private turnSeq = 0;
|
|
118
188
|
|
|
119
|
-
constructor(hooks: SchedulerHooks) {
|
|
120
|
-
this.hooks = hooks;
|
|
121
|
-
}
|
|
189
|
+
constructor(private readonly hooks: SchedulerHooks) {}
|
|
122
190
|
|
|
123
191
|
get size(): number {
|
|
124
192
|
return this.jobs.size;
|
|
@@ -136,7 +204,7 @@ export class LoopScheduler {
|
|
|
136
204
|
return this.list().filter((job) => job.kind === kind);
|
|
137
205
|
}
|
|
138
206
|
|
|
139
|
-
/** The
|
|
207
|
+
/** The self-paced chain that owns the given prompt, if any. */
|
|
140
208
|
findChain(prompt: string): LoopJob | undefined {
|
|
141
209
|
return this.list().find((job) => job.kind === "wakeup" && job.prompt === prompt);
|
|
142
210
|
}
|
|
@@ -148,41 +216,46 @@ export class LoopScheduler {
|
|
|
148
216
|
return job;
|
|
149
217
|
}
|
|
150
218
|
|
|
219
|
+
/** Update a job in place, so references held elsewhere stay current. */
|
|
151
220
|
update(id: string, patch: Partial<LoopJob>): LoopJob | undefined {
|
|
152
221
|
const job = this.jobs.get(id);
|
|
153
222
|
if (!job) return undefined;
|
|
154
|
-
|
|
155
|
-
this.jobs.set(id, updated);
|
|
223
|
+
Object.assign(job, patch);
|
|
156
224
|
this.changed();
|
|
157
|
-
return
|
|
225
|
+
return job;
|
|
158
226
|
}
|
|
159
227
|
|
|
160
228
|
remove(id: string): boolean {
|
|
161
229
|
const existed = this.jobs.delete(id);
|
|
162
|
-
if (existed && this.inFlightId === id) this.inFlightId = null;
|
|
163
230
|
if (existed) this.changed();
|
|
164
231
|
return existed;
|
|
165
232
|
}
|
|
166
233
|
|
|
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
234
|
clear(notify = true): void {
|
|
176
235
|
this.jobs.clear();
|
|
177
|
-
this.inFlightId = null;
|
|
178
236
|
if (notify) this.changed();
|
|
179
237
|
}
|
|
180
238
|
|
|
181
|
-
/**
|
|
239
|
+
/** Put a job back without arming it or announcing a change. */
|
|
182
240
|
restore(job: LoopJob): void {
|
|
183
241
|
this.jobs.set(job.id, job);
|
|
184
242
|
}
|
|
185
243
|
|
|
244
|
+
/** Announce the current job set (after a series of restores). */
|
|
245
|
+
refresh(): void {
|
|
246
|
+
this.changed();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compute a cron job's next run. Recurring jobs measure from `from`
|
|
251
|
+
* (default now); a one-shot always measures from its creation.
|
|
252
|
+
*/
|
|
253
|
+
arm(job: LoopJob, from = Date.now()): void {
|
|
254
|
+
if (job.kind !== "cron") return;
|
|
255
|
+
const next = nextRunTime(job, job.recurring ? from : job.createdAt, this.timing());
|
|
256
|
+
job.nextFireAt = next ?? undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
186
259
|
// ------------------------------------------------------------- lifecycle
|
|
187
260
|
|
|
188
261
|
/** Start the once-per-second scan. */
|
|
@@ -200,46 +273,20 @@ export class LoopScheduler {
|
|
|
200
273
|
}
|
|
201
274
|
}
|
|
202
275
|
|
|
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
276
|
// ------------------------------------------------------------- dispatch
|
|
214
277
|
|
|
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
|
-
*/
|
|
278
|
+
/** Run one scan: housekeeping, mark due jobs pending, dispatch the oldest when idle. */
|
|
219
279
|
tick(now = Date.now()): void {
|
|
220
|
-
this.
|
|
221
|
-
this.retryUnconfirmed(now);
|
|
280
|
+
this.hooks.onScan?.(now);
|
|
222
281
|
this.markDue(now);
|
|
223
282
|
this.tryDispatch(now);
|
|
224
283
|
}
|
|
225
284
|
|
|
226
|
-
/**
|
|
227
|
-
* The agent settled: a dispatched tick has finished its turn, so the next
|
|
228
|
-
* pending job may go out.
|
|
229
|
-
*/
|
|
285
|
+
/** The agent settled: a pending tick may go out now. */
|
|
230
286
|
notifyIdle(): void {
|
|
231
|
-
this.releaseInFlight();
|
|
232
287
|
this.tick();
|
|
233
288
|
}
|
|
234
289
|
|
|
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
290
|
/**
|
|
244
291
|
* Mark jobs whose time has come as pending. A job is pending at most once:
|
|
245
292
|
* missed intervals coalesce into a single tick, like Claude Code.
|
|
@@ -261,112 +308,43 @@ export class LoopScheduler {
|
|
|
261
308
|
if (changed) this.changed();
|
|
262
309
|
}
|
|
263
310
|
|
|
264
|
-
/** Dispatch the oldest pending job when
|
|
265
|
-
private tryDispatch(now: number):
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
311
|
+
/** Dispatch the oldest pending job when the agent is idle. */
|
|
312
|
+
private tryDispatch(now: number): void {
|
|
313
|
+
const job = this.list()
|
|
314
|
+
.filter((candidate) => candidate.pending)
|
|
315
|
+
.sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
|
|
316
|
+
if (!job) return;
|
|
317
|
+
if (this.hooks.canDispatch && !this.hooks.canDispatch()) return;
|
|
270
318
|
|
|
271
319
|
job.pending = false;
|
|
272
320
|
job.dueAt = undefined;
|
|
273
|
-
job.dispatchedAt = now;
|
|
274
|
-
job.dispatchedTurnSeq = undefined;
|
|
275
321
|
job.fireCount = (job.fireCount ?? 0) + 1;
|
|
276
|
-
job.
|
|
277
|
-
|
|
278
|
-
this.inFlightId = job.id;
|
|
322
|
+
job.lastFiredAt = now;
|
|
279
323
|
|
|
324
|
+
let finished = false;
|
|
325
|
+
let expired = false;
|
|
280
326
|
if (job.kind === "wakeup") {
|
|
281
327
|
// A wakeup that fired from the keepalive is the model's last chance.
|
|
282
328
|
if (job.keepaliveUsed) job.keepaliveTurn = true;
|
|
283
329
|
job.awaitingReschedule = true;
|
|
284
330
|
} else {
|
|
285
|
-
|
|
331
|
+
const capped = job.maxFires !== undefined && job.fireCount >= job.maxFires;
|
|
332
|
+
const maxAge = this.hooks.maxAgeMs?.() ?? 0;
|
|
333
|
+
expired = job.recurring && maxAge > 0 && now - job.createdAt >= maxAge;
|
|
334
|
+
const next = job.recurring && !capped && !expired ? nextRunTime(job, now, this.timing()) : null;
|
|
335
|
+
job.nextFireAt = next ?? undefined;
|
|
336
|
+
finished = next === null;
|
|
286
337
|
}
|
|
287
|
-
this.changed();
|
|
288
338
|
|
|
289
339
|
this.hooks.dispatch(job);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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;
|
|
340
|
+
if (finished) this.jobs.delete(job.id);
|
|
341
|
+
this.hooks.afterDispatch?.(job, finished);
|
|
342
|
+
if (expired) this.hooks.onExpire?.(job);
|
|
327
343
|
this.changed();
|
|
328
344
|
}
|
|
329
345
|
|
|
330
|
-
|
|
331
|
-
|
|
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));
|
|
346
|
+
private timing(): TimingConfig {
|
|
347
|
+
return this.hooks.timing?.() ?? DEFAULT_TIMING;
|
|
370
348
|
}
|
|
371
349
|
|
|
372
350
|
private changed(): void {
|
package/tool-rows.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact one-line tool rows and small formatting helpers shared by the pi-cc
|
|
3
|
+
* extensions.
|
|
4
|
+
*
|
|
5
|
+
* This file is identical in pi-cc-loop and pi-cc-monitor; tests/shared.test.mjs
|
|
6
|
+
* fails when the two copies drift apart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
/** Collapse whitespace and cut the text to `max` characters. */
|
|
13
|
+
export function truncate(text: string, max: number): string {
|
|
14
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
15
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** "20s", "5m", "1m 30s", "1h", "1h 5m", "2d 3h". */
|
|
19
|
+
export function formatDuration(ms: number): string {
|
|
20
|
+
if (!Number.isFinite(ms)) return "unknown";
|
|
21
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
22
|
+
if (seconds < 60) return `${seconds}s`;
|
|
23
|
+
const minutes = Math.floor(seconds / 60);
|
|
24
|
+
const restSeconds = seconds % 60;
|
|
25
|
+
if (minutes < 60) return restSeconds ? `${minutes}m ${restSeconds}s` : `${minutes}m`;
|
|
26
|
+
const hours = Math.floor(minutes / 60);
|
|
27
|
+
const restMinutes = minutes % 60;
|
|
28
|
+
if (hours < 24) return restMinutes ? `${hours}h ${restMinutes}m` : `${hours}h`;
|
|
29
|
+
const days = Math.floor(hours / 24);
|
|
30
|
+
const restHours = hours % 24;
|
|
31
|
+
return restHours ? `${days}d ${restHours}h` : `${days}d`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The first text line of a tool result. */
|
|
35
|
+
export function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
|
|
36
|
+
for (const block of result.content ?? []) {
|
|
37
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
38
|
+
return block.text.split("\n")[0] ?? "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CompactRow {
|
|
45
|
+
/** The call row, shown while the call runs. It disappears once the result arrives. */
|
|
46
|
+
call: (args: any) => { label: string; detail?: string };
|
|
47
|
+
/** The one-line summary of a successful result. */
|
|
48
|
+
summary: (details: any, args: any) => string;
|
|
49
|
+
/** Extra lines shown when the row is expanded. */
|
|
50
|
+
detail?: (details: any, args: any) => string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renderers that draw a tool call as a single result line, for example
|
|
55
|
+
* `✓ Scheduled hu2389lz · Every minute · next 04:55 PM`. Errors show the first
|
|
56
|
+
* line of the error text.
|
|
57
|
+
*/
|
|
58
|
+
export function compactRenderers(row: CompactRow): ToolRenderers {
|
|
59
|
+
return {
|
|
60
|
+
renderCall(args: any, theme: Theme, context: any) {
|
|
61
|
+
if (!context?.isPartial && context?.executionStarted) return new Container();
|
|
62
|
+
const { label, detail } = row.call(args ?? {});
|
|
63
|
+
const title = theme.fg("toolTitle", theme.bold(label));
|
|
64
|
+
return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
|
|
65
|
+
},
|
|
66
|
+
renderResult(result: any, options: any, theme: Theme, context: any) {
|
|
67
|
+
if (options?.isPartial) return new Text(theme.fg("warning", "Working\u2026"), 0, 0);
|
|
68
|
+
if (context?.isError) {
|
|
69
|
+
return new Text(`${theme.fg("error", "\u2717 ")}${theme.fg("error", resultText(result) || "Failed")}`, 0, 0);
|
|
70
|
+
}
|
|
71
|
+
const details = result?.details ?? {};
|
|
72
|
+
const args = context?.args ?? {};
|
|
73
|
+
const summary = row.summary(details, args) || resultText(result);
|
|
74
|
+
const line = `${theme.fg("success", "\u2713 ")}${theme.fg("toolOutput", summary)}`;
|
|
75
|
+
const detail = options?.expanded ? row.detail?.(details, args) : undefined;
|
|
76
|
+
return new Text(detail ? `${line}\n${theme.fg("dim", detail)}` : line, 0, 0);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|