@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.
- package/CHANGELOG.md +22 -0
- package/README.md +28 -143
- package/dist/commands/loop-command.d.ts +10 -2
- package/dist/commands/loop-command.js +84 -51
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -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.d.ts +1 -1
- package/dist/monitor-manager.js +13 -4
- 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/tools/monitor-tools.js +1 -1
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/docs/USAGE_GUIDE.md +185 -0
- package/package.json +13 -4
- package/src/commands/loop-command.ts +102 -54
- package/src/index.ts +46 -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 +12 -4
- 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/tools/monitor-tools.ts +1 -1
- 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;
|
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
85
|
+
entry = getStore().create(trigger, prompt, { recurring: true });
|
|
44
86
|
getTriggerSystem().add(entry);
|
|
45
87
|
updateWidget();
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
|
@@ -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 (
|
|
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
|
@@ -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
|
|
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 =
|
|
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
|
}, {
|