@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.
- package/CHANGELOG.md +13 -0
- package/README.md +17 -5
- package/dist/commands/loop-command.d.ts +9 -2
- package/dist/commands/loop-command.js +80 -50
- package/dist/index.d.ts +2 -1
- package/dist/index.js +41 -16
- package/dist/loop-format.d.ts +3 -0
- package/dist/loop-format.js +20 -0
- package/dist/loop-parse.d.ts +1 -0
- package/dist/loop-parse.js +45 -1
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.js +1 -1
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +19 -7
- package/dist/runtime/task-backlog-runtime.d.ts +2 -1
- package/dist/runtime/task-backlog-runtime.js +13 -9
- package/dist/scheduler.js +25 -7
- package/dist/store.d.ts +9 -1
- package/dist/store.js +57 -0
- package/dist/tools/loop-tools.d.ts +8 -0
- package/dist/tools/loop-tools.js +104 -12
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/package.json +10 -2
- package/src/commands/loop-command.ts +99 -54
- package/src/index.ts +43 -16
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +44 -1
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +1 -1
- package/src/notification-reducer.ts +3 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +15 -11
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +128 -12
- package/src/trigger-system.ts +1 -1
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -123
- package/CONTRIBUTING.md +0 -79
- package/Dockerfile +0 -17
- package/benchmarks/experiment-sim.js +0 -30
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -36
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -191
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/api.ts.html +0 -193
- package/coverage/src/commands/index.html +0 -131
- package/coverage/src/commands/loop-command.ts.html +0 -634
- package/coverage/src/commands/tasks-command.ts.html +0 -490
- package/coverage/src/coordinator.ts.html +0 -430
- package/coverage/src/index.html +0 -341
- package/coverage/src/index.ts.html +0 -1249
- package/coverage/src/loop-parse.ts.html +0 -607
- package/coverage/src/loop-reducer.ts.html +0 -559
- package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
- package/coverage/src/monitor-manager.ts.html +0 -964
- package/coverage/src/monitor-reducer.ts.html +0 -583
- package/coverage/src/notification-reducer.ts.html +0 -550
- package/coverage/src/reducer-backed-store.ts.html +0 -589
- package/coverage/src/rpc/channels.ts.html +0 -376
- package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
- package/coverage/src/rpc/index.html +0 -131
- package/coverage/src/runtime/index.html +0 -251
- package/coverage/src/runtime/loop-events.ts.html +0 -265
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
- package/coverage/src/runtime/notification-runtime.ts.html +0 -721
- package/coverage/src/runtime/scope.ts.html +0 -196
- package/coverage/src/runtime/session-runtime.ts.html +0 -679
- package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-mutations.ts.html +0 -394
- package/coverage/src/runtime/task-rpc.ts.html +0 -523
- package/coverage/src/scheduler.ts.html +0 -400
- package/coverage/src/store.ts.html +0 -667
- package/coverage/src/task-backlog-coordinator.ts.html +0 -181
- package/coverage/src/task-reducer.ts.html +0 -550
- package/coverage/src/task-store.ts.html +0 -526
- package/coverage/src/tools/index.html +0 -161
- package/coverage/src/tools/loop-tools.ts.html +0 -991
- package/coverage/src/tools/monitor-tools.ts.html +0 -538
- package/coverage/src/tools/native-task-tools.ts.html +0 -493
- package/coverage/src/tools/tool-result.ts.html +0 -97
- package/coverage/src/trigger-system.ts.html +0 -547
- package/coverage/src/ui/index.html +0 -116
- package/coverage/src/ui/widget.ts.html +0 -292
- package/docker-compose.yml +0 -12
- package/docs/architecture/state-machine-migration.md +0 -502
- package/docs/architecture/state-machine-reducer-event-model.md +0 -822
- package/docs/architecture/state-machine-test-matrix.md +0 -249
- package/docs/architecture/state-machine-transition-map.md +0 -436
- package/release-please-config.json +0 -9
- 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 {
|
|
7
|
-
import
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
84
|
+
entry = getStore().create(trigger, prompt, { recurring: true });
|
|
44
85
|
getTriggerSystem().add(entry);
|
|
45
86
|
updateWidget();
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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:
|
|
248
|
-
prompt:
|
|
249
|
-
trigger:
|
|
250
|
-
timestamp:
|
|
251
|
-
readOnly:
|
|
252
|
-
recurring:
|
|
253
|
-
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 (
|
|
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 (
|
package/src/loop-reducer.ts
CHANGED
|
@@ -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,
|
package/src/monitor-manager.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
90
|
-
|
|
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: "*/
|
|
114
|
+
cron: "*/3 * * * *",
|
|
111
115
|
event: { source: "tasks:created" },
|
|
112
116
|
debounceMs: 30000,
|
|
113
117
|
};
|