@trevonistrevon/pi-loop 0.5.3 → 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 (87) 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/commands/loop-command.js +4 -5
  47. package/dist/commands/tasks-command.js +3 -3
  48. package/dist/goal-reducer.d.ts +9 -0
  49. package/dist/goal-reducer.js +11 -2
  50. package/dist/goal-store.d.ts +4 -15
  51. package/dist/goal-store.js +32 -154
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +4 -3
  54. package/dist/loop-reducer.d.ts +7 -0
  55. package/dist/loop-reducer.js +9 -0
  56. package/dist/monitor-manager.js +4 -4
  57. package/dist/reducer-backed-store.d.ts +65 -0
  58. package/dist/reducer-backed-store.js +160 -0
  59. package/dist/store.d.ts +4 -16
  60. package/dist/store.js +25 -156
  61. package/dist/task-reducer.js +3 -0
  62. package/dist/task-store.d.ts +4 -15
  63. package/dist/task-store.js +25 -147
  64. package/dist/tools/loop-tools.js +7 -5
  65. package/dist/tools/monitor-tools.js +12 -3
  66. package/dist/tools/native-task-tools.js +2 -2
  67. package/dist/trigger-system.js +2 -1
  68. package/dist/ui/widget.js +8 -1
  69. package/package.json +4 -1
  70. package/src/commands/loop-command.ts +4 -5
  71. package/src/commands/tasks-command.ts +3 -3
  72. package/src/goal-reducer.ts +20 -1
  73. package/src/goal-store.ts +35 -142
  74. package/src/index.ts +4 -3
  75. package/src/loop-reducer.ts +10 -0
  76. package/src/monitor-manager.ts +2 -3
  77. package/src/reducer-backed-store.ts +168 -0
  78. package/src/store.ts +28 -141
  79. package/src/task-reducer.ts +3 -0
  80. package/src/task-store.ts +28 -135
  81. package/src/tools/loop-tools.ts +6 -5
  82. package/src/tools/monitor-tools.ts +12 -3
  83. package/src/tools/native-task-tools.ts +2 -2
  84. package/src/trigger-system.ts +2 -1
  85. package/src/ui/widget.ts +8 -1
  86. package/vitest.config.ts +25 -0
  87. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/dist/ui/widget.js CHANGED
@@ -22,7 +22,7 @@ export class LoopWidget {
22
22
  this.uiCtx.setStatus("loops", this.computeStatus());
23
23
  }
24
24
  computeStatus() {
25
- const loops = this.store.list().filter(l => l.status === "active");
25
+ const loops = this.store.list().filter(isStatusVisibleLoop);
26
26
  const monitors = this.monitorManager.list();
27
27
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
28
28
  if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
@@ -47,3 +47,10 @@ export class LoopWidget {
47
47
  function formatCount(count, noun) {
48
48
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
49
49
  }
50
+ function isStatusVisibleLoop(loop) {
51
+ if (loop.status !== "active")
52
+ return false;
53
+ if (loop.recurring)
54
+ return true;
55
+ return !(loop.trigger.type === "event" && loop.trigger.source === "monitor:done");
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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
  },
@@ -66,7 +66,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
66
66
  async function viewLoops(ui: ExtensionUIContext) {
67
67
  const loops = getStore().list();
68
68
  if (loops.length === 0) {
69
- await ui.select("No active loops", ["< Back"]);
69
+ await ui.select("No loops configured", ["< Back"]);
70
70
  return;
71
71
  }
72
72
 
@@ -81,7 +81,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
81
81
  });
82
82
  choices.push("< Back");
83
83
 
84
- const selected = await ui.select("Active Loops", choices);
84
+ const selected = await ui.select("Loops", choices);
85
85
  if (!selected || selected === "< Back") return;
86
86
 
87
87
  const match = selected.match(/#(\d+)/);
@@ -136,14 +136,14 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
136
136
  const choice = await ui.select("Loop", [
137
137
  "Create scheduled loop",
138
138
  "Create event-triggered loop",
139
- "View active loops",
139
+ "View loops",
140
140
  "Settings",
141
141
  ]);
142
142
 
143
143
  if (!choice) return;
144
144
  if (choice.startsWith("Create scheduled")) return scheduleLoop(ui);
145
145
  if (choice.startsWith("Create event")) return eventLoop(ui);
146
- if (choice.startsWith("View active")) return viewLoops(ui);
146
+ if (choice.startsWith("View loops")) return viewLoops(ui);
147
147
  return settings(ui);
148
148
  }
149
149
 
@@ -173,7 +173,6 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
173
173
  const choice = await ui.select("Loop mode", [
174
174
  `Scheduled: "${trimmed.slice(0, 50)}"`,
175
175
  `Event-triggered: "${trimmed.slice(0, 50)}"`,
176
- `Self-paced: "${trimmed.slice(0, 50)}"`,
177
176
  ]);
178
177
 
179
178
  if (!choice) return;
@@ -45,7 +45,7 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
45
45
  const backlog = await emitCreated(entry);
46
46
  ui.notify(`Task #${entry.id} created`, "info");
47
47
  if (backlog.created && backlog.entry) {
48
- ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
48
+ ui.notify(`Backlog worker loop #${backlog.entry.id} created`, "info");
49
49
  }
50
50
  }
51
51
 
@@ -64,7 +64,7 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
64
64
  choices.unshift("+ Create task");
65
65
  choices.push("< Back");
66
66
 
67
- const selected = await ui.select("Native Tasks", choices);
67
+ const selected = await ui.select("Tasks", choices);
68
68
  if (!selected || selected === "< Back") return;
69
69
  if (selected === "+ Create task") {
70
70
  await createNativeTaskInteractively(ui);
@@ -125,7 +125,7 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
125
125
  const backlog = await emitCreated(entry);
126
126
  ctx.ui.notify(`Task #${entry.id} created`, "info");
127
127
  if (backlog.created && backlog.entry) {
128
- ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
128
+ ctx.ui.notify(`Backlog worker loop #${backlog.entry.id} created`, "info");
129
129
  }
130
130
  return;
131
131
  }
@@ -82,6 +82,15 @@ export type GoalReducerEffect =
82
82
  entityType: "goal";
83
83
  entityId: string;
84
84
  payload: { id: string };
85
+ }
86
+ | {
87
+ // Diagnostic: a transition was dropped because the goal is in a terminal
88
+ // state. Surfaced (rather than silently ignored) so the effect sink can log
89
+ // it. Carries no state change.
90
+ type: "GOAL_TRANSITION_REJECTED";
91
+ entityType: "goal";
92
+ entityId: string;
93
+ payload: { id: string; status: GoalEntry["status"]; attempted: string };
85
94
  };
86
95
 
87
96
  export interface GoalReduceResult {
@@ -160,7 +169,17 @@ export function reduceGoalState(state: GoalReducerState, event: GoalReducerEvent
160
169
  const id = event.payload.id;
161
170
  const current = state.goalsById[id];
162
171
  if (!current) return { state, effects: [] };
163
- if (isTerminal(current) && event.type !== "GOAL_ARCHIVED") return { state, effects: [] };
172
+ if (isTerminal(current) && event.type !== "GOAL_ARCHIVED") {
173
+ return {
174
+ state,
175
+ effects: [{
176
+ type: "GOAL_TRANSITION_REJECTED",
177
+ entityType: "goal",
178
+ entityId: id,
179
+ payload: { id, status: current.status, attempted: event.type },
180
+ }],
181
+ };
182
+ }
164
183
 
165
184
  const next = cloneState(state);
166
185
  const goal: GoalEntry = {
package/src/goal-store.ts CHANGED
@@ -1,6 +1,5 @@
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 GoalReducerEvent, reduceGoalState } from "./goal-reducer.js";
5
4
  import type {
6
5
  GoalCriteria,
@@ -10,120 +9,24 @@ import type {
10
9
  GoalScope,
11
10
  GoalStoreData,
12
11
  } from "./goal-types.js";
12
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
13
13
 
14
14
  const GOALS_DIR = join(homedir(), ".pi", "goals");
15
- const LOCK_RETRY_MS = 50;
16
- const LOCK_MAX_RETRIES = 100;
17
15
  const MAX_GOALS = 200;
18
16
 
19
- function acquireLock(lockPath: string): void {
20
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
21
- try {
22
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
23
- return;
24
- } catch (e: any) {
25
- if (e.code === "EEXIST") {
26
- try {
27
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
28
- if (!pid || !isProcessRunning(pid)) {
29
- try { unlinkSync(lockPath); } catch { /* ignore */ }
30
- continue;
31
- }
32
- } catch { /* ignore read errors */ }
33
- const start = Date.now();
34
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
35
- continue;
36
- }
37
- throw e;
38
- }
39
- }
40
- throw new Error(`Failed to acquire lock: ${lockPath}`);
41
- }
42
-
43
- function releaseLock(lockPath: string): void {
44
- try { unlinkSync(lockPath); } catch { /* ignore */ }
45
- }
46
-
47
- function isProcessRunning(pid: number): boolean {
48
- try { process.kill(pid, 0); return true; } catch { return false; }
49
- }
50
-
51
- export class GoalStore {
52
- private filePath: string | undefined;
53
- private lockPath: string | undefined;
54
- private lastLoadedSignature: string | undefined;
55
-
56
- private nextId = 1;
57
- private goals = new Map<string, GoalEntry>();
58
-
17
+ export class GoalStore extends ReducerBackedStore<GoalEntry, GoalReducerState, GoalReducerEvent, GoalStoreData> {
59
18
  constructor(listIdOrPath?: string) {
60
- if (!listIdOrPath) return;
61
- const isAbsPath = isAbsolute(listIdOrPath);
62
- const filePath = isAbsPath ? listIdOrPath : join(GOALS_DIR, `${listIdOrPath}.json`);
63
- mkdirSync(dirname(filePath), { recursive: true });
64
- this.filePath = filePath;
65
- this.lockPath = filePath + ".lock";
66
- this.load();
67
- }
68
-
69
- private getFileSignature(): string | undefined {
70
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
71
- const stat = statSync(this.filePath);
72
- return `${stat.mtimeMs}:${stat.size}`;
73
- }
74
-
75
- private load(force = false): void {
76
- if (!this.filePath) return;
77
- const signature = this.getFileSignature();
78
- if (!signature) return;
79
- if (!force && signature === this.lastLoadedSignature) return;
80
- try {
81
- const data: GoalStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
82
- this.nextId = data.nextId;
83
- this.goals.clear();
84
- for (const goal of data.goals) {
85
- this.goals.set(goal.id, goal);
86
- }
87
- this.lastLoadedSignature = signature;
88
- } catch { /* corrupt file — start fresh */ }
89
- }
90
-
91
- private save(): void {
92
- if (!this.filePath) return;
93
- const data: GoalStoreData = {
94
- nextId: this.nextId,
95
- goals: Array.from(this.goals.values()),
96
- };
97
- const tmpPath = this.filePath + ".tmp";
98
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
99
- renameSync(tmpPath, this.filePath);
100
- this.lastLoadedSignature = this.getFileSignature();
101
- }
102
-
103
- private withLock<T>(fn: () => T): T {
104
- if (!this.lockPath) return fn();
105
- acquireLock(this.lockPath);
106
- try {
107
- this.load(true);
108
- const result = fn();
109
- this.save();
110
- return result;
111
- } finally {
112
- releaseLock(this.lockPath);
113
- }
114
- }
115
-
116
- private toReducerState(): GoalReducerState {
117
- return {
118
- nextId: this.nextId,
119
- goalsById: Object.fromEntries(this.goals.entries()),
120
- };
121
- }
122
-
123
- private applyReducerEvent(event: GoalReducerEvent): void {
124
- const result = reduceGoalState(this.toReducerState(), event);
125
- this.nextId = result.state.nextId;
126
- this.goals = new Map(Object.entries(result.state.goalsById));
19
+ super(
20
+ {
21
+ baseDir: GOALS_DIR,
22
+ reduce: (state, event) => reduceGoalState(state, event),
23
+ toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
24
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
25
+ serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
26
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
27
+ },
28
+ listIdOrPath,
29
+ );
127
30
  }
128
31
 
129
32
  create(
@@ -134,7 +37,7 @@ export class GoalStore {
134
37
  metadata?: Record<string, unknown>,
135
38
  ): GoalEntry {
136
39
  return this.withLock(() => {
137
- if (this.goals.size >= MAX_GOALS) {
40
+ if (this.entries.size >= MAX_GOALS) {
138
41
  throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
139
42
  }
140
43
  this.applyReducerEvent({
@@ -150,23 +53,13 @@ export class GoalStore {
150
53
  metadata,
151
54
  },
152
55
  });
153
- return this.goals.get(String(this.nextId - 1))!;
56
+ return this.entries.get(String(this.nextId - 1))!;
154
57
  });
155
58
  }
156
59
 
157
- get(id: string): GoalEntry | undefined {
158
- if (this.filePath) this.load();
159
- return this.goals.get(id);
160
- }
161
-
162
- list(): GoalEntry[] {
163
- if (this.filePath) this.load();
164
- return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
165
- }
166
-
167
60
  activate(id: string): GoalEntry | undefined {
168
61
  return this.withLock(() => {
169
- if (!this.goals.has(id)) return undefined;
62
+ if (!this.entries.has(id)) return undefined;
170
63
  this.applyReducerEvent({
171
64
  type: "GOAL_ACTIVATED",
172
65
  at: Date.now(),
@@ -175,13 +68,13 @@ export class GoalStore {
175
68
  entityId: id,
176
69
  payload: { id },
177
70
  });
178
- return this.goals.get(id);
71
+ return this.entries.get(id);
179
72
  });
180
73
  }
181
74
 
182
75
  recordProgress(id: string, progress: GoalProgressSnapshot): GoalEntry | undefined {
183
76
  return this.withLock(() => {
184
- if (!this.goals.has(id)) return undefined;
77
+ if (!this.entries.has(id)) return undefined;
185
78
  this.applyReducerEvent({
186
79
  type: "GOAL_PROGRESS_RECORDED",
187
80
  at: Date.now(),
@@ -190,13 +83,13 @@ export class GoalStore {
190
83
  entityId: id,
191
84
  payload: { id, progress },
192
85
  });
193
- return this.goals.get(id);
86
+ return this.entries.get(id);
194
87
  });
195
88
  }
196
89
 
197
90
  markVerificationStarted(id: string): GoalEntry | undefined {
198
91
  return this.withLock(() => {
199
- if (!this.goals.has(id)) return undefined;
92
+ if (!this.entries.has(id)) return undefined;
200
93
  this.applyReducerEvent({
201
94
  type: "GOAL_VERIFICATION_STARTED",
202
95
  at: Date.now(),
@@ -205,13 +98,13 @@ export class GoalStore {
205
98
  entityId: id,
206
99
  payload: { id },
207
100
  });
208
- return this.goals.get(id);
101
+ return this.entries.get(id);
209
102
  });
210
103
  }
211
104
 
212
105
  markVerified(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
213
106
  return this.withLock(() => {
214
- if (!this.goals.has(id)) return undefined;
107
+ if (!this.entries.has(id)) return undefined;
215
108
  this.applyReducerEvent({
216
109
  type: "GOAL_VERIFICATION_PASSED",
217
110
  at: Date.now(),
@@ -220,13 +113,13 @@ export class GoalStore {
220
113
  entityId: id,
221
114
  payload: { id, reason, progress },
222
115
  });
223
- return this.goals.get(id);
116
+ return this.entries.get(id);
224
117
  });
225
118
  }
226
119
 
227
120
  markFailed(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
228
121
  return this.withLock(() => {
229
- if (!this.goals.has(id)) return undefined;
122
+ if (!this.entries.has(id)) return undefined;
230
123
  this.applyReducerEvent({
231
124
  type: "GOAL_FAILED",
232
125
  at: Date.now(),
@@ -235,13 +128,13 @@ export class GoalStore {
235
128
  entityId: id,
236
129
  payload: { id, reason, progress },
237
130
  });
238
- return this.goals.get(id);
131
+ return this.entries.get(id);
239
132
  });
240
133
  }
241
134
 
242
135
  markBlocked(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
243
136
  return this.withLock(() => {
244
- if (!this.goals.has(id)) return undefined;
137
+ if (!this.entries.has(id)) return undefined;
245
138
  this.applyReducerEvent({
246
139
  type: "GOAL_BLOCKED",
247
140
  at: Date.now(),
@@ -250,13 +143,13 @@ export class GoalStore {
250
143
  entityId: id,
251
144
  payload: { id, reason, progress },
252
145
  });
253
- return this.goals.get(id);
146
+ return this.entries.get(id);
254
147
  });
255
148
  }
256
149
 
257
150
  unblock(id: string): GoalEntry | undefined {
258
151
  return this.withLock(() => {
259
- if (!this.goals.has(id)) return undefined;
152
+ if (!this.entries.has(id)) return undefined;
260
153
  this.applyReducerEvent({
261
154
  type: "GOAL_UNBLOCKED",
262
155
  at: Date.now(),
@@ -265,7 +158,7 @@ export class GoalStore {
265
158
  entityId: id,
266
159
  payload: { id },
267
160
  });
268
- return this.goals.get(id);
161
+ return this.entries.get(id);
269
162
  });
270
163
  }
271
164
 
@@ -280,7 +173,7 @@ export class GoalStore {
280
173
  },
281
174
  ): GoalEntry | undefined {
282
175
  return this.withLock(() => {
283
- if (!this.goals.has(id)) return undefined;
176
+ if (!this.entries.has(id)) return undefined;
284
177
  if (
285
178
  fields.title === undefined
286
179
  && fields.description === undefined
@@ -288,7 +181,7 @@ export class GoalStore {
288
181
  && fields.criteria === undefined
289
182
  && fields.metadata === undefined
290
183
  ) {
291
- return this.goals.get(id);
184
+ return this.entries.get(id);
292
185
  }
293
186
  this.applyReducerEvent({
294
187
  type: "GOAL_UPDATED",
@@ -305,13 +198,13 @@ export class GoalStore {
305
198
  metadata: fields.metadata,
306
199
  },
307
200
  });
308
- return this.goals.get(id);
201
+ return this.entries.get(id);
309
202
  });
310
203
  }
311
204
 
312
205
  archive(id: string, reason?: string): GoalEntry | undefined {
313
206
  return this.withLock(() => {
314
- if (!this.goals.has(id)) return undefined;
207
+ if (!this.entries.has(id)) return undefined;
315
208
  this.applyReducerEvent({
316
209
  type: "GOAL_ARCHIVED",
317
210
  at: Date.now(),
@@ -320,7 +213,7 @@ export class GoalStore {
320
213
  entityId: id,
321
214
  payload: { id, reason },
322
215
  });
323
- return this.goals.get(id);
216
+ return this.entries.get(id);
324
217
  });
325
218
  }
326
219
  }
package/src/index.ts CHANGED
@@ -10,13 +10,14 @@
10
10
  * MonitorStop — Stop a running monitor
11
11
  *
12
12
  * Commands:
13
- * /loop — Schedule a re-wake loop: /loop [interval] [prompt]
14
- * /loopsInteractive menu: view, create, cancel, settings
13
+ * /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
14
+ * /tasksView or manage native fallback tasks when pi-tasks is absent
15
15
  */
16
16
 
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { registerLoopCommand } from "./commands/loop-command.js";
19
19
  import { registerTasksCommand } from "./commands/tasks-command.js";
20
+ import { atMaxFires } from "./loop-reducer.js";
20
21
  import { MonitorManager } from "./monitor-manager.js";
21
22
  import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
22
23
  import {
@@ -178,7 +179,7 @@ export default function (pi: ExtensionAPI) {
178
179
  function onLoopFire(entry: LoopEntry): void {
179
180
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
180
181
 
181
- if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
182
+ if (atMaxFires(entry)) {
182
183
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
183
184
  store.delete(entry.id);
184
185
  return;
@@ -2,6 +2,16 @@ import type { LoopEntry, Trigger } from "./types.js";
2
2
 
3
3
  export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4
4
 
5
+ /**
6
+ * Whether a loop has reached its fire cap. Single source of truth for the
7
+ * `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
8
+ * `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
9
+ * only the predicate is shared.
10
+ */
11
+ export function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean {
12
+ return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
13
+ }
14
+
5
15
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
6
16
 
7
17
  export interface LoopReducerState {
@@ -128,9 +128,7 @@ export class MonitorManager {
128
128
  exitCode: code,
129
129
  outputLines: current.outputLines,
130
130
  });
131
- if (status === "completed") {
132
- for (const callback of bp.completionCallbacks) callback();
133
- }
131
+ for (const callback of bp.completionCallbacks) callback();
134
132
  bp.completionCallbacks = [];
135
133
  for (const resolve of bp.waiters) resolve();
136
134
  bp.waiters = [];
@@ -171,6 +169,7 @@ export class MonitorManager {
171
169
  monitorId: id,
172
170
  error: err.message,
173
171
  });
172
+ for (const callback of bp.completionCallbacks) callback();
174
173
  bp.completionCallbacks = [];
175
174
  for (const resolve of bp.waiters) resolve();
176
175
  bp.waiters = [];