@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +28 -143
  3. package/dist/commands/loop-command.d.ts +10 -2
  4. package/dist/commands/loop-command.js +84 -51
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +44 -16
  7. package/dist/loop-format.d.ts +3 -0
  8. package/dist/loop-format.js +20 -0
  9. package/dist/loop-parse.d.ts +1 -0
  10. package/dist/loop-parse.js +45 -1
  11. package/dist/loop-reducer.d.ts +13 -1
  12. package/dist/loop-reducer.js +26 -0
  13. package/dist/monitor-manager.d.ts +1 -1
  14. package/dist/monitor-manager.js +13 -4
  15. package/dist/notification-reducer.d.ts +2 -0
  16. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  17. package/dist/runtime/notification-runtime.d.ts +2 -1
  18. package/dist/runtime/notification-runtime.js +19 -7
  19. package/dist/runtime/task-backlog-runtime.d.ts +2 -1
  20. package/dist/runtime/task-backlog-runtime.js +13 -9
  21. package/dist/scheduler.js +25 -7
  22. package/dist/store.d.ts +9 -1
  23. package/dist/store.js +57 -0
  24. package/dist/tools/loop-tools.d.ts +8 -0
  25. package/dist/tools/loop-tools.js +104 -12
  26. package/dist/tools/monitor-tools.js +1 -1
  27. package/dist/trigger-system.js +1 -1
  28. package/dist/types.d.ts +24 -1
  29. package/docs/USAGE_GUIDE.md +185 -0
  30. package/package.json +13 -4
  31. package/src/commands/loop-command.ts +102 -54
  32. package/src/index.ts +46 -16
  33. package/src/loop-format.ts +22 -0
  34. package/src/loop-parse.ts +44 -1
  35. package/src/loop-reducer.ts +41 -1
  36. package/src/monitor-manager.ts +12 -4
  37. package/src/notification-reducer.ts +3 -0
  38. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  39. package/src/runtime/notification-runtime.ts +21 -8
  40. package/src/runtime/task-backlog-runtime.ts +15 -11
  41. package/src/scheduler.ts +25 -8
  42. package/src/store.ts +59 -2
  43. package/src/tools/loop-tools.ts +128 -12
  44. package/src/tools/monitor-tools.ts +1 -1
  45. package/src/trigger-system.ts +1 -1
  46. package/src/types.ts +29 -1
  47. package/.release-please-manifest.json +0 -3
  48. package/AGENTS.md +0 -123
  49. package/CONTRIBUTING.md +0 -79
  50. package/Dockerfile +0 -17
  51. package/benchmarks/experiment-sim.js +0 -30
  52. package/coverage/base.css +0 -224
  53. package/coverage/block-navigation.js +0 -87
  54. package/coverage/coverage-final.json +0 -36
  55. package/coverage/favicon.png +0 -0
  56. package/coverage/index.html +0 -191
  57. package/coverage/prettify.css +0 -1
  58. package/coverage/prettify.js +0 -2
  59. package/coverage/sort-arrow-sprite.png +0 -0
  60. package/coverage/sorter.js +0 -210
  61. package/coverage/src/api.ts.html +0 -193
  62. package/coverage/src/commands/index.html +0 -131
  63. package/coverage/src/commands/loop-command.ts.html +0 -634
  64. package/coverage/src/commands/tasks-command.ts.html +0 -490
  65. package/coverage/src/coordinator.ts.html +0 -430
  66. package/coverage/src/index.html +0 -341
  67. package/coverage/src/index.ts.html +0 -1249
  68. package/coverage/src/loop-parse.ts.html +0 -607
  69. package/coverage/src/loop-reducer.ts.html +0 -559
  70. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  71. package/coverage/src/monitor-manager.ts.html +0 -964
  72. package/coverage/src/monitor-reducer.ts.html +0 -583
  73. package/coverage/src/notification-reducer.ts.html +0 -550
  74. package/coverage/src/reducer-backed-store.ts.html +0 -589
  75. package/coverage/src/rpc/channels.ts.html +0 -376
  76. package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
  77. package/coverage/src/rpc/index.html +0 -131
  78. package/coverage/src/runtime/index.html +0 -251
  79. package/coverage/src/runtime/loop-events.ts.html +0 -265
  80. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  81. package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
  82. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  83. package/coverage/src/runtime/scope.ts.html +0 -196
  84. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  85. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
  86. package/coverage/src/runtime/task-events.ts.html +0 -208
  87. package/coverage/src/runtime/task-mutations.ts.html +0 -394
  88. package/coverage/src/runtime/task-rpc.ts.html +0 -523
  89. package/coverage/src/scheduler.ts.html +0 -400
  90. package/coverage/src/store.ts.html +0 -667
  91. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  92. package/coverage/src/task-reducer.ts.html +0 -550
  93. package/coverage/src/task-store.ts.html +0 -526
  94. package/coverage/src/tools/index.html +0 -161
  95. package/coverage/src/tools/loop-tools.ts.html +0 -991
  96. package/coverage/src/tools/monitor-tools.ts.html +0 -538
  97. package/coverage/src/tools/native-task-tools.ts.html +0 -493
  98. package/coverage/src/tools/tool-result.ts.html +0 -97
  99. package/coverage/src/trigger-system.ts.html +0 -547
  100. package/coverage/src/ui/index.html +0 -116
  101. package/coverage/src/ui/widget.ts.html +0 -292
  102. package/docker-compose.yml +0 -12
  103. package/docs/architecture/state-machine-migration.md +0 -502
  104. package/docs/architecture/state-machine-reducer-event-model.md +0 -822
  105. package/docs/architecture/state-machine-test-matrix.md +0 -249
  106. package/docs/architecture/state-machine-transition-map.md +0 -436
  107. package/release-please-config.json +0 -9
  108. 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">Cron and event loops for the pi coding agent. Background monitors, scheduled re-wakes, pi-tasks integration, and native task fallback.</h6>
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
- ```text
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
- MonitorCreate command="tail -n0 -f build.log" description="Watch build"
23
- MonitorCreate command="python train.py" onDone="Analyze results and report best loss"
24
- MonitorList
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
- When `pi-tasks` is not installed, `pi-loop` also exposes native task tools after startup detection:
22
+ Or use the tools directly:
29
23
 
30
24
  ```text
31
- TaskCreate subject="Fix deploy polling" description="Switch deploy check to event-driven loop"
32
- TaskList
33
- TaskUpdate id="1" status="in_progress"
34
- TaskDelete id="1"
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
- ## Commands
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
- /loop # menu
43
- /loop 5m check the deploy # 5-minute cron loop
34
+ MonitorCreate command="python train.py" onDone="Analyze results and report best loss"
35
+ MonitorList
36
+ MonitorStop monitorId="1"
44
37
  ```
45
38
 
46
- `/tasks` interactive native task viewer/manager, only registered when `pi-tasks` is absent.
39
+ ## What it provides
47
40
 
48
- ```text
49
- /tasks # open native task viewer
50
- /tasks Write README updates # quick-create native task
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
- ## Tools
47
+ ## Commands and tools
54
48
 
55
- | Tool | What it does |
49
+ | Surface | Purpose |
56
50
  |---|---|
57
- | `LoopCreate` | Schedule a prompt on a cron timer, a pi event, or both with debounce |
58
- | `LoopList` | Show active loops with IDs, triggers, and next-fire times |
59
- | `LoopDelete` | Delete or pause a loop |
60
- | `MonitorCreate` | Run a background command, stream output as `monitor:output` events. Use `onDone` for auto-notify on completion |
61
- | `MonitorList` | Show monitors with status, uptime, and output line count |
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
- 25 active loops, 25 running monitors. Recurring loops expire after 7 days.
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?: Partial<LoopEntry>): LoopEntry;
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 { parseInterval } from "../loop-parse.js";
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
- async function scheduleLoop(ui, prompt) {
5
- const p = prompt || await ui.input("Prompt (what should the agent check?)");
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
- const entry = getStore().create(trigger, p, { recurring: true });
38
+ entry = getStore().create(trigger, prompt, { recurring: true });
15
39
  getTriggerSystem().add(entry);
16
40
  updateWidget();
17
- ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
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 eventLoop(ui, prompt) {
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
- const triggerDesc = l.trigger.type === "cron"
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
- getTriggerSystem().add(entry);
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 repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
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
- if (!trimmed) {
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
- const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
116
- if (intervalMatch) {
117
- const interval = intervalMatch[1] ?? intervalMatch[0];
118
- const prompt = trimmed.slice(intervalMatch[0].length).trim();
119
- if (!prompt) {
120
- ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
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
- const choice = await ui.select("Loop mode", [
137
- `Scheduled: "${trimmed.slice(0, 50)}"`,
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
- if (choice.startsWith("Event"))
143
- return eventLoop(ui, trimmed);
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: /loop [interval] [prompt]
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: /loop [interval] [prompt]
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
- const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
62
- const active = tasks.find(t => t.status === "in_progress");
63
- const next = tasks.find(t => t.status === "pending");
64
- const focus = active
65
- ? `active: ${active.subject.slice(0, 50)}`
66
- : next
67
- ? `next: ${next.subject.slice(0, 50)}`
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: tasks.length, focusText: focus };
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: entry.id,
224
- prompt: entry.prompt,
225
- trigger: entry.trigger,
226
- timestamp: Date.now(),
227
- readOnly: entry.readOnly,
228
- recurring: entry.recurring,
229
- autoTask: entry.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,3 @@
1
+ import type { Trigger } from "./types.js";
2
+ export type TriggerFormatStyle = "command" | "create" | "list" | "notification";
3
+ export declare function formatTrigger(trigger: Trigger | string, style?: TriggerFormatStyle): string;
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ export declare function isValidCronExpression(expr: string): boolean;
1
2
  export declare function parseInterval(input: string): {
2
3
  cron: string;
3
4
  description: string;