@trevonistrevon/pi-loop 0.6.0 → 0.6.2

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 (108) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +28 -143
  3. package/dist/commands/loop-command.d.ts +10 -2
  4. package/dist/commands/loop-command.js +84 -51
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +44 -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.d.ts +1 -1
  14. package/dist/monitor-manager.js +13 -4
  15. package/dist/notification-reducer.d.ts +2 -0
  16. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  17. package/dist/runtime/notification-runtime.d.ts +2 -1
  18. package/dist/runtime/notification-runtime.js +19 -7
  19. package/dist/runtime/task-backlog-runtime.d.ts +2 -1
  20. package/dist/runtime/task-backlog-runtime.js +13 -9
  21. package/dist/scheduler.js +25 -7
  22. package/dist/store.d.ts +9 -1
  23. package/dist/store.js +57 -0
  24. package/dist/tools/loop-tools.d.ts +8 -0
  25. package/dist/tools/loop-tools.js +104 -12
  26. package/dist/tools/monitor-tools.js +1 -1
  27. package/dist/trigger-system.js +1 -1
  28. package/dist/types.d.ts +24 -1
  29. package/docs/USAGE_GUIDE.md +185 -0
  30. package/package.json +13 -4
  31. package/src/commands/loop-command.ts +102 -54
  32. package/src/index.ts +46 -16
  33. package/src/loop-format.ts +22 -0
  34. package/src/loop-parse.ts +44 -1
  35. package/src/loop-reducer.ts +41 -1
  36. package/src/monitor-manager.ts +12 -4
  37. package/src/notification-reducer.ts +3 -0
  38. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  39. package/src/runtime/notification-runtime.ts +21 -8
  40. package/src/runtime/task-backlog-runtime.ts +15 -11
  41. package/src/scheduler.ts +25 -8
  42. package/src/store.ts +59 -2
  43. package/src/tools/loop-tools.ts +128 -12
  44. package/src/tools/monitor-tools.ts +1 -1
  45. package/src/trigger-system.ts +1 -1
  46. package/src/types.ts +29 -1
  47. package/.release-please-manifest.json +0 -3
  48. package/AGENTS.md +0 -123
  49. package/CONTRIBUTING.md +0 -79
  50. package/Dockerfile +0 -17
  51. package/benchmarks/experiment-sim.js +0 -30
  52. package/coverage/base.css +0 -224
  53. package/coverage/block-navigation.js +0 -87
  54. package/coverage/coverage-final.json +0 -36
  55. package/coverage/favicon.png +0 -0
  56. package/coverage/index.html +0 -191
  57. package/coverage/prettify.css +0 -1
  58. package/coverage/prettify.js +0 -2
  59. package/coverage/sort-arrow-sprite.png +0 -0
  60. package/coverage/sorter.js +0 -210
  61. package/coverage/src/api.ts.html +0 -193
  62. package/coverage/src/commands/index.html +0 -131
  63. package/coverage/src/commands/loop-command.ts.html +0 -634
  64. package/coverage/src/commands/tasks-command.ts.html +0 -490
  65. package/coverage/src/coordinator.ts.html +0 -430
  66. package/coverage/src/index.html +0 -341
  67. package/coverage/src/index.ts.html +0 -1249
  68. package/coverage/src/loop-parse.ts.html +0 -607
  69. package/coverage/src/loop-reducer.ts.html +0 -559
  70. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  71. package/coverage/src/monitor-manager.ts.html +0 -964
  72. package/coverage/src/monitor-reducer.ts.html +0 -583
  73. package/coverage/src/notification-reducer.ts.html +0 -550
  74. package/coverage/src/reducer-backed-store.ts.html +0 -589
  75. package/coverage/src/rpc/channels.ts.html +0 -376
  76. package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
  77. package/coverage/src/rpc/index.html +0 -131
  78. package/coverage/src/runtime/index.html +0 -251
  79. package/coverage/src/runtime/loop-events.ts.html +0 -265
  80. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  81. package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
  82. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  83. package/coverage/src/runtime/scope.ts.html +0 -196
  84. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  85. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
  86. package/coverage/src/runtime/task-events.ts.html +0 -208
  87. package/coverage/src/runtime/task-mutations.ts.html +0 -394
  88. package/coverage/src/runtime/task-rpc.ts.html +0 -523
  89. package/coverage/src/scheduler.ts.html +0 -400
  90. package/coverage/src/store.ts.html +0 -667
  91. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  92. package/coverage/src/task-reducer.ts.html +0 -550
  93. package/coverage/src/task-store.ts.html +0 -526
  94. package/coverage/src/tools/index.html +0 -161
  95. package/coverage/src/tools/loop-tools.ts.html +0 -991
  96. package/coverage/src/tools/monitor-tools.ts.html +0 -538
  97. package/coverage/src/tools/native-task-tools.ts.html +0 -493
  98. package/coverage/src/tools/tool-result.ts.html +0 -97
  99. package/coverage/src/trigger-system.ts.html +0 -547
  100. package/coverage/src/ui/index.html +0 -116
  101. package/coverage/src/ui/widget.ts.html +0 -292
  102. package/docker-compose.yml +0 -12
  103. package/docs/architecture/state-machine-migration.md +0 -502
  104. package/docs/architecture/state-machine-reducer-event-model.md +0 -822
  105. package/docs/architecture/state-machine-test-matrix.md +0 -249
  106. package/docs/architecture/state-machine-transition-map.md +0 -436
  107. package/release-please-config.json +0 -9
  108. package/vitest.config.ts +0 -29
@@ -8,7 +8,7 @@ import {
8
8
  type TaskBacklogEvent,
9
9
  } from "../task-backlog-coordinator.js";
10
10
  import type { TaskStore } from "../task-store.js";
11
- import type { LoopEntry, Trigger } from "../types.js";
11
+ import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
12
12
  import {
13
13
  buildLoopAutodeletedPayload,
14
14
  buildTaskBacklogEmptyPayload,
@@ -27,6 +27,7 @@ export interface TaskBacklogRuntimeOptions {
27
27
  maxFires?: number;
28
28
  }) => LoopEntry;
29
29
  deleteLoop: (id: string) => void;
30
+ recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
30
31
  addTrigger: (entry: LoopEntry) => void;
31
32
  removeTrigger: (id: string) => void;
32
33
  updateWidget: () => void;
@@ -52,6 +53,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
52
53
  getLoops,
53
54
  createLoop,
54
55
  deleteLoop,
56
+ recordDeletionTombstone,
55
57
  addTrigger,
56
58
  removeTrigger,
57
59
  updateWidget,
@@ -79,6 +81,14 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
79
81
  return getLoops().find(isAutoTaskWorkerLoop);
80
82
  }
81
83
 
84
+ function deleteTaskBacklogLoop(entry: LoopEntry, pendingCount: number): void {
85
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
86
+ removeTrigger(entry.id);
87
+ recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
88
+ deleteLoop(entry.id);
89
+ emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
90
+ }
91
+
82
92
  async function cleanupTaskBacklogLoops(): Promise<number> {
83
93
  const backlogLoops = getLoops().filter(isTaskBacklogLoop);
84
94
  if (backlogLoops.length === 0) return 0;
@@ -86,15 +96,9 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
86
96
  const pending = await hasPendingTasks();
87
97
  if (pending < 0 || pending > 0) return 0;
88
98
 
89
- emitTaskBacklogEmpty?.(
90
- buildTaskBacklogEmptyPayload(backlogLoops.map((entry) => entry.id)),
91
- );
92
- for (const entry of backlogLoops) {
93
- debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
94
- removeTrigger(entry.id);
95
- deleteLoop(entry.id);
96
- emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
97
- }
99
+ const deletedLoopIds = backlogLoops.map((entry) => entry.id);
100
+ emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
101
+ for (const entry of backlogLoops) deleteTaskBacklogLoop(entry, pending);
98
102
  updateWidget();
99
103
  return backlogLoops.length;
100
104
  }
@@ -107,7 +111,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
107
111
 
108
112
  const trigger: Trigger = {
109
113
  type: "hybrid",
110
- cron: "*/5 * * * *",
114
+ cron: "*/3 * * * *",
111
115
  event: { source: "tasks:created" },
112
116
  debounceMs: 30000,
113
117
  };
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
  });
@@ -57,7 +57,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
57
57
 
58
58
  ## onDone — auto-notify on completion
59
59
 
60
- Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
60
+ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits, fails, or times out. The completion wake lets the agent inspect the final status and buffered output.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
61
61
  promptGuidelines: [
62
62
  "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
63
63
  "When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
@@ -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