@trevonistrevon/pi-loop 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -9
- package/dist/index.js +56 -3
- package/package.json +1 -1
- package/src/index.ts +57 -3
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"`.
|
package/dist/index.js
CHANGED
|
@@ -209,6 +209,35 @@ export default function (pi) {
|
|
|
209
209
|
message: buildLoopFireMessage(data),
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
|
+
function triggerHasEventSource(trigger, source) {
|
|
213
|
+
if (typeof trigger === "string")
|
|
214
|
+
return false;
|
|
215
|
+
return trigger.type === "event"
|
|
216
|
+
? trigger.source === source
|
|
217
|
+
: trigger.type === "hybrid"
|
|
218
|
+
? trigger.event.source === source
|
|
219
|
+
: false;
|
|
220
|
+
}
|
|
221
|
+
async function maybeBootstrapTaskLoop(entry) {
|
|
222
|
+
if (!entry.recurring)
|
|
223
|
+
return false;
|
|
224
|
+
if (!triggerHasEventSource(entry.trigger, "tasks:created"))
|
|
225
|
+
return false;
|
|
226
|
+
const pending = await hasPendingTasks();
|
|
227
|
+
if (pending <= 0)
|
|
228
|
+
return false;
|
|
229
|
+
debug(`loop #${entry.id} — bootstrapping existing pending tasks (${pending})`);
|
|
230
|
+
await queueOrDeliverNotification({
|
|
231
|
+
loopId: entry.id,
|
|
232
|
+
prompt: entry.prompt,
|
|
233
|
+
trigger: entry.trigger,
|
|
234
|
+
timestamp: Date.now(),
|
|
235
|
+
readOnly: entry.readOnly,
|
|
236
|
+
recurring: false,
|
|
237
|
+
autoTask: true,
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
212
241
|
async function deliverNotification(notification) {
|
|
213
242
|
if (notification.autoTask) {
|
|
214
243
|
const pending = await hasPendingTasks();
|
|
@@ -425,8 +454,9 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
425
454
|
"## readOnly mode",
|
|
426
455
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
427
456
|
"## Task-driven workflows",
|
|
428
|
-
"
|
|
429
|
-
"
|
|
457
|
+
"Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
|
|
458
|
+
"Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
|
|
459
|
+
"When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
|
|
430
460
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
431
461
|
],
|
|
432
462
|
parameters: Type.Object({
|
|
@@ -439,7 +469,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
439
469
|
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
440
470
|
maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
|
|
441
471
|
}),
|
|
442
|
-
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
472
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
443
473
|
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
|
|
444
474
|
let trigger;
|
|
445
475
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
@@ -487,6 +517,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
487
517
|
}
|
|
488
518
|
catch { /* filter parse failure, ignore */ }
|
|
489
519
|
}
|
|
520
|
+
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
490
521
|
widget.update();
|
|
491
522
|
const triggerDesc = trigger.type === "cron"
|
|
492
523
|
? `schedule: ${trigger.schedule}`
|
|
@@ -497,6 +528,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
497
528
|
`Trigger: ${triggerDesc}\n` +
|
|
498
529
|
`Recurring: ${entry.recurring}\n` +
|
|
499
530
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
531
|
+
(bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
|
|
500
532
|
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
501
533
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
502
534
|
},
|
|
@@ -956,6 +988,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
956
988
|
}
|
|
957
989
|
if (trimmed) {
|
|
958
990
|
const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
|
|
991
|
+
pi.events.emit("tasks:created", {
|
|
992
|
+
taskId: entry.id,
|
|
993
|
+
subject: entry.subject,
|
|
994
|
+
description: entry.description,
|
|
995
|
+
status: entry.status,
|
|
996
|
+
});
|
|
959
997
|
widget.update();
|
|
960
998
|
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
961
999
|
return;
|
|
@@ -972,12 +1010,22 @@ Fields:
|
|
|
972
1010
|
- subject: brief actionable title
|
|
973
1011
|
- description: detailed requirements
|
|
974
1012
|
- metadata: optional tags/metadata`,
|
|
1013
|
+
promptGuidelines: [
|
|
1014
|
+
"Use TaskCreate to track complex multi-step work across turns.",
|
|
1015
|
+
"TaskCreate accepts `subject` and `description` parameters only — do not invent extra fields unless the schema explicitly adds them.",
|
|
1016
|
+
],
|
|
975
1017
|
parameters: Type.Object({
|
|
976
1018
|
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
977
1019
|
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
978
1020
|
}),
|
|
979
1021
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
980
1022
|
const entry = taskStore.create(params.subject, params.description);
|
|
1023
|
+
pi.events.emit("tasks:created", {
|
|
1024
|
+
taskId: entry.id,
|
|
1025
|
+
subject: entry.subject,
|
|
1026
|
+
description: entry.description,
|
|
1027
|
+
status: entry.status,
|
|
1028
|
+
});
|
|
981
1029
|
widget.update();
|
|
982
1030
|
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
|
|
983
1031
|
},
|
|
@@ -1012,6 +1060,11 @@ Fields:
|
|
|
1012
1060
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1013
1061
|
|
|
1014
1062
|
Statuses: pending → in_progress → completed`,
|
|
1063
|
+
promptGuidelines: [
|
|
1064
|
+
"Use TaskUpdate with parameter `id`, not `taskId`.",
|
|
1065
|
+
"TaskUpdate accepts only `id`, `status`, `subject`, and `description`.",
|
|
1066
|
+
"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.",
|
|
1067
|
+
],
|
|
1015
1068
|
parameters: Type.Object({
|
|
1016
1069
|
id: Type.String({ description: "Task ID to update" }),
|
|
1017
1070
|
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -229,6 +229,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
229
|
};
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
function triggerHasEventSource(trigger: Trigger | string, source: string): boolean {
|
|
233
|
+
if (typeof trigger === "string") return false;
|
|
234
|
+
return trigger.type === "event"
|
|
235
|
+
? trigger.source === source
|
|
236
|
+
: trigger.type === "hybrid"
|
|
237
|
+
? trigger.event.source === source
|
|
238
|
+
: false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function maybeBootstrapTaskLoop(entry: LoopEntry): Promise<boolean> {
|
|
242
|
+
if (!entry.recurring) return false;
|
|
243
|
+
if (!triggerHasEventSource(entry.trigger, "tasks:created")) return false;
|
|
244
|
+
|
|
245
|
+
const pending = await hasPendingTasks();
|
|
246
|
+
if (pending <= 0) return false;
|
|
247
|
+
|
|
248
|
+
debug(`loop #${entry.id} — bootstrapping existing pending tasks (${pending})`);
|
|
249
|
+
await queueOrDeliverNotification({
|
|
250
|
+
loopId: entry.id,
|
|
251
|
+
prompt: entry.prompt,
|
|
252
|
+
trigger: entry.trigger,
|
|
253
|
+
timestamp: Date.now(),
|
|
254
|
+
readOnly: entry.readOnly,
|
|
255
|
+
recurring: false,
|
|
256
|
+
autoTask: true,
|
|
257
|
+
});
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
async function deliverNotification(notification: PendingNotification): Promise<boolean> {
|
|
233
262
|
if (notification.autoTask) {
|
|
234
263
|
const pending = await hasPendingTasks();
|
|
@@ -470,8 +499,9 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
470
499
|
"## readOnly mode",
|
|
471
500
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
472
501
|
"## Task-driven workflows",
|
|
473
|
-
"
|
|
474
|
-
"
|
|
502
|
+
"Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
|
|
503
|
+
"Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
|
|
504
|
+
"When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
|
|
475
505
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
476
506
|
],
|
|
477
507
|
parameters: Type.Object({
|
|
@@ -485,7 +515,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
485
515
|
maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
|
|
486
516
|
}),
|
|
487
517
|
|
|
488
|
-
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
518
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
489
519
|
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
|
|
490
520
|
|
|
491
521
|
let trigger: Trigger;
|
|
@@ -536,6 +566,8 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
536
566
|
} catch { /* filter parse failure, ignore */ }
|
|
537
567
|
}
|
|
538
568
|
|
|
569
|
+
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
570
|
+
|
|
539
571
|
widget.update();
|
|
540
572
|
|
|
541
573
|
const triggerDesc = trigger.type === "cron"
|
|
@@ -549,6 +581,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
549
581
|
`Trigger: ${triggerDesc}\n` +
|
|
550
582
|
`Recurring: ${entry.recurring}\n` +
|
|
551
583
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
584
|
+
(bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
|
|
552
585
|
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
553
586
|
`ID: ${entry.id} (use LoopDelete to cancel)`
|
|
554
587
|
));
|
|
@@ -1040,6 +1073,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
1040
1073
|
}
|
|
1041
1074
|
if (trimmed) {
|
|
1042
1075
|
const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
|
|
1076
|
+
pi.events.emit("tasks:created", {
|
|
1077
|
+
taskId: entry.id,
|
|
1078
|
+
subject: entry.subject,
|
|
1079
|
+
description: entry.description,
|
|
1080
|
+
status: entry.status,
|
|
1081
|
+
});
|
|
1043
1082
|
widget.update();
|
|
1044
1083
|
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
1045
1084
|
return;
|
|
@@ -1057,12 +1096,22 @@ Fields:
|
|
|
1057
1096
|
- subject: brief actionable title
|
|
1058
1097
|
- description: detailed requirements
|
|
1059
1098
|
- metadata: optional tags/metadata`,
|
|
1099
|
+
promptGuidelines: [
|
|
1100
|
+
"Use TaskCreate to track complex multi-step work across turns.",
|
|
1101
|
+
"TaskCreate accepts `subject` and `description` parameters only — do not invent extra fields unless the schema explicitly adds them.",
|
|
1102
|
+
],
|
|
1060
1103
|
parameters: Type.Object({
|
|
1061
1104
|
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
1062
1105
|
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
1063
1106
|
}),
|
|
1064
1107
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1065
1108
|
const entry = taskStore.create(params.subject, params.description);
|
|
1109
|
+
pi.events.emit("tasks:created", {
|
|
1110
|
+
taskId: entry.id,
|
|
1111
|
+
subject: entry.subject,
|
|
1112
|
+
description: entry.description,
|
|
1113
|
+
status: entry.status,
|
|
1114
|
+
});
|
|
1066
1115
|
widget.update();
|
|
1067
1116
|
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
|
|
1068
1117
|
},
|
|
@@ -1099,6 +1148,11 @@ Fields:
|
|
|
1099
1148
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1100
1149
|
|
|
1101
1150
|
Statuses: pending → in_progress → completed`,
|
|
1151
|
+
promptGuidelines: [
|
|
1152
|
+
"Use TaskUpdate with parameter `id`, not `taskId`.",
|
|
1153
|
+
"TaskUpdate accepts only `id`, `status`, `subject`, and `description`.",
|
|
1154
|
+
"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.",
|
|
1155
|
+
],
|
|
1102
1156
|
parameters: Type.Object({
|
|
1103
1157
|
id: Type.String({ description: "Task ID to update" }),
|
|
1104
1158
|
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|