@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.2](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.1...pi-loop-v0.6.2) (2026-07-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **deps:** require patched pi runtime ([459a44a](https://github.com/trvon/pi-loop/commit/459a44af75dce174e53d70ef0427ca773845a912))
|
|
9
|
+
* **loop:** start dynamic goals immediately ([790959c](https://github.com/trvon/pi-loop/commit/790959c02d3c2b301526f780122aacf533429bc5))
|
|
10
|
+
* **monitor:** wake after timeouts ([478535b](https://github.com/trvon/pi-loop/commit/478535b3c837e74074dc022ea35ec3ba9b18f533))
|
|
11
|
+
|
|
12
|
+
## [0.6.1](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.0...pi-loop-v0.6.1) (2026-07-16)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **loop:** add dynamic goal loops ([44e578f](https://github.com/trvon/pi-loop/commit/44e578f5e7cdeaf6aee765835892dd975d2fe913))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* fast failing monitor hit error before onDone registered. Orca/agent never woke. ([56cd43d](https://github.com/trvon/pi-loop/commit/56cd43d2b41a6b0df5c10cccb11c0fa04981d83a))
|
|
23
|
+
* **loop:** harden dynamic goal loops ([036fbaf](https://github.com/trvon/pi-loop/commit/036fbaff9a8c7c60acefce4dec757bfc1e6ff8d0))
|
|
24
|
+
|
|
3
25
|
## [0.6.0](https://github.com/trvon/pi-loop/compare/pi-loop-v0.5.8...pi-loop-v0.6.0) (2026-07-02)
|
|
4
26
|
|
|
5
27
|
|
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,165 +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 [interval] [prompt]` — interactive loop creation.
|
|
31
|
+
Run work in the background and wake the agent when it succeeds, fails, or times out:
|
|
40
32
|
|
|
41
33
|
```text
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
MonitorCreate command="python train.py" onDone="Analyze results and report best loss"
|
|
35
|
+
MonitorList
|
|
36
|
+
MonitorStop monitorId="1"
|
|
44
37
|
```
|
|
45
38
|
|
|
46
|
-
|
|
39
|
+
## What it provides
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
46
|
|
|
53
|
-
##
|
|
47
|
+
## Commands and tools
|
|
54
48
|
|
|
55
|
-
|
|
|
49
|
+
| Surface | Purpose |
|
|
56
50
|
|---|---|
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `LoopDelete` |
|
|
60
|
-
| `MonitorCreate`
|
|
61
|
-
| `
|
|
62
|
-
| `MonitorStop` | Stop a monitor (SIGTERM → 5s → SIGKILL) |
|
|
63
|
-
| `TaskCreate` | Create a native fallback task when `pi-tasks` is absent |
|
|
64
|
-
| `TaskList` | List native fallback tasks |
|
|
65
|
-
| `TaskUpdate` | Update native fallback task status/details |
|
|
66
|
-
| `TaskDelete` | Delete a native fallback task |
|
|
67
|
-
|
|
68
|
-
Trigger types: `cron` (`5m`, `1h`, `0 9 * * 1-5`), `event` (any pi event source), or `hybrid` (both, debounced).
|
|
69
|
-
|
|
70
|
-
## Tasks
|
|
71
|
-
|
|
72
|
-
### With `pi-tasks`
|
|
73
|
-
|
|
74
|
-
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.
|
|
75
|
-
|
|
76
|
-
### Without `pi-tasks`
|
|
77
|
-
|
|
78
|
-
If `pi-tasks` does not respond during startup detection, `pi-loop` registers a native fallback task system for the session:
|
|
79
|
-
|
|
80
|
-
- session- or project-scoped task files under `.pi/tasks/` depending on `PI_LOOP_SCOPE`
|
|
81
|
-
- `TaskCreate`, `TaskList`, `TaskUpdate`, `TaskDelete`
|
|
82
|
-
- `/tasks` interactive viewer
|
|
83
|
-
- compact status-line task tracking
|
|
84
|
-
- native task RPC replies on `tasks:rpc:ping`, `tasks:rpc:create`, `tasks:rpc:pending`, `tasks:rpc:clean`, and `tasks:rpc:update`
|
|
85
|
-
|
|
86
|
-
This fallback is session-sticky: `pi-loop` decides once at startup whether `pi-tasks` or native tasks own task management for that session.
|
|
87
|
-
|
|
88
|
-
### Task and backlog events
|
|
89
|
-
|
|
90
|
-
`pi-loop` emits native task lifecycle events that other extensions can consume directly:
|
|
91
|
-
|
|
92
|
-
- `tasks:created`
|
|
93
|
-
- `tasks:started`
|
|
94
|
-
- `tasks:completed`
|
|
95
|
-
- `tasks:reopened`
|
|
96
|
-
- `tasks:updated`
|
|
97
|
-
- `tasks:deleted`
|
|
98
|
-
|
|
99
|
-
Payloads carry `previousStatus`. Transition events (`tasks:started` /
|
|
100
|
-
`tasks:completed` / `tasks:reopened`) report the status before the transition;
|
|
101
|
-
`tasks:updated` (a details edit) reports the status current at edit time — so a
|
|
102
|
-
combined status+details update never fabricates a second transition. (Changed
|
|
103
|
-
in 0.6.0: the tool path previously reused the pre-transition status.)
|
|
104
|
-
- `tasks:backlog_empty` — emitted when a task-backlog worker observes zero pending tasks and is about to auto-delete
|
|
105
|
-
- `loops:autodeleted` — emitted for each loop that `pi-loop` auto-deletes, including backlog workers removed because the task queue drained
|
|
106
|
-
|
|
107
|
-
### Cross-extension task RPC
|
|
108
|
-
|
|
109
|
-
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:
|
|
110
|
-
|
|
111
|
-
| Channel | Request | Reply |
|
|
112
|
-
|---|---|---|
|
|
113
|
-
| `tasks:rpc:ping` | `{}` | `{ version, provider }` |
|
|
114
|
-
| `tasks:rpc:pending` | `{}` | `{ pending }` |
|
|
115
|
-
| `tasks:rpc:create` | `{ subject, description, metadata? }` | `{ id, task }` |
|
|
116
|
-
| `tasks:rpc:clean` | `{}` | `{ pruned }` |
|
|
117
|
-
| `tasks:rpc:update` | `{ id, status?, subject?, description? }` | `{ task }` |
|
|
118
|
-
|
|
119
|
-
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 }`.
|
|
120
|
-
|
|
121
|
-
`@trevonistrevon/pi-loop/api` exports typed channel constants and a client helper so consumers don't hand-roll the envelope:
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
import { TASKS_RPC, rpcCall } from "@trevonistrevon/pi-loop/api";
|
|
125
|
-
|
|
126
|
-
const { id, task } = await rpcCall(pi.events, TASKS_RPC.create, {
|
|
127
|
-
subject: "Fix deploy polling",
|
|
128
|
-
description: "Switch deploy check to event-driven loop",
|
|
129
|
-
});
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
`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.
|
|
133
|
-
|
|
134
|
-
## Status line
|
|
135
|
-
|
|
136
|
-
`pi-loop` keeps a compact persistent status line in the TUI.
|
|
137
|
-
|
|
138
|
-
When active work exists, it shows a single focus-friendly line such as:
|
|
139
|
-
|
|
140
|
-
```text
|
|
141
|
-
1 loop · 1 monitor
|
|
142
|
-
2 tasks | active: Fix deploy polling
|
|
143
|
-
1 loop · 2 monitors · 3 tasks | next: Update README
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
When no loops, monitors, or native tasks are active, the status line clears completely.
|
|
147
|
-
|
|
148
|
-
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.
|
|
149
|
-
|
|
150
|
-
## Configuration
|
|
151
|
-
|
|
152
|
-
| Variable | Effect | Default |
|
|
153
|
-
|---|---|---|
|
|
154
|
-
| `PI_LOOP` | Store path override. `off` to disable, absolute or project-relative path | unset → derived from `PI_LOOP_SCOPE` |
|
|
155
|
-
| `PI_LOOP_SCOPE` | `memory` (ephemeral), `session` (per-session file), `project` (shared) | `session` |
|
|
156
|
-
| `PI_LOOP_DEBUG` | Debug logging to stderr | unset |
|
|
157
|
-
|
|
158
|
-
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.
|
|
159
|
-
|
|
160
|
-
### Recommended scope policy
|
|
161
|
-
|
|
162
|
-
Keep `PI_LOOP_SCOPE=session` as the default.
|
|
163
|
-
|
|
164
|
-
- `session` is the best balance for normal use: it preserves loops/tasks across a session restart while isolating concurrent sessions and worktrees.
|
|
165
|
-
- `memory` is best for disposable scratch work, tests, or situations where you explicitly do not want any persisted loop/task state.
|
|
166
|
-
- `project` should be opt-in for intentionally shared automation, because it allows multiple sessions in the same repo to see the same persisted state.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
## 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 |
|
|
171
56
|
|
|
172
|
-
|
|
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.
|
|
173
58
|
|
|
174
59
|
## Development
|
|
175
60
|
|
|
@@ -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;
|
|
@@ -17,6 +24,7 @@ export interface LoopCommandOptions {
|
|
|
17
24
|
getStore: () => LoopStoreLike;
|
|
18
25
|
getTriggerSystem: () => TriggerSystemLike;
|
|
19
26
|
updateWidget: () => void;
|
|
27
|
+
onDynamicLoopActivated?: (entry: LoopEntry) => void;
|
|
20
28
|
}
|
|
21
29
|
export declare function registerLoopCommand(options: LoopCommandOptions): void;
|
|
22
30
|
export {};
|
|
@@ -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
|
-
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;
|
|
32
|
+
const { pi, getStore, getTriggerSystem, updateWidget, onDynamicLoopActivated } = options;
|
|
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,18 @@ 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
|
+
onDynamicLoopActivated?.(entry);
|
|
86
|
+
}
|
|
36
87
|
async function viewLoops(ui) {
|
|
37
88
|
const loops = getStore().list();
|
|
38
89
|
if (loops.length === 0) {
|
|
@@ -41,12 +92,7 @@ export function registerLoopCommand(options) {
|
|
|
41
92
|
}
|
|
42
93
|
const choices = loops.map((l) => {
|
|
43
94
|
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})`;
|
|
95
|
+
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${formatTrigger(l.trigger, "command")})`;
|
|
50
96
|
});
|
|
51
97
|
choices.push("< Back");
|
|
52
98
|
const selected = await ui.select("Loops", choices);
|
|
@@ -76,10 +122,14 @@ export function registerLoopCommand(options) {
|
|
|
76
122
|
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
77
123
|
}
|
|
78
124
|
else if (action === "* Resume") {
|
|
79
|
-
getStore().resume(entry.id);
|
|
80
|
-
|
|
125
|
+
const resumed = getStore().resume(entry.id);
|
|
126
|
+
if (!resumed)
|
|
127
|
+
return viewLoops(ui);
|
|
128
|
+
getTriggerSystem().add(resumed);
|
|
81
129
|
updateWidget();
|
|
82
130
|
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
131
|
+
if (resumed.trigger.type === "dynamic")
|
|
132
|
+
onDynamicLoopActivated?.(resumed);
|
|
83
133
|
}
|
|
84
134
|
}
|
|
85
135
|
}
|
|
@@ -91,11 +141,11 @@ export function registerLoopCommand(options) {
|
|
|
91
141
|
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
92
142
|
}
|
|
93
143
|
pi.registerCommand("loop", {
|
|
94
|
-
description: "Create a
|
|
144
|
+
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
145
|
handler: async (args, ctx) => {
|
|
96
|
-
const trimmed = args.trim();
|
|
97
146
|
const ui = ctx.ui;
|
|
98
|
-
|
|
147
|
+
const route = parseLoopCommandRoute(args);
|
|
148
|
+
if (route.type === "menu") {
|
|
99
149
|
const choice = await ui.select("Loop", [
|
|
100
150
|
"Create scheduled loop",
|
|
101
151
|
"Create event-triggered loop",
|
|
@@ -112,36 +162,19 @@ export function registerLoopCommand(options) {
|
|
|
112
162
|
return viewLoops(ui);
|
|
113
163
|
return settings(ui);
|
|
114
164
|
}
|
|
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
|
-
}
|
|
165
|
+
if (route.type === "event")
|
|
166
|
+
return eventLoop(ui, route.prompt, route.source);
|
|
167
|
+
if (route.type === "cron")
|
|
168
|
+
return createCronLoop(ui, route.interval, route.prompt, route.notifyEvery);
|
|
169
|
+
if (route.type === "invalid-cron") {
|
|
170
|
+
ui.notify(`Invalid cron expression: ${route.interval}`, "error");
|
|
134
171
|
return;
|
|
135
172
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`Event-triggered: "${trimmed.slice(0, 50)}"`,
|
|
139
|
-
]);
|
|
140
|
-
if (!choice)
|
|
173
|
+
if (route.type === "missing-interval-prompt") {
|
|
174
|
+
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
141
175
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return scheduleLoop(ui, trimmed);
|
|
176
|
+
}
|
|
177
|
+
return dynamicLoop(ui, route.goal);
|
|
145
178
|
},
|
|
146
179
|
});
|
|
147
180
|
}
|
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 ──
|
|
@@ -306,6 +331,9 @@ export default function (pi) {
|
|
|
306
331
|
updateWidget: () => {
|
|
307
332
|
widget.update();
|
|
308
333
|
},
|
|
334
|
+
onDynamicLoopActivated: (entry) => {
|
|
335
|
+
onLoopFire(entry);
|
|
336
|
+
},
|
|
309
337
|
});
|
|
310
338
|
// ── Native task tools (only when pi-tasks is absent) ──
|
|
311
339
|
// Only tool/command registration stays deferred: the tool names collide with
|
|
@@ -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