@trevonistrevon/pi-loop 0.6.0 → 0.6.2
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.
- package/CHANGELOG.md +22 -0
- package/README.md +28 -143
- package/dist/commands/loop-command.d.ts +10 -2
- package/dist/commands/loop-command.js +84 -51
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -16
- package/dist/loop-format.d.ts +3 -0
- package/dist/loop-format.js +20 -0
- package/dist/loop-parse.d.ts +1 -0
- package/dist/loop-parse.js +45 -1
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.d.ts +1 -1
- package/dist/monitor-manager.js +13 -4
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +19 -7
- package/dist/runtime/task-backlog-runtime.d.ts +2 -1
- package/dist/runtime/task-backlog-runtime.js +13 -9
- package/dist/scheduler.js +25 -7
- package/dist/store.d.ts +9 -1
- package/dist/store.js +57 -0
- package/dist/tools/loop-tools.d.ts +8 -0
- package/dist/tools/loop-tools.js +104 -12
- package/dist/tools/monitor-tools.js +1 -1
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/docs/USAGE_GUIDE.md +185 -0
- package/package.json +13 -4
- package/src/commands/loop-command.ts +102 -54
- package/src/index.ts +46 -16
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +44 -1
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +12 -4
- package/src/notification-reducer.ts +3 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +15 -11
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +128 -12
- package/src/tools/monitor-tools.ts +1 -1
- package/src/trigger-system.ts +1 -1
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -123
- package/CONTRIBUTING.md +0 -79
- package/Dockerfile +0 -17
- package/benchmarks/experiment-sim.js +0 -30
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -36
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -191
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/api.ts.html +0 -193
- package/coverage/src/commands/index.html +0 -131
- package/coverage/src/commands/loop-command.ts.html +0 -634
- package/coverage/src/commands/tasks-command.ts.html +0 -490
- package/coverage/src/coordinator.ts.html +0 -430
- package/coverage/src/index.html +0 -341
- package/coverage/src/index.ts.html +0 -1249
- package/coverage/src/loop-parse.ts.html +0 -607
- package/coverage/src/loop-reducer.ts.html +0 -559
- package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
- package/coverage/src/monitor-manager.ts.html +0 -964
- package/coverage/src/monitor-reducer.ts.html +0 -583
- package/coverage/src/notification-reducer.ts.html +0 -550
- package/coverage/src/reducer-backed-store.ts.html +0 -589
- package/coverage/src/rpc/channels.ts.html +0 -376
- package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
- package/coverage/src/rpc/index.html +0 -131
- package/coverage/src/runtime/index.html +0 -251
- package/coverage/src/runtime/loop-events.ts.html +0 -265
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
- package/coverage/src/runtime/notification-runtime.ts.html +0 -721
- package/coverage/src/runtime/scope.ts.html +0 -196
- package/coverage/src/runtime/session-runtime.ts.html +0 -679
- package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-mutations.ts.html +0 -394
- package/coverage/src/runtime/task-rpc.ts.html +0 -523
- package/coverage/src/scheduler.ts.html +0 -400
- package/coverage/src/store.ts.html +0 -667
- package/coverage/src/task-backlog-coordinator.ts.html +0 -181
- package/coverage/src/task-reducer.ts.html +0 -550
- package/coverage/src/task-store.ts.html +0 -526
- package/coverage/src/tools/index.html +0 -161
- package/coverage/src/tools/loop-tools.ts.html +0 -991
- package/coverage/src/tools/monitor-tools.ts.html +0 -538
- package/coverage/src/tools/native-task-tools.ts.html +0 -493
- package/coverage/src/tools/tool-result.ts.html +0 -97
- package/coverage/src/trigger-system.ts.html +0 -547
- package/coverage/src/ui/index.html +0 -116
- package/coverage/src/ui/widget.ts.html +0 -292
- package/docker-compose.yml +0 -12
- package/docs/architecture/state-machine-migration.md +0 -502
- package/docs/architecture/state-machine-reducer-event-model.md +0 -822
- package/docs/architecture/state-machine-test-matrix.md +0 -249
- package/docs/architecture/state-machine-transition-map.md +0 -436
- package/release-please-config.json +0 -9
- package/vitest.config.ts +0 -29
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type TaskBacklogEvent,
|
|
9
9
|
} from "../task-backlog-coordinator.js";
|
|
10
10
|
import type { TaskStore } from "../task-store.js";
|
|
11
|
-
import type { LoopEntry, Trigger } from "../types.js";
|
|
11
|
+
import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
|
|
12
12
|
import {
|
|
13
13
|
buildLoopAutodeletedPayload,
|
|
14
14
|
buildTaskBacklogEmptyPayload,
|
|
@@ -27,6 +27,7 @@ export interface TaskBacklogRuntimeOptions {
|
|
|
27
27
|
maxFires?: number;
|
|
28
28
|
}) => LoopEntry;
|
|
29
29
|
deleteLoop: (id: string) => void;
|
|
30
|
+
recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
|
|
30
31
|
addTrigger: (entry: LoopEntry) => void;
|
|
31
32
|
removeTrigger: (id: string) => void;
|
|
32
33
|
updateWidget: () => void;
|
|
@@ -52,6 +53,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
52
53
|
getLoops,
|
|
53
54
|
createLoop,
|
|
54
55
|
deleteLoop,
|
|
56
|
+
recordDeletionTombstone,
|
|
55
57
|
addTrigger,
|
|
56
58
|
removeTrigger,
|
|
57
59
|
updateWidget,
|
|
@@ -79,6 +81,14 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
79
81
|
return getLoops().find(isAutoTaskWorkerLoop);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
function deleteTaskBacklogLoop(entry: LoopEntry, pendingCount: number): void {
|
|
85
|
+
debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
86
|
+
removeTrigger(entry.id);
|
|
87
|
+
recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
|
|
88
|
+
deleteLoop(entry.id);
|
|
89
|
+
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
async function cleanupTaskBacklogLoops(): Promise<number> {
|
|
83
93
|
const backlogLoops = getLoops().filter(isTaskBacklogLoop);
|
|
84
94
|
if (backlogLoops.length === 0) return 0;
|
|
@@ -86,15 +96,9 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
86
96
|
const pending = await hasPendingTasks();
|
|
87
97
|
if (pending < 0 || pending > 0) return 0;
|
|
88
98
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
-
for (const entry of backlogLoops) {
|
|
93
|
-
debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
94
|
-
removeTrigger(entry.id);
|
|
95
|
-
deleteLoop(entry.id);
|
|
96
|
-
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
|
|
97
|
-
}
|
|
99
|
+
const deletedLoopIds = backlogLoops.map((entry) => entry.id);
|
|
100
|
+
emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
|
|
101
|
+
for (const entry of backlogLoops) deleteTaskBacklogLoop(entry, pending);
|
|
98
102
|
updateWidget();
|
|
99
103
|
return backlogLoops.length;
|
|
100
104
|
}
|
|
@@ -107,7 +111,7 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
107
111
|
|
|
108
112
|
const trigger: Trigger = {
|
|
109
113
|
type: "hybrid",
|
|
110
|
-
cron: "*/
|
|
114
|
+
cron: "*/3 * * * *",
|
|
111
115
|
event: { source: "tasks:created" },
|
|
112
116
|
debounceMs: 30000,
|
|
113
117
|
};
|
package/src/scheduler.ts
CHANGED
|
@@ -8,6 +8,9 @@ function computeNextFire(entry: LoopEntry): Date {
|
|
|
8
8
|
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
9
9
|
return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
|
|
10
10
|
}
|
|
11
|
+
if (entry.trigger.type === "dynamic") {
|
|
12
|
+
return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
|
|
13
|
+
}
|
|
11
14
|
return new Date(Date.now() + 60000);
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -20,9 +23,19 @@ export class CronScheduler {
|
|
|
20
23
|
) {}
|
|
21
24
|
|
|
22
25
|
start(): void {
|
|
23
|
-
for (const
|
|
26
|
+
for (const storedEntry of this.store.list()) {
|
|
27
|
+
let entry = storedEntry;
|
|
24
28
|
if (entry.status !== "active") continue;
|
|
25
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
29
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
30
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
|
|
31
|
+
entry = this.store.updateDynamic(entry.id, {
|
|
32
|
+
dynamic: {
|
|
33
|
+
awaitingUpdate: false,
|
|
34
|
+
nextWakeAt: undefined,
|
|
35
|
+
lastUpdatedAt: Date.now(),
|
|
36
|
+
},
|
|
37
|
+
}) ?? entry;
|
|
38
|
+
}
|
|
26
39
|
this.armTimer(entry);
|
|
27
40
|
}
|
|
28
41
|
}
|
|
@@ -33,7 +46,7 @@ export class CronScheduler {
|
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
add(entry: LoopEntry): void {
|
|
36
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
49
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
37
50
|
this.armTimer(entry);
|
|
38
51
|
}
|
|
39
52
|
}
|
|
@@ -47,12 +60,14 @@ export class CronScheduler {
|
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
private armTimer(entry: LoopEntry): void {
|
|
50
|
-
const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
|
|
51
|
-
|
|
52
63
|
const nextFire = computeNextFire(entry);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
let jitter = 0;
|
|
65
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
66
|
+
const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
67
|
+
const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
|
|
68
|
+
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
69
|
+
jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
70
|
+
}
|
|
56
71
|
const fireTime = nextFire.getTime() + jitter;
|
|
57
72
|
|
|
58
73
|
if (fireTime > entry.expiresAt) {
|
|
@@ -73,6 +88,8 @@ export class CronScheduler {
|
|
|
73
88
|
continue;
|
|
74
89
|
}
|
|
75
90
|
|
|
91
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) continue;
|
|
92
|
+
|
|
76
93
|
if (filter && !filter(entry)) continue;
|
|
77
94
|
|
|
78
95
|
if (now >= entry.expiresAt) {
|
package/src/store.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
|
-
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
5
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
6
6
|
|
|
7
7
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
8
8
|
const MAX_LOOPS = 25;
|
|
9
|
+
const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
9
10
|
|
|
10
11
|
export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
12
|
+
private tombstones = new Map<string, LoopDeletionTombstone>();
|
|
13
|
+
|
|
11
14
|
constructor(listIdOrPath?: string) {
|
|
12
15
|
super(
|
|
13
16
|
{
|
|
@@ -22,7 +25,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
28
|
+
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number; dynamic?: Partial<DynamicLoopState> }): LoopEntry {
|
|
26
29
|
return this.withLock(() => {
|
|
27
30
|
if (this.entries.size >= MAX_LOOPS) {
|
|
28
31
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
@@ -41,6 +44,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
41
44
|
taskBacklog: opts.taskBacklog,
|
|
42
45
|
readOnly: opts.readOnly,
|
|
43
46
|
maxFires: opts.maxFires,
|
|
47
|
+
dynamic: opts.dynamic,
|
|
44
48
|
},
|
|
45
49
|
});
|
|
46
50
|
return this.entries.get(String(this.nextId - 1))!;
|
|
@@ -75,6 +79,22 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
75
79
|
entityId: id,
|
|
76
80
|
payload: { id },
|
|
77
81
|
});
|
|
82
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
|
|
83
|
+
this.applyReducerEvent({
|
|
84
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
85
|
+
at: Date.now(),
|
|
86
|
+
source: "tool",
|
|
87
|
+
entityType: "loop",
|
|
88
|
+
entityId: id,
|
|
89
|
+
payload: {
|
|
90
|
+
id,
|
|
91
|
+
dynamic: {
|
|
92
|
+
awaitingUpdate: false,
|
|
93
|
+
lastUpdatedAt: Date.now(),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
78
98
|
return this.entries.get(id);
|
|
79
99
|
});
|
|
80
100
|
}
|
|
@@ -119,6 +139,43 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
119
139
|
});
|
|
120
140
|
}
|
|
121
141
|
|
|
142
|
+
updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<DynamicLoopState> }): LoopEntry | undefined {
|
|
143
|
+
return this.withLock(() => {
|
|
144
|
+
if (!this.entries.has(id)) return undefined;
|
|
145
|
+
this.applyReducerEvent({
|
|
146
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
source: "tool",
|
|
149
|
+
entityType: "loop",
|
|
150
|
+
entityId: id,
|
|
151
|
+
payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
|
|
152
|
+
});
|
|
153
|
+
return this.entries.get(id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined {
|
|
158
|
+
const tombstone = this.tombstones.get(id);
|
|
159
|
+
if (!tombstone) return undefined;
|
|
160
|
+
if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS) return tombstone;
|
|
161
|
+
this.tombstones.delete(id);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined {
|
|
166
|
+
const entry = this.entries.get(id);
|
|
167
|
+
if (!entry) return undefined;
|
|
168
|
+
const tombstone: LoopDeletionTombstone = {
|
|
169
|
+
id,
|
|
170
|
+
reason: input.reason,
|
|
171
|
+
pendingCount: input.pendingCount,
|
|
172
|
+
deletedAt: Date.now(),
|
|
173
|
+
prompt: entry.prompt,
|
|
174
|
+
};
|
|
175
|
+
this.tombstones.set(id, tombstone);
|
|
176
|
+
return tombstone;
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
delete(id: string): boolean {
|
|
123
180
|
return this.withLock(() => {
|
|
124
181
|
if (!this.entries.has(id)) return false;
|
package/src/tools/loop-tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { formatTrigger } from "../loop-format.js";
|
|
3
4
|
import { parseInterval } from "../loop-parse.js";
|
|
4
5
|
import type { LoopEntry, Trigger } from "../types.js";
|
|
5
6
|
import { textResult } from "./tool-result.js";
|
|
@@ -15,6 +16,8 @@ interface LoopStoreLike {
|
|
|
15
16
|
maxFires?: number;
|
|
16
17
|
}): LoopEntry;
|
|
17
18
|
pause(id: string): LoopEntry | undefined;
|
|
19
|
+
updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<NonNullable<LoopEntry["dynamic"]>> }): LoopEntry | undefined;
|
|
20
|
+
getDeletionTombstone(id: string): { reason: string; pendingCount?: number } | undefined;
|
|
18
21
|
delete(id: string): boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -82,6 +85,89 @@ function formatRemaining(ms: number): string {
|
|
|
82
85
|
return `${Math.round(ms / 3600000)}h`;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
function parseDelayMs(input: string): number | undefined {
|
|
89
|
+
const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
90
|
+
if (!match) return undefined;
|
|
91
|
+
const value = Number.parseInt(match[1] ?? "", 10);
|
|
92
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
93
|
+
const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
|
|
94
|
+
return value * multiplier;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface LoopUpdateParams {
|
|
98
|
+
id: string;
|
|
99
|
+
status: "continue" | "completed" | "paused";
|
|
100
|
+
state?: string;
|
|
101
|
+
metrics?: string;
|
|
102
|
+
doneCriteria?: string;
|
|
103
|
+
nextInterval?: string;
|
|
104
|
+
prompt?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveNextWakeAt(nextInterval?: string): { nextWakeAt?: number; error?: string } {
|
|
108
|
+
if (!nextInterval) return { nextWakeAt: undefined };
|
|
109
|
+
const parsedDelayMs = parseDelayMs(nextInterval);
|
|
110
|
+
if (!parsedDelayMs) return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
|
|
111
|
+
return { nextWakeAt: Date.now() + parsedDelayMs };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatDynamicUpdateResult(id: string, iteration: number | undefined, nextWakeAt: number | undefined): string {
|
|
115
|
+
const mode = nextWakeAt === undefined
|
|
116
|
+
? "Next wake: when idle"
|
|
117
|
+
: `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
|
|
118
|
+
return `Dynamic loop #${id} updated\n` +
|
|
119
|
+
`Iteration: ${iteration ?? "?"}` +
|
|
120
|
+
`\n${mode}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatDeletionTombstone(id: string, tombstone: { reason: string; pendingCount?: number }): string {
|
|
124
|
+
const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
|
|
125
|
+
return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function continueDynamicLoop(
|
|
129
|
+
params: LoopUpdateParams,
|
|
130
|
+
entry: LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> },
|
|
131
|
+
store: LoopStoreLike,
|
|
132
|
+
triggerSystem: TriggerSystemLike,
|
|
133
|
+
): string {
|
|
134
|
+
const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
|
|
135
|
+
if (error) return error;
|
|
136
|
+
|
|
137
|
+
const updated = store.updateDynamic(params.id, {
|
|
138
|
+
prompt: params.prompt,
|
|
139
|
+
dynamic: {
|
|
140
|
+
goal: params.prompt ?? entry.dynamic.goal,
|
|
141
|
+
state: params.state,
|
|
142
|
+
metrics: params.metrics,
|
|
143
|
+
doneCriteria: params.doneCriteria,
|
|
144
|
+
iteration: (entry.dynamic.iteration ?? 0) + 1,
|
|
145
|
+
nextWakeAt,
|
|
146
|
+
awaitingUpdate: false,
|
|
147
|
+
lastUpdatedAt: Date.now(),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (updated) {
|
|
151
|
+
triggerSystem.remove(params.id);
|
|
152
|
+
triggerSystem.add(updated);
|
|
153
|
+
}
|
|
154
|
+
return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stopDynamicLoop(
|
|
158
|
+
params: LoopUpdateParams,
|
|
159
|
+
store: LoopStoreLike,
|
|
160
|
+
triggerSystem: TriggerSystemLike,
|
|
161
|
+
): string {
|
|
162
|
+
triggerSystem.remove(params.id);
|
|
163
|
+
if (params.status === "completed") {
|
|
164
|
+
store.delete(params.id);
|
|
165
|
+
return `Dynamic loop #${params.id} completed and deleted`;
|
|
166
|
+
}
|
|
167
|
+
store.pause(params.id);
|
|
168
|
+
return `Dynamic loop #${params.id} paused`;
|
|
169
|
+
}
|
|
170
|
+
|
|
85
171
|
export function registerLoopTools(options: LoopToolsOptions): void {
|
|
86
172
|
const {
|
|
87
173
|
pi,
|
|
@@ -216,11 +302,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
216
302
|
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
217
303
|
updateWidget();
|
|
218
304
|
|
|
219
|
-
const triggerDesc = trigger
|
|
220
|
-
? `schedule: ${trigger.schedule}`
|
|
221
|
-
: trigger.type === "event"
|
|
222
|
-
? `event: ${trigger.source}`
|
|
223
|
-
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
305
|
+
const triggerDesc = formatTrigger(trigger, "create");
|
|
224
306
|
|
|
225
307
|
return Promise.resolve(textResult(
|
|
226
308
|
`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
@@ -248,13 +330,9 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
248
330
|
|
|
249
331
|
const lines: string[] = [];
|
|
250
332
|
for (const entry of loops) {
|
|
251
|
-
const triggerDesc = entry.trigger
|
|
252
|
-
? `cron: ${entry.trigger.schedule}`
|
|
253
|
-
: entry.trigger.type === "event"
|
|
254
|
-
? `event: ${entry.trigger.source}`
|
|
255
|
-
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
333
|
+
const triggerDesc = formatTrigger(entry.trigger, "list");
|
|
256
334
|
|
|
257
|
-
const nextFire = entry.trigger.type
|
|
335
|
+
const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
|
|
258
336
|
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
259
337
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
260
338
|
line += ` (${triggerDesc})`;
|
|
@@ -271,6 +349,38 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
271
349
|
},
|
|
272
350
|
});
|
|
273
351
|
|
|
352
|
+
pi.registerTool({
|
|
353
|
+
name: "LoopUpdate",
|
|
354
|
+
label: "LoopUpdate",
|
|
355
|
+
description: `Update progress for a dynamic loop.
|
|
356
|
+
|
|
357
|
+
Use this after a dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval, "completed" when the goal is done, or "paused" to stop temporarily.`,
|
|
358
|
+
parameters: Type.Object({
|
|
359
|
+
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
360
|
+
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
361
|
+
state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
|
|
362
|
+
metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
|
|
363
|
+
doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
|
|
364
|
+
nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
|
|
365
|
+
prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
|
|
366
|
+
}),
|
|
367
|
+
execute(_toolCallId, params: LoopUpdateParams) {
|
|
368
|
+
const store = getStore();
|
|
369
|
+
const triggerSystem = getTriggerSystem();
|
|
370
|
+
const entry = store.get(params.id);
|
|
371
|
+
if (!entry) return Promise.resolve(textResult(`Loop #${params.id} not found`));
|
|
372
|
+
if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
|
|
373
|
+
return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const message = params.status === "continue"
|
|
377
|
+
? continueDynamicLoop(params, entry as LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> }, store, triggerSystem)
|
|
378
|
+
: stopDynamicLoop(params, store, triggerSystem);
|
|
379
|
+
updateWidget();
|
|
380
|
+
return Promise.resolve(textResult(message));
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
274
384
|
pi.registerTool({
|
|
275
385
|
name: "LoopDelete",
|
|
276
386
|
label: "LoopDelete",
|
|
@@ -286,7 +396,11 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
286
396
|
|
|
287
397
|
if (action === "pause") {
|
|
288
398
|
const entry = getStore().pause(id);
|
|
289
|
-
if (!entry)
|
|
399
|
+
if (!entry) {
|
|
400
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
401
|
+
if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
402
|
+
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
403
|
+
}
|
|
290
404
|
getTriggerSystem().remove(id);
|
|
291
405
|
updateWidget();
|
|
292
406
|
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
@@ -296,6 +410,8 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
296
410
|
const deleted = getStore().delete(id);
|
|
297
411
|
updateWidget();
|
|
298
412
|
if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
|
|
413
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
414
|
+
if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
299
415
|
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
300
416
|
},
|
|
301
417
|
});
|
|
@@ -57,7 +57,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
57
57
|
|
|
58
58
|
## onDone — auto-notify on completion
|
|
59
59
|
|
|
60
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake
|
|
60
|
+
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits, fails, or times out. The completion wake lets the agent inspect the final status and buffered output.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
|
|
61
61
|
promptGuidelines: [
|
|
62
62
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
63
63
|
"When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
|
package/src/trigger-system.ts
CHANGED
|
@@ -31,7 +31,7 @@ export class TriggerSystem {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
add(entry: LoopEntry): void {
|
|
34
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
34
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
35
35
|
this.scheduler.add(entry);
|
|
36
36
|
}
|
|
37
37
|
if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export type LoopDeletionReason = "task_backlog_empty";
|
|
2
|
+
|
|
3
|
+
export interface LoopDeletionTombstone {
|
|
4
|
+
id: string;
|
|
5
|
+
reason: LoopDeletionReason;
|
|
6
|
+
deletedAt: number;
|
|
7
|
+
prompt: string;
|
|
8
|
+
pendingCount?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
|
|
12
|
+
|
|
1
13
|
export type LoopStatus = "active" | "paused";
|
|
2
14
|
|
|
3
15
|
export interface CronTrigger {
|
|
@@ -18,7 +30,22 @@ export interface HybridTrigger {
|
|
|
18
30
|
debounceMs: number;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
export
|
|
33
|
+
export interface DynamicTrigger {
|
|
34
|
+
type: "dynamic";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
|
|
38
|
+
|
|
39
|
+
export interface DynamicLoopState {
|
|
40
|
+
goal: string;
|
|
41
|
+
state?: string;
|
|
42
|
+
metrics?: string;
|
|
43
|
+
doneCriteria?: string;
|
|
44
|
+
iteration: number;
|
|
45
|
+
nextWakeAt?: number;
|
|
46
|
+
awaitingUpdate?: boolean;
|
|
47
|
+
lastUpdatedAt?: number;
|
|
48
|
+
}
|
|
22
49
|
|
|
23
50
|
export interface LoopEntry {
|
|
24
51
|
id: string;
|
|
@@ -34,6 +61,7 @@ export interface LoopEntry {
|
|
|
34
61
|
readOnly?: boolean;
|
|
35
62
|
maxFires?: number;
|
|
36
63
|
fireCount?: number;
|
|
64
|
+
dynamic?: DynamicLoopState;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
export interface LoopStoreData {
|
package/AGENTS.md
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# pi-loop Development Guidelines
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
`pi-loop` is a pi extension providing cron/event-based agent re-wake loops and background process monitoring. Modeled after Claude Code's `/loop`, `CronCreate`, and `MonitorCreate` tools.
|
|
5
|
-
|
|
6
|
-
## Stack
|
|
7
|
-
- TypeScript 6.x (strict, ES2022 target, bundler module resolution)
|
|
8
|
-
- `typebox` for tool parameter validation
|
|
9
|
-
- `vitest` for tests
|
|
10
|
-
- `biome` for linting (linter: on, formatter: off)
|
|
11
|
-
- npm packaging as `@trevonistrevon/pi-loop`
|
|
12
|
-
|
|
13
|
-
## Architecture
|
|
14
|
-
```
|
|
15
|
-
src/
|
|
16
|
-
├── index.ts # Extension entry: tool/command registration, session wiring, native task fallback timer
|
|
17
|
-
├── api.ts # Public subpath (@trevonistrevon/pi-loop/api): RPC channels/DTOs, TaskStore, scope resolvers
|
|
18
|
-
├── types.ts # LoopKind, Trigger spec, LoopEntry, MonitorEntry, LoopConfig
|
|
19
|
-
├── task-types.ts # TaskEntry, TaskStatus, TaskStoreData
|
|
20
|
-
├── store.ts # File-backed CRUD (.pi/loops/loops.json) with file locking
|
|
21
|
-
├── task-store.ts # File-backed CRUD for native fallback tasks (.pi/tasks/tasks.json)
|
|
22
|
-
├── reducer-backed-store.ts # Shared atomic-write + pid-lock persistence layer for reducer-driven stores
|
|
23
|
-
├── coordinator.ts # ReducerSource/ReducerEvent/ReducerEffect plumbing shared by *-reducer/*-coordinator modules
|
|
24
|
-
├── loop-reducer.ts # Pure loop state transitions, incl. maxFires/expiry checks
|
|
25
|
-
├── monitor-reducer.ts # Pure monitor state transitions
|
|
26
|
-
├── notification-reducer.ts # Pure pending-notification state transitions
|
|
27
|
-
├── task-reducer.ts # Pure native task state transitions
|
|
28
|
-
├── monitor-completion-coordinator.ts # Reduces monitor "onDone" completion into a fire effect
|
|
29
|
-
├── task-backlog-coordinator.ts # Reduces pending-task counts into auto-create/auto-clean effects
|
|
30
|
-
├── scheduler.ts # Timer-based cron scheduler with jitter + 7-day expiry
|
|
31
|
-
├── trigger-system.ts # Unified trigger engine: cron timers + pi event subscriptions + hybrid
|
|
32
|
-
├── monitor-manager.ts # ChildProcess tracking, output buffering, event emission, stop
|
|
33
|
-
├── loop-parse.ts # Human interval → cron expression, next-fire computation, jitter
|
|
34
|
-
├── rpc/ # VENDORED — canonical copy shared verbatim with pi-orca; see "Cross-extension RPC" below
|
|
35
|
-
│ ├── channels.ts # TASKS_RPC/SUBAGENTS_RPC/TASK_EVENTS channel constants + wire DTOs
|
|
36
|
-
│ └── cross-extension-rpc.ts # rpcCall (client), rpcProbe (detection), handleRpc (server), PROTOCOL_VERSION, RpcError
|
|
37
|
-
├── runtime/ # Extension-init-time wiring, one module per concern
|
|
38
|
-
│ ├── loop-events.ts # Auto-delete payload types for backlog-drained loops
|
|
39
|
-
│ ├── monitor-ondone-runtime.ts # Coordinator wiring for MonitorCreate's onDone → loop fire
|
|
40
|
-
│ ├── native-task-rpc.ts # tasks:rpc:* server: ping/pending/create/clean/update over the native TaskStore
|
|
41
|
-
│ ├── notification-runtime.ts # Pending-notification buffering + idle-driven delivery
|
|
42
|
-
│ ├── scope.ts # PI_LOOP_SCOPE → loop/task store path resolution
|
|
43
|
-
│ ├── session-runtime.ts # Session-switch hooks, store (re)binding
|
|
44
|
-
│ ├── task-backlog-runtime.ts # Auto-create/auto-clean backlog worker evaluation
|
|
45
|
-
│ ├── task-events.ts # emitNativeTaskEvent: tasks:created/started/completed/reopened/updated/deleted
|
|
46
|
-
│ ├── task-mutations.ts # Shared mutation service: mutate → emit event → widget → backlog, used by RPC server and tools
|
|
47
|
-
│ └── task-rpc.ts # Client bridge: probes pi-tasks, falls back to native TaskStore for autoTask/pending/clean
|
|
48
|
-
├── tools/ # Tool implementations
|
|
49
|
-
│ ├── loop-tools.ts # LoopCreate/LoopList/LoopDelete
|
|
50
|
-
│ ├── monitor-tools.ts # MonitorCreate/MonitorList/MonitorStop
|
|
51
|
-
│ ├── native-task-tools.ts # TaskCreate/TaskList/TaskUpdate/TaskDelete (fallback only)
|
|
52
|
-
│ └── tool-result.ts # textResult() helper
|
|
53
|
-
├── commands/
|
|
54
|
-
│ ├── loop-command.ts # /loop interactive loop creation
|
|
55
|
-
│ └── tasks-command.ts # /tasks native task viewer/manager
|
|
56
|
-
└── ui/
|
|
57
|
-
└── widget.ts # Persistent widget: active loops + monitors + task summary
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Cross-extension RPC
|
|
61
|
-
`src/rpc/` is a **vendored** module: the canonical copy is maintained here and copied verbatim into the sibling `pi-orca` repo. If you edit `channels.ts` or `cross-extension-rpc.ts`, copy the change to `pi-orca` and bump the `VENDOR_REV` comment at the top of both files in both repos — the two copies must never drift.
|
|
62
|
-
|
|
63
|
-
The wire contract is request/reply over `pi.events`: a caller emits `{ requestId, ...params }` on a channel (e.g. `tasks:rpc:create`), the server replies on `<channel>:reply:<requestId>` with an envelope, `{success:true,data}` or `{success:false,error}`. `rpcCall` resolves/rejects that promise, `rpcProbe` swallows the rejection into `undefined` for presence detection, `handleRpc` is the server-side helper that turns a handler function into a channel subscription with automatic envelope wrapping. `PROTOCOL_VERSION` (currently `2`) is returned in ping replies so callers can gate on server capability.
|
|
64
|
-
|
|
65
|
-
`pi-loop`'s native tasks RPC server (`src/runtime/native-task-rpc.ts`) registers at extension init — not behind the native-tool-registration fallback timer — so early cross-extension calls never race it. It stands down (silent no-op via `isEnabled`) once an external `pi-tasks` is detected.
|
|
66
|
-
|
|
67
|
-
## Public API surface
|
|
68
|
-
External consumers must import `@trevonistrevon/pi-loop/api` (`src/api.ts`), never a deep `src/...` path. The package `exports` map in `package.json` enforces this — deep imports fail to resolve. `src/api.ts` re-exports the RPC channel constants/DTOs, `rpcCall`/`rpcProbe`/`handleRpc`/`RpcError`/`PROTOCOL_VERSION`, `TaskStore`, `TaskEntry`/`TaskStatus`, `resolveLoopStorePath`/`resolveTaskStorePath`, and `NATIVE_TASKS_PROVIDER`. Anything not re-exported there is internal and may change without notice.
|
|
69
|
-
|
|
70
|
-
## Conventions (mirror pi-tasks)
|
|
71
|
-
- No comments unless answering "why", never "what"
|
|
72
|
-
- `debug(...)` helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
|
|
73
|
-
- `textResult(msg)` helper for uniform tool output
|
|
74
|
-
- All tool params use `Type.Object()` with description strings
|
|
75
|
-
- Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
|
|
76
|
-
- Cross-extension communication via `pi.events` with `requestId` + reply channels
|
|
77
|
-
- File-backed stores use atomic write (write tmp → rename) + pid-based file locking
|
|
78
|
-
- Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
|
|
79
|
-
- Tests co-located in `test/`, named `<module>.test.ts`
|
|
80
|
-
|
|
81
|
-
## Tool Schema Discipline
|
|
82
|
-
- Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
|
|
83
|
-
- Example: `TaskUpdate` uses `id`, not `taskId`.
|
|
84
|
-
- When a tool validation error clearly indicates an immediately recoverable schema mismatch, correct it silently and retry. Do not emit user-facing chatter like "retrying with the correct shape" unless the recovery itself changes the user's understanding.
|
|
85
|
-
- When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
|
|
86
|
-
|
|
87
|
-
## File Locking Pattern
|
|
88
|
-
Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
|
|
89
|
-
|
|
90
|
-
## Trigger Types
|
|
91
|
-
Three trigger types, all stored as `LoopEntry.trigger`:
|
|
92
|
-
- `{ type: "cron", schedule: "*/5 * * * *" }` — timer-based
|
|
93
|
-
- `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
|
|
94
|
-
- `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
|
|
95
|
-
|
|
96
|
-
All cron/hybrid loops are dynamic: they track their next fire time but only deliver on agent idle (`agent_end`/`turn_start`) rather than wall-clock timers.
|
|
97
|
-
|
|
98
|
-
## Re-wake via In-Memory Pending Notifications
|
|
99
|
-
When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension buffers a pending notification in memory, re-checks whether the wake is still relevant, and only then injects a `pi.sendMessage()` custom message to wake the agent. Do not rely on early queued follow-up user messages for loop delivery; those are not extension-cancelable once handed to pi's queue.
|
|
100
|
-
|
|
101
|
-
All loops are idle-driven. Cron and hybrid loops track their next fire time but only deliver when the agent becomes idle (via `agent_end`/`turn_start`), resetting their timer from the actual delivery point.
|
|
102
|
-
|
|
103
|
-
## Monitor Streaming via PI Events
|
|
104
|
-
Monitor stdout/stderr lines are emitted as `pi.events("monitor:output", { monitorId, line, timestamp })`. Tool consumers subscribe to these events. Completion emits `"monitor:done"` / `"monitor:error"`.
|
|
105
|
-
|
|
106
|
-
## pi-tasks Integration
|
|
107
|
-
`pi-loop` probes for `@tintinweb/pi-tasks` at init via `tasks:rpc:ping` (see "Cross-extension RPC" above) and again on a `tasks:ready` listener for late binding. When an external provider answers, `LoopCreate` with `autoTask: true` calls `tasks:rpc:create` to create a tracked task on fire. When no external provider answers, `pi-loop`'s own native RPC server (`src/runtime/native-task-rpc.ts`) serves all five verbs — `ping`/`pending`/`create`/`clean`/`update` — against the native `TaskStore`, and `autoTask: true` creates a native task directly instead of over RPC.
|
|
108
|
-
|
|
109
|
-
## /loop Self-Paced Mode
|
|
110
|
-
When no interval is specified in `/loop prompt`, the loop runs in self-paced mode. The agent receives the prompt, acts on it, and uses `LoopCreate`/`LoopUpdate` to schedule the next iteration. The loop fires once, then the agent decides the next interval dynamically (matching Claude Code's dynamic interval behavior).
|
|
111
|
-
|
|
112
|
-
## Testing
|
|
113
|
-
- `vitest` with `describe`/`it` blocks
|
|
114
|
-
- In-memory stores for unit tests, `tmpdir` for file-backed tests
|
|
115
|
-
- Fake timers (`vi.useFakeTimers`) for scheduler tests
|
|
116
|
-
- Mock pi eventbus for monitor-manager tests
|
|
117
|
-
- `vitest run` in CI, `vitest` for watch mode
|
|
118
|
-
|
|
119
|
-
## Limits
|
|
120
|
-
- Maximum 25 active loops
|
|
121
|
-
- Maximum 25 running monitors
|
|
122
|
-
- 7-day expiry on recurring loops
|
|
123
|
-
- 5-minute default cron interval for self-paced mode
|