@trevonistrevon/pi-loop 0.4.8 → 0.4.9

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/dist/index.js CHANGED
@@ -183,6 +183,7 @@ export default function (pi) {
183
183
  if (nativeTaskStore) {
184
184
  nativeTaskStore.sweepCompleted();
185
185
  widget.update();
186
+ await cleanupTaskBacklogLoops();
186
187
  }
187
188
  }
188
189
  let agentRunning = false;
@@ -378,6 +379,7 @@ export default function (pi) {
378
379
  _latestCtx = ctx;
379
380
  widget.setUICtx(ctx.ui);
380
381
  await flushPendingNotifications({ ignorePendingMessages: true });
382
+ await cleanupTaskBacklogLoops();
381
383
  await pumpLoops();
382
384
  });
383
385
  pi.on("session_shutdown", async () => {
@@ -468,6 +470,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
468
470
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
469
471
  - **recurring**: repeat or fire once (default: true)
470
472
  - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
473
+ - **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
471
474
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
472
475
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
473
476
  promptGuidelines: [
@@ -487,6 +490,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
487
490
  "## Task-driven workflows",
488
491
  "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.",
489
492
  "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.",
493
+ "Set taskBacklog: true for task-worker loops that process the existing pending queue. Task-backlog loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
490
494
  "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
491
495
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
492
496
  ],
@@ -495,13 +499,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
495
499
  prompt: Type.String({ description: "Prompt to run when the loop fires" }),
496
500
  recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
497
501
  autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
502
+ taskBacklog: Type.Optional(Type.Boolean({ description: "Mark as a task-backlog worker loop that auto-deletes when pending tasks reach zero", default: false })),
498
503
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
499
504
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
500
505
  readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
501
506
  maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
502
507
  }),
503
508
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
504
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
509
+ const { trigger: triggerInput, prompt, recurring, autoTask, taskBacklog, triggerType, debounceMs, readOnly, maxFires } = params;
505
510
  let trigger;
506
511
  const inferred = triggerType ?? inferTriggerType(triggerInput);
507
512
  if (inferred === "cron") {
@@ -528,6 +533,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
528
533
  const entry = store.create(trigger, prompt, {
529
534
  recurring: recurring ?? (inferred !== "event"),
530
535
  autoTask,
536
+ taskBacklog,
531
537
  readOnly,
532
538
  maxFires,
533
539
  });
@@ -558,6 +564,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
558
564
  `Trigger: ${triggerDesc}\n` +
559
565
  `Recurring: ${entry.recurring}\n` +
560
566
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
567
+ (entry.taskBacklog ? `Task-backlog: enabled\n` : "") +
561
568
  (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
562
569
  ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
563
570
  `ID: ${entry.id} (use LoopDelete to cancel)`));
@@ -930,10 +937,33 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
930
937
  }
931
938
  const AUTO_TASK_WORKER_THRESHOLD = 5;
932
939
  const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
933
- function findAutoTaskWorkerLoop() {
934
- return store.list().find(entry => entry.status === "active"
940
+ function isAutoTaskWorkerLoop(entry) {
941
+ return entry.status === "active"
935
942
  && entry.prompt === AUTO_TASK_WORKER_PROMPT
936
- && triggerHasEventSource(entry.trigger, "tasks:created"));
943
+ && triggerHasEventSource(entry.trigger, "tasks:created");
944
+ }
945
+ function isTaskBacklogLoop(entry) {
946
+ return entry.status === "active"
947
+ && triggerHasEventSource(entry.trigger, "tasks:created")
948
+ && (entry.taskBacklog === true || isAutoTaskWorkerLoop(entry));
949
+ }
950
+ function findAutoTaskWorkerLoop() {
951
+ return store.list().find(isAutoTaskWorkerLoop);
952
+ }
953
+ async function cleanupTaskBacklogLoops() {
954
+ const backlogLoops = store.list().filter(isTaskBacklogLoop);
955
+ if (backlogLoops.length === 0)
956
+ return 0;
957
+ const pending = await hasPendingTasks();
958
+ if (pending < 0 || pending > 0)
959
+ return 0;
960
+ for (const entry of backlogLoops) {
961
+ debug(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
962
+ triggerSystem.remove(entry.id);
963
+ store.delete(entry.id);
964
+ }
965
+ widget.update();
966
+ return backlogLoops.length;
937
967
  }
938
968
  async function ensureAutoTaskWorkerLoop(taskStore) {
939
969
  if (taskStore.pendingCount() < AUTO_TASK_WORKER_THRESHOLD)
@@ -949,6 +979,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
949
979
  };
950
980
  const entry = store.create(trigger, AUTO_TASK_WORKER_PROMPT, {
951
981
  recurring: true,
982
+ taskBacklog: true,
952
983
  maxFires: 30,
953
984
  });
954
985
  triggerSystem.add(entry);
@@ -1037,6 +1068,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1037
1068
  ui.notify(`Task #${task.id} reopened`, "info");
1038
1069
  }
1039
1070
  widget.update();
1071
+ await cleanupTaskBacklogLoops();
1040
1072
  return viewNativeTasks(ui);
1041
1073
  }
1042
1074
  // ── Native task tools (only when pi-tasks is absent) ──
@@ -1149,7 +1181,7 @@ Parameters: id (required), status, subject, description`,
1149
1181
  subject: Type.Optional(Type.String({ description: "New title" })),
1150
1182
  description: Type.Optional(Type.String({ description: "New description" })),
1151
1183
  }),
1152
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1184
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1153
1185
  const { id, status, subject, description } = params;
1154
1186
  const entry = taskStore.update(id, {
1155
1187
  status: status,
@@ -1159,6 +1191,7 @@ Parameters: id (required), status, subject, description`,
1159
1191
  if (!entry)
1160
1192
  return Promise.resolve(textResult(`Task #${id} not found`));
1161
1193
  widget.update();
1194
+ await cleanupTaskBacklogLoops();
1162
1195
  const statusMsg = status ? ` → ${status}` : "";
1163
1196
  return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
1164
1197
  },
@@ -1170,11 +1203,13 @@ Parameters: id (required), status, subject, description`,
1170
1203
  parameters: Type.Object({
1171
1204
  id: Type.String({ description: "Task ID to delete" }),
1172
1205
  }),
1173
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1206
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1174
1207
  const deleted = taskStore.delete(params.id);
1175
1208
  widget.update();
1176
- if (deleted)
1209
+ if (deleted) {
1210
+ await cleanupTaskBacklogLoops();
1177
1211
  return Promise.resolve(textResult(`Task #${params.id} deleted`));
1212
+ }
1178
1213
  return Promise.resolve(textResult(`Task #${params.id} not found`));
1179
1214
  },
1180
1215
  });
package/dist/store.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare class LoopStore {
11
11
  create(trigger: Trigger, prompt: string, opts: {
12
12
  recurring: boolean;
13
13
  autoTask?: boolean;
14
+ taskBacklog?: boolean;
14
15
  readOnly?: boolean;
15
16
  maxFires?: number;
16
17
  }): LoopEntry;
package/dist/store.js CHANGED
@@ -117,6 +117,7 @@ export class LoopStore {
117
117
  status: "active",
118
118
  recurring: opts.recurring,
119
119
  autoTask: opts.autoTask,
120
+ taskBacklog: opts.taskBacklog,
120
121
  readOnly: opts.readOnly,
121
122
  maxFires: opts.maxFires,
122
123
  fireCount: 0,
package/dist/types.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface LoopEntry {
28
28
  updatedAt: number;
29
29
  expiresAt: number;
30
30
  autoTask?: boolean;
31
+ taskBacklog?: boolean;
31
32
  readOnly?: boolean;
32
33
  maxFires?: number;
33
34
  fireCount?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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
@@ -196,6 +196,7 @@ export default function (pi: ExtensionAPI) {
196
196
  if (nativeTaskStore) {
197
197
  nativeTaskStore.sweepCompleted();
198
198
  widget.update();
199
+ await cleanupTaskBacklogLoops();
199
200
  }
200
201
  }
201
202
 
@@ -410,6 +411,7 @@ export default function (pi: ExtensionAPI) {
410
411
  _latestCtx = ctx;
411
412
  widget.setUICtx(ctx.ui);
412
413
  await flushPendingNotifications({ ignorePendingMessages: true });
414
+ await cleanupTaskBacklogLoops();
413
415
  await pumpLoops();
414
416
  });
415
417
 
@@ -508,6 +510,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
508
510
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
509
511
  - **recurring**: repeat or fire once (default: true)
510
512
  - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
513
+ - **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
511
514
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
512
515
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
513
516
  promptGuidelines: [
@@ -527,6 +530,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
527
530
  "## Task-driven workflows",
528
531
  "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.",
529
532
  "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.",
533
+ "Set taskBacklog: true for task-worker loops that process the existing pending queue. Task-backlog loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
530
534
  "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
531
535
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
532
536
  ],
@@ -535,6 +539,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
535
539
  prompt: Type.String({ description: "Prompt to run when the loop fires" }),
536
540
  recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
537
541
  autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
542
+ taskBacklog: Type.Optional(Type.Boolean({ description: "Mark as a task-backlog worker loop that auto-deletes when pending tasks reach zero", default: false })),
538
543
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
539
544
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
540
545
  readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
@@ -542,7 +547,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
542
547
  }),
543
548
 
544
549
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
545
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
550
+ const { trigger: triggerInput, prompt, recurring, autoTask, taskBacklog, triggerType, debounceMs, readOnly, maxFires } = params;
546
551
 
547
552
  let trigger: Trigger;
548
553
  const inferred = triggerType ?? inferTriggerType(triggerInput);
@@ -570,6 +575,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
570
575
  const entry = store.create(trigger, prompt, {
571
576
  recurring: recurring ?? (inferred !== "event"),
572
577
  autoTask,
578
+ taskBacklog,
573
579
  readOnly,
574
580
  maxFires,
575
581
  });
@@ -606,6 +612,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
606
612
  `Trigger: ${triggerDesc}\n` +
607
613
  `Recurring: ${entry.recurring}\n` +
608
614
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
615
+ (entry.taskBacklog ? `Task-backlog: enabled\n` : "") +
609
616
  (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
610
617
  ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
611
618
  `ID: ${entry.id} (use LoopDelete to cancel)`
@@ -1009,12 +1016,36 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1009
1016
  const AUTO_TASK_WORKER_THRESHOLD = 5;
1010
1017
  const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
1011
1018
 
1012
- function findAutoTaskWorkerLoop(): LoopEntry | undefined {
1013
- return store.list().find(entry =>
1014
- entry.status === "active"
1019
+ function isAutoTaskWorkerLoop(entry: LoopEntry): boolean {
1020
+ return entry.status === "active"
1015
1021
  && entry.prompt === AUTO_TASK_WORKER_PROMPT
1022
+ && triggerHasEventSource(entry.trigger, "tasks:created");
1023
+ }
1024
+
1025
+ function isTaskBacklogLoop(entry: LoopEntry): boolean {
1026
+ return entry.status === "active"
1016
1027
  && triggerHasEventSource(entry.trigger, "tasks:created")
1017
- );
1028
+ && (entry.taskBacklog === true || isAutoTaskWorkerLoop(entry));
1029
+ }
1030
+
1031
+ function findAutoTaskWorkerLoop(): LoopEntry | undefined {
1032
+ return store.list().find(isAutoTaskWorkerLoop);
1033
+ }
1034
+
1035
+ async function cleanupTaskBacklogLoops(): Promise<number> {
1036
+ const backlogLoops = store.list().filter(isTaskBacklogLoop);
1037
+ if (backlogLoops.length === 0) return 0;
1038
+
1039
+ const pending = await hasPendingTasks();
1040
+ if (pending < 0 || pending > 0) return 0;
1041
+
1042
+ for (const entry of backlogLoops) {
1043
+ debug(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
1044
+ triggerSystem.remove(entry.id);
1045
+ store.delete(entry.id);
1046
+ }
1047
+ widget.update();
1048
+ return backlogLoops.length;
1018
1049
  }
1019
1050
 
1020
1051
  async function ensureAutoTaskWorkerLoop(taskStore: TaskStore): Promise<{ entry?: LoopEntry; created: boolean }> {
@@ -1031,6 +1062,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1031
1062
  };
1032
1063
  const entry = store.create(trigger, AUTO_TASK_WORKER_PROMPT, {
1033
1064
  recurring: true,
1065
+ taskBacklog: true,
1034
1066
  maxFires: 30,
1035
1067
  });
1036
1068
  triggerSystem.add(entry);
@@ -1120,6 +1152,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1120
1152
  }
1121
1153
 
1122
1154
  widget.update();
1155
+ await cleanupTaskBacklogLoops();
1123
1156
  return viewNativeTasks(ui);
1124
1157
  }
1125
1158
 
@@ -1238,7 +1271,7 @@ Parameters: id (required), status, subject, description`,
1238
1271
  subject: Type.Optional(Type.String({ description: "New title" })),
1239
1272
  description: Type.Optional(Type.String({ description: "New description" })),
1240
1273
  }),
1241
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1274
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1242
1275
  const { id, status, subject, description } = params;
1243
1276
  const entry = taskStore.update(id, {
1244
1277
  status: status as "pending" | "in_progress" | "completed" | undefined,
@@ -1247,6 +1280,7 @@ Parameters: id (required), status, subject, description`,
1247
1280
  });
1248
1281
  if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
1249
1282
  widget.update();
1283
+ await cleanupTaskBacklogLoops();
1250
1284
  const statusMsg = status ? ` → ${status}` : "";
1251
1285
  return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
1252
1286
  },
@@ -1259,10 +1293,13 @@ Parameters: id (required), status, subject, description`,
1259
1293
  parameters: Type.Object({
1260
1294
  id: Type.String({ description: "Task ID to delete" }),
1261
1295
  }),
1262
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1296
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1263
1297
  const deleted = taskStore.delete(params.id);
1264
1298
  widget.update();
1265
- if (deleted) return Promise.resolve(textResult(`Task #${params.id} deleted`));
1299
+ if (deleted) {
1300
+ await cleanupTaskBacklogLoops();
1301
+ return Promise.resolve(textResult(`Task #${params.id} deleted`));
1302
+ }
1266
1303
  return Promise.resolve(textResult(`Task #${params.id} not found`));
1267
1304
  },
1268
1305
  });
package/src/store.ts CHANGED
@@ -95,7 +95,7 @@ export class LoopStore {
95
95
  }
96
96
  }
97
97
 
98
- create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
98
+ create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
99
99
  return this.withLock(() => {
100
100
  if (this.loops.size >= MAX_LOOPS) {
101
101
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
@@ -108,6 +108,7 @@ export class LoopStore {
108
108
  status: "active",
109
109
  recurring: opts.recurring,
110
110
  autoTask: opts.autoTask,
111
+ taskBacklog: opts.taskBacklog,
111
112
  readOnly: opts.readOnly,
112
113
  maxFires: opts.maxFires,
113
114
  fireCount: 0,
package/src/types.ts CHANGED
@@ -30,6 +30,7 @@ export interface LoopEntry {
30
30
  updatedAt: number;
31
31
  expiresAt: number;
32
32
  autoTask?: boolean;
33
+ taskBacklog?: boolean;
33
34
  readOnly?: boolean;
34
35
  maxFires?: number;
35
36
  fireCount?: number;