@trevonistrevon/pi-loop 0.5.4 → 0.5.6

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 (101) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1063 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +934 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/goal-reducer.d.ts +9 -0
  47. package/dist/goal-reducer.js +11 -2
  48. package/dist/goal-store.d.ts +4 -15
  49. package/dist/goal-store.js +32 -155
  50. package/dist/index.js +5 -1
  51. package/dist/loop-reducer.d.ts +7 -0
  52. package/dist/loop-reducer.js +9 -0
  53. package/dist/monitor-manager.d.ts +9 -0
  54. package/dist/monitor-manager.js +33 -12
  55. package/dist/reducer-backed-store.d.ts +65 -0
  56. package/dist/reducer-backed-store.js +160 -0
  57. package/dist/store.d.ts +4 -16
  58. package/dist/store.js +25 -157
  59. package/dist/task-reducer.js +3 -0
  60. package/dist/task-store.d.ts +4 -15
  61. package/dist/task-store.js +25 -148
  62. package/dist/tools/monitor-tools.js +9 -0
  63. package/dist/trigger-system.js +2 -1
  64. package/package.json +4 -1
  65. package/src/goal-reducer.ts +20 -1
  66. package/src/goal-store.ts +35 -143
  67. package/src/index.ts +5 -1
  68. package/src/loop-reducer.ts +10 -0
  69. package/src/monitor-manager.ts +35 -12
  70. package/src/reducer-backed-store.ts +168 -0
  71. package/src/store.ts +28 -142
  72. package/src/task-reducer.ts +3 -0
  73. package/src/task-store.ts +28 -136
  74. package/src/tools/monitor-tools.ts +9 -0
  75. package/src/trigger-system.ts +2 -1
  76. package/vitest.config.ts +25 -0
  77. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
  78. package/tmp/perf-cap-data/loops.json +0 -455
  79. package/tmp/perf-cap-data/tasks.json +0 -1
  80. package/tmp/perf-data/loops-0.json +0 -1
  81. package/tmp/perf-data/loops-10.json +0 -1
  82. package/tmp/perf-data/loops-100.json +0 -1
  83. package/tmp/perf-data/loops-1000.json +0 -1
  84. package/tmp/perf-data/loops-500.json +0 -1
  85. package/tmp/perf-data/tasks-0.json +0 -1
  86. package/tmp/perf-data/tasks-10.json +0 -1
  87. package/tmp/perf-data/tasks-100.json +0 -1
  88. package/tmp/perf-data/tasks-1000.json +0 -1
  89. package/tmp/perf-data/tasks-500.json +0 -1
  90. package/tmp/perf-data-session/loops-0.json +0 -4
  91. package/tmp/perf-data-session/loops-100.json +0 -1805
  92. package/tmp/perf-data-session/loops-1000.json +0 -18005
  93. package/tmp/perf-data-session/loops-25.json +0 -455
  94. package/tmp/perf-data-session/loops-500.json +0 -9005
  95. package/tmp/perf-data-session/tasks-0.json +0 -1
  96. package/tmp/perf-data-session/tasks-100.json +0 -1
  97. package/tmp/perf-data-session/tasks-1000.json +0 -1
  98. package/tmp/perf-data-session/tasks-25.json +0 -1
  99. package/tmp/perf-data-session/tasks-500.json +0 -1
  100. package/tmp/perf-session-init.js +0 -98
  101. package/tmp/perf-startup-audit.js +0 -80
package/dist/store.d.ts CHANGED
@@ -1,17 +1,8 @@
1
- import type { LoopEntry, Trigger } from "./types.js";
2
- export declare class LoopStore {
3
- private filePath;
4
- private lockPath;
5
- private lastLoadedSignature;
6
- private nextId;
7
- private loops;
1
+ import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
2
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
3
+ import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
4
+ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
8
5
  constructor(listIdOrPath?: string);
9
- private getFileSignature;
10
- private load;
11
- private save;
12
- private withLock;
13
- private toReducerState;
14
- private applyReducerEvent;
15
6
  create(trigger: Trigger, prompt: string, opts: {
16
7
  recurring: boolean;
17
8
  autoTask?: boolean;
@@ -19,8 +10,6 @@ export declare class LoopStore {
19
10
  readOnly?: boolean;
20
11
  maxFires?: number;
21
12
  }): LoopEntry;
22
- get(id: string): LoopEntry | undefined;
23
- list(): LoopEntry[];
24
13
  pause(id: string): LoopEntry | undefined;
25
14
  resume(id: string): LoopEntry | undefined;
26
15
  fire(id: string): LoopEntry | undefined;
@@ -35,5 +24,4 @@ export declare class LoopStore {
35
24
  clearExpired(): number;
36
25
  expireEventLoops(sessionStartedAt: number): number;
37
26
  clearAll(): number;
38
- deleteFileIfEmpty(): boolean;
39
27
  }
package/dist/store.js CHANGED
@@ -1,136 +1,23 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
4
3
  import { reduceLoopState } from "./loop-reducer.js";
4
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
6
- const LOCK_RETRY_MS = 50;
7
- const LOCK_MAX_RETRIES = 100;
8
6
  const MAX_LOOPS = 25;
9
- function acquireLock(lockPath) {
10
- mkdirSync(dirname(lockPath), { recursive: true });
11
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
12
- try {
13
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
14
- return;
15
- }
16
- catch (e) {
17
- if (e.code === "EEXIST") {
18
- try {
19
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
20
- if (!pid || !isProcessRunning(pid)) {
21
- try {
22
- unlinkSync(lockPath);
23
- }
24
- catch { /* ignore */ }
25
- continue;
26
- }
27
- }
28
- catch { /* ignore read errors */ }
29
- const start = Date.now();
30
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
31
- continue;
32
- }
33
- throw e;
34
- }
35
- }
36
- throw new Error(`Failed to acquire lock: ${lockPath}`);
37
- }
38
- function releaseLock(lockPath) {
39
- try {
40
- unlinkSync(lockPath);
41
- }
42
- catch { /* ignore */ }
43
- }
44
- function isProcessRunning(pid) {
45
- try {
46
- process.kill(pid, 0);
47
- return true;
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
- export class LoopStore {
54
- filePath;
55
- lockPath;
56
- lastLoadedSignature;
57
- nextId = 1;
58
- loops = new Map();
7
+ export class LoopStore extends ReducerBackedStore {
59
8
  constructor(listIdOrPath) {
60
- if (!listIdOrPath)
61
- return;
62
- const isAbsPath = isAbsolute(listIdOrPath);
63
- const filePath = isAbsPath ? listIdOrPath : join(LOOPS_DIR, `${listIdOrPath}.json`);
64
- mkdirSync(dirname(filePath), { recursive: true });
65
- this.filePath = filePath;
66
- this.lockPath = filePath + ".lock";
67
- this.load();
68
- }
69
- getFileSignature() {
70
- if (!this.filePath || !existsSync(this.filePath))
71
- return undefined;
72
- const stat = statSync(this.filePath);
73
- return `${stat.mtimeMs}:${stat.size}`;
74
- }
75
- load(force = false) {
76
- if (!this.filePath)
77
- return;
78
- const signature = this.getFileSignature();
79
- if (!signature)
80
- return;
81
- if (!force && signature === this.lastLoadedSignature)
82
- return;
83
- try {
84
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
85
- this.nextId = data.nextId;
86
- this.loops.clear();
87
- for (const loop of data.loops) {
88
- this.loops.set(loop.id, loop);
89
- }
90
- this.lastLoadedSignature = signature;
91
- }
92
- catch { /* corrupt file — start fresh */ }
93
- }
94
- save() {
95
- if (!this.filePath)
96
- return;
97
- const data = {
98
- nextId: this.nextId,
99
- loops: Array.from(this.loops.values()),
100
- };
101
- const tmpPath = this.filePath + ".tmp";
102
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
103
- renameSync(tmpPath, this.filePath);
104
- this.lastLoadedSignature = this.getFileSignature();
105
- }
106
- withLock(fn) {
107
- if (!this.lockPath)
108
- return fn();
109
- acquireLock(this.lockPath);
110
- try {
111
- this.load(true);
112
- const result = fn();
113
- this.save();
114
- return result;
115
- }
116
- finally {
117
- releaseLock(this.lockPath);
118
- }
119
- }
120
- toReducerState() {
121
- return {
122
- nextId: this.nextId,
123
- loopsById: Object.fromEntries(this.loops.entries()),
124
- };
125
- }
126
- applyReducerEvent(event) {
127
- const result = reduceLoopState(this.toReducerState(), event);
128
- this.nextId = result.state.nextId;
129
- this.loops = new Map(Object.entries(result.state.loopsById));
9
+ super({
10
+ baseDir: LOOPS_DIR,
11
+ reduce: (state, event) => reduceLoopState(state, event),
12
+ toReducerState: (nextId, entries) => ({ nextId, loopsById: Object.fromEntries(entries.entries()) }),
13
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.loopsById)) }),
14
+ serialize: (nextId, entries) => ({ nextId, loops: Array.from(entries.values()) }),
15
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.loops.map((l) => [l.id, l])) }),
16
+ }, listIdOrPath);
130
17
  }
131
18
  create(trigger, prompt, opts) {
132
19
  return this.withLock(() => {
133
- if (this.loops.size >= MAX_LOOPS) {
20
+ if (this.entries.size >= MAX_LOOPS) {
134
21
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
135
22
  }
136
23
  const now = Date.now();
@@ -149,22 +36,12 @@ export class LoopStore {
149
36
  maxFires: opts.maxFires,
150
37
  },
151
38
  });
152
- return this.loops.get(String(this.nextId - 1));
39
+ return this.entries.get(String(this.nextId - 1));
153
40
  });
154
41
  }
155
- get(id) {
156
- if (this.filePath)
157
- this.load();
158
- return this.loops.get(id);
159
- }
160
- list() {
161
- if (this.filePath)
162
- this.load();
163
- return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
164
- }
165
42
  pause(id) {
166
43
  return this.withLock(() => {
167
- const entry = this.loops.get(id);
44
+ const entry = this.entries.get(id);
168
45
  if (!entry)
169
46
  return undefined;
170
47
  this.applyReducerEvent({
@@ -175,12 +52,12 @@ export class LoopStore {
175
52
  entityId: id,
176
53
  payload: { id },
177
54
  });
178
- return this.loops.get(id);
55
+ return this.entries.get(id);
179
56
  });
180
57
  }
181
58
  resume(id) {
182
59
  return this.withLock(() => {
183
- const entry = this.loops.get(id);
60
+ const entry = this.entries.get(id);
184
61
  if (!entry)
185
62
  return undefined;
186
63
  this.applyReducerEvent({
@@ -191,12 +68,12 @@ export class LoopStore {
191
68
  entityId: id,
192
69
  payload: { id },
193
70
  });
194
- return this.loops.get(id);
71
+ return this.entries.get(id);
195
72
  });
196
73
  }
197
74
  fire(id) {
198
75
  return this.withLock(() => {
199
- const entry = this.loops.get(id);
76
+ const entry = this.entries.get(id);
200
77
  if (!entry)
201
78
  return undefined;
202
79
  this.applyReducerEvent({
@@ -207,12 +84,12 @@ export class LoopStore {
207
84
  entityId: id,
208
85
  payload: { id },
209
86
  });
210
- return this.loops.get(id);
87
+ return this.entries.get(id);
211
88
  });
212
89
  }
213
90
  updateMetadata(id, fields) {
214
91
  return this.withLock(() => {
215
- const current = this.loops.get(id);
92
+ const current = this.entries.get(id);
216
93
  if (!current)
217
94
  return { entry: undefined, changedFields: [] };
218
95
  const changedFields = [];
@@ -228,12 +105,12 @@ export class LoopStore {
228
105
  if (changedFields.length > 0) {
229
106
  current.updatedAt = now;
230
107
  }
231
- return { entry: this.loops.get(id), changedFields };
108
+ return { entry: this.entries.get(id), changedFields };
232
109
  });
233
110
  }
234
111
  delete(id) {
235
112
  return this.withLock(() => {
236
- if (!this.loops.has(id))
113
+ if (!this.entries.has(id))
237
114
  return false;
238
115
  this.applyReducerEvent({
239
116
  type: "LOOP_DELETED",
@@ -250,7 +127,7 @@ export class LoopStore {
250
127
  return this.withLock(() => {
251
128
  const now = Date.now();
252
129
  let count = 0;
253
- for (const [id, entry] of [...this.loops.entries()]) {
130
+ for (const [id, entry] of [...this.entries.entries()]) {
254
131
  if (now < entry.expiresAt)
255
132
  continue;
256
133
  this.applyReducerEvent({
@@ -269,7 +146,7 @@ export class LoopStore {
269
146
  expireEventLoops(sessionStartedAt) {
270
147
  return this.withLock(() => {
271
148
  let count = 0;
272
- for (const [id, entry] of [...this.loops.entries()]) {
149
+ for (const [id, entry] of [...this.entries.entries()]) {
273
150
  if (entry.status !== "active")
274
151
  continue;
275
152
  if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid")
@@ -291,7 +168,7 @@ export class LoopStore {
291
168
  }
292
169
  clearAll() {
293
170
  return this.withLock(() => {
294
- const ids = [...this.loops.keys()];
171
+ const ids = [...this.entries.keys()];
295
172
  for (const id of ids) {
296
173
  this.applyReducerEvent({
297
174
  type: "LOOP_DELETED",
@@ -305,13 +182,4 @@ export class LoopStore {
305
182
  return ids.length;
306
183
  });
307
184
  }
308
- deleteFileIfEmpty() {
309
- if (!this.filePath || this.loops.size > 0)
310
- return false;
311
- try {
312
- unlinkSync(this.filePath);
313
- }
314
- catch { /* ignore */ }
315
- return true;
316
- }
317
185
  }
@@ -60,6 +60,9 @@ export function reduceTaskState(state, event) {
60
60
  if (event.type === "TASK_REOPENED") {
61
61
  task.status = "pending";
62
62
  task.updatedAt = event.at;
63
+ // `completedAt` is intentionally retained: it records the most recent
64
+ // completion, not "is currently complete" (use `status` for that). A
65
+ // reopened task keeps the timestamp of when it was last completed.
63
66
  }
64
67
  if (event.type === "TASK_UPDATED") {
65
68
  if (event.payload.subject !== undefined)
@@ -1,20 +1,9 @@
1
- import type { TaskEntry } from "./task-types.js";
2
- export declare class TaskStore {
3
- private filePath;
4
- private lockPath;
5
- private lastLoadedSignature;
6
- private nextId;
7
- private tasks;
1
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
2
+ import { type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
3
+ import type { TaskEntry, TaskStoreData } from "./task-types.js";
4
+ export declare class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
8
5
  constructor(listIdOrPath?: string);
9
- private getFileSignature;
10
- private load;
11
- private save;
12
- private withLock;
13
- private toReducerState;
14
- private applyReducerEvent;
15
6
  create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry;
16
- get(id: string): TaskEntry | undefined;
17
- list(): TaskEntry[];
18
7
  start(id: string): TaskEntry | undefined;
19
8
  complete(id: string): TaskEntry | undefined;
20
9
  reopen(id: string): TaskEntry | undefined;
@@ -1,136 +1,23 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
3
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
4
4
  import { reduceTaskState } from "./task-reducer.js";
5
5
  const TASKS_DIR = join(homedir(), ".pi", "tasks");
6
- const LOCK_RETRY_MS = 50;
7
- const LOCK_MAX_RETRIES = 100;
8
6
  const MAX_TASKS = 200;
9
- function acquireLock(lockPath) {
10
- mkdirSync(dirname(lockPath), { recursive: true });
11
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
12
- try {
13
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
14
- return;
15
- }
16
- catch (e) {
17
- if (e.code === "EEXIST") {
18
- try {
19
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
20
- if (!pid || !isProcessRunning(pid)) {
21
- try {
22
- unlinkSync(lockPath);
23
- }
24
- catch { /* ignore */ }
25
- continue;
26
- }
27
- }
28
- catch { /* ignore read errors */ }
29
- const start = Date.now();
30
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
31
- continue;
32
- }
33
- throw e;
34
- }
35
- }
36
- throw new Error(`Failed to acquire lock: ${lockPath}`);
37
- }
38
- function releaseLock(lockPath) {
39
- try {
40
- unlinkSync(lockPath);
41
- }
42
- catch { /* ignore */ }
43
- }
44
- function isProcessRunning(pid) {
45
- try {
46
- process.kill(pid, 0);
47
- return true;
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
- export class TaskStore {
54
- filePath;
55
- lockPath;
56
- lastLoadedSignature;
57
- nextId = 1;
58
- tasks = new Map();
7
+ export class TaskStore extends ReducerBackedStore {
59
8
  constructor(listIdOrPath) {
60
- if (!listIdOrPath)
61
- return;
62
- const isAbsPath = isAbsolute(listIdOrPath);
63
- const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
64
- mkdirSync(dirname(filePath), { recursive: true });
65
- this.filePath = filePath;
66
- this.lockPath = filePath + ".lock";
67
- this.load();
68
- }
69
- getFileSignature() {
70
- if (!this.filePath || !existsSync(this.filePath))
71
- return undefined;
72
- const stat = statSync(this.filePath);
73
- return `${stat.mtimeMs}:${stat.size}`;
74
- }
75
- load(force = false) {
76
- if (!this.filePath)
77
- return;
78
- const signature = this.getFileSignature();
79
- if (!signature)
80
- return;
81
- if (!force && signature === this.lastLoadedSignature)
82
- return;
83
- try {
84
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
85
- this.nextId = data.nextId;
86
- this.tasks.clear();
87
- for (const task of data.tasks) {
88
- this.tasks.set(task.id, task);
89
- }
90
- this.lastLoadedSignature = signature;
91
- }
92
- catch { /* corrupt file — start fresh */ }
93
- }
94
- save() {
95
- if (!this.filePath)
96
- return;
97
- const data = {
98
- nextId: this.nextId,
99
- tasks: Array.from(this.tasks.values()),
100
- };
101
- const tmpPath = this.filePath + ".tmp";
102
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
103
- renameSync(tmpPath, this.filePath);
104
- this.lastLoadedSignature = this.getFileSignature();
105
- }
106
- withLock(fn) {
107
- if (!this.lockPath)
108
- return fn();
109
- acquireLock(this.lockPath);
110
- try {
111
- this.load(true);
112
- const result = fn();
113
- this.save();
114
- return result;
115
- }
116
- finally {
117
- releaseLock(this.lockPath);
118
- }
119
- }
120
- toReducerState() {
121
- return {
122
- nextId: this.nextId,
123
- tasksById: Object.fromEntries(this.tasks.entries()),
124
- };
125
- }
126
- applyReducerEvent(event) {
127
- const result = reduceTaskState(this.toReducerState(), event);
128
- this.nextId = result.state.nextId;
129
- this.tasks = new Map(Object.entries(result.state.tasksById));
9
+ super({
10
+ baseDir: TASKS_DIR,
11
+ reduce: (state, event) => reduceTaskState(state, event),
12
+ toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
13
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
14
+ serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
15
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
16
+ }, listIdOrPath);
130
17
  }
131
18
  create(subject, description, metadata) {
132
19
  return this.withLock(() => {
133
- if (this.tasks.size >= MAX_TASKS) {
20
+ if (this.entries.size >= MAX_TASKS) {
134
21
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
135
22
  }
136
23
  const now = Date.now();
@@ -141,22 +28,12 @@ export class TaskStore {
141
28
  entityType: "task",
142
29
  payload: { subject, description, metadata },
143
30
  });
144
- return this.tasks.get(String(this.nextId - 1));
31
+ return this.entries.get(String(this.nextId - 1));
145
32
  });
146
33
  }
147
- get(id) {
148
- if (this.filePath)
149
- this.load();
150
- return this.tasks.get(id);
151
- }
152
- list() {
153
- if (this.filePath)
154
- this.load();
155
- return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
156
- }
157
34
  start(id) {
158
35
  return this.withLock(() => {
159
- const entry = this.tasks.get(id);
36
+ const entry = this.entries.get(id);
160
37
  if (!entry)
161
38
  return undefined;
162
39
  this.applyReducerEvent({
@@ -167,12 +44,12 @@ export class TaskStore {
167
44
  entityId: id,
168
45
  payload: { id },
169
46
  });
170
- return this.tasks.get(id);
47
+ return this.entries.get(id);
171
48
  });
172
49
  }
173
50
  complete(id) {
174
51
  return this.withLock(() => {
175
- const entry = this.tasks.get(id);
52
+ const entry = this.entries.get(id);
176
53
  if (!entry)
177
54
  return undefined;
178
55
  this.applyReducerEvent({
@@ -183,12 +60,12 @@ export class TaskStore {
183
60
  entityId: id,
184
61
  payload: { id },
185
62
  });
186
- return this.tasks.get(id);
63
+ return this.entries.get(id);
187
64
  });
188
65
  }
189
66
  reopen(id) {
190
67
  return this.withLock(() => {
191
- const entry = this.tasks.get(id);
68
+ const entry = this.entries.get(id);
192
69
  if (!entry)
193
70
  return undefined;
194
71
  this.applyReducerEvent({
@@ -199,12 +76,12 @@ export class TaskStore {
199
76
  entityId: id,
200
77
  payload: { id },
201
78
  });
202
- return this.tasks.get(id);
79
+ return this.entries.get(id);
203
80
  });
204
81
  }
205
82
  updateDetails(id, fields) {
206
83
  return this.withLock(() => {
207
- const entry = this.tasks.get(id);
84
+ const entry = this.entries.get(id);
208
85
  if (!entry)
209
86
  return undefined;
210
87
  if (fields.subject === undefined && fields.description === undefined)
@@ -221,12 +98,12 @@ export class TaskStore {
221
98
  description: fields.description,
222
99
  },
223
100
  });
224
- return this.tasks.get(id);
101
+ return this.entries.get(id);
225
102
  });
226
103
  }
227
104
  delete(id) {
228
105
  return this.withLock(() => {
229
- if (!this.tasks.has(id))
106
+ if (!this.entries.has(id))
230
107
  return false;
231
108
  this.applyReducerEvent({
232
109
  type: "TASK_DELETED",
@@ -241,7 +118,7 @@ export class TaskStore {
241
118
  }
242
119
  pendingCount() {
243
120
  let count = 0;
244
- for (const t of this.tasks.values()) {
121
+ for (const t of this.entries.values()) {
245
122
  if (t.status === "pending" || t.status === "in_progress")
246
123
  count++;
247
124
  }
@@ -249,7 +126,7 @@ export class TaskStore {
249
126
  }
250
127
  pruneCompleted() {
251
128
  return this.withLock(() => {
252
- const before = this.tasks.size;
129
+ const before = this.entries.size;
253
130
  this.applyReducerEvent({
254
131
  type: "TASKS_PRUNED",
255
132
  at: Date.now(),
@@ -257,7 +134,7 @@ export class TaskStore {
257
134
  entityType: "task",
258
135
  payload: { reason: "manual" },
259
136
  });
260
- return before - this.tasks.size;
137
+ return before - this.entries.size;
261
138
  });
262
139
  }
263
140
  }
@@ -52,6 +52,15 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
52
52
  updateWidget();
53
53
  let onDoneMsg = "";
54
54
  if (params.onDone) {
55
+ // onDone delivery is callback-only: the loop below is delivered solely
56
+ // via MonitorManager.onComplete (see handleMonitorDoneLoop →
57
+ // monitor-ondone-runtime), so it fires exactly once by construction.
58
+ // The event-typed trigger is metadata, NOT a live subscription — this
59
+ // loop is deliberately NOT passed to triggerSystem.add(). The "event"
60
+ // type lets expireEventLoops() prune it if orphaned across a session
61
+ // and lets the widget render it as a monitor-completion wake. Do not
62
+ // triggerSystem.add() this loop, or the monitor:done event would fire it
63
+ // a second time.
55
64
  const doneTrigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
56
65
  const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
57
66
  handleMonitorDoneLoop(doneLoop, entry.id);
@@ -1,3 +1,4 @@
1
+ import { atMaxFires } from "./loop-reducer.js";
1
2
  export class TriggerSystem {
2
3
  pi;
3
4
  scheduler;
@@ -103,7 +104,7 @@ export class TriggerSystem {
103
104
  this.remove(entry.id);
104
105
  return;
105
106
  }
106
- if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
107
+ if (fresh.recurring && atMaxFires(fresh)) {
107
108
  this.remove(fresh.id);
108
109
  this.store.delete(fresh.id);
109
110
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -28,7 +28,9 @@
28
28
  "build": "tsc",
29
29
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
30
30
  "test": "vitest run",
31
+ "test:coverage": "vitest run --coverage",
31
32
  "test:watch": "vitest",
33
+ "test:e2e": "bash test/e2e/reminder-injection.sh",
32
34
  "typecheck": "tsc --noEmit",
33
35
  "lint": "biome check src/ test/",
34
36
  "lint:fix": "biome check --fix src/ test/"
@@ -36,6 +38,7 @@
36
38
  "devDependencies": {
37
39
  "@biomejs/biome": "^2.4.13",
38
40
  "@types/node": "^25.0.0",
41
+ "@vitest/coverage-v8": "^4.1.8",
39
42
  "typescript": "^6.0.0",
40
43
  "vitest": "^4.1.5"
41
44
  },