@trevonistrevon/pi-loop 0.4.0 → 0.4.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/AGENTS.md +9 -9
- package/DIFFERENTIAL_REVIEW_REPORT.md +22 -0
- package/dist/index.js +116 -27
- package/dist/trigger-system.js +5 -0
- package/dist/ui/widget.d.ts +1 -3
- package/dist/ui/widget.js +8 -26
- package/package.json +1 -1
- package/src/index.ts +133 -30
- package/src/trigger-system.ts +6 -0
- package/src/ui/widget.ts +13 -27
package/AGENTS.md
CHANGED
|
@@ -32,9 +32,15 @@ src/
|
|
|
32
32
|
- Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
|
|
33
33
|
- Cross-extension communication via `pi.events` with `requestId` + reply channels
|
|
34
34
|
- File-backed stores use atomic write (write tmp → rename) + pid-based file locking
|
|
35
|
-
-
|
|
35
|
+
- Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
|
|
36
36
|
- Tests co-located in `test/`, named `<module>.test.ts`
|
|
37
37
|
|
|
38
|
+
## Tool Schema Discipline
|
|
39
|
+
- Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
|
|
40
|
+
- Example: `TaskUpdate` uses `id`, not `taskId`.
|
|
41
|
+
- When a tool validation error clearly indicates an immediately recoverable schema mismatch, correct it silently and retry. Do not emit user-facing chatter like "retrying with the correct shape" unless the recovery itself changes the user's understanding.
|
|
42
|
+
- When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
|
|
43
|
+
|
|
38
44
|
## File Locking Pattern
|
|
39
45
|
Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
|
|
40
46
|
|
|
@@ -44,14 +50,8 @@ Three trigger types, all stored as `LoopEntry.trigger`:
|
|
|
44
50
|
- `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
|
|
45
51
|
- `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
|
|
46
52
|
|
|
47
|
-
## Re-wake via
|
|
48
|
-
When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension
|
|
49
|
-
```
|
|
50
|
-
<system-reminder>
|
|
51
|
-
Scheduled loop "deploy check" fired. Trigger: schedule: */5 * * * *.
|
|
52
|
-
[loop:abc12345]
|
|
53
|
-
</system-reminder>
|
|
54
|
-
```
|
|
53
|
+
## Re-wake via In-Memory Pending Notifications
|
|
54
|
+
When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension buffers a pending notification in memory, re-checks whether the wake is still relevant, and only then injects a `pi.sendMessage()` custom message to wake the agent. Do not rely on early queued follow-up user messages for loop delivery; those are not extension-cancelable once handed to pi's queue.
|
|
55
55
|
|
|
56
56
|
## Monitor Streaming via PI Events
|
|
57
57
|
Monitor stdout/stderr lines are emitted as `pi.events("monitor:output", { monitorId, line, timestamp })`. Tool consumers subscribe to these events. Completion emits `"monitor:done"` / `"monitor:error"`.
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Differential Review Report
|
|
2
2
|
|
|
3
|
+
## Update — 2026-06-02
|
|
4
|
+
|
|
5
|
+
### Resolved audit finding
|
|
6
|
+
- **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
|
|
7
|
+
- **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
|
|
8
|
+
- **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
|
|
9
|
+
|
|
10
|
+
### Regression coverage added
|
|
11
|
+
- `test/trigger-system.test.ts`
|
|
12
|
+
- recurring `event` loop is deleted immediately at final `maxFires`
|
|
13
|
+
- recurring `hybrid` loop is deleted immediately at final `maxFires`
|
|
14
|
+
- hybrid cleanup also clears scheduled cron state
|
|
15
|
+
- `test/index.test.ts`
|
|
16
|
+
- extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
|
|
17
|
+
|
|
18
|
+
### Validation status
|
|
19
|
+
- `npm run lint` ✅
|
|
20
|
+
- `npm run typecheck` ✅
|
|
21
|
+
- `npm run test` ✅
|
|
22
|
+
- `npm run build` ✅
|
|
23
|
+
- Current suite: **112 passing tests**
|
|
24
|
+
|
|
3
25
|
## Scope
|
|
4
26
|
Reviewed recent uncommitted changes in:
|
|
5
27
|
- `src/index.ts`
|
package/dist/index.js
CHANGED
|
@@ -180,6 +180,87 @@ export default function (pi) {
|
|
|
180
180
|
widget.update();
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
let agentRunning = false;
|
|
184
|
+
const pendingNotifications = new Map();
|
|
185
|
+
let flushPromise;
|
|
186
|
+
function buildLoopFireMessage(data) {
|
|
187
|
+
const triggerInfo = typeof data.trigger === "string"
|
|
188
|
+
? data.trigger
|
|
189
|
+
: data.trigger?.type === "cron"
|
|
190
|
+
? `schedule: ${data.trigger.schedule}`
|
|
191
|
+
: data.trigger?.type === "event"
|
|
192
|
+
? `event: ${data.trigger.source}`
|
|
193
|
+
: "hybrid";
|
|
194
|
+
const loopId = data.loopId || "?";
|
|
195
|
+
const prompt = data.prompt || "loop fired";
|
|
196
|
+
const constraint = data.readOnly
|
|
197
|
+
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
198
|
+
: "";
|
|
199
|
+
return [
|
|
200
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
201
|
+
prompt,
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
function buildPendingNotification(data) {
|
|
205
|
+
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
206
|
+
return {
|
|
207
|
+
...data,
|
|
208
|
+
key,
|
|
209
|
+
message: buildLoopFireMessage(data),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function deliverNotification(notification) {
|
|
213
|
+
if (notification.autoTask) {
|
|
214
|
+
const pending = await hasPendingTasks();
|
|
215
|
+
if (pending === 0) {
|
|
216
|
+
debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
|
|
217
|
+
await cleanDoneTasks();
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
agentRunning = true;
|
|
222
|
+
pi.sendMessage({
|
|
223
|
+
customType: "pi-loop",
|
|
224
|
+
content: notification.message,
|
|
225
|
+
display: false,
|
|
226
|
+
details: {
|
|
227
|
+
loopId: notification.loopId,
|
|
228
|
+
trigger: notification.trigger,
|
|
229
|
+
recurring: notification.recurring,
|
|
230
|
+
readOnly: notification.readOnly,
|
|
231
|
+
autoTask: notification.autoTask,
|
|
232
|
+
timestamp: notification.timestamp,
|
|
233
|
+
},
|
|
234
|
+
}, {
|
|
235
|
+
deliverAs: "steer",
|
|
236
|
+
triggerTurn: true,
|
|
237
|
+
});
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
async function flushPendingNotifications() {
|
|
241
|
+
if (flushPromise)
|
|
242
|
+
return flushPromise;
|
|
243
|
+
flushPromise = (async () => {
|
|
244
|
+
if (agentRunning || _latestCtx?.hasPendingMessages())
|
|
245
|
+
return;
|
|
246
|
+
const entries = [...pendingNotifications.entries()]
|
|
247
|
+
.sort(([, left], [, right]) => left.timestamp - right.timestamp);
|
|
248
|
+
for (const [key, notification] of entries) {
|
|
249
|
+
pendingNotifications.delete(key);
|
|
250
|
+
const delivered = await deliverNotification(notification);
|
|
251
|
+
if (delivered)
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
})().finally(() => {
|
|
255
|
+
flushPromise = undefined;
|
|
256
|
+
});
|
|
257
|
+
return flushPromise;
|
|
258
|
+
}
|
|
259
|
+
async function queueOrDeliverNotification(data) {
|
|
260
|
+
const notification = buildPendingNotification(data);
|
|
261
|
+
pendingNotifications.set(notification.key, notification);
|
|
262
|
+
await flushPendingNotifications();
|
|
263
|
+
}
|
|
183
264
|
// ── Loop fire handler ──
|
|
184
265
|
function onLoopFire(entry) {
|
|
185
266
|
debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
|
|
@@ -248,10 +329,27 @@ export default function (pi) {
|
|
|
248
329
|
showPersistedLoops();
|
|
249
330
|
widget.update();
|
|
250
331
|
});
|
|
332
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
333
|
+
agentRunning = true;
|
|
334
|
+
_latestCtx = ctx;
|
|
335
|
+
widget.setUICtx(ctx.ui);
|
|
336
|
+
});
|
|
337
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
338
|
+
agentRunning = false;
|
|
339
|
+
_latestCtx = ctx;
|
|
340
|
+
widget.setUICtx(ctx.ui);
|
|
341
|
+
await flushPendingNotifications();
|
|
342
|
+
});
|
|
343
|
+
pi.on("session_shutdown", async () => {
|
|
344
|
+
agentRunning = false;
|
|
345
|
+
pendingNotifications.clear();
|
|
346
|
+
});
|
|
251
347
|
pi.on("session_switch", async (event, ctx) => {
|
|
252
348
|
_latestCtx = ctx;
|
|
253
349
|
widget.setUICtx(ctx.ui);
|
|
254
350
|
triggerSystem.stop();
|
|
351
|
+
agentRunning = false;
|
|
352
|
+
pendingNotifications.clear();
|
|
255
353
|
const isResume = event?.reason === "resume";
|
|
256
354
|
storeUpgraded = false;
|
|
257
355
|
persistedShown = false;
|
|
@@ -262,36 +360,18 @@ export default function (pi) {
|
|
|
262
360
|
showPersistedLoops(isResume);
|
|
263
361
|
widget.update();
|
|
264
362
|
});
|
|
265
|
-
// ── Loop fire handler —
|
|
363
|
+
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
266
364
|
pi.events.on("loop:fire", async (event) => {
|
|
267
365
|
const data = event;
|
|
268
|
-
if (data.recurring && _latestCtx?.hasPendingMessages()) {
|
|
269
|
-
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
366
|
if (data.autoTask) {
|
|
273
367
|
const pending = await hasPendingTasks();
|
|
274
368
|
if (pending === 0) {
|
|
275
369
|
debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
|
|
276
|
-
cleanDoneTasks();
|
|
370
|
+
await cleanDoneTasks();
|
|
277
371
|
return;
|
|
278
372
|
}
|
|
279
373
|
}
|
|
280
|
-
|
|
281
|
-
? data.trigger
|
|
282
|
-
: data.trigger?.type === "cron"
|
|
283
|
-
? `schedule: ${data.trigger.schedule}`
|
|
284
|
-
: data.trigger?.type === "event"
|
|
285
|
-
? `event: ${data.trigger.source}`
|
|
286
|
-
: `hybrid`;
|
|
287
|
-
const loopId = data.loopId || "?";
|
|
288
|
-
const prompt = data.prompt || "loop fired";
|
|
289
|
-
const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
|
|
290
|
-
const message = [
|
|
291
|
-
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
292
|
-
prompt,
|
|
293
|
-
].join("\n");
|
|
294
|
-
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
374
|
+
await queueOrDeliverNotification(data);
|
|
295
375
|
});
|
|
296
376
|
// ──────────────────────────────────────────────────
|
|
297
377
|
// Tool 1: LoopCreate
|
|
@@ -327,7 +407,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
327
407
|
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
328
408
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
329
409
|
- **recurring**: repeat or fire once (default: true)
|
|
330
|
-
- **autoTask**:
|
|
410
|
+
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
331
411
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
332
412
|
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
|
|
333
413
|
promptGuidelines: [
|
|
@@ -346,7 +426,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
346
426
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
347
427
|
"## Task-driven workflows",
|
|
348
428
|
"After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
|
|
349
|
-
"When no tasks are pending, the loop skips the
|
|
429
|
+
"When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
|
|
350
430
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
351
431
|
],
|
|
352
432
|
parameters: Type.Object({
|
|
@@ -417,7 +497,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
417
497
|
`Trigger: ${triggerDesc}\n` +
|
|
418
498
|
`Recurring: ${entry.recurring}\n` +
|
|
419
499
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
420
|
-
(tasksAvailable ? "" : "(
|
|
500
|
+
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
421
501
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
422
502
|
},
|
|
423
503
|
});
|
|
@@ -548,7 +628,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
548
628
|
|
|
549
629
|
Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
|
|
550
630
|
|
|
551
|
-
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a
|
|
631
|
+
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
|
|
552
632
|
|
|
553
633
|
DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
|
|
554
634
|
|
|
@@ -562,7 +642,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
562
642
|
|
|
563
643
|
## onDone — auto-notify on completion
|
|
564
644
|
|
|
565
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The
|
|
645
|
+
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\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"`,
|
|
566
646
|
promptGuidelines: [
|
|
567
647
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
568
648
|
"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.",
|
|
@@ -572,7 +652,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
572
652
|
command: Type.String({ description: "Shell command to run in background" }),
|
|
573
653
|
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
574
654
|
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
575
|
-
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion
|
|
655
|
+
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
|
|
576
656
|
}),
|
|
577
657
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
578
658
|
if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
|
|
@@ -892,6 +972,10 @@ Fields:
|
|
|
892
972
|
- subject: brief actionable title
|
|
893
973
|
- description: detailed requirements
|
|
894
974
|
- metadata: optional tags/metadata`,
|
|
975
|
+
promptGuidelines: [
|
|
976
|
+
"Use TaskCreate to track complex multi-step work across turns.",
|
|
977
|
+
"TaskCreate accepts `subject` and `description` parameters only — do not invent extra fields unless the schema explicitly adds them.",
|
|
978
|
+
],
|
|
895
979
|
parameters: Type.Object({
|
|
896
980
|
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
897
981
|
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
@@ -932,6 +1016,11 @@ Fields:
|
|
|
932
1016
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
933
1017
|
|
|
934
1018
|
Statuses: pending → in_progress → completed`,
|
|
1019
|
+
promptGuidelines: [
|
|
1020
|
+
"Use TaskUpdate with parameter `id`, not `taskId`.",
|
|
1021
|
+
"TaskUpdate accepts only `id`, `status`, `subject`, and `description`.",
|
|
1022
|
+
"When a tool validation error clearly indicates a recoverable schema mismatch, correct the arguments and retry without narrating the recovery unless the user needs to know.",
|
|
1023
|
+
],
|
|
935
1024
|
parameters: Type.Object({
|
|
936
1025
|
id: Type.String({ description: "Task ID to update" }),
|
|
937
1026
|
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
package/dist/trigger-system.js
CHANGED
|
@@ -103,6 +103,11 @@ export class TriggerSystem {
|
|
|
103
103
|
this.remove(entry.id);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
|
+
if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
107
|
+
this.remove(fresh.id);
|
|
108
|
+
this.store.delete(fresh.id);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
106
111
|
if (!fresh.recurring) {
|
|
107
112
|
this.remove(fresh.id);
|
|
108
113
|
this.store.delete(fresh.id);
|
package/dist/ui/widget.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ export declare class LoopWidget {
|
|
|
9
9
|
private store;
|
|
10
10
|
private monitorManager;
|
|
11
11
|
private uiCtx;
|
|
12
|
-
private tui;
|
|
13
|
-
private widgetRegistered;
|
|
14
12
|
private interval;
|
|
15
13
|
private taskSummaryProvider;
|
|
16
14
|
constructor(store: LoopStore, monitorManager: MonitorManager);
|
|
@@ -18,7 +16,7 @@ export declare class LoopWidget {
|
|
|
18
16
|
setStore(store: LoopStore): void;
|
|
19
17
|
setTaskSummaryProvider(provider: (() => TaskSummary) | undefined): void;
|
|
20
18
|
update(): void;
|
|
21
|
-
private
|
|
19
|
+
private computeStatus;
|
|
22
20
|
dispose(): void;
|
|
23
21
|
}
|
|
24
22
|
export {};
|
package/dist/ui/widget.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
1
|
export class LoopWidget {
|
|
3
2
|
store;
|
|
4
3
|
monitorManager;
|
|
5
4
|
uiCtx;
|
|
6
|
-
tui;
|
|
7
|
-
widgetRegistered = false;
|
|
8
5
|
interval;
|
|
9
6
|
taskSummaryProvider;
|
|
10
7
|
constructor(store, monitorManager) {
|
|
@@ -23,34 +20,22 @@ export class LoopWidget {
|
|
|
23
20
|
update() {
|
|
24
21
|
if (!this.uiCtx)
|
|
25
22
|
return;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (hasContent && !this.interval) {
|
|
23
|
+
const status = this.computeStatus();
|
|
24
|
+
if (status && !this.interval) {
|
|
29
25
|
this.interval = setInterval(() => this.update(), 5000);
|
|
30
26
|
}
|
|
31
|
-
if (!
|
|
27
|
+
if (!status && this.interval) {
|
|
32
28
|
clearInterval(this.interval);
|
|
33
29
|
this.interval = undefined;
|
|
34
30
|
}
|
|
35
|
-
|
|
36
|
-
this.uiCtx.setWidget("loops", (tui, theme) => {
|
|
37
|
-
this.tui = tui;
|
|
38
|
-
return { render: () => this.renderWidget(tui, theme), invalidate: () => { } };
|
|
39
|
-
}, { placement: "aboveEditor" });
|
|
40
|
-
this.widgetRegistered = true;
|
|
41
|
-
}
|
|
42
|
-
else if (this.tui) {
|
|
43
|
-
this.tui.requestRender();
|
|
44
|
-
}
|
|
31
|
+
this.uiCtx.setStatus("loops", status);
|
|
45
32
|
}
|
|
46
|
-
|
|
33
|
+
computeStatus() {
|
|
47
34
|
const loops = this.store.list().filter(l => l.status === "active");
|
|
48
35
|
const monitors = this.monitorManager.list();
|
|
49
36
|
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
50
|
-
const w = tui.terminal.columns;
|
|
51
|
-
const trunc = (line) => truncateToWidth(line, w);
|
|
52
37
|
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
53
|
-
return
|
|
38
|
+
return undefined;
|
|
54
39
|
}
|
|
55
40
|
const parts = [];
|
|
56
41
|
if (loops.length > 0)
|
|
@@ -62,17 +47,14 @@ export class LoopWidget {
|
|
|
62
47
|
let line = parts.join(" · ");
|
|
63
48
|
if (taskSummary.focusText)
|
|
64
49
|
line += ` | ${taskSummary.focusText}`;
|
|
65
|
-
return
|
|
50
|
+
return line;
|
|
66
51
|
}
|
|
67
52
|
dispose() {
|
|
68
53
|
if (this.interval) {
|
|
69
54
|
clearInterval(this.interval);
|
|
70
55
|
this.interval = undefined;
|
|
71
56
|
}
|
|
72
|
-
|
|
73
|
-
this.uiCtx.setWidget("loops", undefined);
|
|
74
|
-
this.widgetRegistered = false;
|
|
75
|
-
this.tui = undefined;
|
|
57
|
+
this.uiCtx?.setStatus("loops", undefined);
|
|
76
58
|
}
|
|
77
59
|
}
|
|
78
60
|
function formatCount(count, noun) {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -50,6 +50,10 @@ interface SessionSwitchEvent {
|
|
|
50
50
|
reason?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
interface PendingNotification extends LoopFireEvent {
|
|
54
|
+
key: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
export default function (pi: ExtensionAPI) {
|
|
55
59
|
const piLoopEnv = process.env.PI_LOOP;
|
|
@@ -191,6 +195,97 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
let agentRunning = false;
|
|
199
|
+
const pendingNotifications = new Map<string, PendingNotification>();
|
|
200
|
+
let flushPromise: Promise<void> | undefined;
|
|
201
|
+
|
|
202
|
+
function buildLoopFireMessage(data: LoopFireEvent): string {
|
|
203
|
+
const triggerInfo = typeof data.trigger === "string"
|
|
204
|
+
? data.trigger
|
|
205
|
+
: data.trigger?.type === "cron"
|
|
206
|
+
? `schedule: ${data.trigger.schedule}`
|
|
207
|
+
: data.trigger?.type === "event"
|
|
208
|
+
? `event: ${data.trigger.source}`
|
|
209
|
+
: "hybrid";
|
|
210
|
+
|
|
211
|
+
const loopId = data.loopId || "?";
|
|
212
|
+
const prompt = data.prompt || "loop fired";
|
|
213
|
+
const constraint = data.readOnly
|
|
214
|
+
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
215
|
+
: "";
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
219
|
+
prompt,
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildPendingNotification(data: LoopFireEvent): PendingNotification {
|
|
224
|
+
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
225
|
+
return {
|
|
226
|
+
...data,
|
|
227
|
+
key,
|
|
228
|
+
message: buildLoopFireMessage(data),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function deliverNotification(notification: PendingNotification): Promise<boolean> {
|
|
233
|
+
if (notification.autoTask) {
|
|
234
|
+
const pending = await hasPendingTasks();
|
|
235
|
+
if (pending === 0) {
|
|
236
|
+
debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
|
|
237
|
+
await cleanDoneTasks();
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
agentRunning = true;
|
|
243
|
+
pi.sendMessage({
|
|
244
|
+
customType: "pi-loop",
|
|
245
|
+
content: notification.message,
|
|
246
|
+
display: false,
|
|
247
|
+
details: {
|
|
248
|
+
loopId: notification.loopId,
|
|
249
|
+
trigger: notification.trigger,
|
|
250
|
+
recurring: notification.recurring,
|
|
251
|
+
readOnly: notification.readOnly,
|
|
252
|
+
autoTask: notification.autoTask,
|
|
253
|
+
timestamp: notification.timestamp,
|
|
254
|
+
},
|
|
255
|
+
}, {
|
|
256
|
+
deliverAs: "steer",
|
|
257
|
+
triggerTurn: true,
|
|
258
|
+
});
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function flushPendingNotifications(): Promise<void> {
|
|
263
|
+
if (flushPromise) return flushPromise;
|
|
264
|
+
|
|
265
|
+
flushPromise = (async () => {
|
|
266
|
+
if (agentRunning || _latestCtx?.hasPendingMessages()) return;
|
|
267
|
+
|
|
268
|
+
const entries = [...pendingNotifications.entries()]
|
|
269
|
+
.sort(([, left], [, right]) => left.timestamp - right.timestamp);
|
|
270
|
+
|
|
271
|
+
for (const [key, notification] of entries) {
|
|
272
|
+
pendingNotifications.delete(key);
|
|
273
|
+
const delivered = await deliverNotification(notification);
|
|
274
|
+
if (delivered) return;
|
|
275
|
+
}
|
|
276
|
+
})().finally(() => {
|
|
277
|
+
flushPromise = undefined;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return flushPromise;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
|
|
284
|
+
const notification = buildPendingNotification(data);
|
|
285
|
+
pendingNotifications.set(notification.key, notification);
|
|
286
|
+
await flushPendingNotifications();
|
|
287
|
+
}
|
|
288
|
+
|
|
194
289
|
// ── Loop fire handler ──
|
|
195
290
|
|
|
196
291
|
function onLoopFire(entry: LoopEntry): void {
|
|
@@ -267,10 +362,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
362
|
widget.update();
|
|
268
363
|
});
|
|
269
364
|
|
|
365
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
366
|
+
agentRunning = true;
|
|
367
|
+
_latestCtx = ctx;
|
|
368
|
+
widget.setUICtx(ctx.ui);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
372
|
+
agentRunning = false;
|
|
373
|
+
_latestCtx = ctx;
|
|
374
|
+
widget.setUICtx(ctx.ui);
|
|
375
|
+
await flushPendingNotifications();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.on("session_shutdown", async () => {
|
|
379
|
+
agentRunning = false;
|
|
380
|
+
pendingNotifications.clear();
|
|
381
|
+
});
|
|
382
|
+
|
|
270
383
|
pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
|
|
271
384
|
_latestCtx = ctx;
|
|
272
385
|
widget.setUICtx(ctx.ui);
|
|
273
386
|
triggerSystem.stop();
|
|
387
|
+
agentRunning = false;
|
|
388
|
+
pendingNotifications.clear();
|
|
274
389
|
|
|
275
390
|
const isResume = event?.reason === "resume";
|
|
276
391
|
storeUpgraded = false;
|
|
@@ -285,42 +400,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
285
400
|
widget.update();
|
|
286
401
|
});
|
|
287
402
|
|
|
288
|
-
// ── Loop fire handler —
|
|
403
|
+
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
289
404
|
|
|
290
405
|
pi.events.on("loop:fire", async (event: unknown) => {
|
|
291
406
|
const data = event as LoopFireEvent;
|
|
292
407
|
|
|
293
|
-
if (data.recurring && _latestCtx?.hasPendingMessages()) {
|
|
294
|
-
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
408
|
if (data.autoTask) {
|
|
299
409
|
const pending = await hasPendingTasks();
|
|
300
410
|
if (pending === 0) {
|
|
301
411
|
debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
|
|
302
|
-
cleanDoneTasks();
|
|
412
|
+
await cleanDoneTasks();
|
|
303
413
|
return;
|
|
304
414
|
}
|
|
305
415
|
}
|
|
306
416
|
|
|
307
|
-
|
|
308
|
-
? data.trigger
|
|
309
|
-
: data.trigger?.type === "cron"
|
|
310
|
-
? `schedule: ${data.trigger.schedule}`
|
|
311
|
-
: data.trigger?.type === "event"
|
|
312
|
-
? `event: ${data.trigger.source}`
|
|
313
|
-
: `hybrid`;
|
|
314
|
-
|
|
315
|
-
const loopId = data.loopId || "?";
|
|
316
|
-
const prompt = data.prompt || "loop fired";
|
|
317
|
-
const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
|
|
318
|
-
const message = [
|
|
319
|
-
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
320
|
-
prompt,
|
|
321
|
-
].join("\n");
|
|
322
|
-
|
|
323
|
-
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
417
|
+
await queueOrDeliverNotification(data);
|
|
324
418
|
});
|
|
325
419
|
|
|
326
420
|
// ──────────────────────────────────────────────────
|
|
@@ -358,7 +452,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
358
452
|
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
359
453
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
360
454
|
- **recurring**: repeat or fire once (default: true)
|
|
361
|
-
- **autoTask**:
|
|
455
|
+
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
362
456
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
363
457
|
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
|
|
364
458
|
promptGuidelines: [
|
|
@@ -377,7 +471,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
377
471
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
378
472
|
"## Task-driven workflows",
|
|
379
473
|
"After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
|
|
380
|
-
"When no tasks are pending, the loop skips the
|
|
474
|
+
"When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
|
|
381
475
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
382
476
|
],
|
|
383
477
|
parameters: Type.Object({
|
|
@@ -455,7 +549,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
455
549
|
`Trigger: ${triggerDesc}\n` +
|
|
456
550
|
`Recurring: ${entry.recurring}\n` +
|
|
457
551
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
458
|
-
(tasksAvailable ? "" : "(
|
|
552
|
+
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
459
553
|
`ID: ${entry.id} (use LoopDelete to cancel)`
|
|
460
554
|
));
|
|
461
555
|
},
|
|
@@ -594,7 +688,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
594
688
|
|
|
595
689
|
Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
|
|
596
690
|
|
|
597
|
-
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a
|
|
691
|
+
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
|
|
598
692
|
|
|
599
693
|
DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
|
|
600
694
|
|
|
@@ -608,7 +702,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
608
702
|
|
|
609
703
|
## onDone — auto-notify on completion
|
|
610
704
|
|
|
611
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The
|
|
705
|
+
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\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"`,
|
|
612
706
|
promptGuidelines: [
|
|
613
707
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
614
708
|
"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.",
|
|
@@ -618,7 +712,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
618
712
|
command: Type.String({ description: "Shell command to run in background" }),
|
|
619
713
|
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
620
714
|
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
621
|
-
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion
|
|
715
|
+
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
|
|
622
716
|
}),
|
|
623
717
|
|
|
624
718
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
@@ -963,6 +1057,10 @@ Fields:
|
|
|
963
1057
|
- subject: brief actionable title
|
|
964
1058
|
- description: detailed requirements
|
|
965
1059
|
- metadata: optional tags/metadata`,
|
|
1060
|
+
promptGuidelines: [
|
|
1061
|
+
"Use TaskCreate to track complex multi-step work across turns.",
|
|
1062
|
+
"TaskCreate accepts `subject` and `description` parameters only — do not invent extra fields unless the schema explicitly adds them.",
|
|
1063
|
+
],
|
|
966
1064
|
parameters: Type.Object({
|
|
967
1065
|
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
968
1066
|
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
@@ -1005,6 +1103,11 @@ Fields:
|
|
|
1005
1103
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1006
1104
|
|
|
1007
1105
|
Statuses: pending → in_progress → completed`,
|
|
1106
|
+
promptGuidelines: [
|
|
1107
|
+
"Use TaskUpdate with parameter `id`, not `taskId`.",
|
|
1108
|
+
"TaskUpdate accepts only `id`, `status`, `subject`, and `description`.",
|
|
1109
|
+
"When a tool validation error clearly indicates a recoverable schema mismatch, correct the arguments and retry without narrating the recovery unless the user needs to know.",
|
|
1110
|
+
],
|
|
1008
1111
|
parameters: Type.Object({
|
|
1009
1112
|
id: Type.String({ description: "Task ID to update" }),
|
|
1010
1113
|
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
package/src/trigger-system.ts
CHANGED
|
@@ -110,6 +110,12 @@ export class TriggerSystem {
|
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
114
|
+
this.remove(fresh.id);
|
|
115
|
+
this.store.delete(fresh.id);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
if (!fresh.recurring) {
|
|
114
120
|
this.remove(fresh.id);
|
|
115
121
|
this.store.delete(fresh.id);
|
package/src/ui/widget.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type { ExtensionUIContext
|
|
2
|
-
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
2
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
5
3
|
import type { LoopStore } from "../store.js";
|
|
6
4
|
|
|
@@ -11,8 +9,6 @@ interface TaskSummary {
|
|
|
11
9
|
|
|
12
10
|
export class LoopWidget {
|
|
13
11
|
private uiCtx: ExtensionUIContext | undefined;
|
|
14
|
-
private tui: TUI | undefined;
|
|
15
|
-
private widgetRegistered = false;
|
|
16
12
|
private interval: ReturnType<typeof setInterval> | undefined;
|
|
17
13
|
private taskSummaryProvider: (() => TaskSummary) | undefined;
|
|
18
14
|
|
|
@@ -36,36 +32,25 @@ export class LoopWidget {
|
|
|
36
32
|
update() {
|
|
37
33
|
if (!this.uiCtx) return;
|
|
38
34
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
if (hasContent && !this.interval) {
|
|
35
|
+
const status = this.computeStatus();
|
|
36
|
+
if (status && !this.interval) {
|
|
42
37
|
this.interval = setInterval(() => this.update(), 5000);
|
|
43
38
|
}
|
|
44
|
-
if (!
|
|
39
|
+
if (!status && this.interval) {
|
|
45
40
|
clearInterval(this.interval);
|
|
46
41
|
this.interval = undefined;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
this.uiCtx.setWidget("loops", (tui: TUI, theme: Theme) => {
|
|
51
|
-
this.tui = tui;
|
|
52
|
-
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} } as Component & { dispose?(): void };
|
|
53
|
-
}, { placement: "aboveEditor" });
|
|
54
|
-
this.widgetRegistered = true;
|
|
55
|
-
} else if (this.tui) {
|
|
56
|
-
(this.tui as any).requestRender();
|
|
57
|
-
}
|
|
44
|
+
this.uiCtx.setStatus("loops", status);
|
|
58
45
|
}
|
|
59
46
|
|
|
60
|
-
private
|
|
47
|
+
private computeStatus(): string | undefined {
|
|
61
48
|
const loops = this.store.list().filter(l => l.status === "active");
|
|
62
49
|
const monitors = this.monitorManager.list();
|
|
63
50
|
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
64
|
-
const w = tui.terminal.columns;
|
|
65
|
-
const trunc = (line: string) => truncateToWidth(line, w);
|
|
66
51
|
|
|
67
52
|
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
68
|
-
return
|
|
53
|
+
return undefined;
|
|
69
54
|
}
|
|
70
55
|
|
|
71
56
|
const parts: string[] = [];
|
|
@@ -75,14 +60,15 @@ export class LoopWidget {
|
|
|
75
60
|
|
|
76
61
|
let line = parts.join(" · ");
|
|
77
62
|
if (taskSummary.focusText) line += ` | ${taskSummary.focusText}`;
|
|
78
|
-
return
|
|
63
|
+
return line;
|
|
79
64
|
}
|
|
80
65
|
|
|
81
66
|
dispose() {
|
|
82
|
-
if (this.interval) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
67
|
+
if (this.interval) {
|
|
68
|
+
clearInterval(this.interval);
|
|
69
|
+
this.interval = undefined;
|
|
70
|
+
}
|
|
71
|
+
this.uiCtx?.setStatus("loops", undefined);
|
|
86
72
|
}
|
|
87
73
|
}
|
|
88
74
|
|