@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
@@ -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;
@@ -25,35 +33,85 @@ export interface LoopCommandOptions {
25
33
  getStore: () => LoopStoreLike;
26
34
  getTriggerSystem: () => TriggerSystemLike;
27
35
  updateWidget: () => void;
36
+ onDynamicLoopActivated?: (entry: LoopEntry) => void;
28
37
  }
29
38
 
30
- export function registerLoopCommand(options: LoopCommandOptions): void {
31
- const { pi, getStore, getTriggerSystem, updateWidget } = options;
39
+ type LoopCommandRoute =
40
+ | { type: "menu" }
41
+ | { type: "event"; source: string; prompt: string }
42
+ | { type: "cron"; interval: string; prompt: string; notifyEvery: boolean }
43
+ | { type: "invalid-cron"; interval: string }
44
+ | { type: "missing-interval-prompt" }
45
+ | { type: "dynamic"; goal: string };
46
+
47
+ function parseLoopCommandRoute(input: string): LoopCommandRoute {
48
+ const trimmed = input.trim();
49
+ if (!trimmed) return { type: "menu" };
50
+
51
+ const eventMatch = trimmed.match(/^(?:event|when)\s+(\S+)\s+(.+)$/i);
52
+ if (eventMatch?.[1] && eventMatch[2]) {
53
+ return { type: "event", source: eventMatch[1], prompt: eventMatch[2].trim() };
54
+ }
32
55
 
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;
56
+ const parts = trimmed.split(/\s+/);
57
+ if (parts.length > 5) {
58
+ const interval = parts.slice(0, 5).join(" ");
59
+ const cronShaped = parts.slice(0, 5).every((part) => /^[\d*/,-]+$/.test(part));
60
+ if (cronShaped) {
61
+ if (!isValidCronExpression(interval)) return { type: "invalid-cron", interval };
62
+ return { type: "cron", interval, prompt: parts.slice(5).join(" "), notifyEvery: false };
63
+ }
64
+ }
36
65
 
37
- const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
38
- if (!interval) return;
66
+ const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
67
+ if (intervalMatch) {
68
+ const interval = intervalMatch[1] ?? intervalMatch[0];
69
+ const prompt = trimmed.slice(intervalMatch[0].length).trim();
70
+ if (!prompt) return { type: "missing-interval-prompt" };
71
+ return { type: "cron", interval, prompt, notifyEvery: true };
72
+ }
39
73
 
74
+ return { type: "dynamic", goal: trimmed };
75
+ }
76
+
77
+ export function registerLoopCommand(options: LoopCommandOptions): void {
78
+ const { pi, getStore, getTriggerSystem, updateWidget, onDynamicLoopActivated } = options;
79
+
80
+ function createCronLoop(ui: ExtensionUIContext, interval: string, prompt: string, notifyEvery: boolean) {
81
+ let entry: LoopEntry | undefined;
40
82
  try {
41
83
  const parsed = parseInterval(interval);
42
84
  const trigger: Trigger = { type: "cron", schedule: parsed.cron };
43
- const entry = getStore().create(trigger, p, { recurring: true });
85
+ entry = getStore().create(trigger, prompt, { recurring: true });
44
86
  getTriggerSystem().add(entry);
45
87
  updateWidget();
46
- ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
88
+ const cadence = notifyEvery ? `every ${parsed.description}` : parsed.description;
89
+ ui.notify(`Loop #${entry.id} created: ${cadence} — ${prompt.slice(0, 50)}`, "info");
47
90
  } catch (err: unknown) {
91
+ if (entry) {
92
+ getTriggerSystem().remove(entry.id);
93
+ getStore().delete(entry.id);
94
+ updateWidget();
95
+ }
48
96
  ui.notify((err as Error).message, "error");
49
97
  }
50
98
  }
51
99
 
52
- async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
100
+ async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
101
+ const p = prompt || await ui.input("Prompt (what should the agent check?)");
102
+ if (!p) return;
103
+
104
+ const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
105
+ if (!interval) return;
106
+
107
+ createCronLoop(ui, interval, p, true);
108
+ }
109
+
110
+ async function eventLoop(ui: ExtensionUIContext, prompt?: string, sourceOverride?: string) {
53
111
  const p = prompt || await ui.input("Prompt");
54
112
  if (!p) return;
55
113
 
56
- const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
114
+ const source = sourceOverride || await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
57
115
  if (!source) return;
58
116
 
59
117
  const trigger: Trigger = { type: "event", source };
@@ -63,6 +121,19 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
63
121
  ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
64
122
  }
65
123
 
124
+ function dynamicLoop(ui: ExtensionUIContext, goal: string) {
125
+ const trigger: Trigger = { type: "dynamic" };
126
+ const entry = getStore().create(trigger, goal, {
127
+ recurring: true,
128
+ maxFires: 20,
129
+ dynamic: { goal, iteration: 0 },
130
+ });
131
+ getTriggerSystem().add(entry);
132
+ updateWidget();
133
+ ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
134
+ onDynamicLoopActivated?.(entry);
135
+ }
136
+
66
137
  async function viewLoops(ui: ExtensionUIContext) {
67
138
  const loops = getStore().list();
68
139
  if (loops.length === 0) {
@@ -72,12 +143,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
72
143
 
73
144
  const choices = loops.map((l) => {
74
145
  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})`;
146
+ return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${formatTrigger(l.trigger, "command")})`;
81
147
  });
82
148
  choices.push("< Back");
83
149
 
@@ -109,10 +175,12 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
109
175
  updateWidget();
110
176
  ui.notify(`Loop #${entry.id} paused`, "info");
111
177
  } else if (action === "* Resume") {
112
- getStore().resume(entry.id);
113
- getTriggerSystem().add(entry);
178
+ const resumed = getStore().resume(entry.id);
179
+ if (!resumed) return viewLoops(ui);
180
+ getTriggerSystem().add(resumed);
114
181
  updateWidget();
115
182
  ui.notify(`Loop #${entry.id} resumed`, "info");
183
+ if (resumed.trigger.type === "dynamic") onDynamicLoopActivated?.(resumed);
116
184
  }
117
185
  }
118
186
  }
@@ -127,12 +195,12 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
127
195
  }
128
196
 
129
197
  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",
198
+ 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
199
  handler: async (args: string, ctx: ExtensionCommandContext) => {
132
- const trimmed = args.trim();
133
200
  const ui = ctx.ui;
201
+ const route = parseLoopCommandRoute(args);
134
202
 
135
- if (!trimmed) {
203
+ if (route.type === "menu") {
136
204
  const choice = await ui.select("Loop", [
137
205
  "Create scheduled loop",
138
206
  "Create event-triggered loop",
@@ -147,37 +215,17 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
147
215
  return settings(ui);
148
216
  }
149
217
 
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
- }
218
+ if (route.type === "event") return eventLoop(ui, route.prompt, route.source);
219
+ if (route.type === "cron") return createCronLoop(ui, route.interval, route.prompt, route.notifyEvery);
220
+ if (route.type === "invalid-cron") {
221
+ ui.notify(`Invalid cron expression: ${route.interval}`, "error");
170
222
  return;
171
223
  }
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);
224
+ if (route.type === "missing-interval-prompt") {
225
+ ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
226
+ return;
227
+ }
228
+ return dynamicLoop(ui, route.goal);
181
229
  },
182
230
  });
183
231
  }
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
 
@@ -341,6 +368,9 @@ export default function (pi: ExtensionAPI) {
341
368
  updateWidget: () => {
342
369
  widget.update();
343
370
  },
371
+ onDynamicLoopActivated: (entry) => {
372
+ onLoopFire(entry);
373
+ },
344
374
  });
345
375
 
346
376
  // ── Native task tools (only when pi-tasks is absent) ──
@@ -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,
@@ -211,7 +211,7 @@ export class MonitorManager {
211
211
  if (timeout > 0) {
212
212
  setTimeout(() => {
213
213
  if (bp.entry.status === "running") {
214
- this.stop(id);
214
+ void this.stop(id, "timeout");
215
215
  }
216
216
  }, timeout);
217
217
  }
@@ -231,7 +231,7 @@ export class MonitorManager {
231
231
  .sort((a, b) => Number(a.id) - Number(b.id));
232
232
  }
233
233
 
234
- async stop(id: string): Promise<boolean> {
234
+ async stop(id: string, reason: "manual" | "timeout" = "manual"): Promise<boolean> {
235
235
  const bp = this.processes.get(id);
236
236
  if (!bp || bp.entry.status !== "running") return false;
237
237
 
@@ -243,7 +243,7 @@ export class MonitorManager {
243
243
  entityId: id,
244
244
  payload: {
245
245
  id,
246
- reason: "manual",
246
+ reason,
247
247
  },
248
248
  });
249
249
  this.schedulePrune(id);
@@ -261,6 +261,14 @@ export class MonitorManager {
261
261
  });
262
262
  });
263
263
 
264
+ if (reason === "timeout") {
265
+ this.pi.events.emit("monitor:error", {
266
+ monitorId: id,
267
+ error: `Timed out after ${bp.entry.timeout}ms`,
268
+ outputLines: bp.entry.outputLines,
269
+ });
270
+ for (const callback of bp.completionCallbacks) callback();
271
+ }
264
272
  bp.completionCallbacks = [];
265
273
  for (const resolve of bp.waiters) resolve();
266
274
  bp.waiters = [];
@@ -270,7 +278,7 @@ export class MonitorManager {
270
278
  onComplete(id: string, callback: () => void): boolean {
271
279
  const bp = this.processes.get(id);
272
280
  if (!bp) return false;
273
- if (bp.entry.status === "completed") {
281
+ if (bp.entry.status === "completed" || bp.entry.status === "error") {
274
282
  callback();
275
283
  return true;
276
284
  }
@@ -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
  }, {