@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
package/dist/scheduler.js CHANGED
@@ -4,6 +4,9 @@ function computeNextFire(entry) {
4
4
  if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
5
5
  return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
6
6
  }
7
+ if (entry.trigger.type === "dynamic") {
8
+ return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
9
+ }
7
10
  return new Date(Date.now() + 60000);
8
11
  }
9
12
  export class CronScheduler {
@@ -15,10 +18,20 @@ export class CronScheduler {
15
18
  this.onFire = onFire;
16
19
  }
17
20
  start() {
18
- for (const entry of this.store.list()) {
21
+ for (const storedEntry of this.store.list()) {
22
+ let entry = storedEntry;
19
23
  if (entry.status !== "active")
20
24
  continue;
21
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
25
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
26
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
27
+ entry = this.store.updateDynamic(entry.id, {
28
+ dynamic: {
29
+ awaitingUpdate: false,
30
+ nextWakeAt: undefined,
31
+ lastUpdatedAt: Date.now(),
32
+ },
33
+ }) ?? entry;
34
+ }
22
35
  this.armTimer(entry);
23
36
  }
24
37
  }
@@ -27,7 +40,7 @@ export class CronScheduler {
27
40
  this.fireTimes.clear();
28
41
  }
29
42
  add(entry) {
30
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
43
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
31
44
  this.armTimer(entry);
32
45
  }
33
46
  }
@@ -38,11 +51,14 @@ export class CronScheduler {
38
51
  return this.fireTimes.get(id);
39
52
  }
40
53
  armTimer(entry) {
41
- const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
42
54
  const nextFire = computeNextFire(entry);
43
- const minuteField = _scheduleExpr.trim().split(/\s+/)[0];
44
- const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
45
- const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
55
+ let jitter = 0;
56
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
57
+ const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
58
+ const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
59
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
60
+ jitter = computeJitter(entry.id, entry.recurring, minuteStep);
61
+ }
46
62
  const fireTime = nextFire.getTime() + jitter;
47
63
  if (fireTime > entry.expiresAt) {
48
64
  this.store.delete(entry.id);
@@ -59,6 +75,8 @@ export class CronScheduler {
59
75
  this.fireTimes.delete(id);
60
76
  continue;
61
77
  }
78
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate)
79
+ continue;
62
80
  if (filter && !filter(entry))
63
81
  continue;
64
82
  if (now >= entry.expiresAt) {
package/dist/store.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
2
2
  import { ReducerBackedStore } from "./reducer-backed-store.js";
3
- import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
3
+ import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
4
4
  export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
5
+ private tombstones;
5
6
  constructor(listIdOrPath?: string);
6
7
  create(trigger: Trigger, prompt: string, opts: {
7
8
  recurring: boolean;
@@ -9,6 +10,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
9
10
  taskBacklog?: boolean;
10
11
  readOnly?: boolean;
11
12
  maxFires?: number;
13
+ dynamic?: Partial<DynamicLoopState>;
12
14
  }): LoopEntry;
13
15
  pause(id: string): LoopEntry | undefined;
14
16
  resume(id: string): LoopEntry | undefined;
@@ -20,6 +22,12 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
20
22
  entry: LoopEntry | undefined;
21
23
  changedFields: string[];
22
24
  };
25
+ updateDynamic(id: string, fields: {
26
+ prompt?: string;
27
+ dynamic: Partial<DynamicLoopState>;
28
+ }): LoopEntry | undefined;
29
+ getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
30
+ recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
23
31
  delete(id: string): boolean;
24
32
  clearExpired(): number;
25
33
  expireEventLoops(sessionStartedAt: number): number;
package/dist/store.js CHANGED
@@ -4,7 +4,9 @@ import { reduceLoopState } from "./loop-reducer.js";
4
4
  import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
6
6
  const MAX_LOOPS = 25;
7
+ const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
7
8
  export class LoopStore extends ReducerBackedStore {
9
+ tombstones = new Map();
8
10
  constructor(listIdOrPath) {
9
11
  super({
10
12
  baseDir: LOOPS_DIR,
@@ -34,6 +36,7 @@ export class LoopStore extends ReducerBackedStore {
34
36
  taskBacklog: opts.taskBacklog,
35
37
  readOnly: opts.readOnly,
36
38
  maxFires: opts.maxFires,
39
+ dynamic: opts.dynamic,
37
40
  },
38
41
  });
39
42
  return this.entries.get(String(this.nextId - 1));
@@ -68,6 +71,22 @@ export class LoopStore extends ReducerBackedStore {
68
71
  entityId: id,
69
72
  payload: { id },
70
73
  });
74
+ if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
75
+ this.applyReducerEvent({
76
+ type: "LOOP_DYNAMIC_UPDATED",
77
+ at: Date.now(),
78
+ source: "tool",
79
+ entityType: "loop",
80
+ entityId: id,
81
+ payload: {
82
+ id,
83
+ dynamic: {
84
+ awaitingUpdate: false,
85
+ lastUpdatedAt: Date.now(),
86
+ },
87
+ },
88
+ });
89
+ }
71
90
  return this.entries.get(id);
72
91
  });
73
92
  }
@@ -108,6 +127,44 @@ export class LoopStore extends ReducerBackedStore {
108
127
  return { entry: this.entries.get(id), changedFields };
109
128
  });
110
129
  }
130
+ updateDynamic(id, fields) {
131
+ return this.withLock(() => {
132
+ if (!this.entries.has(id))
133
+ return undefined;
134
+ this.applyReducerEvent({
135
+ type: "LOOP_DYNAMIC_UPDATED",
136
+ at: Date.now(),
137
+ source: "tool",
138
+ entityType: "loop",
139
+ entityId: id,
140
+ payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
141
+ });
142
+ return this.entries.get(id);
143
+ });
144
+ }
145
+ getDeletionTombstone(id) {
146
+ const tombstone = this.tombstones.get(id);
147
+ if (!tombstone)
148
+ return undefined;
149
+ if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS)
150
+ return tombstone;
151
+ this.tombstones.delete(id);
152
+ return undefined;
153
+ }
154
+ recordDeletionTombstone(id, input) {
155
+ const entry = this.entries.get(id);
156
+ if (!entry)
157
+ return undefined;
158
+ const tombstone = {
159
+ id,
160
+ reason: input.reason,
161
+ pendingCount: input.pendingCount,
162
+ deletedAt: Date.now(),
163
+ prompt: entry.prompt,
164
+ };
165
+ this.tombstones.set(id, tombstone);
166
+ return tombstone;
167
+ }
111
168
  delete(id) {
112
169
  return this.withLock(() => {
113
170
  if (!this.entries.has(id))
@@ -11,6 +11,14 @@ interface LoopStoreLike {
11
11
  maxFires?: number;
12
12
  }): LoopEntry;
13
13
  pause(id: string): LoopEntry | undefined;
14
+ updateDynamic(id: string, fields: {
15
+ prompt?: string;
16
+ dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
17
+ }): LoopEntry | undefined;
18
+ getDeletionTombstone(id: string): {
19
+ reason: string;
20
+ pendingCount?: number;
21
+ } | undefined;
14
22
  delete(id: string): boolean;
15
23
  }
16
24
  interface TriggerSystemLike {
@@ -1,8 +1,7 @@
1
1
  import { Type } from "typebox";
2
+ import { formatTrigger } from "../loop-format.js";
2
3
  import { parseInterval } from "../loop-parse.js";
3
- function textResult(msg) {
4
- return { content: [{ type: "text", text: msg }], details: undefined };
5
- }
4
+ import { textResult } from "./tool-result.js";
6
5
  function validateTrigger(trigger) {
7
6
  if (trigger.type === "cron") {
8
7
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -42,6 +41,67 @@ function formatRemaining(ms) {
42
41
  return `${Math.round(ms / 60000)}m`;
43
42
  return `${Math.round(ms / 3600000)}h`;
44
43
  }
44
+ function parseDelayMs(input) {
45
+ const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
46
+ if (!match)
47
+ return undefined;
48
+ const value = Number.parseInt(match[1] ?? "", 10);
49
+ const unit = (match[2] ?? "").toLowerCase();
50
+ const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
51
+ return value * multiplier;
52
+ }
53
+ function resolveNextWakeAt(nextInterval) {
54
+ if (!nextInterval)
55
+ return { nextWakeAt: undefined };
56
+ const parsedDelayMs = parseDelayMs(nextInterval);
57
+ if (!parsedDelayMs)
58
+ return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
59
+ return { nextWakeAt: Date.now() + parsedDelayMs };
60
+ }
61
+ function formatDynamicUpdateResult(id, iteration, nextWakeAt) {
62
+ const mode = nextWakeAt === undefined
63
+ ? "Next wake: when idle"
64
+ : `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
65
+ return `Dynamic loop #${id} updated\n` +
66
+ `Iteration: ${iteration ?? "?"}` +
67
+ `\n${mode}`;
68
+ }
69
+ function formatDeletionTombstone(id, tombstone) {
70
+ const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
71
+ return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
72
+ }
73
+ function continueDynamicLoop(params, entry, store, triggerSystem) {
74
+ const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
75
+ if (error)
76
+ return error;
77
+ const updated = store.updateDynamic(params.id, {
78
+ prompt: params.prompt,
79
+ dynamic: {
80
+ goal: params.prompt ?? entry.dynamic.goal,
81
+ state: params.state,
82
+ metrics: params.metrics,
83
+ doneCriteria: params.doneCriteria,
84
+ iteration: (entry.dynamic.iteration ?? 0) + 1,
85
+ nextWakeAt,
86
+ awaitingUpdate: false,
87
+ lastUpdatedAt: Date.now(),
88
+ },
89
+ });
90
+ if (updated) {
91
+ triggerSystem.remove(params.id);
92
+ triggerSystem.add(updated);
93
+ }
94
+ return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
95
+ }
96
+ function stopDynamicLoop(params, store, triggerSystem) {
97
+ triggerSystem.remove(params.id);
98
+ if (params.status === "completed") {
99
+ store.delete(params.id);
100
+ return `Dynamic loop #${params.id} completed and deleted`;
101
+ }
102
+ store.pause(params.id);
103
+ return `Dynamic loop #${params.id} paused`;
104
+ }
45
105
  export function registerLoopTools(options) {
46
106
  const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, } = options;
47
107
  pi.registerTool({
@@ -162,11 +222,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
162
222
  }
163
223
  const bootstrapped = await maybeBootstrapTaskLoop(entry);
164
224
  updateWidget();
165
- const triggerDesc = trigger.type === "cron"
166
- ? `schedule: ${trigger.schedule}`
167
- : trigger.type === "event"
168
- ? `event: ${trigger.source}`
169
- : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
225
+ const triggerDesc = formatTrigger(trigger, "create");
170
226
  return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
171
227
  `Trigger: ${triggerDesc}\n` +
172
228
  `Recurring: ${entry.recurring}\n` +
@@ -190,12 +246,8 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
190
246
  return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
191
247
  const lines = [];
192
248
  for (const entry of loops) {
193
- const triggerDesc = entry.trigger.type === "cron"
194
- ? `cron: ${entry.trigger.schedule}`
195
- : entry.trigger.type === "event"
196
- ? `event: ${entry.trigger.source}`
197
- : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
198
- const nextFire = entry.trigger.type !== "event" ? getScheduler().nextFire(entry.id) : undefined;
249
+ const triggerDesc = formatTrigger(entry.trigger, "list");
250
+ const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
199
251
  const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
200
252
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
201
253
  line += ` (${triggerDesc})`;
@@ -212,6 +264,37 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
212
264
  return Promise.resolve(textResult(lines.join("\n")));
213
265
  },
214
266
  });
267
+ pi.registerTool({
268
+ name: "LoopUpdate",
269
+ label: "LoopUpdate",
270
+ description: `Update progress for a dynamic loop.
271
+
272
+ Use this after a dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval, "completed" when the goal is done, or "paused" to stop temporarily.`,
273
+ parameters: Type.Object({
274
+ id: Type.String({ description: "Dynamic loop ID to update" }),
275
+ status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
276
+ state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
277
+ metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
278
+ doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
279
+ nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
280
+ prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
281
+ }),
282
+ execute(_toolCallId, params) {
283
+ const store = getStore();
284
+ const triggerSystem = getTriggerSystem();
285
+ const entry = store.get(params.id);
286
+ if (!entry)
287
+ return Promise.resolve(textResult(`Loop #${params.id} not found`));
288
+ if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
289
+ return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
290
+ }
291
+ const message = params.status === "continue"
292
+ ? continueDynamicLoop(params, entry, store, triggerSystem)
293
+ : stopDynamicLoop(params, store, triggerSystem);
294
+ updateWidget();
295
+ return Promise.resolve(textResult(message));
296
+ },
297
+ });
215
298
  pi.registerTool({
216
299
  name: "LoopDelete",
217
300
  label: "LoopDelete",
@@ -226,8 +309,12 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
226
309
  const { id, action } = params;
227
310
  if (action === "pause") {
228
311
  const entry = getStore().pause(id);
229
- if (!entry)
312
+ if (!entry) {
313
+ const tombstone = getStore().getDeletionTombstone(id);
314
+ if (tombstone)
315
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
230
316
  return Promise.resolve(textResult(`Loop #${id} not found`));
317
+ }
231
318
  getTriggerSystem().remove(id);
232
319
  updateWidget();
233
320
  return Promise.resolve(textResult(`Loop #${id} paused`));
@@ -237,6 +324,9 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
237
324
  updateWidget();
238
325
  if (deleted)
239
326
  return Promise.resolve(textResult(`Loop #${id} deleted`));
327
+ const tombstone = getStore().getDeletionTombstone(id);
328
+ if (tombstone)
329
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
240
330
  return Promise.resolve(textResult(`Loop #${id} not found`));
241
331
  },
242
332
  });
@@ -1,7 +1,5 @@
1
1
  import { Type } from "typebox";
2
- function textResult(msg) {
3
- return { content: [{ type: "text", text: msg }], details: undefined };
4
- }
2
+ import { textResult } from "./tool-result.js";
5
3
  function formatRemaining(ms) {
6
4
  if (ms < 60000)
7
5
  return `${Math.round(ms / 1000)}s`;
@@ -1,11 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { type TaskBacklogResult } from "../runtime/task-mutations.js";
2
3
  import { TaskStore } from "../task-store.js";
3
- export interface TaskBacklogResult {
4
- created: boolean;
5
- entry?: {
6
- id: string;
7
- };
8
- }
4
+ export type { TaskBacklogResult };
9
5
  export interface NativeTaskToolsOptions {
10
6
  pi: ExtensionAPI;
11
7
  taskStore: TaskStore;
@@ -1,10 +1,14 @@
1
1
  import { Type } from "typebox";
2
- import { emitNativeTaskEvent } from "../runtime/task-events.js";
3
- function textResult(msg) {
4
- return { content: [{ type: "text", text: msg }], details: undefined };
2
+ import { createTask, deleteTask, updateTask, } from "../runtime/task-mutations.js";
3
+ import { textResult } from "./tool-result.js";
4
+ function backlogSuffix(backlog) {
5
+ return backlog.created && backlog.entry
6
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
7
+ : "";
5
8
  }
6
9
  export function registerNativeTaskTools(options) {
7
10
  const { pi, taskStore, evaluateTaskBacklog, updateWidget } = options;
11
+ const mutationCtx = { pi, taskStore, evaluateTaskBacklog, updateWidget };
8
12
  pi.registerTool({
9
13
  name: "TaskCreate",
10
14
  label: "TaskCreate",
@@ -30,14 +34,11 @@ Fields:
30
34
  description: Type.String({ description: "Detailed description of what needs to be done" }),
31
35
  }),
32
36
  async execute(_toolCallId, params) {
33
- const entry = taskStore.create(params.subject, params.description);
34
- emitNativeTaskEvent(pi, "tasks:created", entry);
35
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
36
- updateWidget();
37
- const autoLoopMsg = backlog.created && backlog.entry
38
- ? `\nBacklog worker loop #${backlog.entry.id} created`
39
- : "";
40
- return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
37
+ const { entry, backlog } = await createTask(mutationCtx, {
38
+ subject: params.subject,
39
+ description: params.description,
40
+ });
41
+ return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
41
42
  },
42
43
  });
43
44
  pi.registerTool({
@@ -84,41 +85,16 @@ Parameters: id (required), status, subject, description`,
84
85
  }),
85
86
  async execute(_toolCallId, params) {
86
87
  const { id, status, subject, description } = params;
87
- let entry = taskStore.get(id);
88
- if (!entry)
89
- return Promise.resolve(textResult(`Task #${id} not found`));
90
- const previousStatus = entry.status;
91
- if (status === "in_progress") {
92
- entry = taskStore.start(id);
93
- if (entry)
94
- emitNativeTaskEvent(pi, "tasks:started", entry, previousStatus);
95
- }
96
- else if (status === "completed") {
97
- entry = taskStore.complete(id);
98
- if (entry)
99
- emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
100
- }
101
- else if (status === "pending") {
102
- entry = taskStore.reopen(id);
103
- if (entry)
104
- emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
105
- }
106
- if (!entry)
107
- return Promise.resolve(textResult(`Task #${id} not found`));
108
- if (subject !== undefined || description !== undefined) {
109
- entry = taskStore.updateDetails(id, { subject, description });
110
- if (entry)
111
- emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
112
- }
113
- if (!entry)
114
- return Promise.resolve(textResult(`Task #${id} not found`));
115
- updateWidget();
116
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
88
+ const result = await updateTask(mutationCtx, {
89
+ id,
90
+ status: status,
91
+ subject,
92
+ description,
93
+ });
94
+ if (!result)
95
+ return textResult(`Task #${id} not found`);
117
96
  const statusMsg = status ? ` → ${status}` : "";
118
- const autoLoopMsg = backlog.created && backlog.entry
119
- ? `\nBacklog worker loop #${backlog.entry.id} created`
120
- : "";
121
- return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
97
+ return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
122
98
  },
123
99
  });
124
100
  pi.registerTool({
@@ -129,16 +105,10 @@ Parameters: id (required), status, subject, description`,
129
105
  id: Type.String({ description: "Task ID to delete" }),
130
106
  }),
131
107
  async execute(_toolCallId, params) {
132
- const existing = taskStore.get(params.id);
133
- const deleted = taskStore.delete(params.id);
134
- updateWidget();
135
- if (deleted) {
136
- if (existing)
137
- emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
138
- await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
139
- return Promise.resolve(textResult(`Task #${params.id} deleted`));
140
- }
141
- return Promise.resolve(textResult(`Task #${params.id} not found`));
108
+ const result = await deleteTask(mutationCtx, params.id);
109
+ if (!result)
110
+ return textResult(`Task #${params.id} not found`);
111
+ return textResult(`Task #${params.id} deleted`);
142
112
  },
143
113
  });
144
114
  }
@@ -0,0 +1,8 @@
1
+ /** Plain-text tool result in the shape pi's registerTool expects. */
2
+ export declare function textResult(msg: string): {
3
+ content: {
4
+ type: "text";
5
+ text: string;
6
+ }[];
7
+ details: undefined;
8
+ };
@@ -0,0 +1,4 @@
1
+ /** Plain-text tool result in the shape pi's registerTool expects. */
2
+ export function textResult(msg) {
3
+ return { content: [{ type: "text", text: msg }], details: undefined };
4
+ }
@@ -28,7 +28,7 @@ export class TriggerSystem {
28
28
  this.hybridTimers.clear();
29
29
  }
30
30
  add(entry) {
31
- if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
31
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
32
32
  this.scheduler.add(entry);
33
33
  }
34
34
  if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
package/dist/types.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ export type LoopDeletionReason = "task_backlog_empty";
2
+ export interface LoopDeletionTombstone {
3
+ id: string;
4
+ reason: LoopDeletionReason;
5
+ deletedAt: number;
6
+ prompt: string;
7
+ pendingCount?: number;
8
+ }
9
+ export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
1
10
  export type LoopStatus = "active" | "paused";
2
11
  export interface CronTrigger {
3
12
  type: "cron";
@@ -17,7 +26,20 @@ export interface HybridTrigger {
17
26
  };
18
27
  debounceMs: number;
19
28
  }
20
- export type Trigger = CronTrigger | EventTrigger | HybridTrigger;
29
+ export interface DynamicTrigger {
30
+ type: "dynamic";
31
+ }
32
+ export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
33
+ export interface DynamicLoopState {
34
+ goal: string;
35
+ state?: string;
36
+ metrics?: string;
37
+ doneCriteria?: string;
38
+ iteration: number;
39
+ nextWakeAt?: number;
40
+ awaitingUpdate?: boolean;
41
+ lastUpdatedAt?: number;
42
+ }
21
43
  export interface LoopEntry {
22
44
  id: string;
23
45
  prompt: string;
@@ -32,6 +54,7 @@ export interface LoopEntry {
32
54
  readOnly?: boolean;
33
55
  maxFires?: number;
34
56
  fireCount?: number;
57
+ dynamic?: DynamicLoopState;
35
58
  }
36
59
  export interface LoopStoreData {
37
60
  nextId: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.9",
3
+ "version": "0.6.1",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
@@ -17,6 +17,18 @@
17
17
  "monitor",
18
18
  "scheduler"
19
19
  ],
20
+ "files": [
21
+ "src/",
22
+ "dist/",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/index.ts",
29
+ "./api": "./src/api.ts",
30
+ "./package.json": "./package.json"
31
+ },
20
32
  "peerDependencies": {
21
33
  "@earendil-works/pi-coding-agent": "^0.75.5",
22
34
  "@earendil-works/pi-tui": "^0.75.5"
@@ -25,7 +37,8 @@
25
37
  "typebox": "^1.1.34"
26
38
  },
27
39
  "scripts": {
28
- "build": "tsc",
40
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
41
+ "build": "npm run clean && tsc",
29
42
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
30
43
  "test": "vitest run",
31
44
  "test:coverage": "vitest run --coverage",
package/src/api.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Public API surface for sibling extensions (imported as
3
+ * `@trevonistrevon/pi-loop/api`). Everything else under src/ is internal —
4
+ * the package `exports` map deliberately blocks deep imports.
5
+ */
6
+ export {
7
+ type CleanReply,
8
+ type CreateTaskParams,
9
+ type CreateTaskReply,
10
+ type PendingReply,
11
+ type PingReply,
12
+ replyChannel,
13
+ type SpawnParams,
14
+ type SpawnReply,
15
+ SUBAGENTS_RPC,
16
+ TASK_EVENTS,
17
+ TASKS_RPC,
18
+ type TaskEntryWire,
19
+ type TaskStatusWire,
20
+ type UpdateTaskParams,
21
+ type UpdateTaskReply,
22
+ } from "./rpc/channels.js";
23
+ export {
24
+ type HandleRpcOptions,
25
+ handleRpc,
26
+ PROTOCOL_VERSION,
27
+ RpcError,
28
+ type RpcEventBus,
29
+ type RpcReply,
30
+ rpcCall,
31
+ rpcProbe,
32
+ } from "./rpc/cross-extension-rpc.js";
33
+ export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
34
+ export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
35
+ export { TaskStore } from "./task-store.js";
36
+ export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";