@trevonistrevon/pi-loop 0.6.0 → 0.6.2

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 (108) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +28 -143
  3. package/dist/commands/loop-command.d.ts +10 -2
  4. package/dist/commands/loop-command.js +84 -51
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +44 -16
  7. package/dist/loop-format.d.ts +3 -0
  8. package/dist/loop-format.js +20 -0
  9. package/dist/loop-parse.d.ts +1 -0
  10. package/dist/loop-parse.js +45 -1
  11. package/dist/loop-reducer.d.ts +13 -1
  12. package/dist/loop-reducer.js +26 -0
  13. package/dist/monitor-manager.d.ts +1 -1
  14. package/dist/monitor-manager.js +13 -4
  15. package/dist/notification-reducer.d.ts +2 -0
  16. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  17. package/dist/runtime/notification-runtime.d.ts +2 -1
  18. package/dist/runtime/notification-runtime.js +19 -7
  19. package/dist/runtime/task-backlog-runtime.d.ts +2 -1
  20. package/dist/runtime/task-backlog-runtime.js +13 -9
  21. package/dist/scheduler.js +25 -7
  22. package/dist/store.d.ts +9 -1
  23. package/dist/store.js +57 -0
  24. package/dist/tools/loop-tools.d.ts +8 -0
  25. package/dist/tools/loop-tools.js +104 -12
  26. package/dist/tools/monitor-tools.js +1 -1
  27. package/dist/trigger-system.js +1 -1
  28. package/dist/types.d.ts +24 -1
  29. package/docs/USAGE_GUIDE.md +185 -0
  30. package/package.json +13 -4
  31. package/src/commands/loop-command.ts +102 -54
  32. package/src/index.ts +46 -16
  33. package/src/loop-format.ts +22 -0
  34. package/src/loop-parse.ts +44 -1
  35. package/src/loop-reducer.ts +41 -1
  36. package/src/monitor-manager.ts +12 -4
  37. package/src/notification-reducer.ts +3 -0
  38. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  39. package/src/runtime/notification-runtime.ts +21 -8
  40. package/src/runtime/task-backlog-runtime.ts +15 -11
  41. package/src/scheduler.ts +25 -8
  42. package/src/store.ts +59 -2
  43. package/src/tools/loop-tools.ts +128 -12
  44. package/src/tools/monitor-tools.ts +1 -1
  45. package/src/trigger-system.ts +1 -1
  46. package/src/types.ts +29 -1
  47. package/.release-please-manifest.json +0 -3
  48. package/AGENTS.md +0 -123
  49. package/CONTRIBUTING.md +0 -79
  50. package/Dockerfile +0 -17
  51. package/benchmarks/experiment-sim.js +0 -30
  52. package/coverage/base.css +0 -224
  53. package/coverage/block-navigation.js +0 -87
  54. package/coverage/coverage-final.json +0 -36
  55. package/coverage/favicon.png +0 -0
  56. package/coverage/index.html +0 -191
  57. package/coverage/prettify.css +0 -1
  58. package/coverage/prettify.js +0 -2
  59. package/coverage/sort-arrow-sprite.png +0 -0
  60. package/coverage/sorter.js +0 -210
  61. package/coverage/src/api.ts.html +0 -193
  62. package/coverage/src/commands/index.html +0 -131
  63. package/coverage/src/commands/loop-command.ts.html +0 -634
  64. package/coverage/src/commands/tasks-command.ts.html +0 -490
  65. package/coverage/src/coordinator.ts.html +0 -430
  66. package/coverage/src/index.html +0 -341
  67. package/coverage/src/index.ts.html +0 -1249
  68. package/coverage/src/loop-parse.ts.html +0 -607
  69. package/coverage/src/loop-reducer.ts.html +0 -559
  70. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  71. package/coverage/src/monitor-manager.ts.html +0 -964
  72. package/coverage/src/monitor-reducer.ts.html +0 -583
  73. package/coverage/src/notification-reducer.ts.html +0 -550
  74. package/coverage/src/reducer-backed-store.ts.html +0 -589
  75. package/coverage/src/rpc/channels.ts.html +0 -376
  76. package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
  77. package/coverage/src/rpc/index.html +0 -131
  78. package/coverage/src/runtime/index.html +0 -251
  79. package/coverage/src/runtime/loop-events.ts.html +0 -265
  80. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  81. package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
  82. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  83. package/coverage/src/runtime/scope.ts.html +0 -196
  84. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  85. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
  86. package/coverage/src/runtime/task-events.ts.html +0 -208
  87. package/coverage/src/runtime/task-mutations.ts.html +0 -394
  88. package/coverage/src/runtime/task-rpc.ts.html +0 -523
  89. package/coverage/src/scheduler.ts.html +0 -400
  90. package/coverage/src/store.ts.html +0 -667
  91. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  92. package/coverage/src/task-reducer.ts.html +0 -550
  93. package/coverage/src/task-store.ts.html +0 -526
  94. package/coverage/src/tools/index.html +0 -161
  95. package/coverage/src/tools/loop-tools.ts.html +0 -991
  96. package/coverage/src/tools/monitor-tools.ts.html +0 -538
  97. package/coverage/src/tools/native-task-tools.ts.html +0 -493
  98. package/coverage/src/tools/tool-result.ts.html +0 -97
  99. package/coverage/src/trigger-system.ts.html +0 -547
  100. package/coverage/src/ui/index.html +0 -116
  101. package/coverage/src/ui/widget.ts.html +0 -292
  102. package/docker-compose.yml +0 -12
  103. package/docs/architecture/state-machine-migration.md +0 -502
  104. package/docs/architecture/state-machine-reducer-event-model.md +0 -822
  105. package/docs/architecture/state-machine-test-matrix.md +0 -249
  106. package/docs/architecture/state-machine-transition-map.md +0 -436
  107. package/release-please-config.json +0 -9
  108. package/vitest.config.ts +0 -29
@@ -50,9 +50,53 @@ function isFullCron(expr) {
50
50
  const parts = expr.trim().split(/\s+/);
51
51
  return parts.length === 5;
52
52
  }
53
+ function parseCronNumber(input, min, max) {
54
+ if (!/^\d+$/.test(input))
55
+ return undefined;
56
+ const value = Number.parseInt(input, 10);
57
+ return value >= min && value <= max ? value : undefined;
58
+ }
59
+ function isValidCronField(field, min, max) {
60
+ return field.split(",").every((part) => {
61
+ const [base, step, extra] = part.split("/");
62
+ if (extra !== undefined || base === undefined)
63
+ return false;
64
+ if (step !== undefined && (parseCronNumber(step, 1, max - min + 1) === undefined))
65
+ return false;
66
+ if (base === "*")
67
+ return true;
68
+ const range = base.split("-");
69
+ if (range.length === 1)
70
+ return step === undefined && parseCronNumber(base, min, max) !== undefined;
71
+ if (range.length !== 2)
72
+ return false;
73
+ const start = parseCronNumber(range[0] ?? "", min, max);
74
+ const end = parseCronNumber(range[1] ?? "", min, max);
75
+ return start !== undefined && end !== undefined && start <= end;
76
+ });
77
+ }
78
+ export function isValidCronExpression(expr) {
79
+ const fields = expr.trim().split(/\s+/);
80
+ if (fields.length !== 5)
81
+ return false;
82
+ const ranges = [
83
+ [0, 59],
84
+ [0, 23],
85
+ [1, 31],
86
+ [1, 12],
87
+ [0, 6],
88
+ ];
89
+ return fields.every((field, index) => {
90
+ const range = ranges[index];
91
+ return range !== undefined && isValidCronField(field, range[0], range[1]);
92
+ });
93
+ }
53
94
  export function parseInterval(input) {
54
95
  const trimmed = input.trim();
55
96
  if (isFullCron(trimmed)) {
97
+ if (!isValidCronExpression(trimmed)) {
98
+ throw new Error(`Invalid cron expression: ${trimmed}`);
99
+ }
56
100
  return { cron: trimmed, description: `cron: ${trimmed}` };
57
101
  }
58
102
  const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
@@ -69,7 +113,7 @@ export function parseInterval(input) {
69
113
  }
70
114
  export function cronToNextFire(cronExpr, fromDate = new Date()) {
71
115
  const parts = cronExpr.trim().split(/\s+/);
72
- if (parts.length !== 5)
116
+ if (!isValidCronExpression(cronExpr))
73
117
  throw new Error(`Invalid cron expression: ${cronExpr}`);
74
118
  const [minF, hourF, dayF, monthF, dowF] = parts;
75
119
  if (minF === undefined ||
@@ -1,4 +1,4 @@
1
- import type { LoopEntry, Trigger } from "./types.js";
1
+ import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
2
2
  export declare const MAX_LOOP_EXPIRY_MS: number;
3
3
  /**
4
4
  * Whether a loop has reached its fire cap. Single source of truth for the
@@ -26,6 +26,7 @@ export type LoopReducerEvent = {
26
26
  taskBacklog?: boolean;
27
27
  readOnly?: boolean;
28
28
  maxFires?: number;
29
+ dynamic?: Partial<DynamicLoopState>;
29
30
  };
30
31
  } | {
31
32
  type: "LOOP_PAUSED" | "LOOP_RESUMED" | "LOOP_FIRED" | "LOOP_DELETED" | "LOOP_MAX_FIRES_REACHED" | "LOOP_BACKLOG_EMPTY";
@@ -46,6 +47,17 @@ export type LoopReducerEvent = {
46
47
  id: string;
47
48
  reason: "expires_at" | "resume_event_stale" | "already_completed_monitor";
48
49
  };
50
+ } | {
51
+ type: "LOOP_DYNAMIC_UPDATED";
52
+ at: number;
53
+ source: ReducerSource;
54
+ entityType?: "loop";
55
+ entityId?: string;
56
+ payload: {
57
+ id: string;
58
+ prompt?: string;
59
+ dynamic: Partial<DynamicLoopState>;
60
+ };
49
61
  };
50
62
  export type LoopReducerEffect = {
51
63
  type: "PERSIST_LOOP";
@@ -32,6 +32,18 @@ export function reduceLoopState(state, event) {
32
32
  readOnly: event.payload.readOnly,
33
33
  maxFires: event.payload.maxFires,
34
34
  fireCount: 0,
35
+ dynamic: event.payload.trigger.type === "dynamic" || event.payload.dynamic
36
+ ? {
37
+ goal: event.payload.dynamic?.goal ?? event.payload.prompt,
38
+ state: event.payload.dynamic?.state,
39
+ metrics: event.payload.dynamic?.metrics,
40
+ doneCriteria: event.payload.dynamic?.doneCriteria,
41
+ iteration: event.payload.dynamic?.iteration ?? 0,
42
+ nextWakeAt: event.payload.dynamic?.nextWakeAt,
43
+ awaitingUpdate: event.payload.dynamic?.awaitingUpdate ?? false,
44
+ lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
45
+ }
46
+ : undefined,
35
47
  };
36
48
  next.loopsById[id] = loop;
37
49
  return {
@@ -68,6 +80,20 @@ export function reduceLoopState(state, event) {
68
80
  loop.fireCount = (loop.fireCount ?? 0) + 1;
69
81
  loop.updatedAt = event.at;
70
82
  }
83
+ if (event.type === "LOOP_DYNAMIC_UPDATED") {
84
+ loop.prompt = event.payload.prompt ?? loop.prompt;
85
+ loop.dynamic = {
86
+ goal: event.payload.dynamic.goal ?? loop.dynamic?.goal ?? loop.prompt,
87
+ state: event.payload.dynamic.state ?? loop.dynamic?.state,
88
+ metrics: event.payload.dynamic.metrics ?? loop.dynamic?.metrics,
89
+ doneCriteria: event.payload.dynamic.doneCriteria ?? loop.dynamic?.doneCriteria,
90
+ iteration: event.payload.dynamic.iteration ?? loop.dynamic?.iteration ?? 0,
91
+ nextWakeAt: "nextWakeAt" in event.payload.dynamic ? event.payload.dynamic.nextWakeAt : loop.dynamic?.nextWakeAt,
92
+ awaitingUpdate: event.payload.dynamic.awaitingUpdate ?? loop.dynamic?.awaitingUpdate ?? false,
93
+ lastUpdatedAt: event.payload.dynamic.lastUpdatedAt ?? event.at,
94
+ };
95
+ loop.updatedAt = event.at;
96
+ }
71
97
  next.loopsById[id] = loop;
72
98
  return {
73
99
  state: next,
@@ -22,7 +22,7 @@ export declare class MonitorManager {
22
22
  create(command: string, description?: string, timeout?: number): MonitorEntry;
23
23
  get(id: string): MonitorEntry | undefined;
24
24
  list(): MonitorEntry[];
25
- stop(id: string): Promise<boolean>;
25
+ stop(id: string, reason?: "manual" | "timeout"): Promise<boolean>;
26
26
  onComplete(id: string, callback: () => void): boolean;
27
27
  getProcess(id: string): MonitorProcess | undefined;
28
28
  }
@@ -190,7 +190,7 @@ export class MonitorManager {
190
190
  if (timeout > 0) {
191
191
  setTimeout(() => {
192
192
  if (bp.entry.status === "running") {
193
- this.stop(id);
193
+ void this.stop(id, "timeout");
194
194
  }
195
195
  }, timeout);
196
196
  }
@@ -206,7 +206,7 @@ export class MonitorManager {
206
206
  .map(bp => bp.entry)
207
207
  .sort((a, b) => Number(a.id) - Number(b.id));
208
208
  }
209
- async stop(id) {
209
+ async stop(id, reason = "manual") {
210
210
  const bp = this.processes.get(id);
211
211
  if (!bp || bp.entry.status !== "running")
212
212
  return false;
@@ -218,7 +218,7 @@ export class MonitorManager {
218
218
  entityId: id,
219
219
  payload: {
220
220
  id,
221
- reason: "manual",
221
+ reason,
222
222
  },
223
223
  });
224
224
  this.schedulePrune(id);
@@ -236,6 +236,15 @@ export class MonitorManager {
236
236
  resolve();
237
237
  });
238
238
  });
239
+ if (reason === "timeout") {
240
+ this.pi.events.emit("monitor:error", {
241
+ monitorId: id,
242
+ error: `Timed out after ${bp.entry.timeout}ms`,
243
+ outputLines: bp.entry.outputLines,
244
+ });
245
+ for (const callback of bp.completionCallbacks)
246
+ callback();
247
+ }
239
248
  bp.completionCallbacks = [];
240
249
  for (const resolve of bp.waiters)
241
250
  resolve();
@@ -246,7 +255,7 @@ export class MonitorManager {
246
255
  const bp = this.processes.get(id);
247
256
  if (!bp)
248
257
  return false;
249
- if (bp.entry.status === "completed") {
258
+ if (bp.entry.status === "completed" || bp.entry.status === "error") {
250
259
  callback();
251
260
  return true;
252
261
  }
@@ -1,3 +1,4 @@
1
+ import type { DynamicLoopState } from "./types.js";
1
2
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
2
3
  export interface ReducerNotification {
3
4
  key: string;
@@ -8,6 +9,7 @@ export interface ReducerNotification {
8
9
  recurring?: boolean;
9
10
  autoTask?: boolean;
10
11
  readOnly?: boolean;
12
+ dynamic?: DynamicLoopState;
11
13
  }
12
14
  export interface NotificationReducerState {
13
15
  notificationsByKey: Record<string, ReducerNotification>;
@@ -37,7 +37,7 @@ export function createMonitorOnDoneRuntime(options) {
37
37
  return;
38
38
  const monitor = monitorManager.get(monitorId);
39
39
  if (monitor && monitor.status !== "running") {
40
- if (monitor.status === "completed") {
40
+ if (monitor.status === "completed" || monitor.status === "error") {
41
41
  deliver();
42
42
  return;
43
43
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { Trigger } from "../types.js";
2
+ import type { DynamicLoopState, Trigger } from "../types.js";
3
3
  export interface LoopFireEvent {
4
4
  loopId: string;
5
5
  prompt: string;
@@ -8,6 +8,7 @@ export interface LoopFireEvent {
8
8
  readOnly?: boolean;
9
9
  recurring?: boolean;
10
10
  autoTask?: boolean;
11
+ dynamic?: DynamicLoopState;
11
12
  }
12
13
  export interface PendingNotification extends LoopFireEvent {
13
14
  key: string;
@@ -1,4 +1,5 @@
1
1
  import { createCoordinator, } from "../coordinator.js";
2
+ import { formatTrigger } from "../loop-format.js";
2
3
  import { reduceNotificationState, } from "../notification-reducer.js";
3
4
  export function createNotificationRuntime(options) {
4
5
  const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
@@ -43,18 +44,28 @@ export function createNotificationRuntime(options) {
43
44
  });
44
45
  }
45
46
  function buildLoopFireMessage(data) {
46
- const triggerInfo = typeof data.trigger === "string"
47
- ? data.trigger
48
- : data.trigger?.type === "cron"
49
- ? `schedule: ${data.trigger.schedule}`
50
- : data.trigger?.type === "event"
51
- ? `event: ${data.trigger.source}`
52
- : "hybrid";
47
+ const triggerInfo = formatTrigger(data.trigger, "notification");
53
48
  const loopId = data.loopId || "?";
54
49
  const prompt = data.prompt || "loop fired";
55
50
  const constraint = data.readOnly
56
51
  ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
57
52
  : "";
53
+ if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
54
+ const dynamic = data.dynamic;
55
+ const lines = [
56
+ `[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
57
+ `Goal: ${dynamic?.goal ?? prompt}`,
58
+ `Iteration: ${dynamic?.iteration ?? 0}`,
59
+ ];
60
+ if (dynamic?.state)
61
+ lines.push(`State: ${dynamic.state}`);
62
+ if (dynamic?.metrics)
63
+ lines.push(`Metrics: ${dynamic.metrics}`);
64
+ if (dynamic?.doneCriteria)
65
+ lines.push(`Done criteria: ${dynamic.doneCriteria}`);
66
+ lines.push("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
67
+ return lines.join("\n");
68
+ }
58
69
  return [
59
70
  `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
60
71
  prompt,
@@ -88,6 +99,7 @@ export function createNotificationRuntime(options) {
88
99
  recurring: notification.recurring,
89
100
  readOnly: notification.readOnly,
90
101
  autoTask: notification.autoTask,
102
+ dynamic: notification.dynamic,
91
103
  timestamp: notification.timestamp,
92
104
  },
93
105
  }, {
@@ -1,5 +1,5 @@
1
1
  import type { TaskStore } from "../task-store.js";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
3
3
  import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
4
4
  export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
5
5
  export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
@@ -11,6 +11,7 @@ export interface TaskBacklogRuntimeOptions {
11
11
  maxFires?: number;
12
12
  }) => LoopEntry;
13
13
  deleteLoop: (id: string) => void;
14
+ recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
14
15
  addTrigger: (entry: LoopEntry) => void;
15
16
  removeTrigger: (id: string) => void;
16
17
  updateWidget: () => void;
@@ -4,7 +4,7 @@ import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./lo
4
4
  export const AUTO_TASK_WORKER_THRESHOLD = 5;
5
5
  export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
6
6
  export function createTaskBacklogRuntime(options) {
7
- const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
7
+ const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
8
8
  function isAutoTaskWorkerLoop(entry) {
9
9
  return entry.status === "active"
10
10
  && entry.prompt === AUTO_TASK_WORKER_PROMPT
@@ -18,6 +18,13 @@ export function createTaskBacklogRuntime(options) {
18
18
  function findAutoTaskWorkerLoop() {
19
19
  return getLoops().find(isAutoTaskWorkerLoop);
20
20
  }
21
+ function deleteTaskBacklogLoop(entry, pendingCount) {
22
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
23
+ removeTrigger(entry.id);
24
+ recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
25
+ deleteLoop(entry.id);
26
+ emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
27
+ }
21
28
  async function cleanupTaskBacklogLoops() {
22
29
  const backlogLoops = getLoops().filter(isTaskBacklogLoop);
23
30
  if (backlogLoops.length === 0)
@@ -25,13 +32,10 @@ export function createTaskBacklogRuntime(options) {
25
32
  const pending = await hasPendingTasks();
26
33
  if (pending < 0 || pending > 0)
27
34
  return 0;
28
- emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(backlogLoops.map((entry) => entry.id)));
29
- for (const entry of backlogLoops) {
30
- debug?.(`task backlog loop #${entry.id} no pending tasks remain, deleting`);
31
- removeTrigger(entry.id);
32
- deleteLoop(entry.id);
33
- emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
34
- }
35
+ const deletedLoopIds = backlogLoops.map((entry) => entry.id);
36
+ emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
37
+ for (const entry of backlogLoops)
38
+ deleteTaskBacklogLoop(entry, pending);
35
39
  updateWidget();
36
40
  return backlogLoops.length;
37
41
  }
@@ -43,7 +47,7 @@ export function createTaskBacklogRuntime(options) {
43
47
  return { entry: existing, created: false };
44
48
  const trigger = {
45
49
  type: "hybrid",
46
- cron: "*/5 * * * *",
50
+ cron: "*/3 * * * *",
47
51
  event: { source: "tasks:created" },
48
52
  debounceMs: 30000,
49
53
  };
package/dist/scheduler.js CHANGED
@@ -4,6 +4,9 @@ function computeNextFire(entry) {
4
4
  if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
5
5
  return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
6
6
  }
7
+ if (entry.trigger.type === "dynamic") {
8
+ return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
9
+ }
7
10
  return new Date(Date.now() + 60000);
8
11
  }
9
12
  export class CronScheduler {
@@ -15,10 +18,20 @@ export class CronScheduler {
15
18
  this.onFire = onFire;
16
19
  }
17
20
  start() {
18
- for (const entry of this.store.list()) {
21
+ for (const storedEntry of this.store.list()) {
22
+ let entry = storedEntry;
19
23
  if (entry.status !== "active")
20
24
  continue;
21
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
25
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
26
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
27
+ entry = this.store.updateDynamic(entry.id, {
28
+ dynamic: {
29
+ awaitingUpdate: false,
30
+ nextWakeAt: undefined,
31
+ lastUpdatedAt: Date.now(),
32
+ },
33
+ }) ?? entry;
34
+ }
22
35
  this.armTimer(entry);
23
36
  }
24
37
  }
@@ -27,7 +40,7 @@ export class CronScheduler {
27
40
  this.fireTimes.clear();
28
41
  }
29
42
  add(entry) {
30
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
43
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
31
44
  this.armTimer(entry);
32
45
  }
33
46
  }
@@ -38,11 +51,14 @@ export class CronScheduler {
38
51
  return this.fireTimes.get(id);
39
52
  }
40
53
  armTimer(entry) {
41
- const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
42
54
  const nextFire = computeNextFire(entry);
43
- const minuteField = _scheduleExpr.trim().split(/\s+/)[0] ?? "";
44
- const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
45
- const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
55
+ let jitter = 0;
56
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
57
+ const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
58
+ const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
59
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
60
+ jitter = computeJitter(entry.id, entry.recurring, minuteStep);
61
+ }
46
62
  const fireTime = nextFire.getTime() + jitter;
47
63
  if (fireTime > entry.expiresAt) {
48
64
  this.store.delete(entry.id);
@@ -59,6 +75,8 @@ export class CronScheduler {
59
75
  this.fireTimes.delete(id);
60
76
  continue;
61
77
  }
78
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate)
79
+ continue;
62
80
  if (filter && !filter(entry))
63
81
  continue;
64
82
  if (now >= entry.expiresAt) {
package/dist/store.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
2
2
  import { ReducerBackedStore } from "./reducer-backed-store.js";
3
- import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
3
+ import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
4
4
  export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
5
+ private tombstones;
5
6
  constructor(listIdOrPath?: string);
6
7
  create(trigger: Trigger, prompt: string, opts: {
7
8
  recurring: boolean;
@@ -9,6 +10,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
9
10
  taskBacklog?: boolean;
10
11
  readOnly?: boolean;
11
12
  maxFires?: number;
13
+ dynamic?: Partial<DynamicLoopState>;
12
14
  }): LoopEntry;
13
15
  pause(id: string): LoopEntry | undefined;
14
16
  resume(id: string): LoopEntry | undefined;
@@ -20,6 +22,12 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
20
22
  entry: LoopEntry | undefined;
21
23
  changedFields: string[];
22
24
  };
25
+ updateDynamic(id: string, fields: {
26
+ prompt?: string;
27
+ dynamic: Partial<DynamicLoopState>;
28
+ }): LoopEntry | undefined;
29
+ getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
30
+ recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
23
31
  delete(id: string): boolean;
24
32
  clearExpired(): number;
25
33
  expireEventLoops(sessionStartedAt: number): number;
package/dist/store.js CHANGED
@@ -4,7 +4,9 @@ import { reduceLoopState } from "./loop-reducer.js";
4
4
  import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
6
6
  const MAX_LOOPS = 25;
7
+ const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
7
8
  export class LoopStore extends ReducerBackedStore {
9
+ tombstones = new Map();
8
10
  constructor(listIdOrPath) {
9
11
  super({
10
12
  baseDir: LOOPS_DIR,
@@ -34,6 +36,7 @@ export class LoopStore extends ReducerBackedStore {
34
36
  taskBacklog: opts.taskBacklog,
35
37
  readOnly: opts.readOnly,
36
38
  maxFires: opts.maxFires,
39
+ dynamic: opts.dynamic,
37
40
  },
38
41
  });
39
42
  return this.entries.get(String(this.nextId - 1));
@@ -68,6 +71,22 @@ export class LoopStore extends ReducerBackedStore {
68
71
  entityId: id,
69
72
  payload: { id },
70
73
  });
74
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
75
+ this.applyReducerEvent({
76
+ type: "LOOP_DYNAMIC_UPDATED",
77
+ at: Date.now(),
78
+ source: "tool",
79
+ entityType: "loop",
80
+ entityId: id,
81
+ payload: {
82
+ id,
83
+ dynamic: {
84
+ awaitingUpdate: false,
85
+ lastUpdatedAt: Date.now(),
86
+ },
87
+ },
88
+ });
89
+ }
71
90
  return this.entries.get(id);
72
91
  });
73
92
  }
@@ -108,6 +127,44 @@ export class LoopStore extends ReducerBackedStore {
108
127
  return { entry: this.entries.get(id), changedFields };
109
128
  });
110
129
  }
130
+ updateDynamic(id, fields) {
131
+ return this.withLock(() => {
132
+ if (!this.entries.has(id))
133
+ return undefined;
134
+ this.applyReducerEvent({
135
+ type: "LOOP_DYNAMIC_UPDATED",
136
+ at: Date.now(),
137
+ source: "tool",
138
+ entityType: "loop",
139
+ entityId: id,
140
+ payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
141
+ });
142
+ return this.entries.get(id);
143
+ });
144
+ }
145
+ getDeletionTombstone(id) {
146
+ const tombstone = this.tombstones.get(id);
147
+ if (!tombstone)
148
+ return undefined;
149
+ if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS)
150
+ return tombstone;
151
+ this.tombstones.delete(id);
152
+ return undefined;
153
+ }
154
+ recordDeletionTombstone(id, input) {
155
+ const entry = this.entries.get(id);
156
+ if (!entry)
157
+ return undefined;
158
+ const tombstone = {
159
+ id,
160
+ reason: input.reason,
161
+ pendingCount: input.pendingCount,
162
+ deletedAt: Date.now(),
163
+ prompt: entry.prompt,
164
+ };
165
+ this.tombstones.set(id, tombstone);
166
+ return tombstone;
167
+ }
111
168
  delete(id) {
112
169
  return this.withLock(() => {
113
170
  if (!this.entries.has(id))
@@ -11,6 +11,14 @@ interface LoopStoreLike {
11
11
  maxFires?: number;
12
12
  }): LoopEntry;
13
13
  pause(id: string): LoopEntry | undefined;
14
+ updateDynamic(id: string, fields: {
15
+ prompt?: string;
16
+ dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
17
+ }): LoopEntry | undefined;
18
+ getDeletionTombstone(id: string): {
19
+ reason: string;
20
+ pendingCount?: number;
21
+ } | undefined;
14
22
  delete(id: string): boolean;
15
23
  }
16
24
  interface TriggerSystemLike {