@trevonistrevon/pi-loop 0.6.1 → 0.6.3
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 +17 -0
- package/README.md +28 -155
- package/dist/commands/loop-command.d.ts +1 -0
- package/dist/commands/loop-command.js +4 -1
- package/dist/index.js +7 -0
- package/dist/monitor-manager.d.ts +1 -1
- package/dist/monitor-manager.js +12 -3
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/runtime/notification-runtime.d.ts +2 -0
- package/dist/runtime/notification-runtime.js +9 -1
- package/dist/runtime/session-runtime.js +14 -3
- package/dist/runtime/task-backlog-runtime.d.ts +1 -1
- package/dist/runtime/task-backlog-runtime.js +1 -1
- package/dist/tools/loop-tools.js +12 -6
- package/dist/tools/monitor-tools.js +1 -1
- package/docs/USAGE_GUIDE.md +185 -0
- package/package.json +4 -3
- package/src/commands/loop-command.ts +4 -1
- package/src/index.ts +7 -0
- package/src/monitor-manager.ts +11 -3
- package/src/notification-reducer.ts +2 -0
- package/src/runtime/notification-runtime.ts +13 -1
- package/src/runtime/session-runtime.ts +15 -3
- package/src/runtime/task-backlog-runtime.ts +1 -1
- package/src/tools/loop-tools.ts +12 -6
- package/src/tools/monitor-tools.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.3](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.2...pi-loop-v0.6.3) (2026-07-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **loop:** prevent premature self-deletion ([6049e15](https://github.com/trvon/pi-loop/commit/6049e1599d9c6c1ab99a9eada72ec75307f25ca1))
|
|
9
|
+
* **ui:** restore loop status after resets ([da580f0](https://github.com/trvon/pi-loop/commit/da580f0d2f5ac414bc3cd884e27b757decd44b5d))
|
|
10
|
+
|
|
11
|
+
## [0.6.2](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.1...pi-loop-v0.6.2) (2026-07-17)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **deps:** require patched pi runtime ([459a44a](https://github.com/trvon/pi-loop/commit/459a44af75dce174e53d70ef0427ca773845a912))
|
|
17
|
+
* **loop:** start dynamic goals immediately ([790959c](https://github.com/trvon/pi-loop/commit/790959c02d3c2b301526f780122aacf533429bc5))
|
|
18
|
+
* **monitor:** wake after timeouts ([478535b](https://github.com/trvon/pi-loop/commit/478535b3c837e74074dc022ea35ec3ba9b18f533))
|
|
19
|
+
|
|
3
20
|
## [0.6.1](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.0...pi-loop-v0.6.1) (2026-07-16)
|
|
4
21
|
|
|
5
22
|
|
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">
|
|
3
|
+
<h6 align="center">Scheduled and event-driven agent re-wakes for pi, with dynamic goals and background process monitoring.</h6>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
6
|
## Install
|
|
@@ -11,177 +11,50 @@ pi install npm:@trevonistrevon/pi-loop
|
|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
LoopCreate trigger="5m" prompt="Check if the build passed"
|
|
16
|
-
LoopCreate trigger="tool_execution_start" prompt="Log the tool being used" triggerType="event"
|
|
17
|
-
LoopList
|
|
18
|
-
LoopDelete id="1"
|
|
19
|
-
```
|
|
14
|
+
Create scheduled, event-driven, or self-paced loops:
|
|
20
15
|
|
|
21
16
|
```text
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
MonitorStop monitorId="1"
|
|
17
|
+
/loop 5m check the deploy
|
|
18
|
+
/loop event tasks:created process the backlog
|
|
19
|
+
/loop finish the release
|
|
26
20
|
```
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
Or use the tools directly:
|
|
29
23
|
|
|
30
24
|
```text
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
LoopCreate trigger="5m" prompt="Check if the build passed" maxFires=12
|
|
26
|
+
LoopCreate trigger="tool_execution_start" prompt="Log the tool" triggerType="event" recurring=true
|
|
27
|
+
LoopList
|
|
28
|
+
LoopDelete id="1"
|
|
35
29
|
```
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`/loop` creates scheduled, event-triggered, or self-paced dynamic goal loops.
|
|
31
|
+
Run work in the background and wake the agent when it succeeds, fails, or times out:
|
|
40
32
|
|
|
41
33
|
```text
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/loop finish the release # dynamic goal loop
|
|
34
|
+
MonitorCreate command="python train.py" onDone="Analyze results and report best loss"
|
|
35
|
+
MonitorList
|
|
36
|
+
MonitorStop monitorId="1"
|
|
46
37
|
```
|
|
47
38
|
|
|
48
|
-
|
|
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
|
|
39
|
+
## What it provides
|
|
54
40
|
|
|
55
|
-
|
|
41
|
+
- Cron, event, hybrid, and dynamic goal loops
|
|
42
|
+
- Idle-safe agent re-wakes with dynamic-loop restart/session-switch recovery
|
|
43
|
+
- Background command monitoring with buffered output and `onDone` wakes
|
|
44
|
+
- Optional `pi-tasks` integration and a native task fallback
|
|
45
|
+
- Session-isolated persistence and a compact TUI status line
|
|
56
46
|
|
|
57
|
-
|
|
47
|
+
## Commands and tools
|
|
58
48
|
|
|
59
|
-
|
|
60
|
-
/tasks # open native task viewer
|
|
61
|
-
/tasks Write README updates # quick-create native task
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## Tools
|
|
65
|
-
|
|
66
|
-
| Tool | What it does |
|
|
49
|
+
| Surface | Purpose |
|
|
67
50
|
|---|---|
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `LoopUpdate` |
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `MonitorList` | Show monitors with status, uptime, and output line count |
|
|
74
|
-
| `MonitorStop` | Stop a monitor (SIGTERM → 5s → SIGKILL) |
|
|
75
|
-
| `TaskCreate` | Create a native fallback task when `pi-tasks` is absent |
|
|
76
|
-
| `TaskList` | List native fallback tasks |
|
|
77
|
-
| `TaskUpdate` | Update native fallback task status/details |
|
|
78
|
-
| `TaskDelete` | Delete a native fallback task |
|
|
79
|
-
|
|
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>`).
|
|
81
|
-
|
|
82
|
-
## Tasks
|
|
83
|
-
|
|
84
|
-
### With `pi-tasks`
|
|
85
|
-
|
|
86
|
-
Works with [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks). Pass `autoTask: true` on `LoopCreate` and each loop fire auto-creates a tracked task. Detection happens over pi's event bus — no manual wiring.
|
|
87
|
-
|
|
88
|
-
### Without `pi-tasks`
|
|
89
|
-
|
|
90
|
-
If `pi-tasks` does not respond during startup detection, `pi-loop` registers a native fallback task system for the session:
|
|
91
|
-
|
|
92
|
-
- session- or project-scoped task files under `.pi/tasks/` depending on `PI_LOOP_SCOPE`
|
|
93
|
-
- `TaskCreate`, `TaskList`, `TaskUpdate`, `TaskDelete`
|
|
94
|
-
- `/tasks` interactive viewer
|
|
95
|
-
- compact status-line task tracking
|
|
96
|
-
- native task RPC replies on `tasks:rpc:ping`, `tasks:rpc:create`, `tasks:rpc:pending`, `tasks:rpc:clean`, and `tasks:rpc:update`
|
|
97
|
-
|
|
98
|
-
This fallback is session-sticky: `pi-loop` decides once at startup whether `pi-tasks` or native tasks own task management for that session.
|
|
99
|
-
|
|
100
|
-
### Task and backlog events
|
|
101
|
-
|
|
102
|
-
`pi-loop` emits native task lifecycle events that other extensions can consume directly:
|
|
103
|
-
|
|
104
|
-
- `tasks:created`
|
|
105
|
-
- `tasks:started`
|
|
106
|
-
- `tasks:completed`
|
|
107
|
-
- `tasks:reopened`
|
|
108
|
-
- `tasks:updated`
|
|
109
|
-
- `tasks:deleted`
|
|
110
|
-
|
|
111
|
-
Payloads carry `previousStatus`. Transition events (`tasks:started` /
|
|
112
|
-
`tasks:completed` / `tasks:reopened`) report the status before the transition;
|
|
113
|
-
`tasks:updated` (a details edit) reports the status current at edit time — so a
|
|
114
|
-
combined status+details update never fabricates a second transition. (Changed
|
|
115
|
-
in 0.6.0: the tool path previously reused the pre-transition status.)
|
|
116
|
-
- `tasks:backlog_empty` — emitted when a task-backlog worker observes zero pending tasks and is about to auto-delete
|
|
117
|
-
- `loops:autodeleted` — emitted for each loop that `pi-loop` auto-deletes, including backlog workers removed because the task queue drained
|
|
118
|
-
|
|
119
|
-
### Cross-extension task RPC
|
|
120
|
-
|
|
121
|
-
Other extensions can create and manage native tasks without importing `pi-loop` internals. `pi-loop` answers `ping` over `pi.events` from extension init; the other verbs unlock once the pi-tasks detection probe settles (a few seconds at most, immediate when no provider replies race it). The whole server stands down (silent no-op) once an external `pi-tasks` is detected:
|
|
122
|
-
|
|
123
|
-
| Channel | Request | Reply |
|
|
124
|
-
|---|---|---|
|
|
125
|
-
| `tasks:rpc:ping` | `{}` | `{ version, provider }` |
|
|
126
|
-
| `tasks:rpc:pending` | `{}` | `{ pending }` |
|
|
127
|
-
| `tasks:rpc:create` | `{ subject, description, metadata? }` | `{ id, task }` |
|
|
128
|
-
| `tasks:rpc:clean` | `{}` | `{ pruned }` |
|
|
129
|
-
| `tasks:rpc:update` | `{ id, status?, subject?, description? }` | `{ task }` |
|
|
130
|
-
|
|
131
|
-
Every request/reply pair follows the same contract: emit `{ requestId, ...params }` on the channel, receive an envelope on `<channel>:reply:<requestId>` — `{ success: true, data }` or `{ success: false, error }`.
|
|
132
|
-
|
|
133
|
-
`@trevonistrevon/pi-loop/api` exports typed channel constants and a client helper so consumers don't hand-roll the envelope:
|
|
134
|
-
|
|
135
|
-
```ts
|
|
136
|
-
import { TASKS_RPC, rpcCall } from "@trevonistrevon/pi-loop/api";
|
|
137
|
-
|
|
138
|
-
const { id, task } = await rpcCall(pi.events, TASKS_RPC.create, {
|
|
139
|
-
subject: "Fix deploy polling",
|
|
140
|
-
description: "Switch deploy check to event-driven loop",
|
|
141
|
-
});
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
`rpcCall` rejects on failure or timeout instead of returning a sentinel; wrap it in `try/catch` if you want fallback behavior. Import only from `@trevonistrevon/pi-loop/api` — the package's `exports` map blocks deep `src/` imports.
|
|
145
|
-
|
|
146
|
-
## Status line
|
|
147
|
-
|
|
148
|
-
`pi-loop` keeps a compact persistent status line in the TUI.
|
|
149
|
-
|
|
150
|
-
When active work exists, it shows a single focus-friendly line such as:
|
|
151
|
-
|
|
152
|
-
```text
|
|
153
|
-
1 loop · 1 monitor
|
|
154
|
-
2 tasks | active: Fix deploy polling
|
|
155
|
-
1 loop · 2 monitors · 3 tasks | next: Update README
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
When no loops, monitors, or native tasks are active, the status line clears completely.
|
|
159
|
-
|
|
160
|
-
Only task counts and the single active/next task are shown there so attention stays on what is currently happening. Use `LoopList`, `MonitorList`, and `/tasks` for detail.
|
|
161
|
-
|
|
162
|
-
## Configuration
|
|
163
|
-
|
|
164
|
-
| Variable | Effect | Default |
|
|
165
|
-
|---|---|---|
|
|
166
|
-
| `PI_LOOP` | Store path override. `off` to disable, absolute or project-relative path | unset → derived from `PI_LOOP_SCOPE` |
|
|
167
|
-
| `PI_LOOP_SCOPE` | `memory` (ephemeral), `session` (per-session file), `project` (shared) | `session` |
|
|
168
|
-
| `PI_LOOP_DEBUG` | Debug logging to stderr | unset |
|
|
169
|
-
|
|
170
|
-
In `session` scope (default), loop and task files are saved per session ID (e.g. `.pi/loops/loops-<sessionId>.json` and `.pi/tasks/tasks-<sessionId>.json`) so concurrent sessions and worktree agents do not share state. In `memory` scope nothing persists to disk.
|
|
171
|
-
|
|
172
|
-
### Recommended scope policy
|
|
173
|
-
|
|
174
|
-
Keep `PI_LOOP_SCOPE=session` as the default.
|
|
175
|
-
|
|
176
|
-
- `session` is the best balance for normal use: it preserves loops/tasks across a session restart while isolating concurrent sessions and worktrees.
|
|
177
|
-
- `memory` is best for disposable scratch work, tests, or situations where you explicitly do not want any persisted loop/task state.
|
|
178
|
-
- `project` should be opt-in for intentionally shared automation, because it allows multiple sessions in the same repo to see the same persisted state.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
## Limits
|
|
51
|
+
| `/loop` | Create or manage scheduled, event, and dynamic goal loops |
|
|
52
|
+
| `/tasks` | Manage native fallback tasks when `pi-tasks` is absent |
|
|
53
|
+
| `LoopCreate`, `LoopList`, `LoopUpdate`, `LoopDelete` | Create and control loops |
|
|
54
|
+
| `MonitorCreate`, `MonitorList`, `MonitorStop` | Run and inspect background commands |
|
|
55
|
+
| `TaskCreate`, `TaskList`, `TaskUpdate`, `TaskDelete` | Native fallback task management |
|
|
183
56
|
|
|
184
|
-
|
|
57
|
+
See the [usage guide](./docs/USAGE_GUIDE.md) for trigger types, dynamic loop lifecycle, monitor behavior, task integration, configuration, events, and the public RPC API.
|
|
185
58
|
|
|
186
59
|
## Development
|
|
187
60
|
|
|
@@ -24,6 +24,7 @@ export interface LoopCommandOptions {
|
|
|
24
24
|
getStore: () => LoopStoreLike;
|
|
25
25
|
getTriggerSystem: () => TriggerSystemLike;
|
|
26
26
|
updateWidget: () => void;
|
|
27
|
+
onDynamicLoopActivated?: (entry: LoopEntry) => void;
|
|
27
28
|
}
|
|
28
29
|
export declare function registerLoopCommand(options: LoopCommandOptions): void;
|
|
29
30
|
export {};
|
|
@@ -29,7 +29,7 @@ function parseLoopCommandRoute(input) {
|
|
|
29
29
|
return { type: "dynamic", goal: trimmed };
|
|
30
30
|
}
|
|
31
31
|
export function registerLoopCommand(options) {
|
|
32
|
-
const { pi, getStore, getTriggerSystem, updateWidget } = options;
|
|
32
|
+
const { pi, getStore, getTriggerSystem, updateWidget, onDynamicLoopActivated } = options;
|
|
33
33
|
function createCronLoop(ui, interval, prompt, notifyEvery) {
|
|
34
34
|
let entry;
|
|
35
35
|
try {
|
|
@@ -82,6 +82,7 @@ export function registerLoopCommand(options) {
|
|
|
82
82
|
getTriggerSystem().add(entry);
|
|
83
83
|
updateWidget();
|
|
84
84
|
ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
|
|
85
|
+
onDynamicLoopActivated?.(entry);
|
|
85
86
|
}
|
|
86
87
|
async function viewLoops(ui) {
|
|
87
88
|
const loops = getStore().list();
|
|
@@ -127,6 +128,8 @@ export function registerLoopCommand(options) {
|
|
|
127
128
|
getTriggerSystem().add(resumed);
|
|
128
129
|
updateWidget();
|
|
129
130
|
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
131
|
+
if (resumed.trigger.type === "dynamic")
|
|
132
|
+
onDynamicLoopActivated?.(resumed);
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
}
|
package/dist/index.js
CHANGED
|
@@ -181,7 +181,9 @@ export default function (pi) {
|
|
|
181
181
|
timestamp: Date.now(),
|
|
182
182
|
readOnly: entry.readOnly,
|
|
183
183
|
recurring: false,
|
|
184
|
+
persistent: entry.recurring,
|
|
184
185
|
autoTask: true,
|
|
186
|
+
taskBacklog: entry.taskBacklog,
|
|
185
187
|
});
|
|
186
188
|
return true;
|
|
187
189
|
}
|
|
@@ -250,7 +252,9 @@ export default function (pi) {
|
|
|
250
252
|
timestamp: firedAt,
|
|
251
253
|
readOnly: firedEntry.readOnly,
|
|
252
254
|
recurring: firedEntry.recurring,
|
|
255
|
+
persistent: firedEntry.recurring,
|
|
253
256
|
autoTask: firedEntry.autoTask,
|
|
257
|
+
taskBacklog: firedEntry.taskBacklog,
|
|
254
258
|
dynamic: firedEntry.dynamic,
|
|
255
259
|
});
|
|
256
260
|
}
|
|
@@ -331,6 +335,9 @@ export default function (pi) {
|
|
|
331
335
|
updateWidget: () => {
|
|
332
336
|
widget.update();
|
|
333
337
|
},
|
|
338
|
+
onDynamicLoopActivated: (entry) => {
|
|
339
|
+
onLoopFire(entry);
|
|
340
|
+
},
|
|
334
341
|
});
|
|
335
342
|
// ── Native task tools (only when pi-tasks is absent) ──
|
|
336
343
|
// Only tool/command registration stays deferred: the tool names collide with
|
|
@@ -22,7 +22,7 @@ export declare class MonitorManager {
|
|
|
22
22
|
create(command: string, description?: string, timeout?: number): MonitorEntry;
|
|
23
23
|
get(id: string): MonitorEntry | undefined;
|
|
24
24
|
list(): MonitorEntry[];
|
|
25
|
-
stop(id: string): Promise<boolean>;
|
|
25
|
+
stop(id: string, reason?: "manual" | "timeout"): Promise<boolean>;
|
|
26
26
|
onComplete(id: string, callback: () => void): boolean;
|
|
27
27
|
getProcess(id: string): MonitorProcess | undefined;
|
|
28
28
|
}
|
package/dist/monitor-manager.js
CHANGED
|
@@ -190,7 +190,7 @@ export class MonitorManager {
|
|
|
190
190
|
if (timeout > 0) {
|
|
191
191
|
setTimeout(() => {
|
|
192
192
|
if (bp.entry.status === "running") {
|
|
193
|
-
this.stop(id);
|
|
193
|
+
void this.stop(id, "timeout");
|
|
194
194
|
}
|
|
195
195
|
}, timeout);
|
|
196
196
|
}
|
|
@@ -206,7 +206,7 @@ export class MonitorManager {
|
|
|
206
206
|
.map(bp => bp.entry)
|
|
207
207
|
.sort((a, b) => Number(a.id) - Number(b.id));
|
|
208
208
|
}
|
|
209
|
-
async stop(id) {
|
|
209
|
+
async stop(id, reason = "manual") {
|
|
210
210
|
const bp = this.processes.get(id);
|
|
211
211
|
if (!bp || bp.entry.status !== "running")
|
|
212
212
|
return false;
|
|
@@ -218,7 +218,7 @@ export class MonitorManager {
|
|
|
218
218
|
entityId: id,
|
|
219
219
|
payload: {
|
|
220
220
|
id,
|
|
221
|
-
reason
|
|
221
|
+
reason,
|
|
222
222
|
},
|
|
223
223
|
});
|
|
224
224
|
this.schedulePrune(id);
|
|
@@ -236,6 +236,15 @@ export class MonitorManager {
|
|
|
236
236
|
resolve();
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
|
+
if (reason === "timeout") {
|
|
240
|
+
this.pi.events.emit("monitor:error", {
|
|
241
|
+
monitorId: id,
|
|
242
|
+
error: `Timed out after ${bp.entry.timeout}ms`,
|
|
243
|
+
outputLines: bp.entry.outputLines,
|
|
244
|
+
});
|
|
245
|
+
for (const callback of bp.completionCallbacks)
|
|
246
|
+
callback();
|
|
247
|
+
}
|
|
239
248
|
bp.completionCallbacks = [];
|
|
240
249
|
for (const resolve of bp.waiters)
|
|
241
250
|
resolve();
|
|
@@ -7,7 +7,9 @@ export interface LoopFireEvent {
|
|
|
7
7
|
timestamp: number;
|
|
8
8
|
readOnly?: boolean;
|
|
9
9
|
recurring?: boolean;
|
|
10
|
+
persistent?: boolean;
|
|
10
11
|
autoTask?: boolean;
|
|
12
|
+
taskBacklog?: boolean;
|
|
11
13
|
dynamic?: DynamicLoopState;
|
|
12
14
|
}
|
|
13
15
|
export interface PendingNotification extends LoopFireEvent {
|
|
@@ -63,12 +63,18 @@ export function createNotificationRuntime(options) {
|
|
|
63
63
|
lines.push(`Metrics: ${dynamic.metrics}`);
|
|
64
64
|
if (dynamic?.doneCriteria)
|
|
65
65
|
lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
66
|
-
lines.push(
|
|
66
|
+
lines.push(`Loop lifecycle: Loop #${loopId} is the persistent controller for the overall goal. Do not call LoopDelete after this iteration.`, "Before ending this turn, call LoopUpdate exactly once: use status=\"completed\" only when the overall goal and done criteria are satisfied; use status=\"continue\" when any work remains, with state/metrics and optional nextInterval; use status=\"paused\" only when genuinely blocked. Omit nextInterval for an idle-driven rewake.");
|
|
67
67
|
return lines.join("\n");
|
|
68
68
|
}
|
|
69
|
+
const lifecycle = data.taskBacklog
|
|
70
|
+
? `Backlog lifecycle: Loop #${loopId} is managed automatically. Do not call LoopDelete; when no pending tasks remain, report that and end this iteration.`
|
|
71
|
+
: (data.persistent ?? data.recurring)
|
|
72
|
+
? `Loop lifecycle: Loop #${loopId} is recurring and remains active after this iteration. Do not call LoopDelete or pause it merely because this run finished, found no changes, or has no immediate work. Stop it only when the user or the loop prompt explicitly requires cancellation.`
|
|
73
|
+
: `Loop lifecycle: Loop #${loopId} is a one-shot wake and cleanup is automatic. Do not call LoopDelete.`;
|
|
69
74
|
return [
|
|
70
75
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
71
76
|
prompt,
|
|
77
|
+
lifecycle,
|
|
72
78
|
].join("\n");
|
|
73
79
|
}
|
|
74
80
|
function buildPendingNotification(data) {
|
|
@@ -97,8 +103,10 @@ export function createNotificationRuntime(options) {
|
|
|
97
103
|
loopId: notification.loopId,
|
|
98
104
|
trigger: notification.trigger,
|
|
99
105
|
recurring: notification.recurring,
|
|
106
|
+
persistent: notification.persistent,
|
|
100
107
|
readOnly: notification.readOnly,
|
|
101
108
|
autoTask: notification.autoTask,
|
|
109
|
+
taskBacklog: notification.taskBacklog,
|
|
102
110
|
dynamic: notification.dynamic,
|
|
103
111
|
timestamp: notification.timestamp,
|
|
104
112
|
},
|
|
@@ -15,8 +15,11 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
15
15
|
return;
|
|
16
16
|
heartbeatTimer = setInterval(() => {
|
|
17
17
|
// Swallow pump failures so a transient error never surfaces as an
|
|
18
|
-
// unhandled rejection;
|
|
19
|
-
void pumpLoops()
|
|
18
|
+
// unhandled rejection; repaint still runs so cleared harness UI heals.
|
|
19
|
+
void pumpLoops()
|
|
20
|
+
.catch(() => { })
|
|
21
|
+
.then(() => widget.update())
|
|
22
|
+
.catch(() => { });
|
|
20
23
|
}, HEARTBEAT_MS);
|
|
21
24
|
heartbeatTimer.unref?.();
|
|
22
25
|
}
|
|
@@ -45,7 +48,6 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
45
48
|
getStore().expireEventLoops(sessionStartedAt);
|
|
46
49
|
getTriggerSystem().start();
|
|
47
50
|
ensureHeartbeat();
|
|
48
|
-
widget.update();
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
async function pumpLoops() {
|
|
@@ -66,6 +68,15 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
66
68
|
}
|
|
67
69
|
getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
68
70
|
}
|
|
71
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
72
|
+
setLatestCtx(ctx);
|
|
73
|
+
setSessionId(ctx.sessionManager.getSessionId());
|
|
74
|
+
widget.setUICtx(ctx.ui);
|
|
75
|
+
upgradeStoreIfNeeded(ctx);
|
|
76
|
+
ensureHeartbeat();
|
|
77
|
+
showPersistedLoops();
|
|
78
|
+
widget.update();
|
|
79
|
+
});
|
|
69
80
|
pi.on("turn_start", async (_event, ctx) => {
|
|
70
81
|
setLatestCtx(ctx);
|
|
71
82
|
setSessionId(ctx.sessionManager.getSessionId());
|
|
@@ -2,7 +2,7 @@ import type { TaskStore } from "../task-store.js";
|
|
|
2
2
|
import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
|
|
3
3
|
import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
|
|
4
4
|
export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
|
-
export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain,
|
|
5
|
+
export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
6
6
|
export interface TaskBacklogRuntimeOptions {
|
|
7
7
|
getLoops: () => LoopEntry[];
|
|
8
8
|
createLoop: (trigger: Trigger, prompt: string, options: {
|
|
@@ -2,7 +2,7 @@ import { createCoordinator, } from "../coordinator.js";
|
|
|
2
2
|
import { reduceTaskBacklogEvent, } from "../task-backlog-coordinator.js";
|
|
3
3
|
import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./loop-events.js";
|
|
4
4
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
|
-
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain,
|
|
5
|
+
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
6
6
|
export function createTaskBacklogRuntime(options) {
|
|
7
7
|
const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
8
8
|
function isAutoTaskWorkerLoop(entry) {
|
package/dist/tools/loop-tools.js
CHANGED
|
@@ -138,7 +138,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
138
138
|
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
139
139
|
- **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
|
|
140
140
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
141
|
-
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
141
|
+
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
142
|
+
|
|
143
|
+
## Loop Lifecycle
|
|
144
|
+
|
|
145
|
+
Recurring loops persist across fires. A completed iteration, unchanged result, or temporarily empty check is not a reason to delete the loop. Delete only when the user explicitly cancels it or its stated stop condition is satisfied. Dynamic loops must be advanced with LoopUpdate, not LoopDelete.`,
|
|
142
146
|
promptGuidelines: [
|
|
143
147
|
"Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
|
|
144
148
|
"## Choosing trigger type",
|
|
@@ -150,14 +154,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
150
154
|
"Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
|
|
151
155
|
"## maxFires — prevent infinite token burn",
|
|
152
156
|
"Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
|
|
153
|
-
"
|
|
157
|
+
"Recurring loops are persistent controllers. Do not call LoopDelete after a normal fire, an unchanged check, or one completed iteration; only delete when the user explicitly asks to cancel or the loop's stated stop condition is satisfied.",
|
|
154
158
|
"## readOnly mode",
|
|
155
159
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
156
160
|
"## Task-driven workflows",
|
|
157
161
|
"Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
|
|
158
162
|
"Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
|
|
159
163
|
"Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
|
|
160
|
-
"
|
|
164
|
+
"For taskBacklog loops, do not instruct the agent to delete the loop; pi-loop auto-deletes it when the pending count reaches zero.",
|
|
161
165
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
162
166
|
],
|
|
163
167
|
parameters: Type.Object({
|
|
@@ -230,7 +234,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
230
234
|
(entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
|
|
231
235
|
(bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
|
|
232
236
|
(isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
|
|
233
|
-
`ID: ${entry.id} (
|
|
237
|
+
`ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)`));
|
|
234
238
|
},
|
|
235
239
|
});
|
|
236
240
|
pi.registerTool({
|
|
@@ -269,7 +273,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
269
273
|
label: "LoopUpdate",
|
|
270
274
|
description: `Update progress for a dynamic loop.
|
|
271
275
|
|
|
272
|
-
Use this after
|
|
276
|
+
Use this exactly once after each dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval whenever any work remains, "completed" only when the overall goal and done criteria are satisfied, or "paused" when genuinely blocked. Do not use LoopDelete to finish an iteration.`,
|
|
273
277
|
parameters: Type.Object({
|
|
274
278
|
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
275
279
|
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
@@ -300,7 +304,9 @@ Use this after a dynamic loop wake. Mark status as "continue" with updated state
|
|
|
300
304
|
label: "LoopDelete",
|
|
301
305
|
description: `Delete or pause a loop by its ID.
|
|
302
306
|
|
|
303
|
-
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it
|
|
307
|
+
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
|
|
308
|
+
|
|
309
|
+
Do not use this after a normal loop fire, an unchanged check, an empty iteration, or one step of a dynamic goal. Recurring loops remain active across iterations; dynamic loops use LoopUpdate. Delete only when the user explicitly asks to cancel the loop or its stated stop condition is satisfied.`,
|
|
304
310
|
parameters: Type.Object({
|
|
305
311
|
id: Type.String({ description: "Loop ID to delete or pause" }),
|
|
306
312
|
action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
|
|
@@ -30,7 +30,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
30
30
|
|
|
31
31
|
## onDone — auto-notify on completion
|
|
32
32
|
|
|
33
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake
|
|
33
|
+
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits, fails, or times out. The completion wake lets the agent inspect the final status and buffered output.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
|
|
34
34
|
promptGuidelines: [
|
|
35
35
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
36
36
|
"When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# pi-loop usage guide
|
|
2
|
+
|
|
3
|
+
`pi-loop` re-wakes a pi agent from schedules and events, runs self-paced goal loops, and monitors background commands. This guide covers the operational details; see the [README](../README.md) for installation and a quick start.
|
|
4
|
+
|
|
5
|
+
## Loops
|
|
6
|
+
|
|
7
|
+
### Scheduled loops
|
|
8
|
+
|
|
9
|
+
Use `/loop <interval> <prompt>` or `LoopCreate`:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
/loop 5m check the deploy
|
|
13
|
+
LoopCreate trigger="0 9 * * 1-5" prompt="Review weekday alerts" maxFires=10
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Intervals such as `5m`, `2h`, and `1d` are converted to cron expressions. Full five-field cron expressions are also accepted. Cron and hybrid loops track their next fire time and deliver only when the agent is idle.
|
|
17
|
+
|
|
18
|
+
Use `maxFires` for polling or other bounded work so a loop cannot run indefinitely. Recurring loops expire after seven days.
|
|
19
|
+
|
|
20
|
+
### Event loops
|
|
21
|
+
|
|
22
|
+
Event loops react to pi events instead of polling:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/loop event tasks:created process the backlog
|
|
26
|
+
LoopCreate trigger="tool_execution_end" prompt="Review the completed tool call" triggerType="event" recurring=true
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`LoopCreate` event loops are one-shot by default. Set `recurring=true` to keep listening. Prefer events when a relevant source exists; use hybrid triggers when an event needs a scheduled safety net.
|
|
30
|
+
|
|
31
|
+
### Hybrid loops
|
|
32
|
+
|
|
33
|
+
A hybrid trigger combines cron and event delivery with a debounce window:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
LoopCreate trigger="cron:5m event:tasks:created" prompt="Process pending tasks" triggerType="hybrid" debounceMs=30000
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Dynamic goal loops
|
|
40
|
+
|
|
41
|
+
Free text passed to `/loop` creates a self-paced dynamic goal:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
/loop finish the release
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first wake is immediate when the agent is idle. After each iteration, the agent calls `LoopUpdate`:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
LoopUpdate id="1" status="continue" state="Tests pass; reviewing package"
|
|
51
|
+
LoopUpdate id="1" status="continue" nextInterval="3m"
|
|
52
|
+
LoopUpdate id="1" status="paused" state="Waiting for credentials"
|
|
53
|
+
LoopUpdate id="1" status="completed"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `continue` saves progress and wakes again when idle.
|
|
57
|
+
- `continue` with `nextInterval` schedules a timed wake.
|
|
58
|
+
- `paused` preserves progress without firing.
|
|
59
|
+
- `completed` finishes and deletes the loop.
|
|
60
|
+
|
|
61
|
+
Paused dynamic loops can be resumed from the `/loop` menu. If an in-memory wake is lost during a restart or session switch, persisted dynamic state recovers it.
|
|
62
|
+
|
|
63
|
+
### Inspecting and stopping loops
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
LoopList
|
|
67
|
+
LoopDelete id="1" action="pause"
|
|
68
|
+
LoopDelete id="1" action="delete"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`LoopDelete` defaults to `action="delete"`.
|
|
72
|
+
|
|
73
|
+
## Background monitors
|
|
74
|
+
|
|
75
|
+
`MonitorCreate` runs a shell command without blocking the agent:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
MonitorCreate command="npm test" description="Run test suite" onDone="Inspect the result and fix failures"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Output is buffered and emitted as `monitor:output`. A monitor finishes as one of:
|
|
82
|
+
|
|
83
|
+
- clean exit: emits `monitor:done`
|
|
84
|
+
- nonzero exit or spawn failure: emits `monitor:error`
|
|
85
|
+
- timeout: stops the process, emits `monitor:error`, and reports the timeout
|
|
86
|
+
- explicit `MonitorStop`: cancels the monitor without an `onDone` wake
|
|
87
|
+
|
|
88
|
+
Pass `onDone` whenever the agent should resume work after completion. Its one-shot wake fires on success, failure, or timeout. The default timeout is five minutes; use `timeout=0` to disable it.
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
MonitorList
|
|
92
|
+
MonitorStop monitorId="1"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`MonitorList` includes status, exit code when available, output count, and the last five buffered lines. Finished monitors remain briefly available before pruning.
|
|
96
|
+
|
|
97
|
+
## Task integration
|
|
98
|
+
|
|
99
|
+
### With pi-tasks
|
|
100
|
+
|
|
101
|
+
When [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) is available, `pi-loop` uses it over the task RPC channels. Set `autoTask=true` on `LoopCreate` to create a tracked task for each fire.
|
|
102
|
+
|
|
103
|
+
### Native fallback
|
|
104
|
+
|
|
105
|
+
If `pi-tasks` does not answer during startup detection, `pi-loop` registers:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
TaskCreate subject="Fix deploy polling" description="Replace polling with an event-driven loop"
|
|
109
|
+
TaskList
|
|
110
|
+
TaskUpdate id="1" status="in_progress"
|
|
111
|
+
TaskUpdate id="1" status="completed"
|
|
112
|
+
TaskDelete id="1"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The native provider is selected for the session and exposes `/tasks`, compact status-line tracking, persisted task state, lifecycle events, and task RPC replies.
|
|
116
|
+
|
|
117
|
+
Set `taskBacklog=true` on a loop that processes existing pending tasks. Backlog workers bootstrap when tasks already exist and delete themselves when the queue drains. `autoTask` serves a different purpose: it creates a new task on each loop fire.
|
|
118
|
+
|
|
119
|
+
## Events
|
|
120
|
+
|
|
121
|
+
Monitor events:
|
|
122
|
+
|
|
123
|
+
- `monitor:output`
|
|
124
|
+
- `monitor:done`
|
|
125
|
+
- `monitor:error`
|
|
126
|
+
|
|
127
|
+
Native task lifecycle events:
|
|
128
|
+
|
|
129
|
+
- `tasks:created`
|
|
130
|
+
- `tasks:started`
|
|
131
|
+
- `tasks:completed`
|
|
132
|
+
- `tasks:reopened`
|
|
133
|
+
- `tasks:updated`
|
|
134
|
+
- `tasks:deleted`
|
|
135
|
+
- `tasks:backlog_empty`
|
|
136
|
+
- `loops:autodeleted`
|
|
137
|
+
|
|
138
|
+
Task event payloads include `previousStatus`. Transition events report the status before the transition; details-only `tasks:updated` events report the status current at edit time.
|
|
139
|
+
|
|
140
|
+
## Cross-extension task RPC
|
|
141
|
+
|
|
142
|
+
External consumers should import only from `@trevonistrevon/pi-loop/api`; deep `src/` imports are blocked by the package export map.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { TASKS_RPC, rpcCall } from "@trevonistrevon/pi-loop/api";
|
|
146
|
+
|
|
147
|
+
const { id, task } = await rpcCall(pi.events, TASKS_RPC.create, {
|
|
148
|
+
subject: "Fix deploy polling",
|
|
149
|
+
description: "Replace polling with an event-driven loop",
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`rpcCall` rejects on failure or timeout. The native provider supports:
|
|
154
|
+
|
|
155
|
+
| Channel | Request | Reply |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `tasks:rpc:ping` | `{}` | `{ version, provider }` |
|
|
158
|
+
| `tasks:rpc:pending` | `{}` | `{ pending }` |
|
|
159
|
+
| `tasks:rpc:create` | `{ subject, description, metadata? }` | `{ id, task }` |
|
|
160
|
+
| `tasks:rpc:clean` | `{}` | `{ pruned }` |
|
|
161
|
+
| `tasks:rpc:update` | `{ id, status?, subject?, description? }` | `{ task }` |
|
|
162
|
+
|
|
163
|
+
Requests include `requestId`; replies arrive on `<channel>:reply:<requestId>` as `{ success: true, data }` or `{ success: false, error }`.
|
|
164
|
+
|
|
165
|
+
## Persistence and configuration
|
|
166
|
+
|
|
167
|
+
| Variable | Effect | Default |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `PI_LOOP` | Store path override; use `off` to disable persistence | derived from scope |
|
|
170
|
+
| `PI_LOOP_SCOPE` | `memory`, `session`, or `project` | `session` |
|
|
171
|
+
| `PI_LOOP_DEBUG` | Debug logging to stderr | unset |
|
|
172
|
+
|
|
173
|
+
Scope behavior:
|
|
174
|
+
|
|
175
|
+
- `session`: persists loops and tasks per session ID while isolating concurrent sessions and worktrees
|
|
176
|
+
- `memory`: keeps all state ephemeral
|
|
177
|
+
- `project`: shares persisted automation across sessions in the repository
|
|
178
|
+
|
|
179
|
+
Session files live under `.pi/loops/` and `.pi/tasks/`. Keep `session` as the normal default; use `project` only when shared automation is intentional.
|
|
180
|
+
|
|
181
|
+
## Status line and limits
|
|
182
|
+
|
|
183
|
+
The TUI status line summarizes active loops, monitors, and native tasks. Use `LoopList`, `MonitorList`, and `/tasks` for detail. The status clears when no work is active.
|
|
184
|
+
|
|
185
|
+
The runtime allows at most 25 active loops and 25 running monitors.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevonistrevon/pi-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
|
|
5
5
|
"author": "trevonistrevon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src/",
|
|
22
22
|
"dist/",
|
|
23
|
+
"docs/USAGE_GUIDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"CHANGELOG.md",
|
|
25
26
|
"LICENSE"
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"./package.json": "./package.json"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
34
|
-
"@earendil-works/pi-tui": "^0.
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
35
|
+
"@earendil-works/pi-tui": "^0.80.10"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"typebox": "^1.1.34"
|
|
@@ -33,6 +33,7 @@ export interface LoopCommandOptions {
|
|
|
33
33
|
getStore: () => LoopStoreLike;
|
|
34
34
|
getTriggerSystem: () => TriggerSystemLike;
|
|
35
35
|
updateWidget: () => void;
|
|
36
|
+
onDynamicLoopActivated?: (entry: LoopEntry) => void;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
type LoopCommandRoute =
|
|
@@ -74,7 +75,7 @@ function parseLoopCommandRoute(input: string): LoopCommandRoute {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
77
|
-
const { pi, getStore, getTriggerSystem, updateWidget } = options;
|
|
78
|
+
const { pi, getStore, getTriggerSystem, updateWidget, onDynamicLoopActivated } = options;
|
|
78
79
|
|
|
79
80
|
function createCronLoop(ui: ExtensionUIContext, interval: string, prompt: string, notifyEvery: boolean) {
|
|
80
81
|
let entry: LoopEntry | undefined;
|
|
@@ -130,6 +131,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
130
131
|
getTriggerSystem().add(entry);
|
|
131
132
|
updateWidget();
|
|
132
133
|
ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
|
|
134
|
+
onDynamicLoopActivated?.(entry);
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
async function viewLoops(ui: ExtensionUIContext) {
|
|
@@ -178,6 +180,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
178
180
|
getTriggerSystem().add(resumed);
|
|
179
181
|
updateWidget();
|
|
180
182
|
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
183
|
+
if (resumed.trigger.type === "dynamic") onDynamicLoopActivated?.(resumed);
|
|
181
184
|
}
|
|
182
185
|
}
|
|
183
186
|
}
|
package/src/index.ts
CHANGED
|
@@ -200,7 +200,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
200
|
timestamp: Date.now(),
|
|
201
201
|
readOnly: entry.readOnly,
|
|
202
202
|
recurring: false,
|
|
203
|
+
persistent: entry.recurring,
|
|
203
204
|
autoTask: true,
|
|
205
|
+
taskBacklog: entry.taskBacklog,
|
|
204
206
|
});
|
|
205
207
|
return true;
|
|
206
208
|
}
|
|
@@ -276,7 +278,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
276
278
|
timestamp: firedAt,
|
|
277
279
|
readOnly: firedEntry.readOnly,
|
|
278
280
|
recurring: firedEntry.recurring,
|
|
281
|
+
persistent: firedEntry.recurring,
|
|
279
282
|
autoTask: firedEntry.autoTask,
|
|
283
|
+
taskBacklog: firedEntry.taskBacklog,
|
|
280
284
|
dynamic: firedEntry.dynamic,
|
|
281
285
|
});
|
|
282
286
|
}
|
|
@@ -368,6 +372,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
372
|
updateWidget: () => {
|
|
369
373
|
widget.update();
|
|
370
374
|
},
|
|
375
|
+
onDynamicLoopActivated: (entry) => {
|
|
376
|
+
onLoopFire(entry);
|
|
377
|
+
},
|
|
371
378
|
});
|
|
372
379
|
|
|
373
380
|
// ── Native task tools (only when pi-tasks is absent) ──
|
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 = [];
|
|
@@ -21,7 +21,9 @@ export interface LoopFireEvent {
|
|
|
21
21
|
timestamp: number;
|
|
22
22
|
readOnly?: boolean;
|
|
23
23
|
recurring?: boolean;
|
|
24
|
+
persistent?: boolean;
|
|
24
25
|
autoTask?: boolean;
|
|
26
|
+
taskBacklog?: boolean;
|
|
25
27
|
dynamic?: DynamicLoopState;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -115,14 +117,22 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
115
117
|
if (dynamic?.metrics) lines.push(`Metrics: ${dynamic.metrics}`);
|
|
116
118
|
if (dynamic?.doneCriteria) lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
117
119
|
lines.push(
|
|
118
|
-
|
|
120
|
+
`Loop lifecycle: Loop #${loopId} is the persistent controller for the overall goal. Do not call LoopDelete after this iteration.`,
|
|
121
|
+
"Before ending this turn, call LoopUpdate exactly once: use status=\"completed\" only when the overall goal and done criteria are satisfied; use status=\"continue\" when any work remains, with state/metrics and optional nextInterval; use status=\"paused\" only when genuinely blocked. Omit nextInterval for an idle-driven rewake.",
|
|
119
122
|
);
|
|
120
123
|
return lines.join("\n");
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
const lifecycle = data.taskBacklog
|
|
127
|
+
? `Backlog lifecycle: Loop #${loopId} is managed automatically. Do not call LoopDelete; when no pending tasks remain, report that and end this iteration.`
|
|
128
|
+
: (data.persistent ?? data.recurring)
|
|
129
|
+
? `Loop lifecycle: Loop #${loopId} is recurring and remains active after this iteration. Do not call LoopDelete or pause it merely because this run finished, found no changes, or has no immediate work. Stop it only when the user or the loop prompt explicitly requires cancellation.`
|
|
130
|
+
: `Loop lifecycle: Loop #${loopId} is a one-shot wake and cleanup is automatic. Do not call LoopDelete.`;
|
|
131
|
+
|
|
123
132
|
return [
|
|
124
133
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
125
134
|
prompt,
|
|
135
|
+
lifecycle,
|
|
126
136
|
].join("\n");
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -154,8 +164,10 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
154
164
|
loopId: notification.loopId,
|
|
155
165
|
trigger: notification.trigger,
|
|
156
166
|
recurring: notification.recurring,
|
|
167
|
+
persistent: notification.persistent,
|
|
157
168
|
readOnly: notification.readOnly,
|
|
158
169
|
autoTask: notification.autoTask,
|
|
170
|
+
taskBacklog: notification.taskBacklog,
|
|
159
171
|
dynamic: notification.dynamic,
|
|
160
172
|
timestamp: notification.timestamp,
|
|
161
173
|
},
|
|
@@ -62,8 +62,11 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
62
62
|
if (heartbeatTimer) return;
|
|
63
63
|
heartbeatTimer = setInterval(() => {
|
|
64
64
|
// Swallow pump failures so a transient error never surfaces as an
|
|
65
|
-
// unhandled rejection;
|
|
66
|
-
void pumpLoops()
|
|
65
|
+
// unhandled rejection; repaint still runs so cleared harness UI heals.
|
|
66
|
+
void pumpLoops()
|
|
67
|
+
.catch(() => {})
|
|
68
|
+
.then(() => widget.update())
|
|
69
|
+
.catch(() => {});
|
|
67
70
|
}, HEARTBEAT_MS);
|
|
68
71
|
heartbeatTimer.unref?.();
|
|
69
72
|
}
|
|
@@ -92,7 +95,6 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
92
95
|
getStore().expireEventLoops(sessionStartedAt);
|
|
93
96
|
getTriggerSystem().start();
|
|
94
97
|
ensureHeartbeat();
|
|
95
|
-
widget.update();
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -110,6 +112,16 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
110
112
|
getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
116
|
+
setLatestCtx(ctx);
|
|
117
|
+
setSessionId(ctx.sessionManager.getSessionId());
|
|
118
|
+
widget.setUICtx(ctx.ui);
|
|
119
|
+
upgradeStoreIfNeeded(ctx);
|
|
120
|
+
ensureHeartbeat();
|
|
121
|
+
showPersistedLoops();
|
|
122
|
+
widget.update();
|
|
123
|
+
});
|
|
124
|
+
|
|
113
125
|
pi.on("turn_start", async (_event, ctx) => {
|
|
114
126
|
setLatestCtx(ctx);
|
|
115
127
|
setSessionId(ctx.sessionManager.getSessionId());
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./loop-events.js";
|
|
18
18
|
|
|
19
19
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
20
|
-
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain,
|
|
20
|
+
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
21
21
|
|
|
22
22
|
export interface TaskBacklogRuntimeOptions {
|
|
23
23
|
getLoops: () => LoopEntry[];
|
package/src/tools/loop-tools.ts
CHANGED
|
@@ -214,7 +214,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
214
214
|
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
215
215
|
- **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
|
|
216
216
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
217
|
-
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
217
|
+
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
218
|
+
|
|
219
|
+
## Loop Lifecycle
|
|
220
|
+
|
|
221
|
+
Recurring loops persist across fires. A completed iteration, unchanged result, or temporarily empty check is not a reason to delete the loop. Delete only when the user explicitly cancels it or its stated stop condition is satisfied. Dynamic loops must be advanced with LoopUpdate, not LoopDelete.`,
|
|
218
222
|
promptGuidelines: [
|
|
219
223
|
"Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
|
|
220
224
|
"## Choosing trigger type",
|
|
@@ -226,14 +230,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
226
230
|
"Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
|
|
227
231
|
"## maxFires — prevent infinite token burn",
|
|
228
232
|
"Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
|
|
229
|
-
"
|
|
233
|
+
"Recurring loops are persistent controllers. Do not call LoopDelete after a normal fire, an unchanged check, or one completed iteration; only delete when the user explicitly asks to cancel or the loop's stated stop condition is satisfied.",
|
|
230
234
|
"## readOnly mode",
|
|
231
235
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
232
236
|
"## Task-driven workflows",
|
|
233
237
|
"Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
|
|
234
238
|
"Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
|
|
235
239
|
"Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
|
|
236
|
-
"
|
|
240
|
+
"For taskBacklog loops, do not instruct the agent to delete the loop; pi-loop auto-deletes it when the pending count reaches zero.",
|
|
237
241
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
238
242
|
],
|
|
239
243
|
parameters: Type.Object({
|
|
@@ -312,7 +316,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
312
316
|
(entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
|
|
313
317
|
(bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
|
|
314
318
|
(isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
|
|
315
|
-
`ID: ${entry.id} (
|
|
319
|
+
`ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)`
|
|
316
320
|
));
|
|
317
321
|
},
|
|
318
322
|
});
|
|
@@ -354,7 +358,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
354
358
|
label: "LoopUpdate",
|
|
355
359
|
description: `Update progress for a dynamic loop.
|
|
356
360
|
|
|
357
|
-
Use this after
|
|
361
|
+
Use this exactly once after each dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval whenever any work remains, "completed" only when the overall goal and done criteria are satisfied, or "paused" when genuinely blocked. Do not use LoopDelete to finish an iteration.`,
|
|
358
362
|
parameters: Type.Object({
|
|
359
363
|
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
360
364
|
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
@@ -386,7 +390,9 @@ Use this after a dynamic loop wake. Mark status as "continue" with updated state
|
|
|
386
390
|
label: "LoopDelete",
|
|
387
391
|
description: `Delete or pause a loop by its ID.
|
|
388
392
|
|
|
389
|
-
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it
|
|
393
|
+
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
|
|
394
|
+
|
|
395
|
+
Do not use this after a normal loop fire, an unchanged check, an empty iteration, or one step of a dynamic goal. Recurring loops remain active across iterations; dynamic loops use LoopUpdate. Delete only when the user explicitly asks to cancel the loop or its stated stop condition is satisfied.`,
|
|
390
396
|
parameters: Type.Object({
|
|
391
397
|
id: Type.String({ description: "Loop ID to delete or pause" }),
|
|
392
398
|
action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
|
|
@@ -57,7 +57,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
57
57
|
|
|
58
58
|
## onDone — auto-notify on completion
|
|
59
59
|
|
|
60
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake
|
|
60
|
+
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits, fails, or times out. The completion wake lets the agent inspect the final status and buffered output.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
|
|
61
61
|
promptGuidelines: [
|
|
62
62
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
63
63
|
"When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
|