@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/store.js CHANGED
@@ -1,135 +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
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
11
- try {
12
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
13
- return;
14
- }
15
- catch (e) {
16
- if (e.code === "EEXIST") {
17
- try {
18
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
19
- if (!pid || !isProcessRunning(pid)) {
20
- try {
21
- unlinkSync(lockPath);
22
- }
23
- catch { /* ignore */ }
24
- continue;
25
- }
26
- }
27
- catch { /* ignore read errors */ }
28
- const start = Date.now();
29
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
30
- continue;
31
- }
32
- throw e;
33
- }
34
- }
35
- throw new Error(`Failed to acquire lock: ${lockPath}`);
36
- }
37
- function releaseLock(lockPath) {
38
- try {
39
- unlinkSync(lockPath);
40
- }
41
- catch { /* ignore */ }
42
- }
43
- function isProcessRunning(pid) {
44
- try {
45
- process.kill(pid, 0);
46
- return true;
47
- }
48
- catch {
49
- return false;
50
- }
51
- }
52
- export class LoopStore {
53
- filePath;
54
- lockPath;
55
- lastLoadedSignature;
56
- nextId = 1;
57
- loops = new Map();
7
+ export class LoopStore extends ReducerBackedStore {
58
8
  constructor(listIdOrPath) {
59
- if (!listIdOrPath)
60
- return;
61
- const isAbsPath = isAbsolute(listIdOrPath);
62
- const filePath = isAbsPath ? listIdOrPath : join(LOOPS_DIR, `${listIdOrPath}.json`);
63
- mkdirSync(dirname(filePath), { recursive: true });
64
- this.filePath = filePath;
65
- this.lockPath = filePath + ".lock";
66
- this.load();
67
- }
68
- getFileSignature() {
69
- if (!this.filePath || !existsSync(this.filePath))
70
- return undefined;
71
- const stat = statSync(this.filePath);
72
- return `${stat.mtimeMs}:${stat.size}`;
73
- }
74
- load(force = false) {
75
- if (!this.filePath)
76
- return;
77
- const signature = this.getFileSignature();
78
- if (!signature)
79
- return;
80
- if (!force && signature === this.lastLoadedSignature)
81
- return;
82
- try {
83
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
84
- this.nextId = data.nextId;
85
- this.loops.clear();
86
- for (const loop of data.loops) {
87
- this.loops.set(loop.id, loop);
88
- }
89
- this.lastLoadedSignature = signature;
90
- }
91
- catch { /* corrupt file — start fresh */ }
92
- }
93
- save() {
94
- if (!this.filePath)
95
- return;
96
- const data = {
97
- nextId: this.nextId,
98
- loops: Array.from(this.loops.values()),
99
- };
100
- const tmpPath = this.filePath + ".tmp";
101
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
102
- renameSync(tmpPath, this.filePath);
103
- this.lastLoadedSignature = this.getFileSignature();
104
- }
105
- withLock(fn) {
106
- if (!this.lockPath)
107
- return fn();
108
- acquireLock(this.lockPath);
109
- try {
110
- this.load(true);
111
- const result = fn();
112
- this.save();
113
- return result;
114
- }
115
- finally {
116
- releaseLock(this.lockPath);
117
- }
118
- }
119
- toReducerState() {
120
- return {
121
- nextId: this.nextId,
122
- loopsById: Object.fromEntries(this.loops.entries()),
123
- };
124
- }
125
- applyReducerEvent(event) {
126
- const result = reduceLoopState(this.toReducerState(), event);
127
- this.nextId = result.state.nextId;
128
- 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);
129
17
  }
130
18
  create(trigger, prompt, opts) {
131
19
  return this.withLock(() => {
132
- if (this.loops.size >= MAX_LOOPS) {
20
+ if (this.entries.size >= MAX_LOOPS) {
133
21
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
134
22
  }
135
23
  const now = Date.now();
@@ -148,22 +36,12 @@ export class LoopStore {
148
36
  maxFires: opts.maxFires,
149
37
  },
150
38
  });
151
- return this.loops.get(String(this.nextId - 1));
39
+ return this.entries.get(String(this.nextId - 1));
152
40
  });
153
41
  }
154
- get(id) {
155
- if (this.filePath)
156
- this.load();
157
- return this.loops.get(id);
158
- }
159
- list() {
160
- if (this.filePath)
161
- this.load();
162
- return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
163
- }
164
42
  pause(id) {
165
43
  return this.withLock(() => {
166
- const entry = this.loops.get(id);
44
+ const entry = this.entries.get(id);
167
45
  if (!entry)
168
46
  return undefined;
169
47
  this.applyReducerEvent({
@@ -174,12 +52,12 @@ export class LoopStore {
174
52
  entityId: id,
175
53
  payload: { id },
176
54
  });
177
- return this.loops.get(id);
55
+ return this.entries.get(id);
178
56
  });
179
57
  }
180
58
  resume(id) {
181
59
  return this.withLock(() => {
182
- const entry = this.loops.get(id);
60
+ const entry = this.entries.get(id);
183
61
  if (!entry)
184
62
  return undefined;
185
63
  this.applyReducerEvent({
@@ -190,12 +68,12 @@ export class LoopStore {
190
68
  entityId: id,
191
69
  payload: { id },
192
70
  });
193
- return this.loops.get(id);
71
+ return this.entries.get(id);
194
72
  });
195
73
  }
196
74
  fire(id) {
197
75
  return this.withLock(() => {
198
- const entry = this.loops.get(id);
76
+ const entry = this.entries.get(id);
199
77
  if (!entry)
200
78
  return undefined;
201
79
  this.applyReducerEvent({
@@ -206,12 +84,12 @@ export class LoopStore {
206
84
  entityId: id,
207
85
  payload: { id },
208
86
  });
209
- return this.loops.get(id);
87
+ return this.entries.get(id);
210
88
  });
211
89
  }
212
90
  updateMetadata(id, fields) {
213
91
  return this.withLock(() => {
214
- const current = this.loops.get(id);
92
+ const current = this.entries.get(id);
215
93
  if (!current)
216
94
  return { entry: undefined, changedFields: [] };
217
95
  const changedFields = [];
@@ -227,12 +105,12 @@ export class LoopStore {
227
105
  if (changedFields.length > 0) {
228
106
  current.updatedAt = now;
229
107
  }
230
- return { entry: this.loops.get(id), changedFields };
108
+ return { entry: this.entries.get(id), changedFields };
231
109
  });
232
110
  }
233
111
  delete(id) {
234
112
  return this.withLock(() => {
235
- if (!this.loops.has(id))
113
+ if (!this.entries.has(id))
236
114
  return false;
237
115
  this.applyReducerEvent({
238
116
  type: "LOOP_DELETED",
@@ -249,7 +127,7 @@ export class LoopStore {
249
127
  return this.withLock(() => {
250
128
  const now = Date.now();
251
129
  let count = 0;
252
- for (const [id, entry] of [...this.loops.entries()]) {
130
+ for (const [id, entry] of [...this.entries.entries()]) {
253
131
  if (now < entry.expiresAt)
254
132
  continue;
255
133
  this.applyReducerEvent({
@@ -268,7 +146,7 @@ export class LoopStore {
268
146
  expireEventLoops(sessionStartedAt) {
269
147
  return this.withLock(() => {
270
148
  let count = 0;
271
- for (const [id, entry] of [...this.loops.entries()]) {
149
+ for (const [id, entry] of [...this.entries.entries()]) {
272
150
  if (entry.status !== "active")
273
151
  continue;
274
152
  if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid")
@@ -290,7 +168,7 @@ export class LoopStore {
290
168
  }
291
169
  clearAll() {
292
170
  return this.withLock(() => {
293
- const ids = [...this.loops.keys()];
171
+ const ids = [...this.entries.keys()];
294
172
  for (const id of ids) {
295
173
  this.applyReducerEvent({
296
174
  type: "LOOP_DELETED",
@@ -304,13 +182,4 @@ export class LoopStore {
304
182
  return ids.length;
305
183
  });
306
184
  }
307
- deleteFileIfEmpty() {
308
- if (!this.filePath || this.loops.size > 0)
309
- return false;
310
- try {
311
- unlinkSync(this.filePath);
312
- }
313
- catch { /* ignore */ }
314
- return true;
315
- }
316
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,135 +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
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
11
- try {
12
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
13
- return;
14
- }
15
- catch (e) {
16
- if (e.code === "EEXIST") {
17
- try {
18
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
19
- if (!pid || !isProcessRunning(pid)) {
20
- try {
21
- unlinkSync(lockPath);
22
- }
23
- catch { /* ignore */ }
24
- continue;
25
- }
26
- }
27
- catch { /* ignore read errors */ }
28
- const start = Date.now();
29
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
30
- continue;
31
- }
32
- throw e;
33
- }
34
- }
35
- throw new Error(`Failed to acquire lock: ${lockPath}`);
36
- }
37
- function releaseLock(lockPath) {
38
- try {
39
- unlinkSync(lockPath);
40
- }
41
- catch { /* ignore */ }
42
- }
43
- function isProcessRunning(pid) {
44
- try {
45
- process.kill(pid, 0);
46
- return true;
47
- }
48
- catch {
49
- return false;
50
- }
51
- }
52
- export class TaskStore {
53
- filePath;
54
- lockPath;
55
- lastLoadedSignature;
56
- nextId = 1;
57
- tasks = new Map();
7
+ export class TaskStore extends ReducerBackedStore {
58
8
  constructor(listIdOrPath) {
59
- if (!listIdOrPath)
60
- return;
61
- const isAbsPath = isAbsolute(listIdOrPath);
62
- const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
63
- mkdirSync(dirname(filePath), { recursive: true });
64
- this.filePath = filePath;
65
- this.lockPath = filePath + ".lock";
66
- this.load();
67
- }
68
- getFileSignature() {
69
- if (!this.filePath || !existsSync(this.filePath))
70
- return undefined;
71
- const stat = statSync(this.filePath);
72
- return `${stat.mtimeMs}:${stat.size}`;
73
- }
74
- load(force = false) {
75
- if (!this.filePath)
76
- return;
77
- const signature = this.getFileSignature();
78
- if (!signature)
79
- return;
80
- if (!force && signature === this.lastLoadedSignature)
81
- return;
82
- try {
83
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
84
- this.nextId = data.nextId;
85
- this.tasks.clear();
86
- for (const task of data.tasks) {
87
- this.tasks.set(task.id, task);
88
- }
89
- this.lastLoadedSignature = signature;
90
- }
91
- catch { /* corrupt file — start fresh */ }
92
- }
93
- save() {
94
- if (!this.filePath)
95
- return;
96
- const data = {
97
- nextId: this.nextId,
98
- tasks: Array.from(this.tasks.values()),
99
- };
100
- const tmpPath = this.filePath + ".tmp";
101
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
102
- renameSync(tmpPath, this.filePath);
103
- this.lastLoadedSignature = this.getFileSignature();
104
- }
105
- withLock(fn) {
106
- if (!this.lockPath)
107
- return fn();
108
- acquireLock(this.lockPath);
109
- try {
110
- this.load(true);
111
- const result = fn();
112
- this.save();
113
- return result;
114
- }
115
- finally {
116
- releaseLock(this.lockPath);
117
- }
118
- }
119
- toReducerState() {
120
- return {
121
- nextId: this.nextId,
122
- tasksById: Object.fromEntries(this.tasks.entries()),
123
- };
124
- }
125
- applyReducerEvent(event) {
126
- const result = reduceTaskState(this.toReducerState(), event);
127
- this.nextId = result.state.nextId;
128
- 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);
129
17
  }
130
18
  create(subject, description, metadata) {
131
19
  return this.withLock(() => {
132
- if (this.tasks.size >= MAX_TASKS) {
20
+ if (this.entries.size >= MAX_TASKS) {
133
21
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
134
22
  }
135
23
  const now = Date.now();
@@ -140,22 +28,12 @@ export class TaskStore {
140
28
  entityType: "task",
141
29
  payload: { subject, description, metadata },
142
30
  });
143
- return this.tasks.get(String(this.nextId - 1));
31
+ return this.entries.get(String(this.nextId - 1));
144
32
  });
145
33
  }
146
- get(id) {
147
- if (this.filePath)
148
- this.load();
149
- return this.tasks.get(id);
150
- }
151
- list() {
152
- if (this.filePath)
153
- this.load();
154
- return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
155
- }
156
34
  start(id) {
157
35
  return this.withLock(() => {
158
- const entry = this.tasks.get(id);
36
+ const entry = this.entries.get(id);
159
37
  if (!entry)
160
38
  return undefined;
161
39
  this.applyReducerEvent({
@@ -166,12 +44,12 @@ export class TaskStore {
166
44
  entityId: id,
167
45
  payload: { id },
168
46
  });
169
- return this.tasks.get(id);
47
+ return this.entries.get(id);
170
48
  });
171
49
  }
172
50
  complete(id) {
173
51
  return this.withLock(() => {
174
- const entry = this.tasks.get(id);
52
+ const entry = this.entries.get(id);
175
53
  if (!entry)
176
54
  return undefined;
177
55
  this.applyReducerEvent({
@@ -182,12 +60,12 @@ export class TaskStore {
182
60
  entityId: id,
183
61
  payload: { id },
184
62
  });
185
- return this.tasks.get(id);
63
+ return this.entries.get(id);
186
64
  });
187
65
  }
188
66
  reopen(id) {
189
67
  return this.withLock(() => {
190
- const entry = this.tasks.get(id);
68
+ const entry = this.entries.get(id);
191
69
  if (!entry)
192
70
  return undefined;
193
71
  this.applyReducerEvent({
@@ -198,12 +76,12 @@ export class TaskStore {
198
76
  entityId: id,
199
77
  payload: { id },
200
78
  });
201
- return this.tasks.get(id);
79
+ return this.entries.get(id);
202
80
  });
203
81
  }
204
82
  updateDetails(id, fields) {
205
83
  return this.withLock(() => {
206
- const entry = this.tasks.get(id);
84
+ const entry = this.entries.get(id);
207
85
  if (!entry)
208
86
  return undefined;
209
87
  if (fields.subject === undefined && fields.description === undefined)
@@ -220,12 +98,12 @@ export class TaskStore {
220
98
  description: fields.description,
221
99
  },
222
100
  });
223
- return this.tasks.get(id);
101
+ return this.entries.get(id);
224
102
  });
225
103
  }
226
104
  delete(id) {
227
105
  return this.withLock(() => {
228
- if (!this.tasks.has(id))
106
+ if (!this.entries.has(id))
229
107
  return false;
230
108
  this.applyReducerEvent({
231
109
  type: "TASK_DELETED",
@@ -240,7 +118,7 @@ export class TaskStore {
240
118
  }
241
119
  pendingCount() {
242
120
  let count = 0;
243
- for (const t of this.tasks.values()) {
121
+ for (const t of this.entries.values()) {
244
122
  if (t.status === "pending" || t.status === "in_progress")
245
123
  count++;
246
124
  }
@@ -248,7 +126,7 @@ export class TaskStore {
248
126
  }
249
127
  pruneCompleted() {
250
128
  return this.withLock(() => {
251
- const before = this.tasks.size;
129
+ const before = this.entries.size;
252
130
  this.applyReducerEvent({
253
131
  type: "TASKS_PRUNED",
254
132
  at: Date.now(),
@@ -256,7 +134,7 @@ export class TaskStore {
256
134
  entityType: "task",
257
135
  payload: { reason: "manual" },
258
136
  });
259
- return before - this.tasks.size;
137
+ return before - this.entries.size;
260
138
  });
261
139
  }
262
140
  }
@@ -96,7 +96,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
96
96
  "## Task-driven workflows",
97
97
  "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
98
98
  "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
99
- "Set taskBacklog: true for task-worker loops that process the existing pending queue. Task-backlog loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
99
+ "Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
100
100
  "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
101
101
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
102
102
  ],
@@ -170,10 +170,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
170
170
  return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
171
171
  `Trigger: ${triggerDesc}\n` +
172
172
  `Recurring: ${entry.recurring}\n` +
173
- (entry.autoTask ? "Auto-task: enabled\n" : "") +
174
- (entry.taskBacklog ? "Task-backlog: enabled\n" : "") +
175
- (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
176
- (isTaskSystemReady() ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
173
+ (entry.autoTask ? "Auto-create task: enabled\n" : "") +
174
+ (entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
175
+ (bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
176
+ (isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
177
177
  `ID: ${entry.id} (use LoopDelete to cancel)`));
178
178
  },
179
179
  });
@@ -205,6 +205,8 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
205
205
  }
206
206
  if (entry.autoTask)
207
207
  line += " [auto-task]";
208
+ if (entry.taskBacklog)
209
+ line += " [backlog-worker]";
208
210
  lines.push(line);
209
211
  }
210
212
  return Promise.resolve(textResult(lines.join("\n")));
@@ -52,10 +52,19 @@ 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);
58
- onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
67
+ onDoneMsg = `\nCompletion wake loop #${doneLoop.id}: fires when the monitor completes — no polling needed`;
59
68
  }
60
69
  return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
61
70
  `Output stream: monitor:output (monitorId: ${entry.id})\n` +
@@ -70,10 +79,10 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
70
79
  execute() {
71
80
  const monitors = getMonitorManager().list();
72
81
  if (monitors.length === 0)
73
- return Promise.resolve(textResult("No monitors running."));
82
+ return Promise.resolve(textResult("No monitors."));
74
83
  const lines = [];
75
84
  for (const m of monitors) {
76
- const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
85
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "x";
77
86
  const age = Date.now() - m.startedAt;
78
87
  const ageStr = formatRemaining(age);
79
88
  let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
@@ -39,7 +39,7 @@ Fields:
39
39
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
40
40
  updateWidget();
41
41
  const autoLoopMsg = backlog.created && backlog.entry
42
- ? `\nWorker loop #${backlog.entry.id} auto-created`
42
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
43
43
  : "";
44
44
  return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
45
45
  },
@@ -108,7 +108,7 @@ Parameters: id (required), status, subject, description`,
108
108
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
109
109
  const statusMsg = status ? ` → ${status}` : "";
110
110
  const autoLoopMsg = backlog.created && backlog.entry
111
- ? `\nWorker loop #${backlog.entry.id} auto-created`
111
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
112
112
  : "";
113
113
  return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
114
114
  },
@@ -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;