@trevonistrevon/pi-loop 0.6.0 → 0.6.1

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 (104) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +17 -5
  3. package/dist/commands/loop-command.d.ts +9 -2
  4. package/dist/commands/loop-command.js +80 -50
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +41 -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.js +1 -1
  14. package/dist/notification-reducer.d.ts +2 -0
  15. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  16. package/dist/runtime/notification-runtime.d.ts +2 -1
  17. package/dist/runtime/notification-runtime.js +19 -7
  18. package/dist/runtime/task-backlog-runtime.d.ts +2 -1
  19. package/dist/runtime/task-backlog-runtime.js +13 -9
  20. package/dist/scheduler.js +25 -7
  21. package/dist/store.d.ts +9 -1
  22. package/dist/store.js +57 -0
  23. package/dist/tools/loop-tools.d.ts +8 -0
  24. package/dist/tools/loop-tools.js +104 -12
  25. package/dist/trigger-system.js +1 -1
  26. package/dist/types.d.ts +24 -1
  27. package/package.json +10 -2
  28. package/src/commands/loop-command.ts +99 -54
  29. package/src/index.ts +43 -16
  30. package/src/loop-format.ts +22 -0
  31. package/src/loop-parse.ts +44 -1
  32. package/src/loop-reducer.ts +41 -1
  33. package/src/monitor-manager.ts +1 -1
  34. package/src/notification-reducer.ts +3 -0
  35. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  36. package/src/runtime/notification-runtime.ts +21 -8
  37. package/src/runtime/task-backlog-runtime.ts +15 -11
  38. package/src/scheduler.ts +25 -8
  39. package/src/store.ts +59 -2
  40. package/src/tools/loop-tools.ts +128 -12
  41. package/src/trigger-system.ts +1 -1
  42. package/src/types.ts +29 -1
  43. package/.release-please-manifest.json +0 -3
  44. package/AGENTS.md +0 -123
  45. package/CONTRIBUTING.md +0 -79
  46. package/Dockerfile +0 -17
  47. package/benchmarks/experiment-sim.js +0 -30
  48. package/coverage/base.css +0 -224
  49. package/coverage/block-navigation.js +0 -87
  50. package/coverage/coverage-final.json +0 -36
  51. package/coverage/favicon.png +0 -0
  52. package/coverage/index.html +0 -191
  53. package/coverage/prettify.css +0 -1
  54. package/coverage/prettify.js +0 -2
  55. package/coverage/sort-arrow-sprite.png +0 -0
  56. package/coverage/sorter.js +0 -210
  57. package/coverage/src/api.ts.html +0 -193
  58. package/coverage/src/commands/index.html +0 -131
  59. package/coverage/src/commands/loop-command.ts.html +0 -634
  60. package/coverage/src/commands/tasks-command.ts.html +0 -490
  61. package/coverage/src/coordinator.ts.html +0 -430
  62. package/coverage/src/index.html +0 -341
  63. package/coverage/src/index.ts.html +0 -1249
  64. package/coverage/src/loop-parse.ts.html +0 -607
  65. package/coverage/src/loop-reducer.ts.html +0 -559
  66. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  67. package/coverage/src/monitor-manager.ts.html +0 -964
  68. package/coverage/src/monitor-reducer.ts.html +0 -583
  69. package/coverage/src/notification-reducer.ts.html +0 -550
  70. package/coverage/src/reducer-backed-store.ts.html +0 -589
  71. package/coverage/src/rpc/channels.ts.html +0 -376
  72. package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
  73. package/coverage/src/rpc/index.html +0 -131
  74. package/coverage/src/runtime/index.html +0 -251
  75. package/coverage/src/runtime/loop-events.ts.html +0 -265
  76. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  77. package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
  78. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  79. package/coverage/src/runtime/scope.ts.html +0 -196
  80. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  81. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
  82. package/coverage/src/runtime/task-events.ts.html +0 -208
  83. package/coverage/src/runtime/task-mutations.ts.html +0 -394
  84. package/coverage/src/runtime/task-rpc.ts.html +0 -523
  85. package/coverage/src/scheduler.ts.html +0 -400
  86. package/coverage/src/store.ts.html +0 -667
  87. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  88. package/coverage/src/task-reducer.ts.html +0 -550
  89. package/coverage/src/task-store.ts.html +0 -526
  90. package/coverage/src/tools/index.html +0 -161
  91. package/coverage/src/tools/loop-tools.ts.html +0 -991
  92. package/coverage/src/tools/monitor-tools.ts.html +0 -538
  93. package/coverage/src/tools/native-task-tools.ts.html +0 -493
  94. package/coverage/src/tools/tool-result.ts.html +0 -97
  95. package/coverage/src/trigger-system.ts.html +0 -547
  96. package/coverage/src/ui/index.html +0 -116
  97. package/coverage/src/ui/widget.ts.html +0 -292
  98. package/docker-compose.yml +0 -12
  99. package/docs/architecture/state-machine-migration.md +0 -502
  100. package/docs/architecture/state-machine-reducer-event-model.md +0 -822
  101. package/docs/architecture/state-machine-test-matrix.md +0 -249
  102. package/docs/architecture/state-machine-transition-map.md +0 -436
  103. package/release-please-config.json +0 -9
  104. package/vitest.config.ts +0 -29
@@ -3,13 +3,21 @@ import type {
3
3
  ExtensionCommandContext,
4
4
  ExtensionUIContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { parseInterval } from "../loop-parse.js";
7
- import type { LoopEntry, Trigger } from "../types.js";
6
+ import { formatTrigger } from "../loop-format.js";
7
+ import { isValidCronExpression, parseInterval } from "../loop-parse.js";
8
+ import type { DynamicLoopState, LoopEntry, Trigger } from "../types.js";
8
9
 
9
10
  interface LoopStoreLike {
10
11
  list(): LoopEntry[];
11
12
  get(id: string): LoopEntry | undefined;
12
- create(trigger: Trigger, prompt: string, options?: Partial<LoopEntry>): LoopEntry;
13
+ create(trigger: Trigger, prompt: string, options: {
14
+ recurring: boolean;
15
+ autoTask?: boolean;
16
+ taskBacklog?: boolean;
17
+ readOnly?: boolean;
18
+ maxFires?: number;
19
+ dynamic?: Partial<DynamicLoopState>;
20
+ }): LoopEntry;
13
21
  pause(id: string): LoopEntry | undefined;
14
22
  resume(id: string): LoopEntry | undefined;
15
23
  delete(id: string): boolean;
@@ -27,33 +35,82 @@ export interface LoopCommandOptions {
27
35
  updateWidget: () => void;
28
36
  }
29
37
 
30
- export function registerLoopCommand(options: LoopCommandOptions): void {
31
- const { pi, getStore, getTriggerSystem, updateWidget } = options;
38
+ type LoopCommandRoute =
39
+ | { type: "menu" }
40
+ | { type: "event"; source: string; prompt: string }
41
+ | { type: "cron"; interval: string; prompt: string; notifyEvery: boolean }
42
+ | { type: "invalid-cron"; interval: string }
43
+ | { type: "missing-interval-prompt" }
44
+ | { type: "dynamic"; goal: string };
45
+
46
+ function parseLoopCommandRoute(input: string): LoopCommandRoute {
47
+ const trimmed = input.trim();
48
+ if (!trimmed) return { type: "menu" };
49
+
50
+ const eventMatch = trimmed.match(/^(?:event|when)\s+(\S+)\s+(.+)$/i);
51
+ if (eventMatch?.[1] && eventMatch[2]) {
52
+ return { type: "event", source: eventMatch[1], prompt: eventMatch[2].trim() };
53
+ }
32
54
 
33
- async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
34
- const p = prompt || await ui.input("Prompt (what should the agent check?)");
35
- if (!p) return;
55
+ const parts = trimmed.split(/\s+/);
56
+ if (parts.length > 5) {
57
+ const interval = parts.slice(0, 5).join(" ");
58
+ const cronShaped = parts.slice(0, 5).every((part) => /^[\d*/,-]+$/.test(part));
59
+ if (cronShaped) {
60
+ if (!isValidCronExpression(interval)) return { type: "invalid-cron", interval };
61
+ return { type: "cron", interval, prompt: parts.slice(5).join(" "), notifyEvery: false };
62
+ }
63
+ }
36
64
 
37
- const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
38
- if (!interval) return;
65
+ const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
66
+ if (intervalMatch) {
67
+ const interval = intervalMatch[1] ?? intervalMatch[0];
68
+ const prompt = trimmed.slice(intervalMatch[0].length).trim();
69
+ if (!prompt) return { type: "missing-interval-prompt" };
70
+ return { type: "cron", interval, prompt, notifyEvery: true };
71
+ }
39
72
 
73
+ return { type: "dynamic", goal: trimmed };
74
+ }
75
+
76
+ export function registerLoopCommand(options: LoopCommandOptions): void {
77
+ const { pi, getStore, getTriggerSystem, updateWidget } = options;
78
+
79
+ function createCronLoop(ui: ExtensionUIContext, interval: string, prompt: string, notifyEvery: boolean) {
80
+ let entry: LoopEntry | undefined;
40
81
  try {
41
82
  const parsed = parseInterval(interval);
42
83
  const trigger: Trigger = { type: "cron", schedule: parsed.cron };
43
- const entry = getStore().create(trigger, p, { recurring: true });
84
+ entry = getStore().create(trigger, prompt, { recurring: true });
44
85
  getTriggerSystem().add(entry);
45
86
  updateWidget();
46
- ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
87
+ const cadence = notifyEvery ? `every ${parsed.description}` : parsed.description;
88
+ ui.notify(`Loop #${entry.id} created: ${cadence} — ${prompt.slice(0, 50)}`, "info");
47
89
  } catch (err: unknown) {
90
+ if (entry) {
91
+ getTriggerSystem().remove(entry.id);
92
+ getStore().delete(entry.id);
93
+ updateWidget();
94
+ }
48
95
  ui.notify((err as Error).message, "error");
49
96
  }
50
97
  }
51
98
 
52
- async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
99
+ async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
100
+ const p = prompt || await ui.input("Prompt (what should the agent check?)");
101
+ if (!p) return;
102
+
103
+ const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
104
+ if (!interval) return;
105
+
106
+ createCronLoop(ui, interval, p, true);
107
+ }
108
+
109
+ async function eventLoop(ui: ExtensionUIContext, prompt?: string, sourceOverride?: string) {
53
110
  const p = prompt || await ui.input("Prompt");
54
111
  if (!p) return;
55
112
 
56
- const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
113
+ const source = sourceOverride || await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
57
114
  if (!source) return;
58
115
 
59
116
  const trigger: Trigger = { type: "event", source };
@@ -63,6 +120,18 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
63
120
  ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
64
121
  }
65
122
 
123
+ function dynamicLoop(ui: ExtensionUIContext, goal: string) {
124
+ const trigger: Trigger = { type: "dynamic" };
125
+ const entry = getStore().create(trigger, goal, {
126
+ recurring: true,
127
+ maxFires: 20,
128
+ dynamic: { goal, iteration: 0 },
129
+ });
130
+ getTriggerSystem().add(entry);
131
+ updateWidget();
132
+ ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
133
+ }
134
+
66
135
  async function viewLoops(ui: ExtensionUIContext) {
67
136
  const loops = getStore().list();
68
137
  if (loops.length === 0) {
@@ -72,12 +141,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
72
141
 
73
142
  const choices = loops.map((l) => {
74
143
  const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
75
- const triggerDesc = l.trigger.type === "cron"
76
- ? `cron: ${l.trigger.schedule}`
77
- : l.trigger.type === "event"
78
- ? `event: ${l.trigger.source}`
79
- : `hybrid: ${l.trigger.cron}`;
80
- return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
144
+ return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${formatTrigger(l.trigger, "command")})`;
81
145
  });
82
146
  choices.push("< Back");
83
147
 
@@ -109,8 +173,9 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
109
173
  updateWidget();
110
174
  ui.notify(`Loop #${entry.id} paused`, "info");
111
175
  } else if (action === "* Resume") {
112
- getStore().resume(entry.id);
113
- getTriggerSystem().add(entry);
176
+ const resumed = getStore().resume(entry.id);
177
+ if (!resumed) return viewLoops(ui);
178
+ getTriggerSystem().add(resumed);
114
179
  updateWidget();
115
180
  ui.notify(`Loop #${entry.id} resumed`, "info");
116
181
  }
@@ -127,12 +192,12 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
127
192
  }
128
193
 
129
194
  pi.registerCommand("loop", {
130
- description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
195
+ description: "Create a loop. Use /loop [interval] [prompt] for scheduled loops, /loop event <source> <prompt> for event loops, or /loop <goal> for a dynamic goal loop.",
131
196
  handler: async (args: string, ctx: ExtensionCommandContext) => {
132
- const trimmed = args.trim();
133
197
  const ui = ctx.ui;
198
+ const route = parseLoopCommandRoute(args);
134
199
 
135
- if (!trimmed) {
200
+ if (route.type === "menu") {
136
201
  const choice = await ui.select("Loop", [
137
202
  "Create scheduled loop",
138
203
  "Create event-triggered loop",
@@ -147,37 +212,17 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
147
212
  return settings(ui);
148
213
  }
149
214
 
150
- const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
151
- if (intervalMatch) {
152
- const interval = intervalMatch[1] ?? intervalMatch[0];
153
- const prompt = trimmed.slice(intervalMatch[0].length).trim();
154
-
155
- if (!prompt) {
156
- ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
157
- return;
158
- }
159
-
160
- try {
161
- const parsed = parseInterval(interval);
162
- const trigger: Trigger = { type: "cron", schedule: parsed.cron };
163
- const entry = getStore().create(trigger, prompt, { recurring: true });
164
- getTriggerSystem().add(entry);
165
- updateWidget();
166
- ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
167
- } catch (err: unknown) {
168
- ui.notify((err as Error).message, "error");
169
- }
215
+ if (route.type === "event") return eventLoop(ui, route.prompt, route.source);
216
+ if (route.type === "cron") return createCronLoop(ui, route.interval, route.prompt, route.notifyEvery);
217
+ if (route.type === "invalid-cron") {
218
+ ui.notify(`Invalid cron expression: ${route.interval}`, "error");
170
219
  return;
171
220
  }
172
-
173
- const choice = await ui.select("Loop mode", [
174
- `Scheduled: "${trimmed.slice(0, 50)}"`,
175
- `Event-triggered: "${trimmed.slice(0, 50)}"`,
176
- ]);
177
-
178
- if (!choice) return;
179
- if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
180
- return scheduleLoop(ui, trimmed);
221
+ if (route.type === "missing-interval-prompt") {
222
+ ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
223
+ return;
224
+ }
225
+ return dynamicLoop(ui, route.goal);
181
226
  },
182
227
  });
183
228
  }
package/src/index.ts CHANGED
@@ -4,13 +4,14 @@
4
4
  * Tools:
5
5
  * LoopCreate — Create a scheduled or event-triggered re-wake loop
6
6
  * LoopList — List all active loops with status and next-fire times
7
+ * LoopUpdate — Continue, pause, or complete a dynamic goal loop
7
8
  * LoopDelete — Delete or pause a loop by ID
8
9
  * MonitorCreate — Start a background command that streams output via pi events
9
10
  * MonitorList — List running monitors
10
11
  * MonitorStop — Stop a running monitor
11
12
  *
12
13
  * Commands:
13
- * /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
14
+ * /loop — Schedule or manage re-wake loops, including /loop <goal>
14
15
  * /tasks — View or manage native fallback tasks when pi-tasks is absent
15
16
  */
16
17
 
@@ -67,15 +68,26 @@ export default function (pi: ExtensionAPI) {
67
68
  // Once pi-tasks owns the channels, any native store created during the
68
69
  // detection window is a shadow — never surface it in the status line.
69
70
  if (tasksAvailable || !nativeTaskStore) return { count: 0 };
70
- const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
71
- const active = tasks.find(t => t.status === "in_progress");
72
- const next = tasks.find(t => t.status === "pending");
73
- const focus = active
74
- ? `active: ${active.subject.slice(0, 50)}`
75
- : next
76
- ? `next: ${next.subject.slice(0, 50)}`
71
+
72
+ let count = 0;
73
+ let activeSubject: string | undefined;
74
+ let nextSubject: string | undefined;
75
+ for (const task of nativeTaskStore.list()) {
76
+ if (task.status === "in_progress") {
77
+ count++;
78
+ activeSubject ??= task.subject;
79
+ } else if (task.status === "pending") {
80
+ count++;
81
+ nextSubject ??= task.subject;
82
+ }
83
+ }
84
+
85
+ const focus = activeSubject
86
+ ? `active: ${activeSubject.slice(0, 50)}`
87
+ : nextSubject
88
+ ? `next: ${nextSubject.slice(0, 50)}`
77
89
  : undefined;
78
- return { count: tasks.length, focusText: focus };
90
+ return { count, focusText: focus };
79
91
  });
80
92
 
81
93
  scheduler = new CronScheduler(store, onLoopFire);
@@ -199,6 +211,9 @@ export default function (pi: ExtensionAPI) {
199
211
  deleteLoop: (id) => {
200
212
  store.delete(id);
201
213
  },
214
+ recordDeletionTombstone: (id, tombstone) => {
215
+ store.recordDeletionTombstone(id, tombstone);
216
+ },
202
217
  addTrigger: (entry) => {
203
218
  triggerSystem.add(entry);
204
219
  },
@@ -237,6 +252,17 @@ export default function (pi: ExtensionAPI) {
237
252
  }
238
253
  store.fire(entry.id);
239
254
 
255
+ const firedAt = Date.now();
256
+ const firedEntry = entry.trigger.type === "dynamic"
257
+ ? store.updateDynamic(entry.id, {
258
+ dynamic: {
259
+ awaitingUpdate: true,
260
+ nextWakeAt: undefined,
261
+ lastUpdatedAt: firedAt,
262
+ },
263
+ }) ?? entry
264
+ : entry;
265
+
240
266
  if (entry.autoTask) {
241
267
  autoCreateTask(entry).then((taskId) => {
242
268
  if (taskId) debug(`loop #${entry.id} → task #${taskId}`);
@@ -244,13 +270,14 @@ export default function (pi: ExtensionAPI) {
244
270
  }
245
271
 
246
272
  pi.events.emit("loop:fire", {
247
- loopId: entry.id,
248
- prompt: entry.prompt,
249
- trigger: entry.trigger,
250
- timestamp: Date.now(),
251
- readOnly: entry.readOnly,
252
- recurring: entry.recurring,
253
- autoTask: entry.autoTask,
273
+ loopId: firedEntry.id,
274
+ prompt: firedEntry.prompt,
275
+ trigger: firedEntry.trigger,
276
+ timestamp: firedAt,
277
+ readOnly: firedEntry.readOnly,
278
+ recurring: firedEntry.recurring,
279
+ autoTask: firedEntry.autoTask,
280
+ dynamic: firedEntry.dynamic,
254
281
  });
255
282
  }
256
283
 
@@ -0,0 +1,22 @@
1
+ import type { Trigger } from "./types.js";
2
+
3
+ export type TriggerFormatStyle = "command" | "create" | "list" | "notification";
4
+
5
+ export function formatTrigger(trigger: Trigger | string, style: TriggerFormatStyle = "list"): string {
6
+ if (typeof trigger === "string") return trigger;
7
+
8
+ if (trigger.type === "cron") {
9
+ return style === "create" || style === "notification"
10
+ ? `schedule: ${trigger.schedule}`
11
+ : `cron: ${trigger.schedule}`;
12
+ }
13
+
14
+ if (trigger.type === "event") return `event: ${trigger.source}`;
15
+
16
+ if (trigger.type === "dynamic") return "dynamic";
17
+
18
+ if (style === "command") return `hybrid: ${trigger.cron}`;
19
+ if (style === "create") return `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
20
+ if (style === "notification") return "hybrid";
21
+ return `hybrid: ${trigger.cron} + ${trigger.event.source}`;
22
+ }
package/src/loop-parse.ts CHANGED
@@ -53,10 +53,53 @@ function isFullCron(expr: string): boolean {
53
53
  return parts.length === 5;
54
54
  }
55
55
 
56
+ function parseCronNumber(input: string, min: number, max: number): number | undefined {
57
+ if (!/^\d+$/.test(input)) return undefined;
58
+ const value = Number.parseInt(input, 10);
59
+ return value >= min && value <= max ? value : undefined;
60
+ }
61
+
62
+ function isValidCronField(field: string, min: number, max: number): boolean {
63
+ return field.split(",").every((part) => {
64
+ const [base, step, extra] = part.split("/");
65
+ if (extra !== undefined || base === undefined) return false;
66
+ if (step !== undefined && (parseCronNumber(step, 1, max - min + 1) === undefined)) return false;
67
+ if (base === "*") return true;
68
+
69
+ const range = base.split("-");
70
+ if (range.length === 1) return step === undefined && parseCronNumber(base, min, max) !== undefined;
71
+ if (range.length !== 2) return false;
72
+
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
+
79
+ export function isValidCronExpression(expr: string): boolean {
80
+ const fields = expr.trim().split(/\s+/);
81
+ if (fields.length !== 5) return false;
82
+
83
+ const ranges = [
84
+ [0, 59],
85
+ [0, 23],
86
+ [1, 31],
87
+ [1, 12],
88
+ [0, 6],
89
+ ] as const;
90
+ return fields.every((field, index) => {
91
+ const range = ranges[index];
92
+ return range !== undefined && isValidCronField(field, range[0], range[1]);
93
+ });
94
+ }
95
+
56
96
  export function parseInterval(input: string): { cron: string; description: string } {
57
97
  const trimmed = input.trim();
58
98
 
59
99
  if (isFullCron(trimmed)) {
100
+ if (!isValidCronExpression(trimmed)) {
101
+ throw new Error(`Invalid cron expression: ${trimmed}`);
102
+ }
60
103
  return { cron: trimmed, description: `cron: ${trimmed}` };
61
104
  }
62
105
 
@@ -80,7 +123,7 @@ export function parseInterval(input: string): { cron: string; description: strin
80
123
 
81
124
  export function cronToNextFire(cronExpr: string, fromDate: Date = new Date()): Date {
82
125
  const parts = cronExpr.trim().split(/\s+/);
83
- if (parts.length !== 5) throw new Error(`Invalid cron expression: ${cronExpr}`);
126
+ if (!isValidCronExpression(cronExpr)) throw new Error(`Invalid cron expression: ${cronExpr}`);
84
127
 
85
128
  const [minF, hourF, dayF, monthF, dowF] = parts;
86
129
  if (
@@ -1,4 +1,4 @@
1
- import type { LoopEntry, Trigger } from "./types.js";
1
+ import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
2
2
 
3
3
  export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4
4
 
@@ -34,6 +34,7 @@ export type LoopReducerEvent =
34
34
  taskBacklog?: boolean;
35
35
  readOnly?: boolean;
36
36
  maxFires?: number;
37
+ dynamic?: Partial<DynamicLoopState>;
37
38
  };
38
39
  }
39
40
  | {
@@ -60,6 +61,18 @@ export type LoopReducerEvent =
60
61
  id: string;
61
62
  reason: "expires_at" | "resume_event_stale" | "already_completed_monitor";
62
63
  };
64
+ }
65
+ | {
66
+ type: "LOOP_DYNAMIC_UPDATED";
67
+ at: number;
68
+ source: ReducerSource;
69
+ entityType?: "loop";
70
+ entityId?: string;
71
+ payload: {
72
+ id: string;
73
+ prompt?: string;
74
+ dynamic: Partial<DynamicLoopState>;
75
+ };
63
76
  };
64
77
 
65
78
  export type LoopReducerEffect =
@@ -106,6 +119,18 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
106
119
  readOnly: event.payload.readOnly,
107
120
  maxFires: event.payload.maxFires,
108
121
  fireCount: 0,
122
+ dynamic: event.payload.trigger.type === "dynamic" || event.payload.dynamic
123
+ ? {
124
+ goal: event.payload.dynamic?.goal ?? event.payload.prompt,
125
+ state: event.payload.dynamic?.state,
126
+ metrics: event.payload.dynamic?.metrics,
127
+ doneCriteria: event.payload.dynamic?.doneCriteria,
128
+ iteration: event.payload.dynamic?.iteration ?? 0,
129
+ nextWakeAt: event.payload.dynamic?.nextWakeAt,
130
+ awaitingUpdate: event.payload.dynamic?.awaitingUpdate ?? false,
131
+ lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
132
+ }
133
+ : undefined,
109
134
  };
110
135
  next.loopsById[id] = loop;
111
136
  return {
@@ -150,6 +175,21 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
150
175
  loop.updatedAt = event.at;
151
176
  }
152
177
 
178
+ if (event.type === "LOOP_DYNAMIC_UPDATED") {
179
+ loop.prompt = event.payload.prompt ?? loop.prompt;
180
+ loop.dynamic = {
181
+ goal: event.payload.dynamic.goal ?? loop.dynamic?.goal ?? loop.prompt,
182
+ state: event.payload.dynamic.state ?? loop.dynamic?.state,
183
+ metrics: event.payload.dynamic.metrics ?? loop.dynamic?.metrics,
184
+ doneCriteria: event.payload.dynamic.doneCriteria ?? loop.dynamic?.doneCriteria,
185
+ iteration: event.payload.dynamic.iteration ?? loop.dynamic?.iteration ?? 0,
186
+ nextWakeAt: "nextWakeAt" in event.payload.dynamic ? event.payload.dynamic.nextWakeAt : loop.dynamic?.nextWakeAt,
187
+ awaitingUpdate: event.payload.dynamic.awaitingUpdate ?? loop.dynamic?.awaitingUpdate ?? false,
188
+ lastUpdatedAt: event.payload.dynamic.lastUpdatedAt ?? event.at,
189
+ };
190
+ loop.updatedAt = event.at;
191
+ }
192
+
153
193
  next.loopsById[id] = loop;
154
194
  return {
155
195
  state: next,
@@ -270,7 +270,7 @@ export class MonitorManager {
270
270
  onComplete(id: string, callback: () => void): boolean {
271
271
  const bp = this.processes.get(id);
272
272
  if (!bp) return false;
273
- if (bp.entry.status === "completed") {
273
+ if (bp.entry.status === "completed" || bp.entry.status === "error") {
274
274
  callback();
275
275
  return true;
276
276
  }
@@ -1,3 +1,5 @@
1
+ import type { DynamicLoopState } from "./types.js";
2
+
1
3
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
2
4
 
3
5
  export interface ReducerNotification {
@@ -9,6 +11,7 @@ export interface ReducerNotification {
9
11
  recurring?: boolean;
10
12
  autoTask?: boolean;
11
13
  readOnly?: boolean;
14
+ dynamic?: DynamicLoopState;
12
15
  }
13
16
 
14
17
  export interface NotificationReducerState {
@@ -62,7 +62,7 @@ export function createMonitorOnDoneRuntime(options: MonitorOnDoneRuntimeOptions)
62
62
 
63
63
  const monitor = monitorManager.get(monitorId);
64
64
  if (monitor && monitor.status !== "running") {
65
- if (monitor.status === "completed") {
65
+ if (monitor.status === "completed" || monitor.status === "error") {
66
66
  deliver();
67
67
  return;
68
68
  }
@@ -5,13 +5,14 @@ import {
5
5
  type ReducerEvent,
6
6
  type ReducerHandler,
7
7
  } from "../coordinator.js";
8
+ import { formatTrigger } from "../loop-format.js";
8
9
  import {
9
10
  type NotificationReducerEvent,
10
11
  type NotificationReducerState,
11
12
  type ReducerNotification,
12
13
  reduceNotificationState,
13
14
  } from "../notification-reducer.js";
14
- import type { Trigger } from "../types.js";
15
+ import type { DynamicLoopState, Trigger } from "../types.js";
15
16
 
16
17
  export interface LoopFireEvent {
17
18
  loopId: string;
@@ -21,6 +22,7 @@ export interface LoopFireEvent {
21
22
  readOnly?: boolean;
22
23
  recurring?: boolean;
23
24
  autoTask?: boolean;
25
+ dynamic?: DynamicLoopState;
24
26
  }
25
27
 
26
28
  export interface PendingNotification extends LoopFireEvent {
@@ -94,13 +96,7 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
94
96
  }
95
97
 
96
98
  function buildLoopFireMessage(data: LoopFireEvent): string {
97
- const triggerInfo = typeof data.trigger === "string"
98
- ? data.trigger
99
- : data.trigger?.type === "cron"
100
- ? `schedule: ${data.trigger.schedule}`
101
- : data.trigger?.type === "event"
102
- ? `event: ${data.trigger.source}`
103
- : "hybrid";
99
+ const triggerInfo = formatTrigger(data.trigger, "notification");
104
100
 
105
101
  const loopId = data.loopId || "?";
106
102
  const prompt = data.prompt || "loop fired";
@@ -108,6 +104,22 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
108
104
  ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
109
105
  : "";
110
106
 
107
+ if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
108
+ const dynamic = data.dynamic;
109
+ const lines = [
110
+ `[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
111
+ `Goal: ${dynamic?.goal ?? prompt}`,
112
+ `Iteration: ${dynamic?.iteration ?? 0}`,
113
+ ];
114
+ if (dynamic?.state) lines.push(`State: ${dynamic.state}`);
115
+ if (dynamic?.metrics) lines.push(`Metrics: ${dynamic.metrics}`);
116
+ if (dynamic?.doneCriteria) lines.push(`Done criteria: ${dynamic.doneCriteria}`);
117
+ lines.push(
118
+ "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\".",
119
+ );
120
+ return lines.join("\n");
121
+ }
122
+
111
123
  return [
112
124
  `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
113
125
  prompt,
@@ -144,6 +156,7 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
144
156
  recurring: notification.recurring,
145
157
  readOnly: notification.readOnly,
146
158
  autoTask: notification.autoTask,
159
+ dynamic: notification.dynamic,
147
160
  timestamp: notification.timestamp,
148
161
  },
149
162
  }, {
@@ -8,7 +8,7 @@ import {
8
8
  type TaskBacklogEvent,
9
9
  } from "../task-backlog-coordinator.js";
10
10
  import type { TaskStore } from "../task-store.js";
11
- import type { LoopEntry, Trigger } from "../types.js";
11
+ import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
12
12
  import {
13
13
  buildLoopAutodeletedPayload,
14
14
  buildTaskBacklogEmptyPayload,
@@ -27,6 +27,7 @@ export interface TaskBacklogRuntimeOptions {
27
27
  maxFires?: number;
28
28
  }) => LoopEntry;
29
29
  deleteLoop: (id: string) => void;
30
+ recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
30
31
  addTrigger: (entry: LoopEntry) => void;
31
32
  removeTrigger: (id: string) => void;
32
33
  updateWidget: () => void;
@@ -52,6 +53,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
52
53
  getLoops,
53
54
  createLoop,
54
55
  deleteLoop,
56
+ recordDeletionTombstone,
55
57
  addTrigger,
56
58
  removeTrigger,
57
59
  updateWidget,
@@ -79,6 +81,14 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
79
81
  return getLoops().find(isAutoTaskWorkerLoop);
80
82
  }
81
83
 
84
+ function deleteTaskBacklogLoop(entry: LoopEntry, pendingCount: number): void {
85
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
86
+ removeTrigger(entry.id);
87
+ recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
88
+ deleteLoop(entry.id);
89
+ emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
90
+ }
91
+
82
92
  async function cleanupTaskBacklogLoops(): Promise<number> {
83
93
  const backlogLoops = getLoops().filter(isTaskBacklogLoop);
84
94
  if (backlogLoops.length === 0) return 0;
@@ -86,15 +96,9 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
86
96
  const pending = await hasPendingTasks();
87
97
  if (pending < 0 || pending > 0) return 0;
88
98
 
89
- emitTaskBacklogEmpty?.(
90
- buildTaskBacklogEmptyPayload(backlogLoops.map((entry) => entry.id)),
91
- );
92
- for (const entry of backlogLoops) {
93
- debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
94
- removeTrigger(entry.id);
95
- deleteLoop(entry.id);
96
- emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
97
- }
99
+ const deletedLoopIds = backlogLoops.map((entry) => entry.id);
100
+ emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
101
+ for (const entry of backlogLoops) deleteTaskBacklogLoop(entry, pending);
98
102
  updateWidget();
99
103
  return backlogLoops.length;
100
104
  }
@@ -107,7 +111,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
107
111
 
108
112
  const trigger: Trigger = {
109
113
  type: "hybrid",
110
- cron: "*/5 * * * *",
114
+ cron: "*/3 * * * *",
111
115
  event: { source: "tasks:created" },
112
116
  debounceMs: 30000,
113
117
  };