@alphacoder-v0/pi-loops 0.17.3

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Schedule specs for loops.
3
+ *
4
+ * cron : 5-field crontab ("0 9 * * *", "*\/30 * * * 1-5", "@daily"), local time
5
+ * every : fixed interval ("every 30m", "every 1h30m")
6
+ * once : one-shot ("in 10m", "at 2026-09-08T18:00")
7
+ *
8
+ * All computations are in local time, as crontab is.
9
+ */
10
+
11
+ export type Schedule =
12
+ | { kind: "cron"; expr: string }
13
+ | { kind: "every"; ms: number }
14
+ | { kind: "once"; at: number };
15
+
16
+ const ALIASES: Record<string, string> = {
17
+ "@hourly": "0 * * * *",
18
+ "@daily": "0 0 * * *",
19
+ "@midnight": "0 0 * * *",
20
+ "@weekly": "0 0 * * 0",
21
+ "@monthly": "0 0 1 * *",
22
+ "@yearly": "0 0 1 1 *",
23
+ "@annually": "0 0 1 1 *",
24
+ };
25
+
26
+ const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
27
+ const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
28
+
29
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/;
30
+ const UNIT_MS: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
31
+
32
+ /** "1h30m" → ms. Throws on malformed input. */
33
+ export function parseDuration(input: string): number {
34
+ const text = input.trim().toLowerCase();
35
+ if (!text) throw new Error("empty duration");
36
+ const parts = text.match(/\d+(?:\.\d+)?(?:ms|s|m|h|d|w)/g);
37
+ if (!parts || parts.join("") !== text) {
38
+ throw new Error(`invalid duration "${input}" (use e.g. 30s, 10m, 1h30m, 2d)`);
39
+ }
40
+ let total = 0;
41
+ for (const part of parts) {
42
+ const m = DURATION_RE.exec(part);
43
+ if (!m) throw new Error(`invalid duration "${input}"`);
44
+ total += Number(m[1]) * UNIT_MS[m[2]];
45
+ }
46
+ if (total <= 0) throw new Error("duration must be positive");
47
+ return Math.round(total);
48
+ }
49
+
50
+ export function formatDuration(ms: number): string {
51
+ if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`;
52
+ if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`;
53
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
54
+ if (ms % 1000 === 0) return `${ms / 1000}s`;
55
+ return `${ms}ms`;
56
+ }
57
+
58
+ /** Schedule aliases (daily/weekly land on 09:00 local, Monday for weekly). */
59
+ export function normalizeScheduleAlias(input: string): string | undefined {
60
+ const trimmed = input.trim();
61
+ const lower = trimmed.toLowerCase();
62
+ if (["hourly", "every hour", "once an hour"].includes(lower)) return "0 * * * *";
63
+ if (["daily", "every day", "once a day"].includes(lower)) return "0 9 * * *";
64
+ if (["weekly", "every week", "once a week"].includes(lower)) return "0 9 * * 1";
65
+ if (trimmed.includes("每小时") || trimmed.includes("每個小時")) return "0 * * * *";
66
+ if (trimmed.includes("每天") || trimmed.includes("每日")) return "0 9 * * *";
67
+ if (trimmed.includes("每周") || trimmed.includes("每週")) return "0 9 * * 1";
68
+ return undefined;
69
+ }
70
+
71
+ /** A `Schedule` that came from disk or an archive really is one; anything else is refused. */
72
+ export function isValidSchedule(value: unknown): value is Schedule {
73
+ const candidate = value as any;
74
+ if (!candidate || typeof candidate !== "object") return false;
75
+ if (candidate.kind === "cron") return typeof candidate.expr === "string" && isValidCronExpr(candidate.expr);
76
+ if (candidate.kind === "every") return Number.isFinite(candidate.ms) && candidate.ms >= 60_000;
77
+ if (candidate.kind === "once") return Number.isFinite(candidate.at);
78
+ return false;
79
+ }
80
+
81
+ function isValidCronExpr(expr: string): boolean {
82
+ try {
83
+ parseCron(expr);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Parse a schedule spec. Throws with a human-readable message. */
91
+ export function parseSchedule(spec: string, now: number = Date.now()): Schedule {
92
+ const text = spec.trim();
93
+ if (!text) throw new Error("empty schedule");
94
+ const lower = text.toLowerCase();
95
+ if (ALIASES[lower]) return { kind: "cron", expr: ALIASES[lower] };
96
+ const alias = normalizeScheduleAlias(text);
97
+ if (alias) return { kind: "cron", expr: alias };
98
+
99
+ const tokens = text.split(/\s+/);
100
+ if (tokens.length === 2) {
101
+ const [head, rest] = [tokens[0].toLowerCase(), tokens[1]];
102
+ if (head === "every") return { kind: "every", ms: parseDuration(rest) };
103
+ if (head === "in") return { kind: "once", at: now + parseDuration(rest) };
104
+ if (head === "at") {
105
+ const at = Date.parse(rest);
106
+ if (Number.isNaN(at)) throw new Error(`invalid timestamp "${rest}" (use ISO 8601, e.g. 2026-09-08T18:00)`);
107
+ return { kind: "once", at };
108
+ }
109
+ }
110
+ if (tokens.length === 5) {
111
+ parseCron(text); // validate
112
+ return { kind: "cron", expr: tokens.join(" ") };
113
+ }
114
+ throw new Error(
115
+ `invalid schedule "${spec}": provide a 5-field cron expression, a supported alias such as hourly / every hour / daily / 每小时, "every 30m", "in 10m" or "at <ISO time>"`,
116
+ );
117
+ }
118
+
119
+ export function formatSchedule(schedule: Schedule): string {
120
+ switch (schedule.kind) {
121
+ case "cron":
122
+ return schedule.expr;
123
+ case "every":
124
+ return `every ${formatDuration(schedule.ms)}`;
125
+ case "once":
126
+ return `once ${formatLocal(schedule.at)}`;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The machine's offset from UTC at that instant, as `+08:00`.
132
+ *
133
+ * Everything a person is shown is in this machine's timezone, which is the same one the cron
134
+ * expressions are matched in — so the offset is not needed on every line. It is needed wherever a
135
+ * timestamp travels away from the screen that explains it: into a sub-agent's prompt, most of all,
136
+ * where a model is asked to reason about how long ago something happened.
137
+ */
138
+ export function localOffset(ts: number): string {
139
+ const minutes = -new Date(ts).getTimezoneOffset();
140
+ const sign = minutes < 0 ? "-" : "+";
141
+ const abs = Math.abs(minutes);
142
+ return `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
143
+ }
144
+
145
+ /**
146
+ * A timestamp as pi-loops records them: this machine's clock, carrying the offset that makes it an
147
+ * instant rather than a reading.
148
+ *
149
+ * `2026-09-11T20:37:59.405+08:00` — the same moment `toISOString()` would have written as
150
+ * `12:37:59.405Z`, and any parser that took one takes the other. The difference is what a person
151
+ * sees when they open `runs.jsonl` or a log: the hour they were at their desk, rather than an hour
152
+ * they have to convert. Cron expressions are matched against this machine's clock, `/cron` and
153
+ * `/inbox` print it, and the files now agree with both.
154
+ *
155
+ * Not used for file names, where `+` and `:` are somebody else's problem, and not for pi's own
156
+ * session headers, which are pi's format to decide.
157
+ */
158
+ export function stamp(ms: number = Date.now()): string {
159
+ const shifted = ms - new Date(ms).getTimezoneOffset() * 60_000;
160
+ return `${new Date(shifted).toISOString().slice(0, -1)}${localOffset(ms)}`; // the one stamp built from UTC: this is stamp() itself
161
+ }
162
+
163
+ /** A local timestamp that says which zone it is in, for anywhere the surrounding screen does not. */
164
+ export function formatLocalZoned(ts: number): string {
165
+ return `${formatLocal(ts)} ${localOffset(ts)}`;
166
+ }
167
+
168
+ export function formatLocal(ts: number): string {
169
+ const d = new Date(ts);
170
+ const pad = (n: number) => String(n).padStart(2, "0");
171
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
172
+ }
173
+
174
+ /* ---------------------------------------------------------------- cron */
175
+
176
+ export interface CronFields {
177
+ minutes: Set<number>;
178
+ hours: Set<number>;
179
+ days: Set<number>;
180
+ months: Set<number>;
181
+ dows: Set<number>;
182
+ /** true when the day-of-month field is "*" (crontab OR semantics for dom/dow) */
183
+ anyDay: boolean;
184
+ anyDow: boolean;
185
+ }
186
+
187
+ export function parseCron(expr: string): CronFields {
188
+ const parts = expr.trim().split(/\s+/);
189
+ if (parts.length !== 5) throw new Error("cron schedule must have 5 fields: minute hour day-of-month month day-of-week");
190
+ const [min, hour, dom, mon, dow] = parts;
191
+ return {
192
+ minutes: parseField(min, 0, 59, null),
193
+ hours: parseField(hour, 0, 23, null),
194
+ days: parseField(dom, 1, 31, null),
195
+ months: parseField(mon, 1, 12, MONTH_NAMES),
196
+ dows: normalizeDow(parseField(dow, 0, 7, DOW_NAMES)),
197
+ anyDay: dom === "*",
198
+ anyDow: dow === "*",
199
+ };
200
+ }
201
+
202
+ function normalizeDow(set: Set<number>): Set<number> {
203
+ if (set.has(7)) {
204
+ set.delete(7);
205
+ set.add(0);
206
+ }
207
+ return set;
208
+ }
209
+
210
+ function parseField(field: string, min: number, max: number, names: string[] | null): Set<number> {
211
+ const out = new Set<number>();
212
+ for (const item of field.split(",")) {
213
+ if (!item) throw new Error(`invalid cron field \`${field}\`: empty list item`);
214
+ let [rangePart, stepPart] = item.split("/");
215
+ if (stepPart !== undefined && !/^\d+$/.test(stepPart)) throw new Error(`invalid cron field \`${field}\`: bad step`);
216
+ const step = stepPart === undefined ? 1 : Number(stepPart);
217
+ if (step < 1) throw new Error(`invalid cron field \`${field}\`: step must be at least 1`);
218
+ let lo: number;
219
+ let hi: number;
220
+ if (rangePart === "*") {
221
+ lo = min;
222
+ hi = max;
223
+ } else if (rangePart.includes("-")) {
224
+ const [a, b] = rangePart.split("-");
225
+ lo = parseNumber(a, field, min, max, names);
226
+ hi = parseNumber(b, field, min, max, names);
227
+ if (lo > hi) throw new Error(`invalid cron field \`${field}\`: range start exceeds end`);
228
+ } else {
229
+ lo = parseNumber(rangePart, field, min, max, names);
230
+ hi = stepPart === undefined ? lo : max;
231
+ }
232
+ for (let v = lo; v <= hi; v += step) out.add(v);
233
+ }
234
+ if (out.size === 0) throw new Error(`invalid cron field \`${field}\`: no values`);
235
+ return out;
236
+ }
237
+
238
+ function parseNumber(raw: string, field: string, min: number, max: number, names: string[] | null): number {
239
+ let n: number;
240
+ if (/^\d+$/.test(raw)) n = Number(raw);
241
+ else if (names) {
242
+ const idx = names.indexOf(raw.toLowerCase().slice(0, 3));
243
+ if (idx < 0) throw new Error(`invalid cron field \`${field}\`: unknown name "${raw}"`);
244
+ n = names === MONTH_NAMES ? idx + 1 : idx;
245
+ } else throw new Error(`invalid cron field \`${field}\`: invalid value "${raw}"`);
246
+ if (n < min || n > max) throw new Error(`invalid cron field \`${field}\`: value ${n} out of range ${min}-${max}`);
247
+ return n;
248
+ }
249
+
250
+ /**
251
+ * Whether the date fields (day-of-month, month, day-of-week) can match this calendar day at all,
252
+ * ignoring the time of day. Vixie cron: when both dom and dow are restricted, either matching is
253
+ * enough.
254
+ *
255
+ * One rule, called from two places, because the scans below skip whole days on the strength of it —
256
+ * "this day cannot match, jump to midnight" is only sound while it says exactly what `cronMatches`
257
+ * says, and it was a second copy free to drift. ("0 0 30 2 *" parses and never matches, and walking
258
+ * five years of it one minute at a time cost ~300ms of the leader's tick, every 30 seconds, in every
259
+ * pi window.)
260
+ */
261
+ function dayMatches(fields: CronFields, when: Date): boolean {
262
+ if (!fields.months.has(when.getMonth() + 1)) return false;
263
+ const dayOk = fields.days.has(when.getDate());
264
+ const dowOk = fields.dows.has(when.getDay());
265
+ if (!fields.anyDay && !fields.anyDow) return dayOk || dowOk;
266
+ return dayOk && dowOk;
267
+ }
268
+
269
+ export function cronMatches(fields: CronFields, when: Date): boolean {
270
+ if (!fields.minutes.has(when.getMinutes())) return false;
271
+ if (!fields.hours.has(when.getHours())) return false;
272
+ return dayMatches(fields, when);
273
+ }
274
+
275
+ const MINUTE = 60_000;
276
+ /** Five years: past this a cron expression is treated as never matching. */
277
+ const MAX_LOOKAHEAD_MS = 5 * 366 * 86_400_000;
278
+ /** 400 days: a job offline longer than this owes nothing. */
279
+ const MAX_LOOKBACK_MS = 400 * 86_400_000;
280
+
281
+ function floorMinute(ts: number): number {
282
+ const d = new Date(ts);
283
+ d.setSeconds(0, 0);
284
+ return d.getTime();
285
+ }
286
+
287
+ /**
288
+ * Local midnight of the day after `d`. Floored twice on purpose: in a zone whose DST jump is at
289
+ * midnight there is no 00:00 on the transition day, so the first floor lands on 01:00 and carrying
290
+ * that reading into the next day would skip its first hour — and a match in it.
291
+ */
292
+ function nextMidnight(d: Date): number {
293
+ const n = new Date(d);
294
+ n.setHours(0, 0, 0, 0);
295
+ n.setDate(n.getDate() + 1);
296
+ n.setHours(0, 0, 0, 0);
297
+ return n.getTime();
298
+ }
299
+
300
+ /** The last minute before local midnight of `d`'s day. */
301
+ function lastMinuteBefore(d: Date): number {
302
+ const n = new Date(d);
303
+ n.setHours(0, 0, 0, 0);
304
+ return n.getTime() - MINUTE;
305
+ }
306
+
307
+ /** First cron match strictly after `after` (minute resolution). */
308
+ export function cronNextAfter(fields: CronFields, after: number): number | undefined {
309
+ let t = floorMinute(after) + MINUTE;
310
+ const limit = after + MAX_LOOKAHEAD_MS;
311
+ while (t <= limit) {
312
+ const when = new Date(t);
313
+ if (!dayMatches(fields, when)) {
314
+ t = nextMidnight(when);
315
+ continue;
316
+ }
317
+ if (cronMatches(fields, when)) return t;
318
+ t += MINUTE;
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ /** Latest cron match at or before `now` and strictly after `since`. */
324
+ export function cronLatestBetween(fields: CronFields, since: number, now: number): number | undefined {
325
+ let t = floorMinute(now);
326
+ const floor = Math.max(since, now - MAX_LOOKBACK_MS);
327
+ while (t > floor) {
328
+ const when = new Date(t);
329
+ if (!dayMatches(fields, when)) {
330
+ t = lastMinuteBefore(when);
331
+ continue;
332
+ }
333
+ if (cronMatches(fields, when)) return t;
334
+ t -= MINUTE;
335
+ }
336
+ return undefined;
337
+ }
338
+
339
+ /* ------------------------------------------------------------ due logic */
340
+
341
+ export interface DueInput {
342
+ schedule: Schedule;
343
+ createdAt: number;
344
+ lastDueAt?: number;
345
+ lastFiredAt?: number;
346
+ }
347
+
348
+ /**
349
+ * A stamp from the future (a wrong clock later corrected by NTP, a restored VM snapshot, a synced
350
+ * $HOME whose other machine was ahead) is not evidence about the past: every `since > now`
351
+ * comparison would skip forever, so the job never fires again while `/cron` still renders a next
352
+ * run. Discard it and fall back to whatever the caller uses when there is no stamp at all.
353
+ */
354
+ export function clampFuture(stamp: number | undefined, now: number, slackMs = 60_000): number | undefined {
355
+ if (stamp === undefined || !Number.isFinite(stamp)) return undefined;
356
+ return stamp > now + slackMs ? undefined : stamp;
357
+ }
358
+
359
+ /**
360
+ * The single due time this job owes as of `now`, or undefined.
361
+ * Missed ticks collapse into one (the latest), so a daily job that was offline
362
+ * for a week comes back owing exactly one run.
363
+ */
364
+ export function computeDue(rawInput: DueInput, now: number): number | undefined {
365
+ // Every stamp here comes off disk and may predate a clock correction. A future `lastDueAt` or
366
+ // `lastFiredAt` is simply not evidence about the past, so it is dropped. A future `createdAt`
367
+ // cannot be dropped (it is the fallback), and clamping it to `now` on every call would keep the
368
+ // job permanently "just created" until the real clock caught up — so it is treated as a day old,
369
+ // which makes the job due once and then run from its own real stamps.
370
+ const createdAt = clampFuture(rawInput.createdAt, now) ?? now - 86_400_000;
371
+ const input: DueInput = {
372
+ ...rawInput,
373
+ createdAt,
374
+ lastDueAt: clampFuture(rawInput.lastDueAt, now),
375
+ lastFiredAt: clampFuture(rawInput.lastFiredAt, now),
376
+ };
377
+ const { schedule } = input;
378
+ switch (schedule.kind) {
379
+ case "cron": {
380
+ const since = input.lastDueAt ?? input.createdAt;
381
+ return cronLatestBetween(parseCron(schedule.expr), since, now);
382
+ }
383
+ case "every": {
384
+ const base = input.lastFiredAt ?? input.createdAt;
385
+ if (now - base < schedule.ms) return undefined;
386
+ const k = Math.floor((now - base) / schedule.ms);
387
+ return base + k * schedule.ms;
388
+ }
389
+ case "once": {
390
+ // A one-shot owes exactly one slot, and the slot is spent as soon as the scheduler acted
391
+ // on it — fired, or declined because catch-up was off. Rolling both stamps back (a crashed
392
+ // run, or the single retry a failed one-shot gets) makes it owed again.
393
+ // The *raw* stamps are what count here, not the clamped ones: a stamp from the future is
394
+ // no use for arithmetic, but it is still proof the scheduler acted, and discarding it
395
+ // would make a one-shot that already ran come due a second time.
396
+ if (rawInput.lastFiredAt !== undefined || rawInput.lastDueAt !== undefined) return undefined;
397
+ return schedule.at <= now ? schedule.at : undefined;
398
+ }
399
+ }
400
+ }
401
+
402
+ /** Next planned run after `now`, for display. */
403
+ export function computeNext(input: DueInput, now: number): number | undefined {
404
+ const { schedule } = input;
405
+ switch (schedule.kind) {
406
+ case "cron":
407
+ return cronNextAfter(parseCron(schedule.expr), now);
408
+ case "every": {
409
+ const base = input.lastFiredAt ?? input.createdAt;
410
+ let t = base + schedule.ms;
411
+ while (t <= now) t += schedule.ms;
412
+ return t;
413
+ }
414
+ case "once":
415
+ return input.lastFiredAt === undefined && schedule.at > now ? schedule.at : undefined;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * How long something that has failed `failures` times in a row waits before the next attempt:
421
+ * nothing until `after` failures, then `baseMs` doubling with each further failure, capped at
422
+ * `maxMs` (and at eight doublings, so the arithmetic cannot run away).
423
+ *
424
+ * One function for both pipelines because docs/triggers.md promises the same numbers: a loop run
425
+ * that keeps failing and a trigger check that keeps failing are the same problem — it will not fix
426
+ * itself by the next tick, and every attempt bills a sub-agent.
427
+ */
428
+ export function backoffWaitMs(failures: number, after: number, baseMs: number, maxMs: number): number {
429
+ if (failures < after) return 0;
430
+ return Math.min(maxMs, baseMs * 2 ** Math.min(failures - after, 8));
431
+ }