@trevonistrevon/pi-loop 0.5.9 → 0.6.1
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 +72 -0
- package/README.md +64 -5
- package/dist/api.d.ts +11 -0
- package/dist/api.js +10 -0
- package/dist/commands/loop-command.d.ts +9 -2
- package/dist/commands/loop-command.js +81 -51
- package/dist/commands/tasks-command.js +3 -3
- package/dist/coordinator.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +89 -19
- 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 +59 -6
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.js +1 -1
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/reducer-backed-store.d.ts +1 -1
- package/dist/reducer-backed-store.js +1 -1
- package/dist/rpc/channels.d.ts +73 -0
- package/dist/rpc/channels.js +30 -0
- package/dist/rpc/cross-extension-rpc.d.ts +59 -0
- package/dist/rpc/cross-extension-rpc.js +117 -0
- package/dist/runtime/loop-events.d.ts +26 -0
- package/dist/runtime/loop-events.js +26 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/native-task-rpc.d.ts +22 -0
- package/dist/runtime/native-task-rpc.js +50 -0
- 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 +5 -1
- package/dist/runtime/task-backlog-runtime.js +14 -7
- package/dist/runtime/task-mutations.d.ts +39 -0
- package/dist/runtime/task-mutations.js +68 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +40 -58
- 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 +105 -15
- package/dist/tools/monitor-tools.js +1 -3
- package/dist/tools/native-task-tools.d.ts +2 -6
- package/dist/tools/native-task-tools.js +25 -55
- package/dist/tools/tool-result.d.ts +8 -0
- package/dist/tools/tool-result.js +4 -0
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/package.json +15 -2
- package/src/api.ts +36 -0
- package/src/commands/loop-command.ts +100 -55
- package/src/commands/tasks-command.ts +3 -3
- package/src/coordinator.ts +1 -1
- package/src/index.ts +92 -19
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +62 -8
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +1 -1
- package/src/notification-reducer.ts +3 -0
- package/src/reducer-backed-store.ts +3 -3
- package/src/rpc/channels.ts +97 -0
- package/src/rpc/cross-extension-rpc.ts +152 -0
- package/src/runtime/loop-events.ts +60 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/native-task-rpc.ts +139 -0
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +25 -7
- package/src/runtime/task-mutations.ts +103 -0
- package/src/runtime/task-rpc.ts +48 -54
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +129 -16
- package/src/tools/monitor-tools.ts +1 -4
- package/src/tools/native-task-tools.ts +31 -52
- package/src/tools/tool-result.ts +4 -0
- package/src/trigger-system.ts +5 -5
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -83
- package/CONTRIBUTING.md +0 -69
- 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 -33
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -176
- 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/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/goal-coordinator.ts.html +0 -259
- package/coverage/src/goal-reducer.ts.html +0 -982
- package/coverage/src/goal-store.ts.html +0 -742
- package/coverage/src/goal-verifier.ts.html +0 -808
- package/coverage/src/index.html +0 -386
- package/coverage/src/index.ts.html +0 -1111
- package/coverage/src/loop-parse.ts.html +0 -574
- 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 -940
- 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/runtime/index.html +0 -206
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- 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 -574
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-rpc.ts.html +0 -541
- 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 -146
- package/coverage/src/tools/loop-tools.ts.html +0 -1000
- package/coverage/src/tools/monitor-tools.ts.html +0 -547
- package/coverage/src/tools/native-task-tools.ts.html +0 -556
- 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/dist/goal-coordinator.d.ts +0 -22
- package/dist/goal-coordinator.js +0 -28
- package/dist/goal-reducer.d.ts +0 -98
- package/dist/goal-reducer.js +0 -190
- package/dist/goal-store.d.ts +0 -22
- package/dist/goal-store.js +0 -188
- package/dist/goal-types.d.ts +0 -82
- package/dist/goal-types.js +0 -1
- package/dist/goal-verifier.d.ts +0 -20
- package/dist/goal-verifier.js +0 -198
- package/docker-compose.yml +0 -12
- package/docs/architecture/goal-state-schema.md +0 -505
- package/docs/architecture/state-machine-migration.md +0 -546
- package/docs/architecture/state-machine-reducer-event-model.md +0 -823
- 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/src/goal-coordinator.ts +0 -58
- package/src/goal-reducer.ts +0 -299
- package/src/goal-store.ts +0 -219
- package/src/goal-types.ts +0 -104
- package/src/goal-verifier.ts +0 -241
- package/vitest.config.ts +0 -29
package/src/tools/loop-tools.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
6
|
+
import { textResult } from "./tool-result.js";
|
|
5
7
|
|
|
6
8
|
interface LoopStoreLike {
|
|
7
9
|
list(): LoopEntry[];
|
|
@@ -14,6 +16,8 @@ interface LoopStoreLike {
|
|
|
14
16
|
maxFires?: number;
|
|
15
17
|
}): LoopEntry;
|
|
16
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;
|
|
17
21
|
delete(id: string): boolean;
|
|
18
22
|
}
|
|
19
23
|
|
|
@@ -46,10 +50,6 @@ export interface LoopToolsOptions {
|
|
|
46
50
|
isTaskSystemReady: () => boolean;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
function textResult(msg: string) {
|
|
50
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
53
|
function validateTrigger(trigger: Trigger): string | null {
|
|
54
54
|
if (trigger.type === "cron") {
|
|
55
55
|
const parts = trigger.schedule.trim().split(/\s+/);
|
|
@@ -85,6 +85,89 @@ function formatRemaining(ms: number): string {
|
|
|
85
85
|
return `${Math.round(ms / 3600000)}h`;
|
|
86
86
|
}
|
|
87
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
|
+
|
|
88
171
|
export function registerLoopTools(options: LoopToolsOptions): void {
|
|
89
172
|
const {
|
|
90
173
|
pi,
|
|
@@ -219,11 +302,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
219
302
|
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
220
303
|
updateWidget();
|
|
221
304
|
|
|
222
|
-
const triggerDesc = trigger
|
|
223
|
-
? `schedule: ${trigger.schedule}`
|
|
224
|
-
: trigger.type === "event"
|
|
225
|
-
? `event: ${trigger.source}`
|
|
226
|
-
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
305
|
+
const triggerDesc = formatTrigger(trigger, "create");
|
|
227
306
|
|
|
228
307
|
return Promise.resolve(textResult(
|
|
229
308
|
`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
@@ -251,13 +330,9 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
251
330
|
|
|
252
331
|
const lines: string[] = [];
|
|
253
332
|
for (const entry of loops) {
|
|
254
|
-
const triggerDesc = entry.trigger
|
|
255
|
-
? `cron: ${entry.trigger.schedule}`
|
|
256
|
-
: entry.trigger.type === "event"
|
|
257
|
-
? `event: ${entry.trigger.source}`
|
|
258
|
-
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
333
|
+
const triggerDesc = formatTrigger(entry.trigger, "list");
|
|
259
334
|
|
|
260
|
-
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;
|
|
261
336
|
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
262
337
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
263
338
|
line += ` (${triggerDesc})`;
|
|
@@ -274,6 +349,38 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
274
349
|
},
|
|
275
350
|
});
|
|
276
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
|
+
|
|
277
384
|
pi.registerTool({
|
|
278
385
|
name: "LoopDelete",
|
|
279
386
|
label: "LoopDelete",
|
|
@@ -289,7 +396,11 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
289
396
|
|
|
290
397
|
if (action === "pause") {
|
|
291
398
|
const entry = getStore().pause(id);
|
|
292
|
-
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
|
+
}
|
|
293
404
|
getTriggerSystem().remove(id);
|
|
294
405
|
updateWidget();
|
|
295
406
|
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
@@ -299,6 +410,8 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
299
410
|
const deleted = getStore().delete(id);
|
|
300
411
|
updateWidget();
|
|
301
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)));
|
|
302
415
|
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
303
416
|
},
|
|
304
417
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { LoopEntry, MonitorEntry, Trigger } from "../types.js";
|
|
4
|
+
import { textResult } from "./tool-result.js";
|
|
4
5
|
|
|
5
6
|
interface MonitorManagerLike {
|
|
6
7
|
list(): MonitorEntry[];
|
|
@@ -26,10 +27,6 @@ export interface MonitorToolsOptions {
|
|
|
26
27
|
handleMonitorDoneLoop: (doneLoop: LoopEntry, monitorId: string) => void;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function textResult(msg: string) {
|
|
30
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
30
|
function formatRemaining(ms: number): string {
|
|
34
31
|
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
35
32
|
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createTask,
|
|
5
|
+
deleteTask,
|
|
6
|
+
type TaskBacklogResult,
|
|
7
|
+
type TaskMutationContext,
|
|
8
|
+
updateTask,
|
|
9
|
+
} from "../runtime/task-mutations.js";
|
|
4
10
|
import { TaskStore } from "../task-store.js";
|
|
11
|
+
import type { TaskStatus } from "../task-types.js";
|
|
12
|
+
import { textResult } from "./tool-result.js";
|
|
5
13
|
|
|
6
|
-
export
|
|
7
|
-
created: boolean;
|
|
8
|
-
entry?: { id: string };
|
|
9
|
-
}
|
|
14
|
+
export type { TaskBacklogResult };
|
|
10
15
|
|
|
11
16
|
export interface NativeTaskToolsOptions {
|
|
12
17
|
pi: ExtensionAPI;
|
|
@@ -15,12 +20,15 @@ export interface NativeTaskToolsOptions {
|
|
|
15
20
|
updateWidget: () => void;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
function
|
|
19
|
-
return
|
|
23
|
+
function backlogSuffix(backlog: TaskBacklogResult): string {
|
|
24
|
+
return backlog.created && backlog.entry
|
|
25
|
+
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
26
|
+
: "";
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function registerNativeTaskTools(options: NativeTaskToolsOptions): void {
|
|
23
30
|
const { pi, taskStore, evaluateTaskBacklog, updateWidget } = options;
|
|
31
|
+
const mutationCtx: TaskMutationContext = { pi, taskStore, evaluateTaskBacklog, updateWidget };
|
|
24
32
|
|
|
25
33
|
pi.registerTool({
|
|
26
34
|
name: "TaskCreate",
|
|
@@ -47,15 +55,11 @@ Fields:
|
|
|
47
55
|
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
48
56
|
}),
|
|
49
57
|
async execute(_toolCallId, params) {
|
|
50
|
-
const entry =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const autoLoopMsg = backlog.created && backlog.entry
|
|
56
|
-
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
57
|
-
: "";
|
|
58
|
-
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
|
|
58
|
+
const { entry, backlog } = await createTask(mutationCtx, {
|
|
59
|
+
subject: params.subject,
|
|
60
|
+
description: params.description,
|
|
61
|
+
});
|
|
62
|
+
return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
|
|
59
63
|
},
|
|
60
64
|
});
|
|
61
65
|
|
|
@@ -104,34 +108,15 @@ Parameters: id (required), status, subject, description`,
|
|
|
104
108
|
}),
|
|
105
109
|
async execute(_toolCallId, params) {
|
|
106
110
|
const { id, status, subject, description } = params;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} else if (status === "completed") {
|
|
115
|
-
entry = taskStore.complete(id);
|
|
116
|
-
if (entry) emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
|
|
117
|
-
} else if (status === "pending") {
|
|
118
|
-
entry = taskStore.reopen(id);
|
|
119
|
-
if (entry) emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
|
|
123
|
-
if (subject !== undefined || description !== undefined) {
|
|
124
|
-
entry = taskStore.updateDetails(id, { subject, description });
|
|
125
|
-
if (entry) emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
|
|
126
|
-
}
|
|
127
|
-
if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
|
|
128
|
-
updateWidget();
|
|
129
|
-
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
111
|
+
const result = await updateTask(mutationCtx, {
|
|
112
|
+
id,
|
|
113
|
+
status: status as TaskStatus | undefined,
|
|
114
|
+
subject,
|
|
115
|
+
description,
|
|
116
|
+
});
|
|
117
|
+
if (!result) return textResult(`Task #${id} not found`);
|
|
130
118
|
const statusMsg = status ? ` → ${status}` : "";
|
|
131
|
-
|
|
132
|
-
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
133
|
-
: "";
|
|
134
|
-
return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
|
|
119
|
+
return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
|
|
135
120
|
},
|
|
136
121
|
});
|
|
137
122
|
|
|
@@ -143,15 +128,9 @@ Parameters: id (required), status, subject, description`,
|
|
|
143
128
|
id: Type.String({ description: "Task ID to delete" }),
|
|
144
129
|
}),
|
|
145
130
|
async execute(_toolCallId, params) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (deleted) {
|
|
150
|
-
if (existing) emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
|
|
151
|
-
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
152
|
-
return Promise.resolve(textResult(`Task #${params.id} deleted`));
|
|
153
|
-
}
|
|
154
|
-
return Promise.resolve(textResult(`Task #${params.id} not found`));
|
|
131
|
+
const result = await deleteTask(mutationCtx, params.id);
|
|
132
|
+
if (!result) return textResult(`Task #${params.id} not found`);
|
|
133
|
+
return textResult(`Task #${params.id} deleted`);
|
|
155
134
|
},
|
|
156
135
|
});
|
|
157
136
|
}
|
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") {
|
|
@@ -58,7 +58,7 @@ export class TriggerSystem {
|
|
|
58
58
|
}
|
|
59
59
|
const subs = this.eventSubscriptions.get(source)!;
|
|
60
60
|
|
|
61
|
-
const unsub = this.pi.events.on(source
|
|
61
|
+
const unsub = this.pi.events.on(source, (data: unknown) => {
|
|
62
62
|
if (entry.trigger.type === "hybrid") {
|
|
63
63
|
this.handleHybridFire(entry, data);
|
|
64
64
|
} else {
|
|
@@ -71,7 +71,7 @@ export class TriggerSystem {
|
|
|
71
71
|
subs.set(entry.id, unsub);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
private handleHybridFire(entry: LoopEntry, _data:
|
|
74
|
+
private handleHybridFire(entry: LoopEntry, _data: unknown): void {
|
|
75
75
|
const now = Date.now();
|
|
76
76
|
const last = this.lastFireTime.get(entry.id) ?? 0;
|
|
77
77
|
const debounceMs = entry.trigger.type === "hybrid" ? entry.trigger.debounceMs : 0;
|
|
@@ -123,7 +123,7 @@ export class TriggerSystem {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
private matchesFilter(data:
|
|
126
|
+
private matchesFilter(data: unknown, filter?: string): boolean {
|
|
127
127
|
if (!filter) return true;
|
|
128
128
|
|
|
129
129
|
if (filter.startsWith("regex:")) {
|
|
@@ -138,7 +138,7 @@ export class TriggerSystem {
|
|
|
138
138
|
try {
|
|
139
139
|
const parsed = JSON.parse(filter);
|
|
140
140
|
for (const [key, value] of Object.entries(parsed)) {
|
|
141
|
-
const dataValue = data?.[key];
|
|
141
|
+
const dataValue = (data as Record<string, unknown> | undefined)?.[key];
|
|
142
142
|
if (dataValue === undefined) return false;
|
|
143
143
|
if (typeof value === "object" && typeof dataValue === "object") {
|
|
144
144
|
if (JSON.stringify(value) !== JSON.stringify(dataValue)) return false;
|
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,83 +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: 6 tools + /loop /loops commands + widget
|
|
17
|
-
├── types.ts # LoopKind, Trigger spec, LoopEntry, MonitorEntry, LoopConfig
|
|
18
|
-
├── store.ts # File-backed CRUD (.pi/loops/loops.json) with file locking
|
|
19
|
-
├── scheduler.ts # Timer-based cron scheduler with jitter + 7-day expiry
|
|
20
|
-
├── trigger-system.ts # Unified trigger engine: cron timers + pi event subscriptions + hybrid
|
|
21
|
-
├── monitor-manager.ts # ChildProcess tracking, output buffering, event emission, stop
|
|
22
|
-
├── loop-parse.ts # Human interval → cron expression, next-fire computation, jitter
|
|
23
|
-
└── ui/
|
|
24
|
-
└── widget.ts # Persistent widget: active loops + monitors
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Conventions (mirror pi-tasks)
|
|
28
|
-
- No comments unless answering "why", never "what"
|
|
29
|
-
- `debug(...)` helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
|
|
30
|
-
- `textResult(msg)` helper for uniform tool output
|
|
31
|
-
- All tool params use `Type.Object()` with description strings
|
|
32
|
-
- Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
|
|
33
|
-
- Cross-extension communication via `pi.events` with `requestId` + reply channels
|
|
34
|
-
- File-backed stores use atomic write (write tmp → rename) + pid-based file locking
|
|
35
|
-
- Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
|
|
36
|
-
- Tests co-located in `test/`, named `<module>.test.ts`
|
|
37
|
-
|
|
38
|
-
## Tool Schema Discipline
|
|
39
|
-
- Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
|
|
40
|
-
- Example: `TaskUpdate` uses `id`, not `taskId`.
|
|
41
|
-
- 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.
|
|
42
|
-
- When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
|
|
43
|
-
|
|
44
|
-
## File Locking Pattern
|
|
45
|
-
Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
|
|
46
|
-
|
|
47
|
-
## Trigger Types
|
|
48
|
-
Three trigger types, all stored as `LoopEntry.trigger`:
|
|
49
|
-
- `{ type: "cron", schedule: "*/5 * * * *" }` — timer-based
|
|
50
|
-
- `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
|
|
51
|
-
- `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
|
|
52
|
-
|
|
53
|
-
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.
|
|
54
|
-
|
|
55
|
-
## Re-wake via In-Memory Pending Notifications
|
|
56
|
-
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.
|
|
57
|
-
|
|
58
|
-
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.
|
|
59
|
-
|
|
60
|
-
## Monitor Streaming via PI Events
|
|
61
|
-
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"`.
|
|
62
|
-
|
|
63
|
-
## pi-tasks Integration
|
|
64
|
-
When `@tintinweb/pi-tasks` is present, `LoopCreate` with `autoTask: true` fires an RPC to create a task. Communication via `pi.events`:
|
|
65
|
-
- `tasks:rpc:ping` on init → detect pi-tasks presence
|
|
66
|
-
- `tasks:ready` listener → late-binding detection
|
|
67
|
-
- `tasks:rpc:create` → auto-create task when loop fires (if `autoTask: true`)
|
|
68
|
-
|
|
69
|
-
## /loop Self-Paced Mode
|
|
70
|
-
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).
|
|
71
|
-
|
|
72
|
-
## Testing
|
|
73
|
-
- `vitest` with `describe`/`it` blocks
|
|
74
|
-
- In-memory stores for unit tests, `tmpdir` for file-backed tests
|
|
75
|
-
- Fake timers (`vi.useFakeTimers`) for scheduler tests
|
|
76
|
-
- Mock pi eventbus for monitor-manager tests
|
|
77
|
-
- `vitest run` in CI, `vitest` for watch mode
|
|
78
|
-
|
|
79
|
-
## Limits
|
|
80
|
-
- Maximum 25 active loops
|
|
81
|
-
- Maximum 25 running monitors
|
|
82
|
-
- 7-day expiry on recurring loops
|
|
83
|
-
- 5-minute default cron interval for self-paced mode
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# Contributing to @trevonistrevon/pi-loop
|
|
2
|
-
|
|
3
|
-
Thanks for contributing! This document covers local dev setup, conventions, and workflow.
|
|
4
|
-
|
|
5
|
-
## Getting Started
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
git clone https://github.com/trvon/pi-loop.git
|
|
9
|
-
cd pi-loop
|
|
10
|
-
npm install
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Scripts
|
|
14
|
-
|
|
15
|
-
| Command | Description |
|
|
16
|
-
|---|---|
|
|
17
|
-
| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) |
|
|
18
|
-
| `npm run lint` | Lint with Biome |
|
|
19
|
-
| `npm run lint:fix` | Auto-fix lint issues |
|
|
20
|
-
| `npm test` | Run tests (`vitest run`) |
|
|
21
|
-
| `npm run test:watch` | Watch mode |
|
|
22
|
-
| `npm run build` | Compile TypeScript |
|
|
23
|
-
|
|
24
|
-
## Architecture
|
|
25
|
-
|
|
26
|
-
See `AGENTS.md` for the full architecture overview. Key modules:
|
|
27
|
-
|
|
28
|
-
- **`src/index.ts`** — Extension entry: registers 6 tools + `/loop` `/loops` commands + widget lifecycle
|
|
29
|
-
- **`src/types.ts`** — Core types: `LoopEntry`, `MonitorEntry`, `Trigger` variants
|
|
30
|
-
- **`src/store.ts`** — File-backed CRUD with pid-based file locking (atomic write → rename)
|
|
31
|
-
- **`src/scheduler.ts`** — Timer-based cron scheduler with per-loop jitter and 7-day expiry
|
|
32
|
-
- **`src/trigger-system.ts`** — Unified engine: cron timers + pi event subscriptions + hybrid debounce
|
|
33
|
-
- **`src/monitor-manager.ts`** — `ChildProcess` wrapper: stdout/stderr streaming as pi events
|
|
34
|
-
- **`src/loop-parse.ts`** — Human interval parsing (`5m` → cron), cron matching, next-fire computation
|
|
35
|
-
- **`src/ui/widget.ts`** — TUI widget rendering active loops + monitors above the editor
|
|
36
|
-
|
|
37
|
-
## Conventions
|
|
38
|
-
|
|
39
|
-
- **TypeScript 6.x** strict mode, ES2022 target, bundler module resolution
|
|
40
|
-
- **No comments unless answering "why"** — never "what"
|
|
41
|
-
- **`debug(...)`** helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
|
|
42
|
-
- **`textResult(msg)`** helper for uniform tool output strings
|
|
43
|
-
- **Tool params** use `Type.Object()` with description strings from `typebox`
|
|
44
|
-
- **File-backed stores** use atomic write (write tmp → rename) + pid-based file locking
|
|
45
|
-
|
|
46
|
-
## Testing
|
|
47
|
-
|
|
48
|
-
Tests are co-located in `test/` as `<module>.test.ts`. The suite uses:
|
|
49
|
-
|
|
50
|
-
- **vitest** with `describe`/`it`
|
|
51
|
-
- In-memory stores for unit tests
|
|
52
|
-
- `tmpdir` for file-backed store tests
|
|
53
|
-
- `vi.useFakeTimers()` for scheduler tests
|
|
54
|
-
- Mocked pi eventbus for monitor-manager tests
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
npm test # Run once
|
|
58
|
-
npm run test:watch # Watch mode
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Pull Request Workflow
|
|
62
|
-
|
|
63
|
-
1. Fork, branch, implement
|
|
64
|
-
2. Ensure `npm run typecheck`, `npm run lint`, and `npm test` all pass
|
|
65
|
-
3. Open PR against `main`
|
|
66
|
-
|
|
67
|
-
## License
|
|
68
|
-
|
|
69
|
-
By contributing, you agree that your contributions will be licensed under the MIT License.
|
package/Dockerfile
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
FROM node:22-alpine AS base
|
|
2
|
-
WORKDIR /app
|
|
3
|
-
COPY package.json package-lock.json ./
|
|
4
|
-
RUN npm ci
|
|
5
|
-
COPY . .
|
|
6
|
-
|
|
7
|
-
FROM node:20-alpine AS node20
|
|
8
|
-
WORKDIR /app
|
|
9
|
-
COPY package.json package-lock.json ./
|
|
10
|
-
RUN npm ci
|
|
11
|
-
COPY . .
|
|
12
|
-
|
|
13
|
-
FROM node:22-alpine AS node22
|
|
14
|
-
WORKDIR /app
|
|
15
|
-
COPY package.json package-lock.json ./
|
|
16
|
-
RUN npm ci
|
|
17
|
-
COPY . .
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Synthetic benchmark — mimics a research experiment with periodic progress output.
|
|
4
|
-
* Prints "iteration N/M, metric=0.XX" every 200ms for 30 iterations.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const TOTAL = 30;
|
|
8
|
-
const INTERVAL_MS = 200;
|
|
9
|
-
|
|
10
|
-
let iteration = 0;
|
|
11
|
-
|
|
12
|
-
const timer = setInterval(() => {
|
|
13
|
-
iteration++;
|
|
14
|
-
const metric = (Math.random() * 0.5 + 0.3).toFixed(4);
|
|
15
|
-
console.log(`iteration ${iteration}/${TOTAL}, loss=${metric}, timestamp=${Date.now()}`);
|
|
16
|
-
|
|
17
|
-
if (iteration >= TOTAL) {
|
|
18
|
-
console.log(`experiment complete: best loss=0.${Math.floor(Math.random() * 1000).toString().padStart(4, "0")}`);
|
|
19
|
-
clearInterval(timer);
|
|
20
|
-
process.exit(0);
|
|
21
|
-
}
|
|
22
|
-
}, INTERVAL_MS);
|
|
23
|
-
|
|
24
|
-
process.on("SIGTERM", () => {
|
|
25
|
-
console.log("experiment interrupted — saving checkpoint");
|
|
26
|
-
clearInterval(timer);
|
|
27
|
-
process.exit(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
console.log("experiment starting: total_iterations=30, interval_ms=200");
|