@trevonistrevon/pi-loop 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +17 -5
  3. package/dist/commands/loop-command.d.ts +9 -2
  4. package/dist/commands/loop-command.js +80 -50
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +41 -16
  7. package/dist/loop-format.d.ts +3 -0
  8. package/dist/loop-format.js +20 -0
  9. package/dist/loop-parse.d.ts +1 -0
  10. package/dist/loop-parse.js +45 -1
  11. package/dist/loop-reducer.d.ts +13 -1
  12. package/dist/loop-reducer.js +26 -0
  13. package/dist/monitor-manager.js +1 -1
  14. package/dist/notification-reducer.d.ts +2 -0
  15. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  16. package/dist/runtime/notification-runtime.d.ts +2 -1
  17. package/dist/runtime/notification-runtime.js +19 -7
  18. package/dist/runtime/task-backlog-runtime.d.ts +2 -1
  19. package/dist/runtime/task-backlog-runtime.js +13 -9
  20. package/dist/scheduler.js +25 -7
  21. package/dist/store.d.ts +9 -1
  22. package/dist/store.js +57 -0
  23. package/dist/tools/loop-tools.d.ts +8 -0
  24. package/dist/tools/loop-tools.js +104 -12
  25. package/dist/trigger-system.js +1 -1
  26. package/dist/types.d.ts +24 -1
  27. package/package.json +10 -2
  28. package/src/commands/loop-command.ts +99 -54
  29. package/src/index.ts +43 -16
  30. package/src/loop-format.ts +22 -0
  31. package/src/loop-parse.ts +44 -1
  32. package/src/loop-reducer.ts +41 -1
  33. package/src/monitor-manager.ts +1 -1
  34. package/src/notification-reducer.ts +3 -0
  35. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  36. package/src/runtime/notification-runtime.ts +21 -8
  37. package/src/runtime/task-backlog-runtime.ts +15 -11
  38. package/src/scheduler.ts +25 -8
  39. package/src/store.ts +59 -2
  40. package/src/tools/loop-tools.ts +128 -12
  41. package/src/trigger-system.ts +1 -1
  42. package/src/types.ts +29 -1
  43. package/.release-please-manifest.json +0 -3
  44. package/AGENTS.md +0 -123
  45. package/CONTRIBUTING.md +0 -79
  46. package/Dockerfile +0 -17
  47. package/benchmarks/experiment-sim.js +0 -30
  48. package/coverage/base.css +0 -224
  49. package/coverage/block-navigation.js +0 -87
  50. package/coverage/coverage-final.json +0 -36
  51. package/coverage/favicon.png +0 -0
  52. package/coverage/index.html +0 -191
  53. package/coverage/prettify.css +0 -1
  54. package/coverage/prettify.js +0 -2
  55. package/coverage/sort-arrow-sprite.png +0 -0
  56. package/coverage/sorter.js +0 -210
  57. package/coverage/src/api.ts.html +0 -193
  58. package/coverage/src/commands/index.html +0 -131
  59. package/coverage/src/commands/loop-command.ts.html +0 -634
  60. package/coverage/src/commands/tasks-command.ts.html +0 -490
  61. package/coverage/src/coordinator.ts.html +0 -430
  62. package/coverage/src/index.html +0 -341
  63. package/coverage/src/index.ts.html +0 -1249
  64. package/coverage/src/loop-parse.ts.html +0 -607
  65. package/coverage/src/loop-reducer.ts.html +0 -559
  66. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  67. package/coverage/src/monitor-manager.ts.html +0 -964
  68. package/coverage/src/monitor-reducer.ts.html +0 -583
  69. package/coverage/src/notification-reducer.ts.html +0 -550
  70. package/coverage/src/reducer-backed-store.ts.html +0 -589
  71. package/coverage/src/rpc/channels.ts.html +0 -376
  72. package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
  73. package/coverage/src/rpc/index.html +0 -131
  74. package/coverage/src/runtime/index.html +0 -251
  75. package/coverage/src/runtime/loop-events.ts.html +0 -265
  76. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  77. package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
  78. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  79. package/coverage/src/runtime/scope.ts.html +0 -196
  80. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  81. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
  82. package/coverage/src/runtime/task-events.ts.html +0 -208
  83. package/coverage/src/runtime/task-mutations.ts.html +0 -394
  84. package/coverage/src/runtime/task-rpc.ts.html +0 -523
  85. package/coverage/src/scheduler.ts.html +0 -400
  86. package/coverage/src/store.ts.html +0 -667
  87. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  88. package/coverage/src/task-reducer.ts.html +0 -550
  89. package/coverage/src/task-store.ts.html +0 -526
  90. package/coverage/src/tools/index.html +0 -161
  91. package/coverage/src/tools/loop-tools.ts.html +0 -991
  92. package/coverage/src/tools/monitor-tools.ts.html +0 -538
  93. package/coverage/src/tools/native-task-tools.ts.html +0 -493
  94. package/coverage/src/tools/tool-result.ts.html +0 -97
  95. package/coverage/src/trigger-system.ts.html +0 -547
  96. package/coverage/src/ui/index.html +0 -116
  97. package/coverage/src/ui/widget.ts.html +0 -292
  98. package/docker-compose.yml +0 -12
  99. package/docs/architecture/state-machine-migration.md +0 -502
  100. package/docs/architecture/state-machine-reducer-event-model.md +0 -822
  101. package/docs/architecture/state-machine-test-matrix.md +0 -249
  102. package/docs/architecture/state-machine-transition-map.md +0 -436
  103. package/release-please-config.json +0 -9
  104. package/vitest.config.ts +0 -29
@@ -1,4 +1,5 @@
1
1
  import { createCoordinator, } from "../coordinator.js";
2
+ import { formatTrigger } from "../loop-format.js";
2
3
  import { reduceNotificationState, } from "../notification-reducer.js";
3
4
  export function createNotificationRuntime(options) {
4
5
  const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
@@ -43,18 +44,28 @@ export function createNotificationRuntime(options) {
43
44
  });
44
45
  }
45
46
  function buildLoopFireMessage(data) {
46
- const triggerInfo = typeof data.trigger === "string"
47
- ? data.trigger
48
- : data.trigger?.type === "cron"
49
- ? `schedule: ${data.trigger.schedule}`
50
- : data.trigger?.type === "event"
51
- ? `event: ${data.trigger.source}`
52
- : "hybrid";
47
+ const triggerInfo = formatTrigger(data.trigger, "notification");
53
48
  const loopId = data.loopId || "?";
54
49
  const prompt = data.prompt || "loop fired";
55
50
  const constraint = data.readOnly
56
51
  ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
57
52
  : "";
53
+ if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
54
+ const dynamic = data.dynamic;
55
+ const lines = [
56
+ `[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
57
+ `Goal: ${dynamic?.goal ?? prompt}`,
58
+ `Iteration: ${dynamic?.iteration ?? 0}`,
59
+ ];
60
+ if (dynamic?.state)
61
+ lines.push(`State: ${dynamic.state}`);
62
+ if (dynamic?.metrics)
63
+ lines.push(`Metrics: ${dynamic.metrics}`);
64
+ if (dynamic?.doneCriteria)
65
+ lines.push(`Done criteria: ${dynamic.doneCriteria}`);
66
+ lines.push("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
67
+ return lines.join("\n");
68
+ }
58
69
  return [
59
70
  `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
60
71
  prompt,
@@ -88,6 +99,7 @@ export function createNotificationRuntime(options) {
88
99
  recurring: notification.recurring,
89
100
  readOnly: notification.readOnly,
90
101
  autoTask: notification.autoTask,
102
+ dynamic: notification.dynamic,
91
103
  timestamp: notification.timestamp,
92
104
  },
93
105
  }, {
@@ -1,5 +1,5 @@
1
1
  import type { TaskStore } from "../task-store.js";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
3
3
  import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
4
4
  export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
5
5
  export declare 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.";
@@ -11,6 +11,7 @@ export interface TaskBacklogRuntimeOptions {
11
11
  maxFires?: number;
12
12
  }) => LoopEntry;
13
13
  deleteLoop: (id: string) => void;
14
+ recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
14
15
  addTrigger: (entry: LoopEntry) => void;
15
16
  removeTrigger: (id: string) => void;
16
17
  updateWidget: () => void;
@@ -4,7 +4,7 @@ import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./lo
4
4
  export const AUTO_TASK_WORKER_THRESHOLD = 5;
5
5
  export 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.";
6
6
  export function createTaskBacklogRuntime(options) {
7
- const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
7
+ const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
8
8
  function isAutoTaskWorkerLoop(entry) {
9
9
  return entry.status === "active"
10
10
  && entry.prompt === AUTO_TASK_WORKER_PROMPT
@@ -18,6 +18,13 @@ export function createTaskBacklogRuntime(options) {
18
18
  function findAutoTaskWorkerLoop() {
19
19
  return getLoops().find(isAutoTaskWorkerLoop);
20
20
  }
21
+ function deleteTaskBacklogLoop(entry, pendingCount) {
22
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
23
+ removeTrigger(entry.id);
24
+ recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
25
+ deleteLoop(entry.id);
26
+ emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
27
+ }
21
28
  async function cleanupTaskBacklogLoops() {
22
29
  const backlogLoops = getLoops().filter(isTaskBacklogLoop);
23
30
  if (backlogLoops.length === 0)
@@ -25,13 +32,10 @@ export function createTaskBacklogRuntime(options) {
25
32
  const pending = await hasPendingTasks();
26
33
  if (pending < 0 || pending > 0)
27
34
  return 0;
28
- emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(backlogLoops.map((entry) => entry.id)));
29
- for (const entry of backlogLoops) {
30
- debug?.(`task backlog loop #${entry.id} no pending tasks remain, deleting`);
31
- removeTrigger(entry.id);
32
- deleteLoop(entry.id);
33
- emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
34
- }
35
+ const deletedLoopIds = backlogLoops.map((entry) => entry.id);
36
+ emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
37
+ for (const entry of backlogLoops)
38
+ deleteTaskBacklogLoop(entry, pending);
35
39
  updateWidget();
36
40
  return backlogLoops.length;
37
41
  }
@@ -43,7 +47,7 @@ export function createTaskBacklogRuntime(options) {
43
47
  return { entry: existing, created: false };
44
48
  const trigger = {
45
49
  type: "hybrid",
46
- cron: "*/5 * * * *",
50
+ cron: "*/3 * * * *",
47
51
  event: { source: "tasks:created" },
48
52
  debounceMs: 30000,
49
53
  };
package/dist/scheduler.js CHANGED
@@ -4,6 +4,9 @@ function computeNextFire(entry) {
4
4
  if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
5
5
  return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
6
6
  }
7
+ if (entry.trigger.type === "dynamic") {
8
+ return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
9
+ }
7
10
  return new Date(Date.now() + 60000);
8
11
  }
9
12
  export class CronScheduler {
@@ -15,10 +18,20 @@ export class CronScheduler {
15
18
  this.onFire = onFire;
16
19
  }
17
20
  start() {
18
- for (const entry of this.store.list()) {
21
+ for (const storedEntry of this.store.list()) {
22
+ let entry = storedEntry;
19
23
  if (entry.status !== "active")
20
24
  continue;
21
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
25
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
26
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
27
+ entry = this.store.updateDynamic(entry.id, {
28
+ dynamic: {
29
+ awaitingUpdate: false,
30
+ nextWakeAt: undefined,
31
+ lastUpdatedAt: Date.now(),
32
+ },
33
+ }) ?? entry;
34
+ }
22
35
  this.armTimer(entry);
23
36
  }
24
37
  }
@@ -27,7 +40,7 @@ export class CronScheduler {
27
40
  this.fireTimes.clear();
28
41
  }
29
42
  add(entry) {
30
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
43
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
31
44
  this.armTimer(entry);
32
45
  }
33
46
  }
@@ -38,11 +51,14 @@ export class CronScheduler {
38
51
  return this.fireTimes.get(id);
39
52
  }
40
53
  armTimer(entry) {
41
- const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
42
54
  const nextFire = computeNextFire(entry);
43
- const minuteField = _scheduleExpr.trim().split(/\s+/)[0] ?? "";
44
- const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
45
- const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
55
+ let jitter = 0;
56
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
57
+ const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
58
+ const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
59
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
60
+ jitter = computeJitter(entry.id, entry.recurring, minuteStep);
61
+ }
46
62
  const fireTime = nextFire.getTime() + jitter;
47
63
  if (fireTime > entry.expiresAt) {
48
64
  this.store.delete(entry.id);
@@ -59,6 +75,8 @@ export class CronScheduler {
59
75
  this.fireTimes.delete(id);
60
76
  continue;
61
77
  }
78
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate)
79
+ continue;
62
80
  if (filter && !filter(entry))
63
81
  continue;
64
82
  if (now >= entry.expiresAt) {
package/dist/store.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
2
2
  import { ReducerBackedStore } from "./reducer-backed-store.js";
3
- import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
3
+ import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
4
4
  export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
5
+ private tombstones;
5
6
  constructor(listIdOrPath?: string);
6
7
  create(trigger: Trigger, prompt: string, opts: {
7
8
  recurring: boolean;
@@ -9,6 +10,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
9
10
  taskBacklog?: boolean;
10
11
  readOnly?: boolean;
11
12
  maxFires?: number;
13
+ dynamic?: Partial<DynamicLoopState>;
12
14
  }): LoopEntry;
13
15
  pause(id: string): LoopEntry | undefined;
14
16
  resume(id: string): LoopEntry | undefined;
@@ -20,6 +22,12 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
20
22
  entry: LoopEntry | undefined;
21
23
  changedFields: string[];
22
24
  };
25
+ updateDynamic(id: string, fields: {
26
+ prompt?: string;
27
+ dynamic: Partial<DynamicLoopState>;
28
+ }): LoopEntry | undefined;
29
+ getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
30
+ recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
23
31
  delete(id: string): boolean;
24
32
  clearExpired(): number;
25
33
  expireEventLoops(sessionStartedAt: number): number;
package/dist/store.js CHANGED
@@ -4,7 +4,9 @@ import { reduceLoopState } from "./loop-reducer.js";
4
4
  import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
6
6
  const MAX_LOOPS = 25;
7
+ const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
7
8
  export class LoopStore extends ReducerBackedStore {
9
+ tombstones = new Map();
8
10
  constructor(listIdOrPath) {
9
11
  super({
10
12
  baseDir: LOOPS_DIR,
@@ -34,6 +36,7 @@ export class LoopStore extends ReducerBackedStore {
34
36
  taskBacklog: opts.taskBacklog,
35
37
  readOnly: opts.readOnly,
36
38
  maxFires: opts.maxFires,
39
+ dynamic: opts.dynamic,
37
40
  },
38
41
  });
39
42
  return this.entries.get(String(this.nextId - 1));
@@ -68,6 +71,22 @@ export class LoopStore extends ReducerBackedStore {
68
71
  entityId: id,
69
72
  payload: { id },
70
73
  });
74
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
75
+ this.applyReducerEvent({
76
+ type: "LOOP_DYNAMIC_UPDATED",
77
+ at: Date.now(),
78
+ source: "tool",
79
+ entityType: "loop",
80
+ entityId: id,
81
+ payload: {
82
+ id,
83
+ dynamic: {
84
+ awaitingUpdate: false,
85
+ lastUpdatedAt: Date.now(),
86
+ },
87
+ },
88
+ });
89
+ }
71
90
  return this.entries.get(id);
72
91
  });
73
92
  }
@@ -108,6 +127,44 @@ export class LoopStore extends ReducerBackedStore {
108
127
  return { entry: this.entries.get(id), changedFields };
109
128
  });
110
129
  }
130
+ updateDynamic(id, fields) {
131
+ return this.withLock(() => {
132
+ if (!this.entries.has(id))
133
+ return undefined;
134
+ this.applyReducerEvent({
135
+ type: "LOOP_DYNAMIC_UPDATED",
136
+ at: Date.now(),
137
+ source: "tool",
138
+ entityType: "loop",
139
+ entityId: id,
140
+ payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
141
+ });
142
+ return this.entries.get(id);
143
+ });
144
+ }
145
+ getDeletionTombstone(id) {
146
+ const tombstone = this.tombstones.get(id);
147
+ if (!tombstone)
148
+ return undefined;
149
+ if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS)
150
+ return tombstone;
151
+ this.tombstones.delete(id);
152
+ return undefined;
153
+ }
154
+ recordDeletionTombstone(id, input) {
155
+ const entry = this.entries.get(id);
156
+ if (!entry)
157
+ return undefined;
158
+ const tombstone = {
159
+ id,
160
+ reason: input.reason,
161
+ pendingCount: input.pendingCount,
162
+ deletedAt: Date.now(),
163
+ prompt: entry.prompt,
164
+ };
165
+ this.tombstones.set(id, tombstone);
166
+ return tombstone;
167
+ }
111
168
  delete(id) {
112
169
  return this.withLock(() => {
113
170
  if (!this.entries.has(id))
@@ -11,6 +11,14 @@ interface LoopStoreLike {
11
11
  maxFires?: number;
12
12
  }): LoopEntry;
13
13
  pause(id: string): LoopEntry | undefined;
14
+ updateDynamic(id: string, fields: {
15
+ prompt?: string;
16
+ dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
17
+ }): LoopEntry | undefined;
18
+ getDeletionTombstone(id: string): {
19
+ reason: string;
20
+ pendingCount?: number;
21
+ } | undefined;
14
22
  delete(id: string): boolean;
15
23
  }
16
24
  interface TriggerSystemLike {
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { formatTrigger } from "../loop-format.js";
2
3
  import { parseInterval } from "../loop-parse.js";
3
4
  import { textResult } from "./tool-result.js";
4
5
  function validateTrigger(trigger) {
@@ -40,6 +41,67 @@ function formatRemaining(ms) {
40
41
  return `${Math.round(ms / 60000)}m`;
41
42
  return `${Math.round(ms / 3600000)}h`;
42
43
  }
44
+ function parseDelayMs(input) {
45
+ const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
46
+ if (!match)
47
+ return undefined;
48
+ const value = Number.parseInt(match[1] ?? "", 10);
49
+ const unit = (match[2] ?? "").toLowerCase();
50
+ const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
51
+ return value * multiplier;
52
+ }
53
+ function resolveNextWakeAt(nextInterval) {
54
+ if (!nextInterval)
55
+ return { nextWakeAt: undefined };
56
+ const parsedDelayMs = parseDelayMs(nextInterval);
57
+ if (!parsedDelayMs)
58
+ return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
59
+ return { nextWakeAt: Date.now() + parsedDelayMs };
60
+ }
61
+ function formatDynamicUpdateResult(id, iteration, nextWakeAt) {
62
+ const mode = nextWakeAt === undefined
63
+ ? "Next wake: when idle"
64
+ : `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
65
+ return `Dynamic loop #${id} updated\n` +
66
+ `Iteration: ${iteration ?? "?"}` +
67
+ `\n${mode}`;
68
+ }
69
+ function formatDeletionTombstone(id, tombstone) {
70
+ const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
71
+ return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
72
+ }
73
+ function continueDynamicLoop(params, entry, store, triggerSystem) {
74
+ const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
75
+ if (error)
76
+ return error;
77
+ const updated = store.updateDynamic(params.id, {
78
+ prompt: params.prompt,
79
+ dynamic: {
80
+ goal: params.prompt ?? entry.dynamic.goal,
81
+ state: params.state,
82
+ metrics: params.metrics,
83
+ doneCriteria: params.doneCriteria,
84
+ iteration: (entry.dynamic.iteration ?? 0) + 1,
85
+ nextWakeAt,
86
+ awaitingUpdate: false,
87
+ lastUpdatedAt: Date.now(),
88
+ },
89
+ });
90
+ if (updated) {
91
+ triggerSystem.remove(params.id);
92
+ triggerSystem.add(updated);
93
+ }
94
+ return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
95
+ }
96
+ function stopDynamicLoop(params, store, triggerSystem) {
97
+ triggerSystem.remove(params.id);
98
+ if (params.status === "completed") {
99
+ store.delete(params.id);
100
+ return `Dynamic loop #${params.id} completed and deleted`;
101
+ }
102
+ store.pause(params.id);
103
+ return `Dynamic loop #${params.id} paused`;
104
+ }
43
105
  export function registerLoopTools(options) {
44
106
  const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, } = options;
45
107
  pi.registerTool({
@@ -160,11 +222,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
160
222
  }
161
223
  const bootstrapped = await maybeBootstrapTaskLoop(entry);
162
224
  updateWidget();
163
- const triggerDesc = trigger.type === "cron"
164
- ? `schedule: ${trigger.schedule}`
165
- : trigger.type === "event"
166
- ? `event: ${trigger.source}`
167
- : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
225
+ const triggerDesc = formatTrigger(trigger, "create");
168
226
  return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
169
227
  `Trigger: ${triggerDesc}\n` +
170
228
  `Recurring: ${entry.recurring}\n` +
@@ -188,12 +246,8 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
188
246
  return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
189
247
  const lines = [];
190
248
  for (const entry of loops) {
191
- const triggerDesc = entry.trigger.type === "cron"
192
- ? `cron: ${entry.trigger.schedule}`
193
- : entry.trigger.type === "event"
194
- ? `event: ${entry.trigger.source}`
195
- : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
196
- const nextFire = entry.trigger.type !== "event" ? getScheduler().nextFire(entry.id) : undefined;
249
+ const triggerDesc = formatTrigger(entry.trigger, "list");
250
+ const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
197
251
  const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
198
252
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
199
253
  line += ` (${triggerDesc})`;
@@ -210,6 +264,37 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
210
264
  return Promise.resolve(textResult(lines.join("\n")));
211
265
  },
212
266
  });
267
+ pi.registerTool({
268
+ name: "LoopUpdate",
269
+ label: "LoopUpdate",
270
+ description: `Update progress for a dynamic loop.
271
+
272
+ Use this after a dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval, "completed" when the goal is done, or "paused" to stop temporarily.`,
273
+ parameters: Type.Object({
274
+ id: Type.String({ description: "Dynamic loop ID to update" }),
275
+ status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
276
+ state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
277
+ metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
278
+ doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
279
+ nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
280
+ prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
281
+ }),
282
+ execute(_toolCallId, params) {
283
+ const store = getStore();
284
+ const triggerSystem = getTriggerSystem();
285
+ const entry = store.get(params.id);
286
+ if (!entry)
287
+ return Promise.resolve(textResult(`Loop #${params.id} not found`));
288
+ if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
289
+ return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
290
+ }
291
+ const message = params.status === "continue"
292
+ ? continueDynamicLoop(params, entry, store, triggerSystem)
293
+ : stopDynamicLoop(params, store, triggerSystem);
294
+ updateWidget();
295
+ return Promise.resolve(textResult(message));
296
+ },
297
+ });
213
298
  pi.registerTool({
214
299
  name: "LoopDelete",
215
300
  label: "LoopDelete",
@@ -224,8 +309,12 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
224
309
  const { id, action } = params;
225
310
  if (action === "pause") {
226
311
  const entry = getStore().pause(id);
227
- if (!entry)
312
+ if (!entry) {
313
+ const tombstone = getStore().getDeletionTombstone(id);
314
+ if (tombstone)
315
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
228
316
  return Promise.resolve(textResult(`Loop #${id} not found`));
317
+ }
229
318
  getTriggerSystem().remove(id);
230
319
  updateWidget();
231
320
  return Promise.resolve(textResult(`Loop #${id} paused`));
@@ -235,6 +324,9 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
235
324
  updateWidget();
236
325
  if (deleted)
237
326
  return Promise.resolve(textResult(`Loop #${id} deleted`));
327
+ const tombstone = getStore().getDeletionTombstone(id);
328
+ if (tombstone)
329
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
238
330
  return Promise.resolve(textResult(`Loop #${id} not found`));
239
331
  },
240
332
  });
@@ -28,7 +28,7 @@ export class TriggerSystem {
28
28
  this.hybridTimers.clear();
29
29
  }
30
30
  add(entry) {
31
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
31
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
32
32
  this.scheduler.add(entry);
33
33
  }
34
34
  if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
package/dist/types.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ export type LoopDeletionReason = "task_backlog_empty";
2
+ export interface LoopDeletionTombstone {
3
+ id: string;
4
+ reason: LoopDeletionReason;
5
+ deletedAt: number;
6
+ prompt: string;
7
+ pendingCount?: number;
8
+ }
9
+ export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
1
10
  export type LoopStatus = "active" | "paused";
2
11
  export interface CronTrigger {
3
12
  type: "cron";
@@ -17,7 +26,20 @@ export interface HybridTrigger {
17
26
  };
18
27
  debounceMs: number;
19
28
  }
20
- export type Trigger = CronTrigger | EventTrigger | HybridTrigger;
29
+ export interface DynamicTrigger {
30
+ type: "dynamic";
31
+ }
32
+ export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
33
+ export interface DynamicLoopState {
34
+ goal: string;
35
+ state?: string;
36
+ metrics?: string;
37
+ doneCriteria?: string;
38
+ iteration: number;
39
+ nextWakeAt?: number;
40
+ awaitingUpdate?: boolean;
41
+ lastUpdatedAt?: number;
42
+ }
21
43
  export interface LoopEntry {
22
44
  id: string;
23
45
  prompt: string;
@@ -32,6 +54,7 @@ export interface LoopEntry {
32
54
  readOnly?: boolean;
33
55
  maxFires?: number;
34
56
  fireCount?: number;
57
+ dynamic?: DynamicLoopState;
35
58
  }
36
59
  export interface LoopStoreData {
37
60
  nextId: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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",
@@ -17,6 +17,13 @@
17
17
  "monitor",
18
18
  "scheduler"
19
19
  ],
20
+ "files": [
21
+ "src/",
22
+ "dist/",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
20
27
  "exports": {
21
28
  ".": "./src/index.ts",
22
29
  "./api": "./src/api.ts",
@@ -30,7 +37,8 @@
30
37
  "typebox": "^1.1.34"
31
38
  },
32
39
  "scripts": {
33
- "build": "tsc",
40
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
41
+ "build": "npm run clean && tsc",
34
42
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
35
43
  "test": "vitest run",
36
44
  "test:coverage": "vitest run --coverage",