@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.0...pi-loop-v0.6.1) (2026-07-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **loop:** add dynamic goal loops ([44e578f](https://github.com/trvon/pi-loop/commit/44e578f5e7cdeaf6aee765835892dd975d2fe913))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * fast failing monitor hit error before onDone registered. Orca/agent never woke. ([56cd43d](https://github.com/trvon/pi-loop/commit/56cd43d2b41a6b0df5c10cccb11c0fa04981d83a))
14
+ * **loop:** harden dynamic goal loops ([036fbaf](https://github.com/trvon/pi-loop/commit/036fbaff9a8c7c60acefce4dec757bfc1e6ff8d0))
15
+
3
16
  ## [0.6.0](https://github.com/trvon/pi-loop/compare/pi-loop-v0.5.8...pi-loop-v0.6.0) (2026-07-02)
4
17
 
5
18
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <h1 align="center">@trevonistrevon/pi-loop</h1>
3
- <h6 align="center">Cron and event loops for the pi coding agent. Background monitors, scheduled re-wakes, pi-tasks integration, and native task fallback.</h6>
3
+ <h6 align="center">Cron, event, and dynamic goal loops for the pi coding agent. Background monitors, scheduled re-wakes, pi-tasks integration, and native task fallback.</h6>
4
4
  </p>
5
5
 
6
6
  ## Install
@@ -36,13 +36,24 @@ TaskDelete id="1"
36
36
 
37
37
  ## Commands
38
38
 
39
- `/loop [interval] [prompt]` interactive loop creation.
39
+ `/loop` creates scheduled, event-triggered, or self-paced dynamic goal loops.
40
40
 
41
41
  ```text
42
- /loop # menu
43
- /loop 5m check the deploy # 5-minute cron loop
42
+ /loop # menu
43
+ /loop 5m check the deploy # 5-minute cron loop
44
+ /loop event tasks:created process backlog # event loop
45
+ /loop finish the release # dynamic goal loop
44
46
  ```
45
47
 
48
+ Dynamic goal loops wake immediately when the agent is idle. After each iteration, the agent calls `LoopUpdate` with one of:
49
+
50
+ - `status="continue"` to save progress and wake again when idle
51
+ - `status="continue" nextInterval="3m"` to schedule a timed next wake
52
+ - `status="paused"` when blocked
53
+ - `status="completed"` to finish and delete the loop
54
+
55
+ Paused dynamic loops can be resumed from the `/loop` menu. Dynamic loops recover their wake after a process restart or session switch if an in-memory notification was lost.
56
+
46
57
  `/tasks` — interactive native task viewer/manager, only registered when `pi-tasks` is absent.
47
58
 
48
59
  ```text
@@ -56,6 +67,7 @@ TaskDelete id="1"
56
67
  |---|---|
57
68
  | `LoopCreate` | Schedule a prompt on a cron timer, a pi event, or both with debounce |
58
69
  | `LoopList` | Show active loops with IDs, triggers, and next-fire times |
70
+ | `LoopUpdate` | Continue, pause, or complete a dynamic goal loop and save its progress |
59
71
  | `LoopDelete` | Delete or pause a loop |
60
72
  | `MonitorCreate` | Run a background command, stream output as `monitor:output` events. Use `onDone` for auto-notify on completion |
61
73
  | `MonitorList` | Show monitors with status, uptime, and output line count |
@@ -65,7 +77,7 @@ TaskDelete id="1"
65
77
  | `TaskUpdate` | Update native fallback task status/details |
66
78
  | `TaskDelete` | Delete a native fallback task |
67
79
 
68
- Trigger types: `cron` (`5m`, `1h`, `0 9 * * 1-5`), `event` (any pi event source), or `hybrid` (both, debounced).
80
+ Trigger types: `cron` (`5m`, `1h`, `0 9 * * 1-5`), `event` (any pi event source), `hybrid` (both, debounced), or `dynamic` (self-paced goal loops created with `/loop <goal>`).
69
81
 
70
82
  ## Tasks
71
83
 
@@ -1,9 +1,16 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { DynamicLoopState, LoopEntry, Trigger } from "../types.js";
3
3
  interface LoopStoreLike {
4
4
  list(): LoopEntry[];
5
5
  get(id: string): LoopEntry | undefined;
6
- create(trigger: Trigger, prompt: string, options?: Partial<LoopEntry>): LoopEntry;
6
+ create(trigger: Trigger, prompt: string, options: {
7
+ recurring: boolean;
8
+ autoTask?: boolean;
9
+ taskBacklog?: boolean;
10
+ readOnly?: boolean;
11
+ maxFires?: number;
12
+ dynamic?: Partial<DynamicLoopState>;
13
+ }): LoopEntry;
7
14
  pause(id: string): LoopEntry | undefined;
8
15
  resume(id: string): LoopEntry | undefined;
9
16
  delete(id: string): boolean;
@@ -1,30 +1,69 @@
1
- import { parseInterval } from "../loop-parse.js";
1
+ import { formatTrigger } from "../loop-format.js";
2
+ import { isValidCronExpression, parseInterval } from "../loop-parse.js";
3
+ function parseLoopCommandRoute(input) {
4
+ const trimmed = input.trim();
5
+ if (!trimmed)
6
+ return { type: "menu" };
7
+ const eventMatch = trimmed.match(/^(?:event|when)\s+(\S+)\s+(.+)$/i);
8
+ if (eventMatch?.[1] && eventMatch[2]) {
9
+ return { type: "event", source: eventMatch[1], prompt: eventMatch[2].trim() };
10
+ }
11
+ const parts = trimmed.split(/\s+/);
12
+ if (parts.length > 5) {
13
+ const interval = parts.slice(0, 5).join(" ");
14
+ const cronShaped = parts.slice(0, 5).every((part) => /^[\d*/,-]+$/.test(part));
15
+ if (cronShaped) {
16
+ if (!isValidCronExpression(interval))
17
+ return { type: "invalid-cron", interval };
18
+ return { type: "cron", interval, prompt: parts.slice(5).join(" "), notifyEvery: false };
19
+ }
20
+ }
21
+ const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
22
+ if (intervalMatch) {
23
+ const interval = intervalMatch[1] ?? intervalMatch[0];
24
+ const prompt = trimmed.slice(intervalMatch[0].length).trim();
25
+ if (!prompt)
26
+ return { type: "missing-interval-prompt" };
27
+ return { type: "cron", interval, prompt, notifyEvery: true };
28
+ }
29
+ return { type: "dynamic", goal: trimmed };
30
+ }
2
31
  export function registerLoopCommand(options) {
3
32
  const { pi, getStore, getTriggerSystem, updateWidget } = options;
4
- async function scheduleLoop(ui, prompt) {
5
- const p = prompt || await ui.input("Prompt (what should the agent check?)");
6
- if (!p)
7
- return;
8
- const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
9
- if (!interval)
10
- return;
33
+ function createCronLoop(ui, interval, prompt, notifyEvery) {
34
+ let entry;
11
35
  try {
12
36
  const parsed = parseInterval(interval);
13
37
  const trigger = { type: "cron", schedule: parsed.cron };
14
- const entry = getStore().create(trigger, p, { recurring: true });
38
+ entry = getStore().create(trigger, prompt, { recurring: true });
15
39
  getTriggerSystem().add(entry);
16
40
  updateWidget();
17
- ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
41
+ const cadence = notifyEvery ? `every ${parsed.description}` : parsed.description;
42
+ ui.notify(`Loop #${entry.id} created: ${cadence} — ${prompt.slice(0, 50)}`, "info");
18
43
  }
19
44
  catch (err) {
45
+ if (entry) {
46
+ getTriggerSystem().remove(entry.id);
47
+ getStore().delete(entry.id);
48
+ updateWidget();
49
+ }
20
50
  ui.notify(err.message, "error");
21
51
  }
22
52
  }
23
- async function eventLoop(ui, prompt) {
53
+ async function scheduleLoop(ui, prompt) {
54
+ const p = prompt || await ui.input("Prompt (what should the agent check?)");
55
+ if (!p)
56
+ return;
57
+ const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
58
+ if (!interval)
59
+ return;
60
+ createCronLoop(ui, interval, p, true);
61
+ }
62
+ async function eventLoop(ui, prompt, sourceOverride) {
24
63
  const p = prompt || await ui.input("Prompt");
25
64
  if (!p)
26
65
  return;
27
- const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
66
+ const source = sourceOverride || await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
28
67
  if (!source)
29
68
  return;
30
69
  const trigger = { type: "event", source };
@@ -33,6 +72,17 @@ export function registerLoopCommand(options) {
33
72
  updateWidget();
34
73
  ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
35
74
  }
75
+ function dynamicLoop(ui, goal) {
76
+ const trigger = { type: "dynamic" };
77
+ const entry = getStore().create(trigger, goal, {
78
+ recurring: true,
79
+ maxFires: 20,
80
+ dynamic: { goal, iteration: 0 },
81
+ });
82
+ getTriggerSystem().add(entry);
83
+ updateWidget();
84
+ ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
85
+ }
36
86
  async function viewLoops(ui) {
37
87
  const loops = getStore().list();
38
88
  if (loops.length === 0) {
@@ -41,12 +91,7 @@ export function registerLoopCommand(options) {
41
91
  }
42
92
  const choices = loops.map((l) => {
43
93
  const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
44
- const triggerDesc = l.trigger.type === "cron"
45
- ? `cron: ${l.trigger.schedule}`
46
- : l.trigger.type === "event"
47
- ? `event: ${l.trigger.source}`
48
- : `hybrid: ${l.trigger.cron}`;
49
- return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
94
+ return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${formatTrigger(l.trigger, "command")})`;
50
95
  });
51
96
  choices.push("< Back");
52
97
  const selected = await ui.select("Loops", choices);
@@ -76,8 +121,10 @@ export function registerLoopCommand(options) {
76
121
  ui.notify(`Loop #${entry.id} paused`, "info");
77
122
  }
78
123
  else if (action === "* Resume") {
79
- getStore().resume(entry.id);
80
- getTriggerSystem().add(entry);
124
+ const resumed = getStore().resume(entry.id);
125
+ if (!resumed)
126
+ return viewLoops(ui);
127
+ getTriggerSystem().add(resumed);
81
128
  updateWidget();
82
129
  ui.notify(`Loop #${entry.id} resumed`, "info");
83
130
  }
@@ -91,11 +138,11 @@ export function registerLoopCommand(options) {
91
138
  ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
92
139
  }
93
140
  pi.registerCommand("loop", {
94
- description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
141
+ 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.",
95
142
  handler: async (args, ctx) => {
96
- const trimmed = args.trim();
97
143
  const ui = ctx.ui;
98
- if (!trimmed) {
144
+ const route = parseLoopCommandRoute(args);
145
+ if (route.type === "menu") {
99
146
  const choice = await ui.select("Loop", [
100
147
  "Create scheduled loop",
101
148
  "Create event-triggered loop",
@@ -112,36 +159,19 @@ export function registerLoopCommand(options) {
112
159
  return viewLoops(ui);
113
160
  return settings(ui);
114
161
  }
115
- const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
116
- if (intervalMatch) {
117
- const interval = intervalMatch[1] ?? intervalMatch[0];
118
- const prompt = trimmed.slice(intervalMatch[0].length).trim();
119
- if (!prompt) {
120
- ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
121
- return;
122
- }
123
- try {
124
- const parsed = parseInterval(interval);
125
- const trigger = { type: "cron", schedule: parsed.cron };
126
- const entry = getStore().create(trigger, prompt, { recurring: true });
127
- getTriggerSystem().add(entry);
128
- updateWidget();
129
- ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
130
- }
131
- catch (err) {
132
- ui.notify(err.message, "error");
133
- }
162
+ if (route.type === "event")
163
+ return eventLoop(ui, route.prompt, route.source);
164
+ if (route.type === "cron")
165
+ return createCronLoop(ui, route.interval, route.prompt, route.notifyEvery);
166
+ if (route.type === "invalid-cron") {
167
+ ui.notify(`Invalid cron expression: ${route.interval}`, "error");
134
168
  return;
135
169
  }
136
- const choice = await ui.select("Loop mode", [
137
- `Scheduled: "${trimmed.slice(0, 50)}"`,
138
- `Event-triggered: "${trimmed.slice(0, 50)}"`,
139
- ]);
140
- if (!choice)
170
+ if (route.type === "missing-interval-prompt") {
171
+ ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
141
172
  return;
142
- if (choice.startsWith("Event"))
143
- return eventLoop(ui, trimmed);
144
- return scheduleLoop(ui, trimmed);
173
+ }
174
+ return dynamicLoop(ui, route.goal);
145
175
  },
146
176
  });
147
177
  }
package/dist/index.d.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
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
package/dist/index.js 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
  import { registerLoopCommand } from "./commands/loop-command.js";
@@ -58,15 +59,25 @@ export default function (pi) {
58
59
  // detection window is a shadow — never surface it in the status line.
59
60
  if (tasksAvailable || !nativeTaskStore)
60
61
  return { count: 0 };
61
- const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
62
- const active = tasks.find(t => t.status === "in_progress");
63
- const next = tasks.find(t => t.status === "pending");
64
- const focus = active
65
- ? `active: ${active.subject.slice(0, 50)}`
66
- : next
67
- ? `next: ${next.subject.slice(0, 50)}`
62
+ let count = 0;
63
+ let activeSubject;
64
+ let nextSubject;
65
+ for (const task of nativeTaskStore.list()) {
66
+ if (task.status === "in_progress") {
67
+ count++;
68
+ activeSubject ??= task.subject;
69
+ }
70
+ else if (task.status === "pending") {
71
+ count++;
72
+ nextSubject ??= task.subject;
73
+ }
74
+ }
75
+ const focus = activeSubject
76
+ ? `active: ${activeSubject.slice(0, 50)}`
77
+ : nextSubject
78
+ ? `next: ${nextSubject.slice(0, 50)}`
68
79
  : undefined;
69
- return { count: tasks.length, focusText: focus };
80
+ return { count, focusText: focus };
70
81
  });
71
82
  scheduler = new CronScheduler(store, onLoopFire);
72
83
  triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
@@ -180,6 +191,9 @@ export default function (pi) {
180
191
  deleteLoop: (id) => {
181
192
  store.delete(id);
182
193
  },
194
+ recordDeletionTombstone: (id, tombstone) => {
195
+ store.recordDeletionTombstone(id, tombstone);
196
+ },
183
197
  addTrigger: (entry) => {
184
198
  triggerSystem.add(entry);
185
199
  },
@@ -213,6 +227,16 @@ export default function (pi) {
213
227
  return;
214
228
  }
215
229
  store.fire(entry.id);
230
+ const firedAt = Date.now();
231
+ const firedEntry = entry.trigger.type === "dynamic"
232
+ ? store.updateDynamic(entry.id, {
233
+ dynamic: {
234
+ awaitingUpdate: true,
235
+ nextWakeAt: undefined,
236
+ lastUpdatedAt: firedAt,
237
+ },
238
+ }) ?? entry
239
+ : entry;
216
240
  if (entry.autoTask) {
217
241
  autoCreateTask(entry).then((taskId) => {
218
242
  if (taskId)
@@ -220,13 +244,14 @@ export default function (pi) {
220
244
  });
221
245
  }
222
246
  pi.events.emit("loop:fire", {
223
- loopId: entry.id,
224
- prompt: entry.prompt,
225
- trigger: entry.trigger,
226
- timestamp: Date.now(),
227
- readOnly: entry.readOnly,
228
- recurring: entry.recurring,
229
- autoTask: entry.autoTask,
247
+ loopId: firedEntry.id,
248
+ prompt: firedEntry.prompt,
249
+ trigger: firedEntry.trigger,
250
+ timestamp: firedAt,
251
+ readOnly: firedEntry.readOnly,
252
+ recurring: firedEntry.recurring,
253
+ autoTask: firedEntry.autoTask,
254
+ dynamic: firedEntry.dynamic,
230
255
  });
231
256
  }
232
257
  // ── Session lifecycle ──
@@ -0,0 +1,3 @@
1
+ import type { Trigger } from "./types.js";
2
+ export type TriggerFormatStyle = "command" | "create" | "list" | "notification";
3
+ export declare function formatTrigger(trigger: Trigger | string, style?: TriggerFormatStyle): string;
@@ -0,0 +1,20 @@
1
+ export function formatTrigger(trigger, style = "list") {
2
+ if (typeof trigger === "string")
3
+ return trigger;
4
+ if (trigger.type === "cron") {
5
+ return style === "create" || style === "notification"
6
+ ? `schedule: ${trigger.schedule}`
7
+ : `cron: ${trigger.schedule}`;
8
+ }
9
+ if (trigger.type === "event")
10
+ return `event: ${trigger.source}`;
11
+ if (trigger.type === "dynamic")
12
+ return "dynamic";
13
+ if (style === "command")
14
+ return `hybrid: ${trigger.cron}`;
15
+ if (style === "create")
16
+ return `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
17
+ if (style === "notification")
18
+ return "hybrid";
19
+ return `hybrid: ${trigger.cron} + ${trigger.event.source}`;
20
+ }
@@ -1,3 +1,4 @@
1
+ export declare function isValidCronExpression(expr: string): boolean;
1
2
  export declare function parseInterval(input: string): {
2
3
  cron: string;
3
4
  description: string;
@@ -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,
@@ -246,7 +246,7 @@ export class MonitorManager {
246
246
  const bp = this.processes.get(id);
247
247
  if (!bp)
248
248
  return false;
249
- if (bp.entry.status === "completed") {
249
+ if (bp.entry.status === "completed" || bp.entry.status === "error") {
250
250
  callback();
251
251
  return true;
252
252
  }
@@ -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;