@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
package/src/scheduler.ts CHANGED
@@ -8,6 +8,9 @@ function computeNextFire(entry: LoopEntry): Date {
8
8
  if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
9
9
  return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
10
10
  }
11
+ if (entry.trigger.type === "dynamic") {
12
+ return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
13
+ }
11
14
  return new Date(Date.now() + 60000);
12
15
  }
13
16
 
@@ -20,9 +23,19 @@ export class CronScheduler {
20
23
  ) {}
21
24
 
22
25
  start(): void {
23
- for (const entry of this.store.list()) {
26
+ for (const storedEntry of this.store.list()) {
27
+ let entry = storedEntry;
24
28
  if (entry.status !== "active") continue;
25
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
29
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
30
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
31
+ entry = this.store.updateDynamic(entry.id, {
32
+ dynamic: {
33
+ awaitingUpdate: false,
34
+ nextWakeAt: undefined,
35
+ lastUpdatedAt: Date.now(),
36
+ },
37
+ }) ?? entry;
38
+ }
26
39
  this.armTimer(entry);
27
40
  }
28
41
  }
@@ -33,7 +46,7 @@ export class CronScheduler {
33
46
  }
34
47
 
35
48
  add(entry: LoopEntry): void {
36
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
49
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
37
50
  this.armTimer(entry);
38
51
  }
39
52
  }
@@ -47,12 +60,14 @@ export class CronScheduler {
47
60
  }
48
61
 
49
62
  private armTimer(entry: LoopEntry): void {
50
- const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
51
-
52
63
  const nextFire = computeNextFire(entry);
53
- const minuteField = _scheduleExpr.trim().split(/\s+/)[0] ?? "";
54
- const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
55
- const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
64
+ let jitter = 0;
65
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
66
+ const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
67
+ const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
68
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
69
+ jitter = computeJitter(entry.id, entry.recurring, minuteStep);
70
+ }
56
71
  const fireTime = nextFire.getTime() + jitter;
57
72
 
58
73
  if (fireTime > entry.expiresAt) {
@@ -73,6 +88,8 @@ export class CronScheduler {
73
88
  continue;
74
89
  }
75
90
 
91
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) continue;
92
+
76
93
  if (filter && !filter(entry)) continue;
77
94
 
78
95
  if (now >= entry.expiresAt) {
package/src/store.ts CHANGED
@@ -2,12 +2,15 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
4
4
  import { ReducerBackedStore } from "./reducer-backed-store.js";
5
- import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
5
+ import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
6
6
 
7
7
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
8
8
  const MAX_LOOPS = 25;
9
+ const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
9
10
 
10
11
  export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
12
+ private tombstones = new Map<string, LoopDeletionTombstone>();
13
+
11
14
  constructor(listIdOrPath?: string) {
12
15
  super(
13
16
  {
@@ -22,7 +25,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
22
25
  );
23
26
  }
24
27
 
25
- create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
28
+ create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number; dynamic?: Partial<DynamicLoopState> }): LoopEntry {
26
29
  return this.withLock(() => {
27
30
  if (this.entries.size >= MAX_LOOPS) {
28
31
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
@@ -41,6 +44,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
41
44
  taskBacklog: opts.taskBacklog,
42
45
  readOnly: opts.readOnly,
43
46
  maxFires: opts.maxFires,
47
+ dynamic: opts.dynamic,
44
48
  },
45
49
  });
46
50
  return this.entries.get(String(this.nextId - 1))!;
@@ -75,6 +79,22 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
75
79
  entityId: id,
76
80
  payload: { id },
77
81
  });
82
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
83
+ this.applyReducerEvent({
84
+ type: "LOOP_DYNAMIC_UPDATED",
85
+ at: Date.now(),
86
+ source: "tool",
87
+ entityType: "loop",
88
+ entityId: id,
89
+ payload: {
90
+ id,
91
+ dynamic: {
92
+ awaitingUpdate: false,
93
+ lastUpdatedAt: Date.now(),
94
+ },
95
+ },
96
+ });
97
+ }
78
98
  return this.entries.get(id);
79
99
  });
80
100
  }
@@ -119,6 +139,43 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
119
139
  });
120
140
  }
121
141
 
142
+ updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<DynamicLoopState> }): LoopEntry | undefined {
143
+ return this.withLock(() => {
144
+ if (!this.entries.has(id)) return undefined;
145
+ this.applyReducerEvent({
146
+ type: "LOOP_DYNAMIC_UPDATED",
147
+ at: Date.now(),
148
+ source: "tool",
149
+ entityType: "loop",
150
+ entityId: id,
151
+ payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
152
+ });
153
+ return this.entries.get(id);
154
+ });
155
+ }
156
+
157
+ getDeletionTombstone(id: string): LoopDeletionTombstone | undefined {
158
+ const tombstone = this.tombstones.get(id);
159
+ if (!tombstone) return undefined;
160
+ if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS) return tombstone;
161
+ this.tombstones.delete(id);
162
+ return undefined;
163
+ }
164
+
165
+ recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined {
166
+ const entry = this.entries.get(id);
167
+ if (!entry) return undefined;
168
+ const tombstone: LoopDeletionTombstone = {
169
+ id,
170
+ reason: input.reason,
171
+ pendingCount: input.pendingCount,
172
+ deletedAt: Date.now(),
173
+ prompt: entry.prompt,
174
+ };
175
+ this.tombstones.set(id, tombstone);
176
+ return tombstone;
177
+ }
178
+
122
179
  delete(id: string): boolean {
123
180
  return this.withLock(() => {
124
181
  if (!this.entries.has(id)) return false;
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { formatTrigger } from "../loop-format.js";
3
4
  import { parseInterval } from "../loop-parse.js";
4
5
  import type { LoopEntry, Trigger } from "../types.js";
5
6
  import { textResult } from "./tool-result.js";
@@ -15,6 +16,8 @@ interface LoopStoreLike {
15
16
  maxFires?: number;
16
17
  }): LoopEntry;
17
18
  pause(id: string): LoopEntry | undefined;
19
+ updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<NonNullable<LoopEntry["dynamic"]>> }): LoopEntry | undefined;
20
+ getDeletionTombstone(id: string): { reason: string; pendingCount?: number } | undefined;
18
21
  delete(id: string): boolean;
19
22
  }
20
23
 
@@ -82,6 +85,89 @@ function formatRemaining(ms: number): string {
82
85
  return `${Math.round(ms / 3600000)}h`;
83
86
  }
84
87
 
88
+ function parseDelayMs(input: string): number | undefined {
89
+ const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
90
+ if (!match) return undefined;
91
+ const value = Number.parseInt(match[1] ?? "", 10);
92
+ const unit = (match[2] ?? "").toLowerCase();
93
+ const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
94
+ return value * multiplier;
95
+ }
96
+
97
+ interface LoopUpdateParams {
98
+ id: string;
99
+ status: "continue" | "completed" | "paused";
100
+ state?: string;
101
+ metrics?: string;
102
+ doneCriteria?: string;
103
+ nextInterval?: string;
104
+ prompt?: string;
105
+ }
106
+
107
+ function resolveNextWakeAt(nextInterval?: string): { nextWakeAt?: number; error?: string } {
108
+ if (!nextInterval) return { nextWakeAt: undefined };
109
+ const parsedDelayMs = parseDelayMs(nextInterval);
110
+ if (!parsedDelayMs) return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
111
+ return { nextWakeAt: Date.now() + parsedDelayMs };
112
+ }
113
+
114
+ function formatDynamicUpdateResult(id: string, iteration: number | undefined, nextWakeAt: number | undefined): string {
115
+ const mode = nextWakeAt === undefined
116
+ ? "Next wake: when idle"
117
+ : `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
118
+ return `Dynamic loop #${id} updated\n` +
119
+ `Iteration: ${iteration ?? "?"}` +
120
+ `\n${mode}`;
121
+ }
122
+
123
+ function formatDeletionTombstone(id: string, tombstone: { reason: string; pendingCount?: number }): string {
124
+ const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
125
+ return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
126
+ }
127
+
128
+ function continueDynamicLoop(
129
+ params: LoopUpdateParams,
130
+ entry: LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> },
131
+ store: LoopStoreLike,
132
+ triggerSystem: TriggerSystemLike,
133
+ ): string {
134
+ const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
135
+ if (error) return error;
136
+
137
+ const updated = store.updateDynamic(params.id, {
138
+ prompt: params.prompt,
139
+ dynamic: {
140
+ goal: params.prompt ?? entry.dynamic.goal,
141
+ state: params.state,
142
+ metrics: params.metrics,
143
+ doneCriteria: params.doneCriteria,
144
+ iteration: (entry.dynamic.iteration ?? 0) + 1,
145
+ nextWakeAt,
146
+ awaitingUpdate: false,
147
+ lastUpdatedAt: Date.now(),
148
+ },
149
+ });
150
+ if (updated) {
151
+ triggerSystem.remove(params.id);
152
+ triggerSystem.add(updated);
153
+ }
154
+ return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
155
+ }
156
+
157
+ function stopDynamicLoop(
158
+ params: LoopUpdateParams,
159
+ store: LoopStoreLike,
160
+ triggerSystem: TriggerSystemLike,
161
+ ): string {
162
+ triggerSystem.remove(params.id);
163
+ if (params.status === "completed") {
164
+ store.delete(params.id);
165
+ return `Dynamic loop #${params.id} completed and deleted`;
166
+ }
167
+ store.pause(params.id);
168
+ return `Dynamic loop #${params.id} paused`;
169
+ }
170
+
85
171
  export function registerLoopTools(options: LoopToolsOptions): void {
86
172
  const {
87
173
  pi,
@@ -216,11 +302,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
216
302
  const bootstrapped = await maybeBootstrapTaskLoop(entry);
217
303
  updateWidget();
218
304
 
219
- const triggerDesc = trigger.type === "cron"
220
- ? `schedule: ${trigger.schedule}`
221
- : trigger.type === "event"
222
- ? `event: ${trigger.source}`
223
- : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
305
+ const triggerDesc = formatTrigger(trigger, "create");
224
306
 
225
307
  return Promise.resolve(textResult(
226
308
  `Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
@@ -248,13 +330,9 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
248
330
 
249
331
  const lines: string[] = [];
250
332
  for (const entry of loops) {
251
- const triggerDesc = entry.trigger.type === "cron"
252
- ? `cron: ${entry.trigger.schedule}`
253
- : entry.trigger.type === "event"
254
- ? `event: ${entry.trigger.source}`
255
- : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
333
+ const triggerDesc = formatTrigger(entry.trigger, "list");
256
334
 
257
- const nextFire = entry.trigger.type !== "event" ? getScheduler().nextFire(entry.id) : undefined;
335
+ const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
258
336
  const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
259
337
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
260
338
  line += ` (${triggerDesc})`;
@@ -271,6 +349,38 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
271
349
  },
272
350
  });
273
351
 
352
+ pi.registerTool({
353
+ name: "LoopUpdate",
354
+ label: "LoopUpdate",
355
+ description: `Update progress for a dynamic loop.
356
+
357
+ 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.`,
358
+ parameters: Type.Object({
359
+ id: Type.String({ description: "Dynamic loop ID to update" }),
360
+ status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
361
+ state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
362
+ metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
363
+ doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
364
+ nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
365
+ prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
366
+ }),
367
+ execute(_toolCallId, params: LoopUpdateParams) {
368
+ const store = getStore();
369
+ const triggerSystem = getTriggerSystem();
370
+ const entry = store.get(params.id);
371
+ if (!entry) return Promise.resolve(textResult(`Loop #${params.id} not found`));
372
+ if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
373
+ return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
374
+ }
375
+
376
+ const message = params.status === "continue"
377
+ ? continueDynamicLoop(params, entry as LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> }, store, triggerSystem)
378
+ : stopDynamicLoop(params, store, triggerSystem);
379
+ updateWidget();
380
+ return Promise.resolve(textResult(message));
381
+ },
382
+ });
383
+
274
384
  pi.registerTool({
275
385
  name: "LoopDelete",
276
386
  label: "LoopDelete",
@@ -286,7 +396,11 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
286
396
 
287
397
  if (action === "pause") {
288
398
  const entry = getStore().pause(id);
289
- if (!entry) return Promise.resolve(textResult(`Loop #${id} not found`));
399
+ if (!entry) {
400
+ const tombstone = getStore().getDeletionTombstone(id);
401
+ if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
402
+ return Promise.resolve(textResult(`Loop #${id} not found`));
403
+ }
290
404
  getTriggerSystem().remove(id);
291
405
  updateWidget();
292
406
  return Promise.resolve(textResult(`Loop #${id} paused`));
@@ -296,6 +410,8 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
296
410
  const deleted = getStore().delete(id);
297
411
  updateWidget();
298
412
  if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
413
+ const tombstone = getStore().getDeletionTombstone(id);
414
+ if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
299
415
  return Promise.resolve(textResult(`Loop #${id} not found`));
300
416
  },
301
417
  });
@@ -31,7 +31,7 @@ export class TriggerSystem {
31
31
  }
32
32
 
33
33
  add(entry: LoopEntry): void {
34
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
34
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
35
35
  this.scheduler.add(entry);
36
36
  }
37
37
  if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
package/src/types.ts CHANGED
@@ -1,3 +1,15 @@
1
+ export type LoopDeletionReason = "task_backlog_empty";
2
+
3
+ export interface LoopDeletionTombstone {
4
+ id: string;
5
+ reason: LoopDeletionReason;
6
+ deletedAt: number;
7
+ prompt: string;
8
+ pendingCount?: number;
9
+ }
10
+
11
+ export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
12
+
1
13
  export type LoopStatus = "active" | "paused";
2
14
 
3
15
  export interface CronTrigger {
@@ -18,7 +30,22 @@ export interface HybridTrigger {
18
30
  debounceMs: number;
19
31
  }
20
32
 
21
- export type Trigger = CronTrigger | EventTrigger | HybridTrigger;
33
+ export interface DynamicTrigger {
34
+ type: "dynamic";
35
+ }
36
+
37
+ export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
38
+
39
+ export interface DynamicLoopState {
40
+ goal: string;
41
+ state?: string;
42
+ metrics?: string;
43
+ doneCriteria?: string;
44
+ iteration: number;
45
+ nextWakeAt?: number;
46
+ awaitingUpdate?: boolean;
47
+ lastUpdatedAt?: number;
48
+ }
22
49
 
23
50
  export interface LoopEntry {
24
51
  id: string;
@@ -34,6 +61,7 @@ export interface LoopEntry {
34
61
  readOnly?: boolean;
35
62
  maxFires?: number;
36
63
  fireCount?: number;
64
+ dynamic?: DynamicLoopState;
37
65
  }
38
66
 
39
67
  export interface LoopStoreData {
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.6.0"
3
- }
package/AGENTS.md DELETED
@@ -1,123 +0,0 @@
1
- # pi-loop Development Guidelines
2
-
3
- ## Overview
4
- `pi-loop` is a pi extension providing cron/event-based agent re-wake loops and background process monitoring. Modeled after Claude Code's `/loop`, `CronCreate`, and `MonitorCreate` tools.
5
-
6
- ## Stack
7
- - TypeScript 6.x (strict, ES2022 target, bundler module resolution)
8
- - `typebox` for tool parameter validation
9
- - `vitest` for tests
10
- - `biome` for linting (linter: on, formatter: off)
11
- - npm packaging as `@trevonistrevon/pi-loop`
12
-
13
- ## Architecture
14
- ```
15
- src/
16
- ├── index.ts # Extension entry: tool/command registration, session wiring, native task fallback timer
17
- ├── api.ts # Public subpath (@trevonistrevon/pi-loop/api): RPC channels/DTOs, TaskStore, scope resolvers
18
- ├── types.ts # LoopKind, Trigger spec, LoopEntry, MonitorEntry, LoopConfig
19
- ├── task-types.ts # TaskEntry, TaskStatus, TaskStoreData
20
- ├── store.ts # File-backed CRUD (.pi/loops/loops.json) with file locking
21
- ├── task-store.ts # File-backed CRUD for native fallback tasks (.pi/tasks/tasks.json)
22
- ├── reducer-backed-store.ts # Shared atomic-write + pid-lock persistence layer for reducer-driven stores
23
- ├── coordinator.ts # ReducerSource/ReducerEvent/ReducerEffect plumbing shared by *-reducer/*-coordinator modules
24
- ├── loop-reducer.ts # Pure loop state transitions, incl. maxFires/expiry checks
25
- ├── monitor-reducer.ts # Pure monitor state transitions
26
- ├── notification-reducer.ts # Pure pending-notification state transitions
27
- ├── task-reducer.ts # Pure native task state transitions
28
- ├── monitor-completion-coordinator.ts # Reduces monitor "onDone" completion into a fire effect
29
- ├── task-backlog-coordinator.ts # Reduces pending-task counts into auto-create/auto-clean effects
30
- ├── scheduler.ts # Timer-based cron scheduler with jitter + 7-day expiry
31
- ├── trigger-system.ts # Unified trigger engine: cron timers + pi event subscriptions + hybrid
32
- ├── monitor-manager.ts # ChildProcess tracking, output buffering, event emission, stop
33
- ├── loop-parse.ts # Human interval → cron expression, next-fire computation, jitter
34
- ├── rpc/ # VENDORED — canonical copy shared verbatim with pi-orca; see "Cross-extension RPC" below
35
- │ ├── channels.ts # TASKS_RPC/SUBAGENTS_RPC/TASK_EVENTS channel constants + wire DTOs
36
- │ └── cross-extension-rpc.ts # rpcCall (client), rpcProbe (detection), handleRpc (server), PROTOCOL_VERSION, RpcError
37
- ├── runtime/ # Extension-init-time wiring, one module per concern
38
- │ ├── loop-events.ts # Auto-delete payload types for backlog-drained loops
39
- │ ├── monitor-ondone-runtime.ts # Coordinator wiring for MonitorCreate's onDone → loop fire
40
- │ ├── native-task-rpc.ts # tasks:rpc:* server: ping/pending/create/clean/update over the native TaskStore
41
- │ ├── notification-runtime.ts # Pending-notification buffering + idle-driven delivery
42
- │ ├── scope.ts # PI_LOOP_SCOPE → loop/task store path resolution
43
- │ ├── session-runtime.ts # Session-switch hooks, store (re)binding
44
- │ ├── task-backlog-runtime.ts # Auto-create/auto-clean backlog worker evaluation
45
- │ ├── task-events.ts # emitNativeTaskEvent: tasks:created/started/completed/reopened/updated/deleted
46
- │ ├── task-mutations.ts # Shared mutation service: mutate → emit event → widget → backlog, used by RPC server and tools
47
- │ └── task-rpc.ts # Client bridge: probes pi-tasks, falls back to native TaskStore for autoTask/pending/clean
48
- ├── tools/ # Tool implementations
49
- │ ├── loop-tools.ts # LoopCreate/LoopList/LoopDelete
50
- │ ├── monitor-tools.ts # MonitorCreate/MonitorList/MonitorStop
51
- │ ├── native-task-tools.ts # TaskCreate/TaskList/TaskUpdate/TaskDelete (fallback only)
52
- │ └── tool-result.ts # textResult() helper
53
- ├── commands/
54
- │ ├── loop-command.ts # /loop interactive loop creation
55
- │ └── tasks-command.ts # /tasks native task viewer/manager
56
- └── ui/
57
- └── widget.ts # Persistent widget: active loops + monitors + task summary
58
- ```
59
-
60
- ## Cross-extension RPC
61
- `src/rpc/` is a **vendored** module: the canonical copy is maintained here and copied verbatim into the sibling `pi-orca` repo. If you edit `channels.ts` or `cross-extension-rpc.ts`, copy the change to `pi-orca` and bump the `VENDOR_REV` comment at the top of both files in both repos — the two copies must never drift.
62
-
63
- The wire contract is request/reply over `pi.events`: a caller emits `{ requestId, ...params }` on a channel (e.g. `tasks:rpc:create`), the server replies on `<channel>:reply:<requestId>` with an envelope, `{success:true,data}` or `{success:false,error}`. `rpcCall` resolves/rejects that promise, `rpcProbe` swallows the rejection into `undefined` for presence detection, `handleRpc` is the server-side helper that turns a handler function into a channel subscription with automatic envelope wrapping. `PROTOCOL_VERSION` (currently `2`) is returned in ping replies so callers can gate on server capability.
64
-
65
- `pi-loop`'s native tasks RPC server (`src/runtime/native-task-rpc.ts`) registers at extension init — not behind the native-tool-registration fallback timer — so early cross-extension calls never race it. It stands down (silent no-op via `isEnabled`) once an external `pi-tasks` is detected.
66
-
67
- ## Public API surface
68
- External consumers must import `@trevonistrevon/pi-loop/api` (`src/api.ts`), never a deep `src/...` path. The package `exports` map in `package.json` enforces this — deep imports fail to resolve. `src/api.ts` re-exports the RPC channel constants/DTOs, `rpcCall`/`rpcProbe`/`handleRpc`/`RpcError`/`PROTOCOL_VERSION`, `TaskStore`, `TaskEntry`/`TaskStatus`, `resolveLoopStorePath`/`resolveTaskStorePath`, and `NATIVE_TASKS_PROVIDER`. Anything not re-exported there is internal and may change without notice.
69
-
70
- ## Conventions (mirror pi-tasks)
71
- - No comments unless answering "why", never "what"
72
- - `debug(...)` helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
73
- - `textResult(msg)` helper for uniform tool output
74
- - All tool params use `Type.Object()` with description strings
75
- - Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
76
- - Cross-extension communication via `pi.events` with `requestId` + reply channels
77
- - File-backed stores use atomic write (write tmp → rename) + pid-based file locking
78
- - Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
79
- - Tests co-located in `test/`, named `<module>.test.ts`
80
-
81
- ## Tool Schema Discipline
82
- - Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
83
- - Example: `TaskUpdate` uses `id`, not `taskId`.
84
- - 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.
85
- - When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
86
-
87
- ## File Locking Pattern
88
- Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
89
-
90
- ## Trigger Types
91
- Three trigger types, all stored as `LoopEntry.trigger`:
92
- - `{ type: "cron", schedule: "*/5 * * * *" }` — timer-based
93
- - `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
94
- - `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
95
-
96
- All cron/hybrid loops are dynamic: they track their next fire time but only deliver on agent idle (`agent_end`/`turn_start`) rather than wall-clock timers.
97
-
98
- ## Re-wake via In-Memory Pending Notifications
99
- 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.
100
-
101
- All loops are idle-driven. Cron and hybrid loops track their next fire time but only deliver when the agent becomes idle (via `agent_end`/`turn_start`), resetting their timer from the actual delivery point.
102
-
103
- ## Monitor Streaming via PI Events
104
- 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"`.
105
-
106
- ## pi-tasks Integration
107
- `pi-loop` probes for `@tintinweb/pi-tasks` at init via `tasks:rpc:ping` (see "Cross-extension RPC" above) and again on a `tasks:ready` listener for late binding. When an external provider answers, `LoopCreate` with `autoTask: true` calls `tasks:rpc:create` to create a tracked task on fire. When no external provider answers, `pi-loop`'s own native RPC server (`src/runtime/native-task-rpc.ts`) serves all five verbs — `ping`/`pending`/`create`/`clean`/`update` — against the native `TaskStore`, and `autoTask: true` creates a native task directly instead of over RPC.
108
-
109
- ## /loop Self-Paced Mode
110
- When no interval is specified in `/loop prompt`, the loop runs in self-paced mode. The agent receives the prompt, acts on it, and uses `LoopCreate`/`LoopUpdate` to schedule the next iteration. The loop fires once, then the agent decides the next interval dynamically (matching Claude Code's dynamic interval behavior).
111
-
112
- ## Testing
113
- - `vitest` with `describe`/`it` blocks
114
- - In-memory stores for unit tests, `tmpdir` for file-backed tests
115
- - Fake timers (`vi.useFakeTimers`) for scheduler tests
116
- - Mock pi eventbus for monitor-manager tests
117
- - `vitest run` in CI, `vitest` for watch mode
118
-
119
- ## Limits
120
- - Maximum 25 active loops
121
- - Maximum 25 running monitors
122
- - 7-day expiry on recurring loops
123
- - 5-minute default cron interval for self-paced mode
package/CONTRIBUTING.md DELETED
@@ -1,79 +0,0 @@
1
- # Contributing to @trevonistrevon/pi-loop
2
-
3
- Thanks for contributing! This document covers local dev setup, conventions, and workflow.
4
-
5
- ## Getting Started
6
-
7
- ```bash
8
- git clone https://github.com/trvon/pi-loop.git
9
- cd pi-loop
10
- npm install
11
- ```
12
-
13
- ## Scripts
14
-
15
- | Command | Description |
16
- |---|---|
17
- | `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) |
18
- | `npm run lint` | Lint with Biome |
19
- | `npm run lint:fix` | Auto-fix lint issues |
20
- | `npm test` | Run tests (`vitest run`) |
21
- | `npm run test:watch` | Watch mode |
22
- | `npm run test:coverage` | Run tests with coverage (used in CI) |
23
- | `npm run test:e2e` | Run `test/e2e/reminder-injection.sh` (self-skips without a local llama server) |
24
- | `npm run build` | Compile TypeScript |
25
-
26
- ## Architecture
27
-
28
- See `AGENTS.md` for the full architecture overview and directory tree, including `src/rpc/` (vendored cross-extension RPC) and `src/api.ts` (public `@trevonistrevon/pi-loop/api` subpath — the only supported import path for external consumers). Key modules:
29
-
30
- - **`src/index.ts`** — Extension entry: registers tools, `/loop` `/tasks` commands, widget lifecycle, cross-extension RPC server
31
- - **`src/types.ts`** — Core types: `LoopEntry`, `MonitorEntry`, `Trigger` variants
32
- - **`src/store.ts`** — File-backed CRUD with pid-based file locking (atomic write → rename)
33
- - **`src/scheduler.ts`** — Timer-based cron scheduler with per-loop jitter and 7-day expiry
34
- - **`src/trigger-system.ts`** — Unified engine: cron timers + pi event subscriptions + hybrid debounce
35
- - **`src/monitor-manager.ts`** — `ChildProcess` wrapper: stdout/stderr streaming as pi events
36
- - **`src/loop-parse.ts`** — Human interval parsing (`5m` → cron), cron matching, next-fire computation
37
- - **`src/runtime/native-task-rpc.ts`** — `tasks:rpc:*` server for the native fallback task store
38
- - **`src/ui/widget.ts`** — TUI widget rendering active loops + monitors above the editor
39
-
40
- ## Conventions
41
-
42
- - **TypeScript 6.x** strict mode, ES2022 target, bundler module resolution
43
- - **No comments unless answering "why"** — never "what"
44
- - **`debug(...)`** helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
45
- - **`textResult(msg)`** helper for uniform tool output strings
46
- - **Tool params** use `Type.Object()` with description strings from `typebox`
47
- - **File-backed stores** use atomic write (write tmp → rename) + pid-based file locking
48
-
49
- ## Testing
50
-
51
- Tests are co-located in `test/` as `<module>.test.ts`. The suite uses:
52
-
53
- - **vitest** with `describe`/`it`
54
- - In-memory stores for unit tests
55
- - `tmpdir` for file-backed store tests
56
- - `vi.useFakeTimers()` for scheduler tests
57
- - Mocked pi eventbus for monitor-manager tests
58
-
59
- ```bash
60
- npm test # Run once
61
- npm run test:watch # Watch mode
62
- ```
63
-
64
- ### Manual fixtures (not run in CI)
65
-
66
- - `benchmarks/experiment-sim.js` — synthetic monitor fixture: prints periodic `iteration N/M, loss=...` lines and handles `SIGTERM` with a checkpoint message. Used to manually exercise `MonitorCreate` streaming, `onDone`, and stop handling. Run with `node benchmarks/experiment-sim.js`. Not wired into CI by design — it's a fixture, not an assertion.
67
- - `test/e2e/reminder-injection.sh` (`npm run test:e2e`) — drives a real `pi` session against a local llama server to validate loop/reminder injection end to end. Requires a model server reachable at `localhost:2276`; the script checks for it and self-skips (exit 0) when absent. Never runs in CI — there is no llama server in the CI environment.
68
-
69
- ## Pull Request Workflow
70
-
71
- 1. Fork, branch, implement
72
- 2. Ensure `npm run lint`, `npm run typecheck`, and `npm run test:coverage` all pass, then `npm run build`
73
- 3. Open PR against `main`
74
-
75
- CI (`.github/workflows/ci.yml`) runs the same validation in this order on Node 20.x and 22.x: `lint` → `typecheck` → `test:coverage` → `build`.
76
-
77
- ## License
78
-
79
- By contributing, you agree that your contributions will be licensed under the MIT License.
package/Dockerfile DELETED
@@ -1,17 +0,0 @@
1
- FROM node:22-alpine AS base
2
- WORKDIR /app
3
- COPY package.json package-lock.json ./
4
- RUN npm ci
5
- COPY . .
6
-
7
- FROM node:20-alpine AS node20
8
- WORKDIR /app
9
- COPY package.json package-lock.json ./
10
- RUN npm ci
11
- COPY . .
12
-
13
- FROM node:22-alpine AS node22
14
- WORKDIR /app
15
- COPY package.json package-lock.json ./
16
- RUN npm ci
17
- COPY . .