@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 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
- - Widget uses `UICtx.setWidget()` with `render()` callback pattern
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 System Reminder
48
- When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension's listener queues a reminder. On the next `tool_result` event, the reminder is injected as `<system-reminder>` text (mirroring pi-tasks' pattern):
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
- "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.'",
429
- "When no tasks are pending, the loop skips the wake entirely no tokens burned on empty polls.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
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
- "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.'",
474
- "When no tasks are pending, the loop skips the wake entirely no tokens burned on empty polls.",
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"] })),