@trevonistrevon/pi-loop 0.5.4 → 0.5.5

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 (74) 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 +1054 -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 +865 -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 +2 -1
  51. package/dist/loop-reducer.d.ts +7 -0
  52. package/dist/loop-reducer.js +9 -0
  53. package/dist/reducer-backed-store.d.ts +65 -0
  54. package/dist/reducer-backed-store.js +160 -0
  55. package/dist/store.d.ts +4 -16
  56. package/dist/store.js +25 -157
  57. package/dist/task-reducer.js +3 -0
  58. package/dist/task-store.d.ts +4 -15
  59. package/dist/task-store.js +25 -148
  60. package/dist/tools/monitor-tools.js +9 -0
  61. package/dist/trigger-system.js +2 -1
  62. package/package.json +4 -1
  63. package/src/goal-reducer.ts +20 -1
  64. package/src/goal-store.ts +35 -143
  65. package/src/index.ts +2 -1
  66. package/src/loop-reducer.ts +10 -0
  67. package/src/reducer-backed-store.ts +168 -0
  68. package/src/store.ts +28 -142
  69. package/src/task-reducer.ts +3 -0
  70. package/src/task-store.ts +28 -136
  71. package/src/tools/monitor-tools.ts +9 -0
  72. package/src/trigger-system.ts +2 -1
  73. package/vitest.config.ts +25 -0
  74. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/src/store.ts CHANGED
@@ -1,128 +1,30 @@
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 { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
4
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
6
6
 
7
7
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
8
- const LOCK_RETRY_MS = 50;
9
- const LOCK_MAX_RETRIES = 100;
10
8
  const MAX_LOOPS = 25;
11
9
 
12
- function acquireLock(lockPath: string): void {
13
- mkdirSync(dirname(lockPath), { recursive: true });
14
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
15
- try {
16
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
17
- return;
18
- } catch (e: any) {
19
- if (e.code === "EEXIST") {
20
- try {
21
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
22
- if (!pid || !isProcessRunning(pid)) {
23
- try { unlinkSync(lockPath); } catch { /* ignore */ }
24
- continue;
25
- }
26
- } catch { /* ignore read errors */ }
27
- const start = Date.now();
28
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
29
- continue;
30
- }
31
- throw e;
32
- }
33
- }
34
- throw new Error(`Failed to acquire lock: ${lockPath}`);
35
- }
36
-
37
- function releaseLock(lockPath: string): void {
38
- try { unlinkSync(lockPath); } catch { /* ignore */ }
39
- }
40
-
41
- function isProcessRunning(pid: number): boolean {
42
- try { process.kill(pid, 0); return true; } catch { return false; }
43
- }
44
-
45
- export class LoopStore {
46
- private filePath: string | undefined;
47
- private lockPath: string | undefined;
48
- private lastLoadedSignature: string | undefined;
49
-
50
- private nextId = 1;
51
- private loops = new Map<string, LoopEntry>();
52
-
10
+ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
53
11
  constructor(listIdOrPath?: string) {
54
- if (!listIdOrPath) return;
55
- const isAbsPath = isAbsolute(listIdOrPath);
56
- const filePath = isAbsPath ? listIdOrPath : join(LOOPS_DIR, `${listIdOrPath}.json`);
57
- mkdirSync(dirname(filePath), { recursive: true });
58
- this.filePath = filePath;
59
- this.lockPath = filePath + ".lock";
60
- this.load();
61
- }
62
-
63
- private getFileSignature(): string | undefined {
64
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
65
- const stat = statSync(this.filePath);
66
- return `${stat.mtimeMs}:${stat.size}`;
67
- }
68
-
69
- private load(force = false): void {
70
- if (!this.filePath) return;
71
- const signature = this.getFileSignature();
72
- if (!signature) return;
73
- if (!force && signature === this.lastLoadedSignature) return;
74
- try {
75
- const data: LoopStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
76
- this.nextId = data.nextId;
77
- this.loops.clear();
78
- for (const loop of data.loops) {
79
- this.loops.set(loop.id, loop);
80
- }
81
- this.lastLoadedSignature = signature;
82
- } catch { /* corrupt file — start fresh */ }
83
- }
84
-
85
- private save(): void {
86
- if (!this.filePath) return;
87
- const data: LoopStoreData = {
88
- nextId: this.nextId,
89
- loops: Array.from(this.loops.values()),
90
- };
91
- const tmpPath = this.filePath + ".tmp";
92
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
93
- renameSync(tmpPath, this.filePath);
94
- this.lastLoadedSignature = this.getFileSignature();
95
- }
96
-
97
- private withLock<T>(fn: () => T): T {
98
- if (!this.lockPath) return fn();
99
- acquireLock(this.lockPath);
100
- try {
101
- this.load(true);
102
- const result = fn();
103
- this.save();
104
- return result;
105
- } finally {
106
- releaseLock(this.lockPath);
107
- }
108
- }
109
-
110
- private toReducerState(): LoopReducerState {
111
- return {
112
- nextId: this.nextId,
113
- loopsById: Object.fromEntries(this.loops.entries()),
114
- };
115
- }
116
-
117
- private applyReducerEvent(event: LoopReducerEvent): void {
118
- const result = reduceLoopState(this.toReducerState(), event);
119
- this.nextId = result.state.nextId;
120
- this.loops = new Map(Object.entries(result.state.loopsById));
12
+ super(
13
+ {
14
+ baseDir: LOOPS_DIR,
15
+ reduce: (state, event) => reduceLoopState(state, event),
16
+ toReducerState: (nextId, entries) => ({ nextId, loopsById: Object.fromEntries(entries.entries()) }),
17
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.loopsById)) }),
18
+ serialize: (nextId, entries) => ({ nextId, loops: Array.from(entries.values()) }),
19
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.loops.map((l) => [l.id, l])) }),
20
+ },
21
+ listIdOrPath,
22
+ );
121
23
  }
122
24
 
123
25
  create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
124
26
  return this.withLock(() => {
125
- if (this.loops.size >= MAX_LOOPS) {
27
+ if (this.entries.size >= MAX_LOOPS) {
126
28
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
127
29
  }
128
30
  const now = Date.now();
@@ -141,23 +43,13 @@ export class LoopStore {
141
43
  maxFires: opts.maxFires,
142
44
  },
143
45
  });
144
- return this.loops.get(String(this.nextId - 1))!;
46
+ return this.entries.get(String(this.nextId - 1))!;
145
47
  });
146
48
  }
147
49
 
148
- get(id: string): LoopEntry | undefined {
149
- if (this.filePath) this.load();
150
- return this.loops.get(id);
151
- }
152
-
153
- list(): LoopEntry[] {
154
- if (this.filePath) this.load();
155
- return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
156
- }
157
-
158
50
  pause(id: string): LoopEntry | undefined {
159
51
  return this.withLock(() => {
160
- const entry = this.loops.get(id);
52
+ const entry = this.entries.get(id);
161
53
  if (!entry) return undefined;
162
54
  this.applyReducerEvent({
163
55
  type: "LOOP_PAUSED",
@@ -167,13 +59,13 @@ export class LoopStore {
167
59
  entityId: id,
168
60
  payload: { id },
169
61
  });
170
- return this.loops.get(id);
62
+ return this.entries.get(id);
171
63
  });
172
64
  }
173
65
 
174
66
  resume(id: string): LoopEntry | undefined {
175
67
  return this.withLock(() => {
176
- const entry = this.loops.get(id);
68
+ const entry = this.entries.get(id);
177
69
  if (!entry) return undefined;
178
70
  this.applyReducerEvent({
179
71
  type: "LOOP_RESUMED",
@@ -183,13 +75,13 @@ export class LoopStore {
183
75
  entityId: id,
184
76
  payload: { id },
185
77
  });
186
- return this.loops.get(id);
78
+ return this.entries.get(id);
187
79
  });
188
80
  }
189
81
 
190
82
  fire(id: string): LoopEntry | undefined {
191
83
  return this.withLock(() => {
192
- const entry = this.loops.get(id);
84
+ const entry = this.entries.get(id);
193
85
  if (!entry) return undefined;
194
86
  this.applyReducerEvent({
195
87
  type: "LOOP_FIRED",
@@ -199,13 +91,13 @@ export class LoopStore {
199
91
  entityId: id,
200
92
  payload: { id },
201
93
  });
202
- return this.loops.get(id);
94
+ return this.entries.get(id);
203
95
  });
204
96
  }
205
97
 
206
98
  updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
207
99
  return this.withLock(() => {
208
- const current = this.loops.get(id);
100
+ const current = this.entries.get(id);
209
101
  if (!current) return { entry: undefined, changedFields: [] };
210
102
 
211
103
  const changedFields: string[] = [];
@@ -223,13 +115,13 @@ export class LoopStore {
223
115
  current.updatedAt = now;
224
116
  }
225
117
 
226
- return { entry: this.loops.get(id), changedFields };
118
+ return { entry: this.entries.get(id), changedFields };
227
119
  });
228
120
  }
229
121
 
230
122
  delete(id: string): boolean {
231
123
  return this.withLock(() => {
232
- if (!this.loops.has(id)) return false;
124
+ if (!this.entries.has(id)) return false;
233
125
  this.applyReducerEvent({
234
126
  type: "LOOP_DELETED",
235
127
  at: Date.now(),
@@ -246,7 +138,7 @@ export class LoopStore {
246
138
  return this.withLock(() => {
247
139
  const now = Date.now();
248
140
  let count = 0;
249
- for (const [id, entry] of [...this.loops.entries()]) {
141
+ for (const [id, entry] of [...this.entries.entries()]) {
250
142
  if (now < entry.expiresAt) continue;
251
143
  this.applyReducerEvent({
252
144
  type: "LOOP_EXPIRED",
@@ -265,7 +157,7 @@ export class LoopStore {
265
157
  expireEventLoops(sessionStartedAt: number): number {
266
158
  return this.withLock(() => {
267
159
  let count = 0;
268
- for (const [id, entry] of [...this.loops.entries()]) {
160
+ for (const [id, entry] of [...this.entries.entries()]) {
269
161
  if (entry.status !== "active") continue;
270
162
  if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
271
163
  if (entry.createdAt >= sessionStartedAt) continue;
@@ -285,7 +177,7 @@ export class LoopStore {
285
177
 
286
178
  clearAll(): number {
287
179
  return this.withLock(() => {
288
- const ids = [...this.loops.keys()];
180
+ const ids = [...this.entries.keys()];
289
181
  for (const id of ids) {
290
182
  this.applyReducerEvent({
291
183
  type: "LOOP_DELETED",
@@ -299,10 +191,4 @@ export class LoopStore {
299
191
  return ids.length;
300
192
  });
301
193
  }
302
-
303
- deleteFileIfEmpty(): boolean {
304
- if (!this.filePath || this.loops.size > 0) return false;
305
- try { unlinkSync(this.filePath); } catch { /* ignore */ }
306
- return true;
307
- }
308
194
  }
@@ -136,6 +136,9 @@ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent
136
136
  if (event.type === "TASK_REOPENED") {
137
137
  task.status = "pending";
138
138
  task.updatedAt = event.at;
139
+ // `completedAt` is intentionally retained: it records the most recent
140
+ // completion, not "is currently complete" (use `status` for that). A
141
+ // reopened task keeps the timestamp of when it was last completed.
139
142
  }
140
143
 
141
144
  if (event.type === "TASK_UPDATED") {
package/src/task-store.ts CHANGED
@@ -1,128 +1,30 @@
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, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
5
5
  import type { TaskEntry, TaskStoreData } from "./task-types.js";
6
6
 
7
7
  const TASKS_DIR = join(homedir(), ".pi", "tasks");
8
- const LOCK_RETRY_MS = 50;
9
- const LOCK_MAX_RETRIES = 100;
10
8
  const MAX_TASKS = 200;
11
9
 
12
- function acquireLock(lockPath: string): void {
13
- mkdirSync(dirname(lockPath), { recursive: true });
14
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
15
- try {
16
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
17
- return;
18
- } catch (e: any) {
19
- if (e.code === "EEXIST") {
20
- try {
21
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
22
- if (!pid || !isProcessRunning(pid)) {
23
- try { unlinkSync(lockPath); } catch { /* ignore */ }
24
- continue;
25
- }
26
- } catch { /* ignore read errors */ }
27
- const start = Date.now();
28
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
29
- continue;
30
- }
31
- throw e;
32
- }
33
- }
34
- throw new Error(`Failed to acquire lock: ${lockPath}`);
35
- }
36
-
37
- function releaseLock(lockPath: string): void {
38
- try { unlinkSync(lockPath); } catch { /* ignore */ }
39
- }
40
-
41
- function isProcessRunning(pid: number): boolean {
42
- try { process.kill(pid, 0); return true; } catch { return false; }
43
- }
44
-
45
- export class TaskStore {
46
- private filePath: string | undefined;
47
- private lockPath: string | undefined;
48
- private lastLoadedSignature: string | undefined;
49
-
50
- private nextId = 1;
51
- private tasks = new Map<string, TaskEntry>();
52
-
10
+ export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
53
11
  constructor(listIdOrPath?: string) {
54
- if (!listIdOrPath) return;
55
- const isAbsPath = isAbsolute(listIdOrPath);
56
- const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
57
- mkdirSync(dirname(filePath), { recursive: true });
58
- this.filePath = filePath;
59
- this.lockPath = filePath + ".lock";
60
- this.load();
61
- }
62
-
63
- private getFileSignature(): string | undefined {
64
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
65
- const stat = statSync(this.filePath);
66
- return `${stat.mtimeMs}:${stat.size}`;
67
- }
68
-
69
- private load(force = false): void {
70
- if (!this.filePath) return;
71
- const signature = this.getFileSignature();
72
- if (!signature) return;
73
- if (!force && signature === this.lastLoadedSignature) return;
74
- try {
75
- const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
76
- this.nextId = data.nextId;
77
- this.tasks.clear();
78
- for (const task of data.tasks) {
79
- this.tasks.set(task.id, task);
80
- }
81
- this.lastLoadedSignature = signature;
82
- } catch { /* corrupt file — start fresh */ }
83
- }
84
-
85
- private save(): void {
86
- if (!this.filePath) return;
87
- const data: TaskStoreData = {
88
- nextId: this.nextId,
89
- tasks: Array.from(this.tasks.values()),
90
- };
91
- const tmpPath = this.filePath + ".tmp";
92
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
93
- renameSync(tmpPath, this.filePath);
94
- this.lastLoadedSignature = this.getFileSignature();
95
- }
96
-
97
- private withLock<T>(fn: () => T): T {
98
- if (!this.lockPath) return fn();
99
- acquireLock(this.lockPath);
100
- try {
101
- this.load(true);
102
- const result = fn();
103
- this.save();
104
- return result;
105
- } finally {
106
- releaseLock(this.lockPath);
107
- }
108
- }
109
-
110
- private toReducerState(): TaskReducerState {
111
- return {
112
- nextId: this.nextId,
113
- tasksById: Object.fromEntries(this.tasks.entries()),
114
- };
115
- }
116
-
117
- private applyReducerEvent(event: TaskReducerEvent): void {
118
- const result = reduceTaskState(this.toReducerState(), event);
119
- this.nextId = result.state.nextId;
120
- this.tasks = new Map(Object.entries(result.state.tasksById));
12
+ super(
13
+ {
14
+ baseDir: TASKS_DIR,
15
+ reduce: (state, event) => reduceTaskState(state, event),
16
+ toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
17
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
18
+ serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
19
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
20
+ },
21
+ listIdOrPath,
22
+ );
121
23
  }
122
24
 
123
25
  create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
124
26
  return this.withLock(() => {
125
- if (this.tasks.size >= MAX_TASKS) {
27
+ if (this.entries.size >= MAX_TASKS) {
126
28
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
127
29
  }
128
30
  const now = Date.now();
@@ -133,23 +35,13 @@ export class TaskStore {
133
35
  entityType: "task",
134
36
  payload: { subject, description, metadata },
135
37
  });
136
- return this.tasks.get(String(this.nextId - 1))!;
38
+ return this.entries.get(String(this.nextId - 1))!;
137
39
  });
138
40
  }
139
41
 
140
- get(id: string): TaskEntry | undefined {
141
- if (this.filePath) this.load();
142
- return this.tasks.get(id);
143
- }
144
-
145
- list(): TaskEntry[] {
146
- if (this.filePath) this.load();
147
- return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
148
- }
149
-
150
42
  start(id: string): TaskEntry | undefined {
151
43
  return this.withLock(() => {
152
- const entry = this.tasks.get(id);
44
+ const entry = this.entries.get(id);
153
45
  if (!entry) return undefined;
154
46
  this.applyReducerEvent({
155
47
  type: "TASK_STARTED",
@@ -159,13 +51,13 @@ export class TaskStore {
159
51
  entityId: id,
160
52
  payload: { id },
161
53
  });
162
- return this.tasks.get(id);
54
+ return this.entries.get(id);
163
55
  });
164
56
  }
165
57
 
166
58
  complete(id: string): TaskEntry | undefined {
167
59
  return this.withLock(() => {
168
- const entry = this.tasks.get(id);
60
+ const entry = this.entries.get(id);
169
61
  if (!entry) return undefined;
170
62
  this.applyReducerEvent({
171
63
  type: "TASK_COMPLETED",
@@ -175,13 +67,13 @@ export class TaskStore {
175
67
  entityId: id,
176
68
  payload: { id },
177
69
  });
178
- return this.tasks.get(id);
70
+ return this.entries.get(id);
179
71
  });
180
72
  }
181
73
 
182
74
  reopen(id: string): TaskEntry | undefined {
183
75
  return this.withLock(() => {
184
- const entry = this.tasks.get(id);
76
+ const entry = this.entries.get(id);
185
77
  if (!entry) return undefined;
186
78
  this.applyReducerEvent({
187
79
  type: "TASK_REOPENED",
@@ -191,13 +83,13 @@ export class TaskStore {
191
83
  entityId: id,
192
84
  payload: { id },
193
85
  });
194
- return this.tasks.get(id);
86
+ return this.entries.get(id);
195
87
  });
196
88
  }
197
89
 
198
90
  updateDetails(id: string, fields: { subject?: string; description?: string }): TaskEntry | undefined {
199
91
  return this.withLock(() => {
200
- const entry = this.tasks.get(id);
92
+ const entry = this.entries.get(id);
201
93
  if (!entry) return undefined;
202
94
  if (fields.subject === undefined && fields.description === undefined) return entry;
203
95
  this.applyReducerEvent({
@@ -212,13 +104,13 @@ export class TaskStore {
212
104
  description: fields.description,
213
105
  },
214
106
  });
215
- return this.tasks.get(id);
107
+ return this.entries.get(id);
216
108
  });
217
109
  }
218
110
 
219
111
  delete(id: string): boolean {
220
112
  return this.withLock(() => {
221
- if (!this.tasks.has(id)) return false;
113
+ if (!this.entries.has(id)) return false;
222
114
  this.applyReducerEvent({
223
115
  type: "TASK_DELETED",
224
116
  at: Date.now(),
@@ -233,7 +125,7 @@ export class TaskStore {
233
125
 
234
126
  pendingCount(): number {
235
127
  let count = 0;
236
- for (const t of this.tasks.values()) {
128
+ for (const t of this.entries.values()) {
237
129
  if (t.status === "pending" || t.status === "in_progress") count++;
238
130
  }
239
131
  return count;
@@ -241,7 +133,7 @@ export class TaskStore {
241
133
 
242
134
  pruneCompleted(): number {
243
135
  return this.withLock(() => {
244
- const before = this.tasks.size;
136
+ const before = this.entries.size;
245
137
  this.applyReducerEvent({
246
138
  type: "TASKS_PRUNED",
247
139
  at: Date.now(),
@@ -249,7 +141,7 @@ export class TaskStore {
249
141
  entityType: "task",
250
142
  payload: { reason: "manual" },
251
143
  });
252
- return before - this.tasks.size;
144
+ return before - this.entries.size;
253
145
  });
254
146
  }
255
147
  }
@@ -82,6 +82,15 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
82
82
 
83
83
  let onDoneMsg = "";
84
84
  if (params.onDone) {
85
+ // onDone delivery is callback-only: the loop below is delivered solely
86
+ // via MonitorManager.onComplete (see handleMonitorDoneLoop →
87
+ // monitor-ondone-runtime), so it fires exactly once by construction.
88
+ // The event-typed trigger is metadata, NOT a live subscription — this
89
+ // loop is deliberately NOT passed to triggerSystem.add(). The "event"
90
+ // type lets expireEventLoops() prune it if orphaned across a session
91
+ // and lets the widget render it as a monitor-completion wake. Do not
92
+ // triggerSystem.add() this loop, or the monitor:done event would fire it
93
+ // a second time.
85
94
  const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
86
95
  const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
87
96
  handleMonitorDoneLoop(doneLoop, entry.id);
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { atMaxFires } from "./loop-reducer.js";
2
3
  import type { CronScheduler } from "./scheduler.js";
3
4
  import type { LoopStore } from "./store.js";
4
5
  import type { LoopEntry } from "./types.js";
@@ -110,7 +111,7 @@ export class TriggerSystem {
110
111
  return;
111
112
  }
112
113
 
113
- if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
114
+ if (fresh.recurring && atMaxFires(fresh)) {
114
115
  this.remove(fresh.id);
115
116
  this.store.delete(fresh.id);
116
117
  return;
@@ -0,0 +1,25 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["test/**/*.test.ts"],
7
+ exclude: ["node_modules", "dist"],
8
+ coverage: {
9
+ provider: "v8",
10
+ reporter: ["text-summary", "html", "json"],
11
+ include: ["src/**/*.ts"],
12
+ // Type-only modules and test helpers carry no executable logic worth gating.
13
+ exclude: ["src/**/*-types.ts", "src/types.ts"],
14
+ // Floors set just below current actuals (stmts 80%, branches 69.5%,
15
+ // funcs 92.5%, lines 82.9%) to catch regressions. Ratchet up as the
16
+ // runtime/ and tools/ suites land in Phase 4.
17
+ thresholds: {
18
+ statements: 78,
19
+ branches: 67,
20
+ functions: 90,
21
+ lines: 80,
22
+ },
23
+ },
24
+ },
25
+ });
@@ -1,81 +0,0 @@
1
- # Differential Review Report
2
-
3
- ## Update — 2026-06-02
4
-
5
- ### Resolved audit finding
6
- - **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
7
- - **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
8
- - **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
9
-
10
- ### Regression coverage added
11
- - `test/trigger-system.test.ts`
12
- - recurring `event` loop is deleted immediately at final `maxFires`
13
- - recurring `hybrid` loop is deleted immediately at final `maxFires`
14
- - hybrid cleanup also clears scheduled cron state
15
- - `test/index.test.ts`
16
- - extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
17
-
18
- ### Validation status
19
- - `npm run lint` ✅
20
- - `npm run typecheck` ✅
21
- - `npm run test` ✅
22
- - `npm run build` ✅
23
- - Current suite: **112 passing tests**
24
-
25
- ## Scope
26
- Reviewed recent uncommitted changes in:
27
- - `src/index.ts`
28
- - `src/ui/widget.ts`
29
- - `test/index.test.ts`
30
- - `test/widget.test.ts`
31
- - `README.md`
32
-
33
- ## Risk Summary
34
- - **Overall risk:** Medium
35
- - **Primary areas affected:** runtime task fallback routing, UI/widget behavior, interactive command registration
36
- - **Security impact:** Low direct security impact; main risks are state-management regressions and missing coverage around command behavior
37
-
38
- ## Findings
39
-
40
- ### 🟡 Warning
41
- `src/index.ts` - Native task fallback is covered for registration and file persistence, but not for interactive `/tasks` flows or loop-driven task lifecycle integration.
42
-
43
- **Why it matters:**
44
- The new `/tasks` command and interactive actions (`Start`, `Complete`, `Reopen`, `Delete`) are stateful and user-facing. Regressions here would not be caught by current tests. Similarly, native fallback behavior for `autoTask`, `hasPendingTasks()`, and `cleanDoneTasks()` is only indirectly covered.
45
-
46
- **Recommended follow-up:**
47
- Add tests for:
48
- - `/tasks` command registration and quick-create path
49
- - native `autoTask` creation path
50
- - `hasPendingTasks()` using native fallback
51
- - completed-task sweep behavior for native tasks
52
-
53
- ### 🟢 Suggestion
54
- `src/ui/widget.ts` - Compact widget behavior is appropriately simplified and focus-oriented.
55
-
56
- **Good pattern:**
57
- The single-line status approach reduces noise and matches the intended UX. Showing only active/next task focus text is a good constraint.
58
-
59
- ## Test Coverage Review
60
-
61
- ### Covered well
62
- - Native tool registration when `pi-tasks` is absent/present
63
- - Native task persistence path (`.pi/tasks/tasks.json`)
64
- - Compact widget states:
65
- - `none`
66
- - monitor-only count
67
- - loop + monitor count
68
- - active task focus text
69
- - next task focus text
70
- - retained widget rendering after content clears
71
-
72
- ### Coverage gaps
73
- - No tests for `/tasks` command behavior beyond registration
74
- - No direct tests for native `TaskUpdate` → widget focus transitions
75
- - No direct tests for native `cleanDoneTasks()` sweep behavior
76
- - No end-to-end tests for `LoopCreate(autoTask: true)` using native fallback
77
-
78
- ## Review Verdict
79
- - **No merge-blocking issues found**
80
- - Safe to release after version bump and validation
81
- - Recommended follow-up: add native task lifecycle command/integration tests in a later pass