@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
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
|
|
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
|
|
39
|
+
`/loop` creates scheduled, event-triggered, or self-paced dynamic goal loops.
|
|
40
40
|
|
|
41
41
|
```text
|
|
42
|
-
/loop
|
|
43
|
-
/loop 5m check the deploy
|
|
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),
|
|
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
|
|
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 {
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
38
|
+
entry = getStore().create(trigger, prompt, { recurring: true });
|
|
15
39
|
getTriggerSystem().add(entry);
|
|
16
40
|
updateWidget();
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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:
|
|
224
|
-
prompt:
|
|
225
|
-
trigger:
|
|
226
|
-
timestamp:
|
|
227
|
-
readOnly:
|
|
228
|
-
recurring:
|
|
229
|
-
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,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
|
+
}
|
package/dist/loop-parse.d.ts
CHANGED
package/dist/loop-parse.js
CHANGED
|
@@ -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 (
|
|
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 ||
|
package/dist/loop-reducer.d.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
|
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";
|
package/dist/loop-reducer.js
CHANGED
|
@@ -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,
|
package/dist/monitor-manager.js
CHANGED
|
@@ -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;
|