@trevonistrevon/pi-loop 0.5.9 → 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 (152) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +64 -5
  3. package/dist/api.d.ts +11 -0
  4. package/dist/api.js +10 -0
  5. package/dist/commands/loop-command.d.ts +9 -2
  6. package/dist/commands/loop-command.js +81 -51
  7. package/dist/commands/tasks-command.js +3 -3
  8. package/dist/coordinator.d.ts +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +89 -19
  11. package/dist/loop-format.d.ts +3 -0
  12. package/dist/loop-format.js +20 -0
  13. package/dist/loop-parse.d.ts +1 -0
  14. package/dist/loop-parse.js +59 -6
  15. package/dist/loop-reducer.d.ts +13 -1
  16. package/dist/loop-reducer.js +26 -0
  17. package/dist/monitor-manager.js +1 -1
  18. package/dist/notification-reducer.d.ts +2 -0
  19. package/dist/reducer-backed-store.d.ts +1 -1
  20. package/dist/reducer-backed-store.js +1 -1
  21. package/dist/rpc/channels.d.ts +73 -0
  22. package/dist/rpc/channels.js +30 -0
  23. package/dist/rpc/cross-extension-rpc.d.ts +59 -0
  24. package/dist/rpc/cross-extension-rpc.js +117 -0
  25. package/dist/runtime/loop-events.d.ts +26 -0
  26. package/dist/runtime/loop-events.js +26 -0
  27. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  28. package/dist/runtime/native-task-rpc.d.ts +22 -0
  29. package/dist/runtime/native-task-rpc.js +50 -0
  30. package/dist/runtime/notification-runtime.d.ts +2 -1
  31. package/dist/runtime/notification-runtime.js +19 -7
  32. package/dist/runtime/task-backlog-runtime.d.ts +5 -1
  33. package/dist/runtime/task-backlog-runtime.js +14 -7
  34. package/dist/runtime/task-mutations.d.ts +39 -0
  35. package/dist/runtime/task-mutations.js +68 -0
  36. package/dist/runtime/task-rpc.d.ts +4 -0
  37. package/dist/runtime/task-rpc.js +40 -58
  38. package/dist/scheduler.js +25 -7
  39. package/dist/store.d.ts +9 -1
  40. package/dist/store.js +57 -0
  41. package/dist/tools/loop-tools.d.ts +8 -0
  42. package/dist/tools/loop-tools.js +105 -15
  43. package/dist/tools/monitor-tools.js +1 -3
  44. package/dist/tools/native-task-tools.d.ts +2 -6
  45. package/dist/tools/native-task-tools.js +25 -55
  46. package/dist/tools/tool-result.d.ts +8 -0
  47. package/dist/tools/tool-result.js +4 -0
  48. package/dist/trigger-system.js +1 -1
  49. package/dist/types.d.ts +24 -1
  50. package/package.json +15 -2
  51. package/src/api.ts +36 -0
  52. package/src/commands/loop-command.ts +100 -55
  53. package/src/commands/tasks-command.ts +3 -3
  54. package/src/coordinator.ts +1 -1
  55. package/src/index.ts +92 -19
  56. package/src/loop-format.ts +22 -0
  57. package/src/loop-parse.ts +62 -8
  58. package/src/loop-reducer.ts +41 -1
  59. package/src/monitor-manager.ts +1 -1
  60. package/src/notification-reducer.ts +3 -0
  61. package/src/reducer-backed-store.ts +3 -3
  62. package/src/rpc/channels.ts +97 -0
  63. package/src/rpc/cross-extension-rpc.ts +152 -0
  64. package/src/runtime/loop-events.ts +60 -0
  65. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  66. package/src/runtime/native-task-rpc.ts +139 -0
  67. package/src/runtime/notification-runtime.ts +21 -8
  68. package/src/runtime/task-backlog-runtime.ts +25 -7
  69. package/src/runtime/task-mutations.ts +103 -0
  70. package/src/runtime/task-rpc.ts +48 -54
  71. package/src/scheduler.ts +25 -8
  72. package/src/store.ts +59 -2
  73. package/src/tools/loop-tools.ts +129 -16
  74. package/src/tools/monitor-tools.ts +1 -4
  75. package/src/tools/native-task-tools.ts +31 -52
  76. package/src/tools/tool-result.ts +4 -0
  77. package/src/trigger-system.ts +5 -5
  78. package/src/types.ts +29 -1
  79. package/.release-please-manifest.json +0 -3
  80. package/AGENTS.md +0 -83
  81. package/CONTRIBUTING.md +0 -69
  82. package/Dockerfile +0 -17
  83. package/benchmarks/experiment-sim.js +0 -30
  84. package/coverage/base.css +0 -224
  85. package/coverage/block-navigation.js +0 -87
  86. package/coverage/coverage-final.json +0 -33
  87. package/coverage/favicon.png +0 -0
  88. package/coverage/index.html +0 -176
  89. package/coverage/prettify.css +0 -1
  90. package/coverage/prettify.js +0 -2
  91. package/coverage/sort-arrow-sprite.png +0 -0
  92. package/coverage/sorter.js +0 -210
  93. package/coverage/src/commands/index.html +0 -131
  94. package/coverage/src/commands/loop-command.ts.html +0 -634
  95. package/coverage/src/commands/tasks-command.ts.html +0 -490
  96. package/coverage/src/coordinator.ts.html +0 -430
  97. package/coverage/src/goal-coordinator.ts.html +0 -259
  98. package/coverage/src/goal-reducer.ts.html +0 -982
  99. package/coverage/src/goal-store.ts.html +0 -742
  100. package/coverage/src/goal-verifier.ts.html +0 -808
  101. package/coverage/src/index.html +0 -386
  102. package/coverage/src/index.ts.html +0 -1111
  103. package/coverage/src/loop-parse.ts.html +0 -574
  104. package/coverage/src/loop-reducer.ts.html +0 -559
  105. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  106. package/coverage/src/monitor-manager.ts.html +0 -940
  107. package/coverage/src/monitor-reducer.ts.html +0 -583
  108. package/coverage/src/notification-reducer.ts.html +0 -550
  109. package/coverage/src/reducer-backed-store.ts.html +0 -589
  110. package/coverage/src/runtime/index.html +0 -206
  111. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  112. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  113. package/coverage/src/runtime/scope.ts.html +0 -196
  114. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  115. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -574
  116. package/coverage/src/runtime/task-events.ts.html +0 -208
  117. package/coverage/src/runtime/task-rpc.ts.html +0 -541
  118. package/coverage/src/scheduler.ts.html +0 -400
  119. package/coverage/src/store.ts.html +0 -667
  120. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  121. package/coverage/src/task-reducer.ts.html +0 -550
  122. package/coverage/src/task-store.ts.html +0 -526
  123. package/coverage/src/tools/index.html +0 -146
  124. package/coverage/src/tools/loop-tools.ts.html +0 -1000
  125. package/coverage/src/tools/monitor-tools.ts.html +0 -547
  126. package/coverage/src/tools/native-task-tools.ts.html +0 -556
  127. package/coverage/src/trigger-system.ts.html +0 -547
  128. package/coverage/src/ui/index.html +0 -116
  129. package/coverage/src/ui/widget.ts.html +0 -292
  130. package/dist/goal-coordinator.d.ts +0 -22
  131. package/dist/goal-coordinator.js +0 -28
  132. package/dist/goal-reducer.d.ts +0 -98
  133. package/dist/goal-reducer.js +0 -190
  134. package/dist/goal-store.d.ts +0 -22
  135. package/dist/goal-store.js +0 -188
  136. package/dist/goal-types.d.ts +0 -82
  137. package/dist/goal-types.js +0 -1
  138. package/dist/goal-verifier.d.ts +0 -20
  139. package/dist/goal-verifier.js +0 -198
  140. package/docker-compose.yml +0 -12
  141. package/docs/architecture/goal-state-schema.md +0 -505
  142. package/docs/architecture/state-machine-migration.md +0 -546
  143. package/docs/architecture/state-machine-reducer-event-model.md +0 -823
  144. package/docs/architecture/state-machine-test-matrix.md +0 -249
  145. package/docs/architecture/state-machine-transition-map.md +0 -436
  146. package/release-please-config.json +0 -9
  147. package/src/goal-coordinator.ts +0 -58
  148. package/src/goal-reducer.ts +0 -299
  149. package/src/goal-store.ts +0 -219
  150. package/src/goal-types.ts +0 -104
  151. package/src/goal-verifier.ts +0 -241
  152. package/vitest.config.ts +0 -29
@@ -1,7 +1,9 @@
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";
6
+ import { textResult } from "./tool-result.js";
5
7
 
6
8
  interface LoopStoreLike {
7
9
  list(): LoopEntry[];
@@ -14,6 +16,8 @@ interface LoopStoreLike {
14
16
  maxFires?: number;
15
17
  }): LoopEntry;
16
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;
17
21
  delete(id: string): boolean;
18
22
  }
19
23
 
@@ -46,10 +50,6 @@ export interface LoopToolsOptions {
46
50
  isTaskSystemReady: () => boolean;
47
51
  }
48
52
 
49
- function textResult(msg: string) {
50
- return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
51
- }
52
-
53
53
  function validateTrigger(trigger: Trigger): string | null {
54
54
  if (trigger.type === "cron") {
55
55
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -85,6 +85,89 @@ function formatRemaining(ms: number): string {
85
85
  return `${Math.round(ms / 3600000)}h`;
86
86
  }
87
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
+
88
171
  export function registerLoopTools(options: LoopToolsOptions): void {
89
172
  const {
90
173
  pi,
@@ -219,11 +302,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
219
302
  const bootstrapped = await maybeBootstrapTaskLoop(entry);
220
303
  updateWidget();
221
304
 
222
- const triggerDesc = trigger.type === "cron"
223
- ? `schedule: ${trigger.schedule}`
224
- : trigger.type === "event"
225
- ? `event: ${trigger.source}`
226
- : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
305
+ const triggerDesc = formatTrigger(trigger, "create");
227
306
 
228
307
  return Promise.resolve(textResult(
229
308
  `Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
@@ -251,13 +330,9 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
251
330
 
252
331
  const lines: string[] = [];
253
332
  for (const entry of loops) {
254
- const triggerDesc = entry.trigger.type === "cron"
255
- ? `cron: ${entry.trigger.schedule}`
256
- : entry.trigger.type === "event"
257
- ? `event: ${entry.trigger.source}`
258
- : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
333
+ const triggerDesc = formatTrigger(entry.trigger, "list");
259
334
 
260
- 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;
261
336
  const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
262
337
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
263
338
  line += ` (${triggerDesc})`;
@@ -274,6 +349,38 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
274
349
  },
275
350
  });
276
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
+
277
384
  pi.registerTool({
278
385
  name: "LoopDelete",
279
386
  label: "LoopDelete",
@@ -289,7 +396,11 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
289
396
 
290
397
  if (action === "pause") {
291
398
  const entry = getStore().pause(id);
292
- 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
+ }
293
404
  getTriggerSystem().remove(id);
294
405
  updateWidget();
295
406
  return Promise.resolve(textResult(`Loop #${id} paused`));
@@ -299,6 +410,8 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
299
410
  const deleted = getStore().delete(id);
300
411
  updateWidget();
301
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)));
302
415
  return Promise.resolve(textResult(`Loop #${id} not found`));
303
416
  },
304
417
  });
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { LoopEntry, MonitorEntry, Trigger } from "../types.js";
4
+ import { textResult } from "./tool-result.js";
4
5
 
5
6
  interface MonitorManagerLike {
6
7
  list(): MonitorEntry[];
@@ -26,10 +27,6 @@ export interface MonitorToolsOptions {
26
27
  handleMonitorDoneLoop: (doneLoop: LoopEntry, monitorId: string) => void;
27
28
  }
28
29
 
29
- function textResult(msg: string) {
30
- return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
31
- }
32
-
33
30
  function formatRemaining(ms: number): string {
34
31
  if (ms < 60000) return `${Math.round(ms / 1000)}s`;
35
32
  if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
@@ -1,12 +1,17 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { emitNativeTaskEvent } from "../runtime/task-events.js";
3
+ import {
4
+ createTask,
5
+ deleteTask,
6
+ type TaskBacklogResult,
7
+ type TaskMutationContext,
8
+ updateTask,
9
+ } from "../runtime/task-mutations.js";
4
10
  import { TaskStore } from "../task-store.js";
11
+ import type { TaskStatus } from "../task-types.js";
12
+ import { textResult } from "./tool-result.js";
5
13
 
6
- export interface TaskBacklogResult {
7
- created: boolean;
8
- entry?: { id: string };
9
- }
14
+ export type { TaskBacklogResult };
10
15
 
11
16
  export interface NativeTaskToolsOptions {
12
17
  pi: ExtensionAPI;
@@ -15,12 +20,15 @@ export interface NativeTaskToolsOptions {
15
20
  updateWidget: () => void;
16
21
  }
17
22
 
18
- function textResult(msg: string) {
19
- return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
23
+ function backlogSuffix(backlog: TaskBacklogResult): string {
24
+ return backlog.created && backlog.entry
25
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
26
+ : "";
20
27
  }
21
28
 
22
29
  export function registerNativeTaskTools(options: NativeTaskToolsOptions): void {
23
30
  const { pi, taskStore, evaluateTaskBacklog, updateWidget } = options;
31
+ const mutationCtx: TaskMutationContext = { pi, taskStore, evaluateTaskBacklog, updateWidget };
24
32
 
25
33
  pi.registerTool({
26
34
  name: "TaskCreate",
@@ -47,15 +55,11 @@ Fields:
47
55
  description: Type.String({ description: "Detailed description of what needs to be done" }),
48
56
  }),
49
57
  async execute(_toolCallId, params) {
50
- const entry = taskStore.create(params.subject, params.description);
51
- emitNativeTaskEvent(pi, "tasks:created", entry);
52
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
53
- updateWidget();
54
-
55
- const autoLoopMsg = backlog.created && backlog.entry
56
- ? `\nBacklog worker loop #${backlog.entry.id} created`
57
- : "";
58
- return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
58
+ const { entry, backlog } = await createTask(mutationCtx, {
59
+ subject: params.subject,
60
+ description: params.description,
61
+ });
62
+ return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
59
63
  },
60
64
  });
61
65
 
@@ -104,34 +108,15 @@ Parameters: id (required), status, subject, description`,
104
108
  }),
105
109
  async execute(_toolCallId, params) {
106
110
  const { id, status, subject, description } = params;
107
- let entry = taskStore.get(id);
108
- if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
109
-
110
- const previousStatus = entry.status;
111
- if (status === "in_progress") {
112
- entry = taskStore.start(id);
113
- if (entry) emitNativeTaskEvent(pi, "tasks:started", entry, previousStatus);
114
- } else if (status === "completed") {
115
- entry = taskStore.complete(id);
116
- if (entry) emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
117
- } else if (status === "pending") {
118
- entry = taskStore.reopen(id);
119
- if (entry) emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
120
- }
121
-
122
- if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
123
- if (subject !== undefined || description !== undefined) {
124
- entry = taskStore.updateDetails(id, { subject, description });
125
- if (entry) emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
126
- }
127
- if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
128
- updateWidget();
129
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
111
+ const result = await updateTask(mutationCtx, {
112
+ id,
113
+ status: status as TaskStatus | undefined,
114
+ subject,
115
+ description,
116
+ });
117
+ if (!result) return textResult(`Task #${id} not found`);
130
118
  const statusMsg = status ? ` → ${status}` : "";
131
- const autoLoopMsg = backlog.created && backlog.entry
132
- ? `\nBacklog worker loop #${backlog.entry.id} created`
133
- : "";
134
- return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
119
+ return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
135
120
  },
136
121
  });
137
122
 
@@ -143,15 +128,9 @@ Parameters: id (required), status, subject, description`,
143
128
  id: Type.String({ description: "Task ID to delete" }),
144
129
  }),
145
130
  async execute(_toolCallId, params) {
146
- const existing = taskStore.get(params.id);
147
- const deleted = taskStore.delete(params.id);
148
- updateWidget();
149
- if (deleted) {
150
- if (existing) emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
151
- await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
152
- return Promise.resolve(textResult(`Task #${params.id} deleted`));
153
- }
154
- return Promise.resolve(textResult(`Task #${params.id} not found`));
131
+ const result = await deleteTask(mutationCtx, params.id);
132
+ if (!result) return textResult(`Task #${params.id} not found`);
133
+ return textResult(`Task #${params.id} deleted`);
155
134
  },
156
135
  });
157
136
  }
@@ -0,0 +1,4 @@
1
+ /** Plain-text tool result in the shape pi's registerTool expects. */
2
+ export function textResult(msg: string) {
3
+ return { content: [{ type: "text" as const, text: msg }], details: undefined };
4
+ }
@@ -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") {
@@ -58,7 +58,7 @@ export class TriggerSystem {
58
58
  }
59
59
  const subs = this.eventSubscriptions.get(source)!;
60
60
 
61
- const unsub = this.pi.events.on(source as any, (data: any) => {
61
+ const unsub = this.pi.events.on(source, (data: unknown) => {
62
62
  if (entry.trigger.type === "hybrid") {
63
63
  this.handleHybridFire(entry, data);
64
64
  } else {
@@ -71,7 +71,7 @@ export class TriggerSystem {
71
71
  subs.set(entry.id, unsub);
72
72
  }
73
73
 
74
- private handleHybridFire(entry: LoopEntry, _data: any): void {
74
+ private handleHybridFire(entry: LoopEntry, _data: unknown): void {
75
75
  const now = Date.now();
76
76
  const last = this.lastFireTime.get(entry.id) ?? 0;
77
77
  const debounceMs = entry.trigger.type === "hybrid" ? entry.trigger.debounceMs : 0;
@@ -123,7 +123,7 @@ export class TriggerSystem {
123
123
  }
124
124
  }
125
125
 
126
- private matchesFilter(data: any, filter?: string): boolean {
126
+ private matchesFilter(data: unknown, filter?: string): boolean {
127
127
  if (!filter) return true;
128
128
 
129
129
  if (filter.startsWith("regex:")) {
@@ -138,7 +138,7 @@ export class TriggerSystem {
138
138
  try {
139
139
  const parsed = JSON.parse(filter);
140
140
  for (const [key, value] of Object.entries(parsed)) {
141
- const dataValue = data?.[key];
141
+ const dataValue = (data as Record<string, unknown> | undefined)?.[key];
142
142
  if (dataValue === undefined) return false;
143
143
  if (typeof value === "object" && typeof dataValue === "object") {
144
144
  if (JSON.stringify(value) !== JSON.stringify(dataValue)) return false;
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.5.8"
3
- }
package/AGENTS.md DELETED
@@ -1,83 +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: 6 tools + /loop /loops commands + widget
17
- ├── types.ts # LoopKind, Trigger spec, LoopEntry, MonitorEntry, LoopConfig
18
- ├── store.ts # File-backed CRUD (.pi/loops/loops.json) with file locking
19
- ├── scheduler.ts # Timer-based cron scheduler with jitter + 7-day expiry
20
- ├── trigger-system.ts # Unified trigger engine: cron timers + pi event subscriptions + hybrid
21
- ├── monitor-manager.ts # ChildProcess tracking, output buffering, event emission, stop
22
- ├── loop-parse.ts # Human interval → cron expression, next-fire computation, jitter
23
- └── ui/
24
- └── widget.ts # Persistent widget: active loops + monitors
25
- ```
26
-
27
- ## Conventions (mirror pi-tasks)
28
- - No comments unless answering "why", never "what"
29
- - `debug(...)` helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
30
- - `textResult(msg)` helper for uniform tool output
31
- - All tool params use `Type.Object()` with description strings
32
- - Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
33
- - Cross-extension communication via `pi.events` with `requestId` + reply channels
34
- - File-backed stores use atomic write (write tmp → rename) + pid-based file locking
35
- - Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
36
- - Tests co-located in `test/`, named `<module>.test.ts`
37
-
38
- ## Tool Schema Discipline
39
- - Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
40
- - Example: `TaskUpdate` uses `id`, not `taskId`.
41
- - When a tool validation error clearly indicates an immediately recoverable schema mismatch, correct it silently and retry. Do not emit user-facing chatter like "retrying with the correct shape" unless the recovery itself changes the user's understanding.
42
- - When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
43
-
44
- ## File Locking Pattern
45
- Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
46
-
47
- ## Trigger Types
48
- Three trigger types, all stored as `LoopEntry.trigger`:
49
- - `{ type: "cron", schedule: "*/5 * * * *" }` — timer-based
50
- - `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
51
- - `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
52
-
53
- 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.
54
-
55
- ## Re-wake via In-Memory Pending Notifications
56
- 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.
57
-
58
- 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.
59
-
60
- ## Monitor Streaming via PI Events
61
- 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"`.
62
-
63
- ## pi-tasks Integration
64
- When `@tintinweb/pi-tasks` is present, `LoopCreate` with `autoTask: true` fires an RPC to create a task. Communication via `pi.events`:
65
- - `tasks:rpc:ping` on init → detect pi-tasks presence
66
- - `tasks:ready` listener → late-binding detection
67
- - `tasks:rpc:create` → auto-create task when loop fires (if `autoTask: true`)
68
-
69
- ## /loop Self-Paced Mode
70
- 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).
71
-
72
- ## Testing
73
- - `vitest` with `describe`/`it` blocks
74
- - In-memory stores for unit tests, `tmpdir` for file-backed tests
75
- - Fake timers (`vi.useFakeTimers`) for scheduler tests
76
- - Mock pi eventbus for monitor-manager tests
77
- - `vitest run` in CI, `vitest` for watch mode
78
-
79
- ## Limits
80
- - Maximum 25 active loops
81
- - Maximum 25 running monitors
82
- - 7-day expiry on recurring loops
83
- - 5-minute default cron interval for self-paced mode
package/CONTRIBUTING.md DELETED
@@ -1,69 +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 build` | Compile TypeScript |
23
-
24
- ## Architecture
25
-
26
- See `AGENTS.md` for the full architecture overview. Key modules:
27
-
28
- - **`src/index.ts`** — Extension entry: registers 6 tools + `/loop` `/loops` commands + widget lifecycle
29
- - **`src/types.ts`** — Core types: `LoopEntry`, `MonitorEntry`, `Trigger` variants
30
- - **`src/store.ts`** — File-backed CRUD with pid-based file locking (atomic write → rename)
31
- - **`src/scheduler.ts`** — Timer-based cron scheduler with per-loop jitter and 7-day expiry
32
- - **`src/trigger-system.ts`** — Unified engine: cron timers + pi event subscriptions + hybrid debounce
33
- - **`src/monitor-manager.ts`** — `ChildProcess` wrapper: stdout/stderr streaming as pi events
34
- - **`src/loop-parse.ts`** — Human interval parsing (`5m` → cron), cron matching, next-fire computation
35
- - **`src/ui/widget.ts`** — TUI widget rendering active loops + monitors above the editor
36
-
37
- ## Conventions
38
-
39
- - **TypeScript 6.x** strict mode, ES2022 target, bundler module resolution
40
- - **No comments unless answering "why"** — never "what"
41
- - **`debug(...)`** helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
42
- - **`textResult(msg)`** helper for uniform tool output strings
43
- - **Tool params** use `Type.Object()` with description strings from `typebox`
44
- - **File-backed stores** use atomic write (write tmp → rename) + pid-based file locking
45
-
46
- ## Testing
47
-
48
- Tests are co-located in `test/` as `<module>.test.ts`. The suite uses:
49
-
50
- - **vitest** with `describe`/`it`
51
- - In-memory stores for unit tests
52
- - `tmpdir` for file-backed store tests
53
- - `vi.useFakeTimers()` for scheduler tests
54
- - Mocked pi eventbus for monitor-manager tests
55
-
56
- ```bash
57
- npm test # Run once
58
- npm run test:watch # Watch mode
59
- ```
60
-
61
- ## Pull Request Workflow
62
-
63
- 1. Fork, branch, implement
64
- 2. Ensure `npm run typecheck`, `npm run lint`, and `npm test` all pass
65
- 3. Open PR against `main`
66
-
67
- ## License
68
-
69
- 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 . .
@@ -1,30 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Synthetic benchmark — mimics a research experiment with periodic progress output.
4
- * Prints "iteration N/M, metric=0.XX" every 200ms for 30 iterations.
5
- */
6
-
7
- const TOTAL = 30;
8
- const INTERVAL_MS = 200;
9
-
10
- let iteration = 0;
11
-
12
- const timer = setInterval(() => {
13
- iteration++;
14
- const metric = (Math.random() * 0.5 + 0.3).toFixed(4);
15
- console.log(`iteration ${iteration}/${TOTAL}, loss=${metric}, timestamp=${Date.now()}`);
16
-
17
- if (iteration >= TOTAL) {
18
- console.log(`experiment complete: best loss=0.${Math.floor(Math.random() * 1000).toString().padStart(4, "0")}`);
19
- clearInterval(timer);
20
- process.exit(0);
21
- }
22
- }, INTERVAL_MS);
23
-
24
- process.on("SIGTERM", () => {
25
- console.log("experiment interrupted — saving checkpoint");
26
- clearInterval(timer);
27
- process.exit(0);
28
- });
29
-
30
- console.log("experiment starting: total_iterations=30, interval_ms=200");