@trevonistrevon/pi-loop 0.5.0 → 0.5.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/README.md +11 -10
- package/dist/commands/loop-command.d.ts +22 -0
- package/dist/commands/loop-command.js +148 -0
- package/dist/commands/tasks-command.d.ts +15 -0
- package/dist/commands/tasks-command.js +117 -0
- package/dist/goal-coordinator.d.ts +22 -0
- package/dist/goal-coordinator.js +28 -0
- package/dist/goal-reducer.d.ts +89 -0
- package/dist/goal-reducer.js +181 -0
- package/dist/goal-store.d.ts +31 -0
- package/dist/goal-store.js +298 -0
- package/dist/goal-types.d.ts +4 -0
- package/dist/index.js +125 -1212
- package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
- package/dist/runtime/monitor-ondone-runtime.js +49 -0
- package/dist/runtime/notification-runtime.d.ts +34 -0
- package/dist/runtime/notification-runtime.js +152 -0
- package/dist/runtime/scope.d.ts +8 -0
- package/dist/runtime/scope.js +33 -0
- package/dist/runtime/session-runtime.d.ts +39 -0
- package/dist/runtime/session-runtime.js +110 -0
- package/dist/runtime/task-backlog-runtime.d.ts +36 -0
- package/dist/runtime/task-backlog-runtime.js +105 -0
- package/dist/runtime/task-rpc.d.ts +19 -0
- package/dist/runtime/task-rpc.js +118 -0
- package/dist/store.d.ts +5 -4
- package/dist/store.js +50 -44
- package/dist/task-store.d.ts +6 -4
- package/dist/task-store.js +64 -47
- package/dist/tools/loop-tools.d.ts +41 -0
- package/dist/tools/loop-tools.js +241 -0
- package/dist/tools/monitor-tools.d.ts +25 -0
- package/dist/tools/monitor-tools.js +110 -0
- package/dist/tools/native-task-tools.d.ts +15 -0
- package/dist/tools/native-task-tools.js +127 -0
- package/package.json +1 -1
- package/src/commands/loop-command.ts +184 -0
- package/src/commands/tasks-command.ts +135 -0
- package/src/goal-coordinator.ts +58 -0
- package/src/goal-reducer.ts +280 -0
- package/src/goal-store.ts +315 -0
- package/src/goal-types.ts +5 -0
- package/src/index.ts +129 -1299
- package/src/runtime/monitor-ondone-runtime.ts +75 -0
- package/src/runtime/notification-runtime.ts +212 -0
- package/src/runtime/scope.ts +37 -0
- package/src/runtime/session-runtime.ts +168 -0
- package/src/runtime/task-backlog-runtime.ts +163 -0
- package/src/runtime/task-rpc.ts +150 -0
- package/src/store.ts +51 -45
- package/src/task-store.ts +61 -46
- package/src/tools/loop-tools.ts +304 -0
- package/src/tools/monitor-tools.ts +145 -0
- package/src/tools/native-task-tools.ts +144 -0
package/src/index.ts
CHANGED
|
@@ -14,35 +14,25 @@
|
|
|
14
14
|
* /loops — Interactive menu: view, create, cancel, settings
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import { Type } from "typebox";
|
|
21
|
-
import {
|
|
22
|
-
createCoordinator,
|
|
23
|
-
type ReducerEffect,
|
|
24
|
-
type ReducerEvent,
|
|
25
|
-
type ReducerHandler,
|
|
26
|
-
} from "./coordinator.js";
|
|
27
|
-
import { parseInterval } from "./loop-parse.js";
|
|
28
|
-
import {
|
|
29
|
-
type MonitorCompletionEvent,
|
|
30
|
-
reduceMonitorCompletionEvent,
|
|
31
|
-
} from "./monitor-completion-coordinator.js";
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { registerLoopCommand } from "./commands/loop-command.js";
|
|
19
|
+
import { registerTasksCommand } from "./commands/tasks-command.js";
|
|
32
20
|
import { MonitorManager } from "./monitor-manager.js";
|
|
21
|
+
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
|
|
33
22
|
import {
|
|
34
|
-
|
|
35
|
-
type
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} from "./
|
|
23
|
+
createNotificationRuntime,
|
|
24
|
+
type LoopFireEvent,
|
|
25
|
+
} from "./runtime/notification-runtime.js";
|
|
26
|
+
import { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
|
|
27
|
+
import { registerSessionRuntimeHooks } from "./runtime/session-runtime.js";
|
|
28
|
+
import { createTaskBacklogRuntime } from "./runtime/task-backlog-runtime.js";
|
|
29
|
+
import { createTaskRuntimeBridge } from "./runtime/task-rpc.js";
|
|
39
30
|
import { CronScheduler } from "./scheduler.js";
|
|
40
31
|
import { LoopStore } from "./store.js";
|
|
41
|
-
import {
|
|
42
|
-
reduceTaskBacklogEvent,
|
|
43
|
-
type TaskBacklogEvent,
|
|
44
|
-
} from "./task-backlog-coordinator.js";
|
|
45
32
|
import { TaskStore } from "./task-store.js";
|
|
33
|
+
import { registerLoopTools } from "./tools/loop-tools.js";
|
|
34
|
+
import { registerMonitorTools } from "./tools/monitor-tools.js";
|
|
35
|
+
import { registerNativeTaskTools } from "./tools/native-task-tools.js";
|
|
46
36
|
import { TriggerSystem } from "./trigger-system.js";
|
|
47
37
|
import type { LoopEntry, Trigger } from "./types.js";
|
|
48
38
|
import { LoopWidget } from "./ui/widget.js";
|
|
@@ -52,57 +42,14 @@ function debug(...args: unknown[]) {
|
|
|
52
42
|
if (DEBUG) console.error("[pi-loop]", ...args);
|
|
53
43
|
}
|
|
54
44
|
|
|
55
|
-
function textResult(msg: string) {
|
|
56
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface LoopFireEvent {
|
|
60
|
-
loopId: string;
|
|
61
|
-
prompt: string;
|
|
62
|
-
trigger: Trigger | string;
|
|
63
|
-
timestamp: number;
|
|
64
|
-
readOnly?: boolean;
|
|
65
|
-
recurring?: boolean;
|
|
66
|
-
autoTask?: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface SessionSwitchEvent {
|
|
70
|
-
reason?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
interface PendingNotification extends LoopFireEvent {
|
|
74
|
-
key: string;
|
|
75
|
-
message: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
45
|
export default function (pi: ExtensionAPI) {
|
|
79
46
|
const piLoopEnv = process.env.PI_LOOP;
|
|
80
47
|
const piLoopScope = process.env.PI_LOOP_SCOPE as "memory" | "session" | "project" | undefined;
|
|
81
48
|
let loopScope: "memory" | "session" | "project" = piLoopScope ?? "session";
|
|
82
49
|
|
|
83
|
-
|
|
84
|
-
if (piLoopEnv === "off") return undefined;
|
|
85
|
-
if (piLoopEnv?.startsWith("/")) return piLoopEnv;
|
|
86
|
-
if (piLoopEnv?.startsWith(".")) return resolve(piLoopEnv);
|
|
87
|
-
if (piLoopEnv) return piLoopEnv;
|
|
88
|
-
if (loopScope === "memory") return undefined;
|
|
89
|
-
if (loopScope === "session" && sessionId) {
|
|
90
|
-
return join(process.cwd(), ".pi", "loops", `loops-${sessionId}.json`);
|
|
91
|
-
}
|
|
92
|
-
if (loopScope === "session") return undefined;
|
|
93
|
-
return join(process.cwd(), ".pi", "loops", "loops.json");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function resolveTaskStorePath(sessionId?: string): string | undefined {
|
|
97
|
-
if (loopScope === "memory") return undefined;
|
|
98
|
-
if (loopScope === "session" && sessionId) {
|
|
99
|
-
return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`);
|
|
100
|
-
}
|
|
101
|
-
if (loopScope === "session") return undefined;
|
|
102
|
-
return join(process.cwd(), ".pi", "tasks", "tasks.json");
|
|
103
|
-
}
|
|
50
|
+
const getScopeOptions = () => ({ piLoopEnv, loopScope });
|
|
104
51
|
|
|
105
|
-
let store = new LoopStore(
|
|
52
|
+
let store = new LoopStore(resolveLoopStorePath(getScopeOptions()));
|
|
106
53
|
const monitorManager = new MonitorManager(pi);
|
|
107
54
|
let scheduler: CronScheduler;
|
|
108
55
|
let triggerSystem: TriggerSystem;
|
|
@@ -128,215 +75,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
75
|
let nativeTaskStore: TaskStore | undefined;
|
|
129
76
|
let nativeTasksRegistered = false;
|
|
130
77
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
pi.events.emit("tasks:rpc:ping", { requestId });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
checkTasksVersion();
|
|
143
|
-
pi.events.on("tasks:ready", () => checkTasksVersion());
|
|
144
|
-
|
|
145
|
-
async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
|
|
146
|
-
if (!entry.autoTask) return undefined;
|
|
147
|
-
if (tasksAvailable) {
|
|
148
|
-
try {
|
|
149
|
-
const requestId = randomUUID();
|
|
150
|
-
const taskId = await new Promise<string | undefined>((resolve, _reject) => {
|
|
151
|
-
const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
|
|
152
|
-
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
|
|
153
|
-
unsub(); clearTimeout(timer);
|
|
154
|
-
const reply = raw as { success: boolean; data?: { id: string }; error?: string };
|
|
155
|
-
if (reply.success && reply.data) resolve(reply.data.id);
|
|
156
|
-
else resolve(undefined);
|
|
157
|
-
});
|
|
158
|
-
pi.events.emit("tasks:rpc:create", {
|
|
159
|
-
requestId,
|
|
160
|
-
subject: entry.prompt.slice(0, 80),
|
|
161
|
-
description: `Auto-created from loop #${entry.id}`,
|
|
162
|
-
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
return taskId;
|
|
166
|
-
} catch {
|
|
167
|
-
return undefined;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (!nativeTaskStore) return undefined;
|
|
171
|
-
const task = nativeTaskStore.create(entry.prompt.slice(0, 80), `Auto-created from loop #${entry.id}`, {
|
|
172
|
-
loopId: entry.id,
|
|
173
|
-
trigger: entry.trigger,
|
|
174
|
-
});
|
|
175
|
-
widget.update();
|
|
176
|
-
return task.id;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function hasPendingTasks(): Promise<number> {
|
|
180
|
-
if (tasksAvailable) {
|
|
181
|
-
try {
|
|
182
|
-
const requestId = randomUUID();
|
|
183
|
-
const count = await new Promise<number>((resolve) => {
|
|
184
|
-
const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
|
|
185
|
-
const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
|
|
186
|
-
unsub(); clearTimeout(timer);
|
|
187
|
-
const reply = raw as { success: boolean; data?: { pending: number }; error?: string };
|
|
188
|
-
resolve(reply.success && reply.data ? reply.data.pending : -1);
|
|
189
|
-
});
|
|
190
|
-
pi.events.emit("tasks:rpc:pending", { requestId });
|
|
191
|
-
});
|
|
192
|
-
return count;
|
|
193
|
-
} catch {
|
|
194
|
-
return -1;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return nativeTaskStore ? nativeTaskStore.pendingCount() : -1;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function cleanDoneTasks(): Promise<void> {
|
|
201
|
-
if (tasksAvailable) {
|
|
202
|
-
try {
|
|
203
|
-
const requestId = randomUUID();
|
|
204
|
-
await new Promise<void>((resolve) => {
|
|
205
|
-
const timer = setTimeout(() => { unsub(); resolve(); }, 3000);
|
|
206
|
-
const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
|
|
207
|
-
unsub(); clearTimeout(timer);
|
|
208
|
-
debug("tasks:rpc:clean — done tasks swept");
|
|
209
|
-
resolve();
|
|
210
|
-
});
|
|
211
|
-
pi.events.emit("tasks:rpc:clean", { requestId });
|
|
212
|
-
});
|
|
213
|
-
} catch { /* timeout or error, ignore */ }
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (nativeTaskStore) {
|
|
217
|
-
nativeTaskStore.sweepCompleted();
|
|
78
|
+
const taskRuntime = createTaskRuntimeBridge({
|
|
79
|
+
pi,
|
|
80
|
+
isTasksAvailable: () => tasksAvailable,
|
|
81
|
+
setTasksAvailable: (available) => {
|
|
82
|
+
if (available) tasksAvailable = true;
|
|
83
|
+
},
|
|
84
|
+
getNativeTaskStore: () => nativeTaskStore,
|
|
85
|
+
onNativeTaskCreated: () => {
|
|
218
86
|
widget.update();
|
|
219
|
-
await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
let notificationState: NotificationReducerState = {
|
|
224
|
-
notificationsByKey: {},
|
|
225
|
-
agentRunning: false,
|
|
226
|
-
hasPendingMessages: false,
|
|
227
|
-
};
|
|
228
|
-
let flushPromise: Promise<void> | undefined;
|
|
229
|
-
let notificationCoordinatorDelivered = false;
|
|
230
|
-
let notificationCoordinatorDeliveredSuccessfully = false;
|
|
231
|
-
|
|
232
|
-
const notificationReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
|
|
233
|
-
const result = reduceNotificationState(notificationState, incoming as NotificationReducerEvent);
|
|
234
|
-
notificationState = result.state;
|
|
235
|
-
return result.effects;
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const notificationCoordinator = createCoordinator({
|
|
239
|
-
reducers: [notificationReducerHandler],
|
|
240
|
-
effectHandlers: {
|
|
241
|
-
REQUEST_NOTIFICATION_FLUSH: () => {},
|
|
242
|
-
DELIVER_NOTIFICATION: async (effect: ReducerEffect) => {
|
|
243
|
-
notificationCoordinatorDelivered = true;
|
|
244
|
-
notificationCoordinatorDeliveredSuccessfully = await deliverNotification(
|
|
245
|
-
(effect.payload as { notification: ReducerNotification }).notification,
|
|
246
|
-
);
|
|
247
|
-
},
|
|
248
87
|
},
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (incoming.type !== "MONITOR_ONDONE_TRIGGERED") return [];
|
|
253
|
-
return reduceMonitorCompletionEvent(incoming as MonitorCompletionEvent);
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const monitorCompletionCoordinator = createCoordinator({
|
|
257
|
-
reducers: [monitorCompletionReducerHandler],
|
|
258
|
-
effectHandlers: {
|
|
259
|
-
DELIVER_MONITOR_ONDONE_WAKE: (effect: ReducerEffect) => {
|
|
260
|
-
const { loopId, monitorId } = effect.payload as { loopId: string; monitorId: string };
|
|
261
|
-
const current = store.get(loopId);
|
|
262
|
-
if (!current) return;
|
|
263
|
-
debug(`onDone loop #${loopId} — monitor #${monitorId} completed, delivering through coordinator`);
|
|
264
|
-
onLoopFire(current);
|
|
265
|
-
store.delete(loopId);
|
|
266
|
-
},
|
|
88
|
+
onNativeTasksPruned: async (taskStore) => {
|
|
89
|
+
widget.update();
|
|
90
|
+
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
267
91
|
},
|
|
92
|
+
debug,
|
|
268
93
|
});
|
|
269
94
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
let taskBacklogCoordinatorCleanupCount = 0;
|
|
95
|
+
taskRuntime.checkTasksVersion();
|
|
96
|
+
pi.events.on("tasks:ready", () => taskRuntime.checkTasksVersion());
|
|
273
97
|
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
};
|
|
98
|
+
const autoCreateTask = taskRuntime.autoCreateTask;
|
|
99
|
+
const hasPendingTasks = taskRuntime.hasPendingTasks;
|
|
100
|
+
const cleanDoneTasks = taskRuntime.cleanDoneTasks;
|
|
278
101
|
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
},
|
|
286
|
-
CLEANUP_TASK_BACKLOG_LOOPS: async () => {
|
|
287
|
-
taskBacklogCoordinatorCleanupCount = await cleanupTaskBacklogLoops();
|
|
288
|
-
},
|
|
289
|
-
},
|
|
102
|
+
const notificationRuntime = createNotificationRuntime({
|
|
103
|
+
pi,
|
|
104
|
+
hasPendingTasks: () => hasPendingTasks(),
|
|
105
|
+
cleanDoneTasks: () => cleanDoneTasks(),
|
|
106
|
+
getHasPendingMessages: () => _latestCtx?.hasPendingMessages() ?? false,
|
|
107
|
+
debug,
|
|
290
108
|
});
|
|
291
109
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
at: Date.now(),
|
|
302
|
-
source: "system",
|
|
303
|
-
entityType: "notification",
|
|
304
|
-
payload: {
|
|
305
|
-
agentRunning: options?.agentRunning ?? notificationState.agentRunning,
|
|
306
|
-
hasPendingMessages: options?.hasPendingMessages ?? (_latestCtx?.hasPendingMessages() ?? false),
|
|
307
|
-
},
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function buildLoopFireMessage(data: LoopFireEvent): string {
|
|
312
|
-
const triggerInfo = typeof data.trigger === "string"
|
|
313
|
-
? data.trigger
|
|
314
|
-
: data.trigger?.type === "cron"
|
|
315
|
-
? `schedule: ${data.trigger.schedule}`
|
|
316
|
-
: data.trigger?.type === "event"
|
|
317
|
-
? `event: ${data.trigger.source}`
|
|
318
|
-
: "hybrid";
|
|
319
|
-
|
|
320
|
-
const loopId = data.loopId || "?";
|
|
321
|
-
const prompt = data.prompt || "loop fired";
|
|
322
|
-
const constraint = data.readOnly
|
|
323
|
-
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
324
|
-
: "";
|
|
325
|
-
|
|
326
|
-
return [
|
|
327
|
-
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
328
|
-
prompt,
|
|
329
|
-
].join("\n");
|
|
330
|
-
}
|
|
110
|
+
const monitorOnDoneRuntime = createMonitorOnDoneRuntime({
|
|
111
|
+
monitorManager,
|
|
112
|
+
getLoop: (id) => store.get(id),
|
|
113
|
+
deleteLoop: (id) => {
|
|
114
|
+
store.delete(id);
|
|
115
|
+
},
|
|
116
|
+
onLoopFire,
|
|
117
|
+
debug,
|
|
118
|
+
});
|
|
331
119
|
|
|
332
|
-
function buildPendingNotification(data: LoopFireEvent): PendingNotification {
|
|
333
|
-
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
334
|
-
return {
|
|
335
|
-
...data,
|
|
336
|
-
key,
|
|
337
|
-
message: buildLoopFireMessage(data),
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
120
|
|
|
341
121
|
function triggerHasEventSource(trigger: Trigger | string, source: string): boolean {
|
|
342
122
|
if (typeof trigger === "string") return false;
|
|
@@ -367,76 +147,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
367
147
|
return true;
|
|
368
148
|
}
|
|
369
149
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
autoTask: notification.autoTask,
|
|
391
|
-
timestamp: notification.timestamp,
|
|
392
|
-
},
|
|
393
|
-
}, {
|
|
394
|
-
deliverAs: "steer",
|
|
395
|
-
triggerTurn: true,
|
|
396
|
-
});
|
|
397
|
-
return true;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async function flushPendingNotifications(options?: { ignorePendingMessages?: boolean }): Promise<void> {
|
|
401
|
-
if (flushPromise) return flushPromise;
|
|
402
|
-
|
|
403
|
-
flushPromise = (async () => {
|
|
404
|
-
syncNotificationRuntimeState({
|
|
405
|
-
hasPendingMessages: _latestCtx?.hasPendingMessages() ?? false,
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
while (true) {
|
|
409
|
-
notificationCoordinatorDelivered = false;
|
|
410
|
-
notificationCoordinatorDeliveredSuccessfully = false;
|
|
411
|
-
await notificationCoordinator.dispatch({
|
|
412
|
-
type: "NOTIFICATION_FLUSH_REQUESTED",
|
|
413
|
-
at: Date.now(),
|
|
414
|
-
source: "system",
|
|
415
|
-
entityType: "notification",
|
|
416
|
-
payload: { ignorePendingMessages: options?.ignorePendingMessages },
|
|
417
|
-
});
|
|
418
|
-
if (!notificationCoordinatorDelivered) return;
|
|
419
|
-
if (notificationCoordinatorDeliveredSuccessfully) return;
|
|
420
|
-
}
|
|
421
|
-
})().finally(() => {
|
|
422
|
-
flushPromise = undefined;
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
return flushPromise;
|
|
426
|
-
}
|
|
150
|
+
const taskBacklogRuntime = createTaskBacklogRuntime({
|
|
151
|
+
getLoops: () => store.list(),
|
|
152
|
+
createLoop: (trigger, prompt, options) => store.create(trigger, prompt, options),
|
|
153
|
+
deleteLoop: (id) => {
|
|
154
|
+
store.delete(id);
|
|
155
|
+
},
|
|
156
|
+
addTrigger: (entry) => {
|
|
157
|
+
triggerSystem.add(entry);
|
|
158
|
+
},
|
|
159
|
+
removeTrigger: (id) => {
|
|
160
|
+
triggerSystem.remove(id);
|
|
161
|
+
},
|
|
162
|
+
updateWidget: () => {
|
|
163
|
+
widget.update();
|
|
164
|
+
},
|
|
165
|
+
hasPendingTasks: () => hasPendingTasks(),
|
|
166
|
+
bootstrapTaskLoop: (entry) => maybeBootstrapTaskLoop(entry),
|
|
167
|
+
triggerHasEventSource,
|
|
168
|
+
debug,
|
|
169
|
+
});
|
|
427
170
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
at: notification.timestamp,
|
|
433
|
-
source: "system",
|
|
434
|
-
entityType: "notification",
|
|
435
|
-
entityId: notification.key,
|
|
436
|
-
payload: { notification },
|
|
437
|
-
});
|
|
438
|
-
await flushPendingNotifications();
|
|
439
|
-
}
|
|
171
|
+
const flushPendingNotifications = notificationRuntime.flushPendingNotifications;
|
|
172
|
+
const queueOrDeliverNotification = notificationRuntime.queueOrDeliverNotification;
|
|
173
|
+
const cleanupTaskBacklogLoops = taskBacklogRuntime.cleanupTaskBacklogLoops;
|
|
174
|
+
const evaluateTaskBacklog = taskBacklogRuntime.evaluateTaskBacklog;
|
|
440
175
|
|
|
441
176
|
// ── Loop fire handler ──
|
|
442
177
|
|
|
@@ -448,7 +183,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
448
183
|
store.delete(entry.id);
|
|
449
184
|
return;
|
|
450
185
|
}
|
|
451
|
-
store.
|
|
186
|
+
store.fire(entry.id);
|
|
452
187
|
|
|
453
188
|
if (entry.autoTask) {
|
|
454
189
|
autoCreateTask(entry).then((taskId) => {
|
|
@@ -469,136 +204,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
469
204
|
|
|
470
205
|
// ── Session lifecycle ──
|
|
471
206
|
|
|
472
|
-
let storeUpgraded = false;
|
|
473
|
-
let persistedShown = false;
|
|
474
207
|
let _latestCtx: ExtensionContext | undefined;
|
|
475
208
|
let _sessionId: string | undefined;
|
|
476
209
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
210
|
+
registerSessionRuntimeHooks({
|
|
211
|
+
pi,
|
|
212
|
+
getLoopScope: () => loopScope,
|
|
213
|
+
getPiLoopEnv: () => piLoopEnv,
|
|
214
|
+
recreateSessionStore: (sessionId: string) => {
|
|
215
|
+
const path = resolveLoopStorePath(getScopeOptions(), sessionId);
|
|
482
216
|
store = new LoopStore(path);
|
|
483
217
|
widget.setStore(store);
|
|
484
218
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
485
219
|
triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function showPersistedLoops(_isResume = false) {
|
|
491
|
-
if (persistedShown) return;
|
|
492
|
-
persistedShown = true;
|
|
493
|
-
const sessionStartedAt = Date.now();
|
|
494
|
-
const loops = store.list();
|
|
495
|
-
if (loops.length > 0) {
|
|
496
|
-
store.clearExpired();
|
|
497
|
-
store.expireEventLoops(sessionStartedAt);
|
|
498
|
-
triggerSystem.start();
|
|
499
|
-
widget.update();
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
pi.on("turn_start", async (_event, ctx) => {
|
|
504
|
-
_latestCtx = ctx;
|
|
505
|
-
_sessionId = ctx.sessionManager.getSessionId();
|
|
506
|
-
widget.setUICtx(ctx.ui);
|
|
507
|
-
upgradeStoreIfNeeded(ctx);
|
|
508
|
-
widget.update();
|
|
509
|
-
await pumpLoops();
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
pi.on("before_agent_start", async (_event, ctx) => {
|
|
513
|
-
_latestCtx = ctx;
|
|
514
|
-
widget.setUICtx(ctx.ui);
|
|
515
|
-
upgradeStoreIfNeeded(ctx);
|
|
516
|
-
showPersistedLoops();
|
|
517
|
-
widget.update();
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
pi.on("agent_start", async (_event, ctx) => {
|
|
521
|
-
syncNotificationRuntimeState({ agentRunning: true, hasPendingMessages: ctx.hasPendingMessages() });
|
|
522
|
-
_latestCtx = ctx;
|
|
523
|
-
widget.setUICtx(ctx.ui);
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
527
|
-
_latestCtx = ctx;
|
|
528
|
-
widget.setUICtx(ctx.ui);
|
|
529
|
-
syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: ctx.hasPendingMessages() });
|
|
530
|
-
await flushPendingNotifications({ ignorePendingMessages: true });
|
|
531
|
-
await cleanupTaskBacklogLoops();
|
|
532
|
-
await pumpLoops();
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
pi.on("session_shutdown", async () => {
|
|
536
|
-
syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: false });
|
|
537
|
-
applyNotificationEvent({
|
|
538
|
-
type: "NOTIFICATION_CLEARED",
|
|
539
|
-
at: Date.now(),
|
|
540
|
-
source: "session",
|
|
541
|
-
entityType: "notification",
|
|
542
|
-
payload: { reason: "session_shutdown" },
|
|
543
|
-
});
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
|
|
547
|
-
_latestCtx = ctx;
|
|
548
|
-
widget.setUICtx(ctx.ui);
|
|
549
|
-
triggerSystem.stop();
|
|
550
|
-
syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: false });
|
|
551
|
-
applyNotificationEvent({
|
|
552
|
-
type: "NOTIFICATION_CLEARED",
|
|
553
|
-
at: Date.now(),
|
|
554
|
-
source: "session",
|
|
555
|
-
entityType: "notification",
|
|
556
|
-
payload: { reason: "session_switch" },
|
|
557
|
-
});
|
|
558
|
-
_sessionId = undefined;
|
|
559
|
-
|
|
560
|
-
const isResume = event?.reason === "resume";
|
|
561
|
-
storeUpgraded = false;
|
|
562
|
-
persistedShown = false;
|
|
563
|
-
|
|
564
|
-
if (!isResume && loopScope === "memory") {
|
|
220
|
+
},
|
|
221
|
+
clearAllLoops: () => {
|
|
565
222
|
store.clearAll();
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
await cleanDoneTasks();
|
|
223
|
+
},
|
|
224
|
+
getStore: () => store,
|
|
225
|
+
getScheduler: () => scheduler,
|
|
226
|
+
getTriggerSystem: () => triggerSystem,
|
|
227
|
+
setLatestCtx: (ctx) => {
|
|
228
|
+
_latestCtx = ctx;
|
|
229
|
+
},
|
|
230
|
+
setSessionId: (sessionId) => {
|
|
231
|
+
_sessionId = sessionId;
|
|
232
|
+
},
|
|
233
|
+
widget,
|
|
234
|
+
notificationRuntime,
|
|
235
|
+
flushPendingNotifications,
|
|
236
|
+
cleanupTaskBacklogLoops,
|
|
237
|
+
hasPendingTasks,
|
|
238
|
+
cleanDoneTasks,
|
|
584
239
|
});
|
|
585
240
|
|
|
586
|
-
// ── Dynamic loop pump — fires cron/hybrid loops on idle instead of wall-clock timers ──
|
|
587
|
-
|
|
588
|
-
async function pumpLoops(): Promise<void> {
|
|
589
|
-
const pendingTasks = new Map<string, boolean>();
|
|
590
|
-
for (const entry of store.list()) {
|
|
591
|
-
if (entry.status !== "active") continue;
|
|
592
|
-
if (!entry.autoTask) continue;
|
|
593
|
-
if (entry.trigger.type !== "cron" && entry.trigger.type !== "hybrid") continue;
|
|
594
|
-
const nextFire = scheduler.nextFire(entry.id);
|
|
595
|
-
if (!nextFire || Date.now() < nextFire) continue;
|
|
596
|
-
const pending = await hasPendingTasks();
|
|
597
|
-
if (pending <= 0) pendingTasks.set(entry.id, true);
|
|
598
|
-
}
|
|
599
|
-
scheduler.pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
600
|
-
}
|
|
601
|
-
|
|
602
241
|
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
603
242
|
|
|
604
243
|
pi.events.on("loop:fire", async (event: unknown) => {
|
|
@@ -616,874 +255,65 @@ export default function (pi: ExtensionAPI) {
|
|
|
616
255
|
await queueOrDeliverNotification(data);
|
|
617
256
|
});
|
|
618
257
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
|
|
627
|
-
|
|
628
|
-
Use this tool whenever the user asks to:
|
|
629
|
-
- "create a loop" to check something periodically
|
|
630
|
-
- "run every X seconds/minutes/hours"
|
|
631
|
-
- "remind me to check..."
|
|
632
|
-
- "watch for..." or "when X happens, do Y"
|
|
633
|
-
- "schedule a recurring check"
|
|
634
|
-
- set up a periodic monitor or poller
|
|
635
|
-
- has a task list with open items — create a loop to work through tasks automatically
|
|
636
|
-
|
|
637
|
-
DO NOT use raw Bash loops (for/sleep/while). Use LoopCreate instead — it integrates with the session lifecycle, survives across turns, and the scheduler handles timing.
|
|
638
|
-
|
|
639
|
-
## When NOT to Use
|
|
640
|
-
|
|
641
|
-
Skip this tool when the task is a one-off check (just do it directly) or when the user wants a purely reactive hook.
|
|
642
|
-
|
|
643
|
-
## Trigger Types
|
|
644
|
-
|
|
645
|
-
- **cron**: time-based. "30s" (rounded to 1m), "5m", "2h", "1d", or full cron like "0 9 * * 1-5"
|
|
646
|
-
- **event**: fires on pi events like "tool_execution_start", "before_agent_start"
|
|
647
|
-
- **hybrid**: both cron + event with debounce
|
|
648
|
-
|
|
649
|
-
## Parameters
|
|
650
|
-
|
|
651
|
-
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
652
|
-
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
653
|
-
- **recurring**: repeat or fire once (default: true)
|
|
654
|
-
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
655
|
-
- **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
|
|
656
|
-
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
657
|
-
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
|
|
658
|
-
promptGuidelines: [
|
|
659
|
-
"Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
|
|
660
|
-
"## Choosing trigger type",
|
|
661
|
-
"Prefer event triggers over cron when possible — they fire exactly when needed instead of polling.",
|
|
662
|
-
"Use event triggers for: tool completion ('tool_execution_end'), task creation ('tasks:created'), monitor completion ('monitor:done').",
|
|
663
|
-
"Use cron triggers only when: the user explicitly asks for a time interval, or there's no relevant pi event to subscribe to.",
|
|
664
|
-
"Hybrid triggers (cron + event) give you both: event-driven responsiveness with a cron safety-net fallback.",
|
|
665
|
-
"## Choosing an interval",
|
|
666
|
-
"Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
|
|
667
|
-
"## maxFires — prevent infinite token burn",
|
|
668
|
-
"Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
|
|
669
|
-
"When a loop fires and finds nothing to do, call LoopDelete on its own ID to stop it — don't keep polling.",
|
|
670
|
-
"## readOnly mode",
|
|
671
|
-
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
672
|
-
"## Task-driven workflows",
|
|
673
|
-
"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.",
|
|
674
|
-
"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.",
|
|
675
|
-
"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.",
|
|
676
|
-
"When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
|
|
677
|
-
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
678
|
-
],
|
|
679
|
-
parameters: Type.Object({
|
|
680
|
-
trigger: Type.String({ description: "Cron expression (e.g., '5m', '1h', '0 9 * * 1-5'), event source (e.g., 'tool_execution_start'), or JSON hybrid spec" }),
|
|
681
|
-
prompt: Type.String({ description: "Prompt to run when the loop fires" }),
|
|
682
|
-
recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
|
|
683
|
-
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
684
|
-
taskBacklog: Type.Optional(Type.Boolean({ description: "Mark as a task-backlog worker loop that auto-deletes when pending tasks reach zero", default: false })),
|
|
685
|
-
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
686
|
-
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
687
|
-
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
688
|
-
maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
|
|
689
|
-
}),
|
|
690
|
-
|
|
691
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
692
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, taskBacklog, triggerType, debounceMs, readOnly, maxFires } = params;
|
|
693
|
-
|
|
694
|
-
let trigger: Trigger;
|
|
695
|
-
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
696
|
-
|
|
697
|
-
if (inferred === "cron") {
|
|
698
|
-
const parsed = parseInterval(triggerInput);
|
|
699
|
-
trigger = { type: "cron", schedule: parsed.cron };
|
|
700
|
-
} else if (inferred === "event") {
|
|
701
|
-
trigger = { type: "event", source: triggerInput };
|
|
702
|
-
} else {
|
|
703
|
-
const cronPart = triggerInput.match(/cron:?\s*(\S+)/)?.[1] || triggerInput;
|
|
704
|
-
const eventPart = triggerInput.match(/event:?\s*(\S+)/)?.[1];
|
|
705
|
-
const parsed = parseInterval(cronPart);
|
|
706
|
-
trigger = {
|
|
707
|
-
type: "hybrid",
|
|
708
|
-
cron: parsed.cron,
|
|
709
|
-
event: { source: eventPart || "tool_execution_start" },
|
|
710
|
-
debounceMs: debounceMs ?? 30000,
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
const validationError = validateTrigger(trigger);
|
|
715
|
-
if (validationError) return Promise.resolve(textResult(validationError));
|
|
716
|
-
|
|
717
|
-
const entry = store.create(trigger, prompt, {
|
|
718
|
-
recurring: recurring ?? (inferred !== "event"),
|
|
719
|
-
autoTask,
|
|
720
|
-
taskBacklog,
|
|
721
|
-
readOnly,
|
|
722
|
-
maxFires,
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
triggerSystem.add(entry);
|
|
726
|
-
|
|
727
|
-
if (trigger.type === "event" && trigger.source === "monitor:done" && trigger.filter) {
|
|
728
|
-
try {
|
|
729
|
-
const filterObj = JSON.parse(trigger.filter);
|
|
730
|
-
const monitorId = filterObj.monitorId as string | undefined;
|
|
731
|
-
if (monitorId) {
|
|
732
|
-
const monitor = monitorManager.get(monitorId);
|
|
733
|
-
if (monitor && monitor.status !== "running") {
|
|
734
|
-
debug(`loop #${entry.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
|
|
735
|
-
triggerSystem.remove(entry.id);
|
|
736
|
-
store.delete(entry.id);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
} catch { /* filter parse failure, ignore */ }
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
743
|
-
|
|
258
|
+
registerLoopTools({
|
|
259
|
+
pi,
|
|
260
|
+
getStore: () => store,
|
|
261
|
+
getTriggerSystem: () => triggerSystem,
|
|
262
|
+
getScheduler: () => scheduler,
|
|
263
|
+
getMonitorManager: () => monitorManager,
|
|
264
|
+
updateWidget: () => {
|
|
744
265
|
widget.update();
|
|
745
|
-
|
|
746
|
-
const triggerDesc = trigger.type === "cron"
|
|
747
|
-
? `schedule: ${trigger.schedule}`
|
|
748
|
-
: trigger.type === "event"
|
|
749
|
-
? `event: ${trigger.source}`
|
|
750
|
-
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
751
|
-
|
|
752
|
-
return Promise.resolve(textResult(
|
|
753
|
-
`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
754
|
-
`Trigger: ${triggerDesc}\n` +
|
|
755
|
-
`Recurring: ${entry.recurring}\n` +
|
|
756
|
-
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
757
|
-
(entry.taskBacklog ? `Task-backlog: enabled\n` : "") +
|
|
758
|
-
(bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
|
|
759
|
-
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
760
|
-
`ID: ${entry.id} (use LoopDelete to cancel)`
|
|
761
|
-
));
|
|
762
266
|
},
|
|
267
|
+
maybeBootstrapTaskLoop,
|
|
268
|
+
isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
|
|
763
269
|
});
|
|
764
270
|
|
|
765
271
|
function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
|
|
766
|
-
|
|
767
|
-
void monitorCompletionCoordinator.dispatch({
|
|
768
|
-
type: "MONITOR_ONDONE_TRIGGERED",
|
|
769
|
-
at: Date.now(),
|
|
770
|
-
source: "monitor",
|
|
771
|
-
entityType: "monitor",
|
|
772
|
-
entityId: monitorId,
|
|
773
|
-
payload: { loopId: doneLoop.id, monitorId },
|
|
774
|
-
});
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
const registered = monitorManager.onComplete(monitorId, deliver);
|
|
778
|
-
if (registered) return;
|
|
779
|
-
|
|
780
|
-
const monitor = monitorManager.get(monitorId);
|
|
781
|
-
if (monitor && monitor.status !== "running") {
|
|
782
|
-
if (monitor.status === "completed") {
|
|
783
|
-
deliver();
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
debug(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
|
|
787
|
-
store.delete(doneLoop.id);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function validateTrigger(trigger: Trigger): string | null {
|
|
792
|
-
if (trigger.type === "cron") {
|
|
793
|
-
const parts = trigger.schedule.trim().split(/\s+/);
|
|
794
|
-
if (parts.length !== 5) {
|
|
795
|
-
return `Invalid cron trigger. Expected 5 fields, got ${parts.length}: "${trigger.schedule}". Use formats like "5m", "1h", "0 9 * * 1-5", or set triggerType to "event" for event sources.`;
|
|
796
|
-
}
|
|
797
|
-
} else if (trigger.type === "event") {
|
|
798
|
-
if (!trigger.source || trigger.source.trim().length === 0) {
|
|
799
|
-
return `Invalid event trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
800
|
-
}
|
|
801
|
-
} else if (trigger.type === "hybrid") {
|
|
802
|
-
const cronParts = trigger.cron.trim().split(/\s+/);
|
|
803
|
-
if (cronParts.length !== 5) {
|
|
804
|
-
return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
|
|
805
|
-
}
|
|
806
|
-
if (!trigger.event.source || trigger.event.source.trim().length === 0) {
|
|
807
|
-
return `Invalid hybrid trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return null;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
function inferTriggerType(input: string): "cron" | "event" | "hybrid" {
|
|
814
|
-
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event"))) return "hybrid";
|
|
815
|
-
if (/^\d+\s*[smhd]$/i.test(input.trim())) return "cron";
|
|
816
|
-
if (/^(\*|\d+)/.test(input.trim()) && input.trim().split(/\s+/).length === 5) return "cron";
|
|
817
|
-
return "event";
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// ──────────────────────────────────────────────────
|
|
821
|
-
// Tool 2: LoopList
|
|
822
|
-
// ──────────────────────────────────────────────────
|
|
823
|
-
|
|
824
|
-
pi.registerTool({
|
|
825
|
-
name: "LoopList",
|
|
826
|
-
label: "LoopList",
|
|
827
|
-
description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
|
|
828
|
-
|
|
829
|
-
Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
|
|
830
|
-
parameters: Type.Object({}),
|
|
831
|
-
|
|
832
|
-
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
833
|
-
const loops = store.list();
|
|
834
|
-
if (loops.length === 0) return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
|
|
835
|
-
|
|
836
|
-
const lines: string[] = [];
|
|
837
|
-
for (const entry of loops) {
|
|
838
|
-
const triggerDesc = entry.trigger.type === "cron"
|
|
839
|
-
? `cron: ${entry.trigger.schedule}`
|
|
840
|
-
: entry.trigger.type === "event"
|
|
841
|
-
? `event: ${entry.trigger.source}`
|
|
842
|
-
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
843
|
-
|
|
844
|
-
const nextFire = entry.trigger.type !== "event"
|
|
845
|
-
? scheduler.nextFire(entry.id)
|
|
846
|
-
: undefined;
|
|
847
|
-
|
|
848
|
-
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
849
|
-
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
850
|
-
line += ` (${triggerDesc})`;
|
|
851
|
-
if (nextFire) {
|
|
852
|
-
const remaining = Math.max(0, nextFire - Date.now());
|
|
853
|
-
line += ` next: ${formatRemaining(remaining)}`;
|
|
854
|
-
}
|
|
855
|
-
if (entry.autoTask) line += " [auto-task]";
|
|
856
|
-
lines.push(line);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
return Promise.resolve(textResult(lines.join("\n")));
|
|
860
|
-
},
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
function formatRemaining(ms: number): string {
|
|
864
|
-
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
865
|
-
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
866
|
-
return `${Math.round(ms / 3600000)}h`;
|
|
272
|
+
monitorOnDoneRuntime.register(doneLoop, monitorId);
|
|
867
273
|
}
|
|
868
274
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
name: "LoopDelete",
|
|
875
|
-
label: "LoopDelete",
|
|
876
|
-
description: `Delete or pause a loop by its ID.
|
|
877
|
-
|
|
878
|
-
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.`,
|
|
879
|
-
parameters: Type.Object({
|
|
880
|
-
id: Type.String({ description: "Loop ID to delete or pause" }),
|
|
881
|
-
action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
|
|
882
|
-
}),
|
|
883
|
-
|
|
884
|
-
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
885
|
-
const { id, action } = params;
|
|
886
|
-
|
|
887
|
-
if (action === "pause") {
|
|
888
|
-
const result = store.update(id, { status: "paused" });
|
|
889
|
-
if (!result.entry) return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
890
|
-
triggerSystem.remove(id);
|
|
891
|
-
widget.update();
|
|
892
|
-
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
triggerSystem.remove(id);
|
|
896
|
-
const deleted = store.delete(id);
|
|
897
|
-
widget.update();
|
|
898
|
-
if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
|
|
899
|
-
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
900
|
-
},
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
// ──────────────────────────────────────────────────
|
|
904
|
-
// Tool 4: MonitorCreate
|
|
905
|
-
// ──────────────────────────────────────────────────
|
|
906
|
-
|
|
907
|
-
pi.registerTool({
|
|
908
|
-
name: "MonitorCreate",
|
|
909
|
-
label: "MonitorCreate",
|
|
910
|
-
description: `Run a shell command in the background and get notified when it finishes. The core tool for async/parallel work.
|
|
911
|
-
|
|
912
|
-
Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
|
|
913
|
-
|
|
914
|
-
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
|
|
915
|
-
|
|
916
|
-
DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
|
|
917
|
-
|
|
918
|
-
## When to Use
|
|
919
|
-
|
|
920
|
-
Default to MonitorCreate for any long-running or background work:\n- Watch a CI/CD build (hut, gh, curl polling) while you work on something else\n- Run experiments, benchmarks, or training scripts in parallel\n- Tail a log or poll an API endpoint\n- Fire off a slow curl/fetch and check the result later\n- Run any script or command you don't need to wait on inline
|
|
921
|
-
|
|
922
|
-
## Events emitted
|
|
923
|
-
|
|
924
|
-
- "monitor:output" — { monitorId, line, timestamp } for each output line\n- "monitor:done" — { monitorId, exitCode, outputLines } on clean exit\n- "monitor:error" — { monitorId, error } on failure
|
|
925
|
-
|
|
926
|
-
## onDone — auto-notify on completion
|
|
927
|
-
|
|
928
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\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"`,
|
|
929
|
-
promptGuidelines: [
|
|
930
|
-
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
931
|
-
"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.",
|
|
932
|
-
"Use onDone to auto-notify when a background command finishes — the agent will pick up the results automatically.",
|
|
933
|
-
],
|
|
934
|
-
parameters: Type.Object({
|
|
935
|
-
command: Type.String({ description: "Shell command to run in background" }),
|
|
936
|
-
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
937
|
-
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
938
|
-
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
|
|
939
|
-
}),
|
|
940
|
-
|
|
941
|
-
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
942
|
-
if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
|
|
943
|
-
return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const entry = monitorManager.create(params.command, params.description, params.timeout);
|
|
275
|
+
registerMonitorTools({
|
|
276
|
+
pi,
|
|
277
|
+
getStore: () => store,
|
|
278
|
+
getMonitorManager: () => monitorManager,
|
|
279
|
+
updateWidget: () => {
|
|
947
280
|
widget.update();
|
|
948
|
-
|
|
949
|
-
let onDoneMsg = "";
|
|
950
|
-
if (params.onDone) {
|
|
951
|
-
const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
|
|
952
|
-
const doneLoop = store.create(doneTrigger, params.onDone, { recurring: false });
|
|
953
|
-
handleMonitorDoneLoop(doneLoop, entry.id);
|
|
954
|
-
onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
return Promise.resolve(textResult(
|
|
958
|
-
`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
|
|
959
|
-
`Output stream: monitor:output (monitorId: ${entry.id})\n` +
|
|
960
|
-
`Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`
|
|
961
|
-
));
|
|
962
|
-
},
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
// ──────────────────────────────────────────────────
|
|
966
|
-
// Tool 5: MonitorList
|
|
967
|
-
// ──────────────────────────────────────────────────
|
|
968
|
-
|
|
969
|
-
pi.registerTool({
|
|
970
|
-
name: "MonitorList",
|
|
971
|
-
label: "MonitorList",
|
|
972
|
-
description: `List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.`,
|
|
973
|
-
parameters: Type.Object({}),
|
|
974
|
-
|
|
975
|
-
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
976
|
-
const monitors = monitorManager.list();
|
|
977
|
-
if (monitors.length === 0) return Promise.resolve(textResult("No monitors running."));
|
|
978
|
-
|
|
979
|
-
const lines: string[] = [];
|
|
980
|
-
for (const m of monitors) {
|
|
981
|
-
const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
|
|
982
|
-
const age = Date.now() - m.startedAt;
|
|
983
|
-
const ageStr = formatRemaining(age);
|
|
984
|
-
let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
|
|
985
|
-
if (m.exitCode !== undefined) line += ` exit=${m.exitCode}`;
|
|
986
|
-
lines.push(line);
|
|
987
|
-
|
|
988
|
-
if (m.status !== "running" && m.outputBuffer.length > 0) {
|
|
989
|
-
const tail = m.outputBuffer.slice(-5);
|
|
990
|
-
for (const out of tail) {
|
|
991
|
-
lines.push(` | ${out.slice(0, 100)}`);
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
return Promise.resolve(textResult(lines.join("\n")));
|
|
997
281
|
},
|
|
282
|
+
handleMonitorDoneLoop,
|
|
998
283
|
});
|
|
999
284
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
name: "MonitorStop",
|
|
1006
|
-
label: "MonitorStop",
|
|
1007
|
-
description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
|
|
1008
|
-
|
|
1009
|
-
Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
1010
|
-
parameters: Type.Object({
|
|
1011
|
-
monitorId: Type.String({ description: "Monitor ID to stop" }),
|
|
1012
|
-
}),
|
|
1013
|
-
|
|
1014
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1015
|
-
const stopped = await monitorManager.stop(params.monitorId);
|
|
285
|
+
registerLoopCommand({
|
|
286
|
+
pi,
|
|
287
|
+
getStore: () => store,
|
|
288
|
+
getTriggerSystem: () => triggerSystem,
|
|
289
|
+
updateWidget: () => {
|
|
1016
290
|
widget.update();
|
|
1017
|
-
if (stopped) return textResult(`Monitor #${params.monitorId} stopped`);
|
|
1018
|
-
return textResult(`Monitor #${params.monitorId} not found or not running`);
|
|
1019
|
-
},
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
// ──────────────────────────────────────────────────
|
|
1023
|
-
// /loop command
|
|
1024
|
-
// ──────────────────────────────────────────────────
|
|
1025
|
-
|
|
1026
|
-
pi.registerCommand("loop", {
|
|
1027
|
-
description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
|
|
1028
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1029
|
-
const trimmed = args.trim();
|
|
1030
|
-
const ui = ctx.ui;
|
|
1031
|
-
|
|
1032
|
-
if (!trimmed) {
|
|
1033
|
-
const choice = await ui.select("Loop", [
|
|
1034
|
-
"Create scheduled loop",
|
|
1035
|
-
"Create event-triggered loop",
|
|
1036
|
-
"View active loops",
|
|
1037
|
-
"Settings",
|
|
1038
|
-
]);
|
|
1039
|
-
|
|
1040
|
-
if (!choice) return;
|
|
1041
|
-
if (choice.startsWith("Create scheduled")) return scheduleLoop(ui);
|
|
1042
|
-
if (choice.startsWith("Create event")) return eventLoop(ui);
|
|
1043
|
-
if (choice.startsWith("View active")) return viewLoops(ui);
|
|
1044
|
-
return settings(ui);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
|
|
1048
|
-
if (intervalMatch) {
|
|
1049
|
-
const interval = intervalMatch[1];
|
|
1050
|
-
const prompt = trimmed.slice(intervalMatch[0].length).trim();
|
|
1051
|
-
|
|
1052
|
-
if (!prompt) {
|
|
1053
|
-
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
try {
|
|
1058
|
-
const parsed = parseInterval(interval);
|
|
1059
|
-
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
1060
|
-
const entry = store.create(trigger, prompt, { recurring: true });
|
|
1061
|
-
triggerSystem.add(entry);
|
|
1062
|
-
widget.update();
|
|
1063
|
-
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
1064
|
-
} catch (err: unknown) {
|
|
1065
|
-
ui.notify((err as Error).message, "error");
|
|
1066
|
-
}
|
|
1067
|
-
return;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const choice = await ui.select("Loop mode", [
|
|
1071
|
-
`Scheduled: "${trimmed.slice(0, 50)}"`,
|
|
1072
|
-
`Event-triggered: "${trimmed.slice(0, 50)}"`,
|
|
1073
|
-
`Self-paced: "${trimmed.slice(0, 50)}"`,
|
|
1074
|
-
]);
|
|
1075
|
-
|
|
1076
|
-
if (!choice) return;
|
|
1077
|
-
if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
|
|
1078
|
-
return scheduleLoop(ui, trimmed);
|
|
1079
291
|
},
|
|
1080
292
|
});
|
|
1081
293
|
|
|
1082
|
-
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
1083
|
-
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
1084
|
-
if (!p) return;
|
|
1085
|
-
|
|
1086
|
-
const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
|
|
1087
|
-
if (!interval) return;
|
|
1088
|
-
|
|
1089
|
-
try {
|
|
1090
|
-
const parsed = parseInterval(interval);
|
|
1091
|
-
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
1092
|
-
const entry = store.create(trigger, p, { recurring: true });
|
|
1093
|
-
triggerSystem.add(entry);
|
|
1094
|
-
widget.update();
|
|
1095
|
-
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
1096
|
-
} catch (err: unknown) {
|
|
1097
|
-
ui.notify((err as Error).message, "error");
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
1102
|
-
const p = prompt || await ui.input("Prompt");
|
|
1103
|
-
if (!p) return;
|
|
1104
|
-
|
|
1105
|
-
const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
|
|
1106
|
-
if (!source) return;
|
|
1107
|
-
|
|
1108
|
-
const trigger: Trigger = { type: "event", source };
|
|
1109
|
-
const entry = store.create(trigger, p, { recurring: true });
|
|
1110
|
-
triggerSystem.add(entry);
|
|
1111
|
-
widget.update();
|
|
1112
|
-
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
async function viewLoops(ui: ExtensionUIContext) {
|
|
1116
|
-
const loops = store.list();
|
|
1117
|
-
if (loops.length === 0) {
|
|
1118
|
-
await ui.select("No active loops", ["< Back"]);
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
const choices = loops.map((l: LoopEntry) => {
|
|
1123
|
-
const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
|
|
1124
|
-
const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
|
|
1125
|
-
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
1126
|
-
});
|
|
1127
|
-
choices.push("< Back");
|
|
1128
|
-
|
|
1129
|
-
const selected = await ui.select("Active Loops", choices);
|
|
1130
|
-
if (!selected || selected === "< Back") return;
|
|
1131
|
-
|
|
1132
|
-
const match = selected.match(/#(\d+)/);
|
|
1133
|
-
if (match) {
|
|
1134
|
-
const entry = store.get(match[1]);
|
|
1135
|
-
if (entry) {
|
|
1136
|
-
const actions = ["x Delete"];
|
|
1137
|
-
if (entry.status === "active") actions.unshift("- Pause");
|
|
1138
|
-
else if (entry.status === "paused") actions.unshift("* Resume");
|
|
1139
|
-
actions.push("< Back");
|
|
1140
|
-
|
|
1141
|
-
const action = await ui.select(
|
|
1142
|
-
`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
|
|
1143
|
-
actions,
|
|
1144
|
-
);
|
|
1145
|
-
|
|
1146
|
-
if (action === "x Delete") {
|
|
1147
|
-
triggerSystem.remove(entry.id);
|
|
1148
|
-
store.delete(entry.id);
|
|
1149
|
-
widget.update();
|
|
1150
|
-
ui.notify(`Loop #${entry.id} deleted`, "info");
|
|
1151
|
-
} else if (action === "- Pause") {
|
|
1152
|
-
store.update(entry.id, { status: "paused" });
|
|
1153
|
-
triggerSystem.remove(entry.id);
|
|
1154
|
-
widget.update();
|
|
1155
|
-
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
1156
|
-
} else if (action === "* Resume") {
|
|
1157
|
-
store.update(entry.id, { status: "active" });
|
|
1158
|
-
triggerSystem.add(entry);
|
|
1159
|
-
widget.update();
|
|
1160
|
-
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
return viewLoops(ui);
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
async function settings(ui: ExtensionUIContext) {
|
|
1169
|
-
const loops = store.list();
|
|
1170
|
-
const active = loops.filter(l => l.status === "active").length;
|
|
1171
|
-
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
1175
|
-
const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
1176
|
-
|
|
1177
|
-
function isAutoTaskWorkerLoop(entry: LoopEntry): boolean {
|
|
1178
|
-
return entry.status === "active"
|
|
1179
|
-
&& entry.prompt === AUTO_TASK_WORKER_PROMPT
|
|
1180
|
-
&& triggerHasEventSource(entry.trigger, "tasks:created");
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
function isTaskBacklogLoop(entry: LoopEntry): boolean {
|
|
1184
|
-
return entry.status === "active"
|
|
1185
|
-
&& triggerHasEventSource(entry.trigger, "tasks:created")
|
|
1186
|
-
&& (entry.taskBacklog === true || isAutoTaskWorkerLoop(entry));
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function findAutoTaskWorkerLoop(): LoopEntry | undefined {
|
|
1190
|
-
return store.list().find(isAutoTaskWorkerLoop);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
async function cleanupTaskBacklogLoops(): Promise<number> {
|
|
1194
|
-
const backlogLoops = store.list().filter(isTaskBacklogLoop);
|
|
1195
|
-
if (backlogLoops.length === 0) return 0;
|
|
1196
|
-
|
|
1197
|
-
const pending = await hasPendingTasks();
|
|
1198
|
-
if (pending < 0 || pending > 0) return 0;
|
|
1199
|
-
|
|
1200
|
-
for (const entry of backlogLoops) {
|
|
1201
|
-
debug(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
1202
|
-
triggerSystem.remove(entry.id);
|
|
1203
|
-
store.delete(entry.id);
|
|
1204
|
-
}
|
|
1205
|
-
widget.update();
|
|
1206
|
-
return backlogLoops.length;
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
async function ensureAutoTaskWorkerLoop(taskStore: TaskStore): Promise<{ entry?: LoopEntry; created: boolean }> {
|
|
1210
|
-
if (taskStore.pendingCount() < AUTO_TASK_WORKER_THRESHOLD) return { created: false };
|
|
1211
|
-
|
|
1212
|
-
const existing = findAutoTaskWorkerLoop();
|
|
1213
|
-
if (existing) return { entry: existing, created: false };
|
|
1214
|
-
|
|
1215
|
-
const trigger: Trigger = {
|
|
1216
|
-
type: "hybrid",
|
|
1217
|
-
cron: "*/5 * * * *",
|
|
1218
|
-
event: { source: "tasks:created" },
|
|
1219
|
-
debounceMs: 30000,
|
|
1220
|
-
};
|
|
1221
|
-
const entry = store.create(trigger, AUTO_TASK_WORKER_PROMPT, {
|
|
1222
|
-
recurring: true,
|
|
1223
|
-
taskBacklog: true,
|
|
1224
|
-
maxFires: 30,
|
|
1225
|
-
});
|
|
1226
|
-
triggerSystem.add(entry);
|
|
1227
|
-
await maybeBootstrapTaskLoop(entry);
|
|
1228
|
-
widget.update();
|
|
1229
|
-
return { entry, created: true };
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
async function evaluateTaskBacklog(taskStore?: TaskStore, pendingCount?: number): Promise<{ entry?: LoopEntry; created: boolean; cleaned: number }> {
|
|
1233
|
-
const resolvedPending = pendingCount ?? (taskStore ? taskStore.pendingCount() : await hasPendingTasks());
|
|
1234
|
-
taskBacklogCoordinatorStore = taskStore;
|
|
1235
|
-
taskBacklogCoordinatorWorker = { created: false };
|
|
1236
|
-
taskBacklogCoordinatorCleanupCount = 0;
|
|
1237
|
-
|
|
1238
|
-
await taskBacklogCoordinator.dispatch({
|
|
1239
|
-
type: "TASK_BACKLOG_EVALUATED",
|
|
1240
|
-
at: Date.now(),
|
|
1241
|
-
source: "system",
|
|
1242
|
-
entityType: "task",
|
|
1243
|
-
payload: { pendingCount: resolvedPending, threshold: AUTO_TASK_WORKER_THRESHOLD },
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
taskBacklogCoordinatorStore = undefined;
|
|
1247
|
-
return {
|
|
1248
|
-
entry: taskBacklogCoordinatorWorker.entry,
|
|
1249
|
-
created: taskBacklogCoordinatorWorker.created,
|
|
1250
|
-
cleaned: taskBacklogCoordinatorCleanupCount,
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
async function createNativeTaskInteractively(ui: ExtensionUIContext) {
|
|
1255
|
-
if (!nativeTaskStore) {
|
|
1256
|
-
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
const subject = await ui.input("Task subject");
|
|
1261
|
-
if (!subject) return;
|
|
1262
|
-
const description = await ui.input("Task description") || subject;
|
|
1263
|
-
const entry = nativeTaskStore.create(subject, description);
|
|
1264
|
-
pi.events.emit("tasks:created", {
|
|
1265
|
-
taskId: entry.id,
|
|
1266
|
-
subject: entry.subject,
|
|
1267
|
-
description: entry.description,
|
|
1268
|
-
status: entry.status,
|
|
1269
|
-
});
|
|
1270
|
-
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1271
|
-
widget.update();
|
|
1272
|
-
ui.notify(`Task #${entry.id} created`, "info");
|
|
1273
|
-
if (backlog.created && backlog.entry) {
|
|
1274
|
-
ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
async function viewNativeTasks(ui: ExtensionUIContext): Promise<void> {
|
|
1279
|
-
if (!nativeTaskStore) {
|
|
1280
|
-
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
const tasks = nativeTaskStore.list();
|
|
1285
|
-
const choices = tasks.map((task) => {
|
|
1286
|
-
const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
|
|
1287
|
-
return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
|
|
1288
|
-
});
|
|
1289
|
-
choices.unshift("+ Create task");
|
|
1290
|
-
choices.push("< Back");
|
|
1291
|
-
|
|
1292
|
-
const selected = await ui.select("Native Tasks", choices);
|
|
1293
|
-
if (!selected || selected === "< Back") return;
|
|
1294
|
-
if (selected === "+ Create task") {
|
|
1295
|
-
await createNativeTaskInteractively(ui);
|
|
1296
|
-
return viewNativeTasks(ui);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
const match = selected.match(/#(\d+)/);
|
|
1300
|
-
if (!match) return viewNativeTasks(ui);
|
|
1301
|
-
|
|
1302
|
-
const task = nativeTaskStore.get(match[1]);
|
|
1303
|
-
if (!task) return viewNativeTasks(ui);
|
|
1304
|
-
|
|
1305
|
-
const actions = ["x Delete"];
|
|
1306
|
-
if (task.status === "pending") {
|
|
1307
|
-
actions.unshift("ok Complete");
|
|
1308
|
-
actions.unshift("> Start");
|
|
1309
|
-
} else if (task.status === "in_progress") {
|
|
1310
|
-
actions.unshift("ok Complete");
|
|
1311
|
-
actions.unshift("* Return to pending");
|
|
1312
|
-
} else {
|
|
1313
|
-
actions.unshift("* Reopen");
|
|
1314
|
-
}
|
|
1315
|
-
actions.push("< Back");
|
|
1316
|
-
|
|
1317
|
-
const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
|
|
1318
|
-
if (!action || action === "< Back") return viewNativeTasks(ui);
|
|
1319
|
-
|
|
1320
|
-
if (action === "x Delete") {
|
|
1321
|
-
nativeTaskStore.delete(task.id);
|
|
1322
|
-
ui.notify(`Task #${task.id} deleted`, "info");
|
|
1323
|
-
} else if (action === "> Start") {
|
|
1324
|
-
nativeTaskStore.update(task.id, { status: "in_progress" });
|
|
1325
|
-
ui.notify(`Task #${task.id} started`, "info");
|
|
1326
|
-
} else if (action === "ok Complete") {
|
|
1327
|
-
nativeTaskStore.update(task.id, { status: "completed" });
|
|
1328
|
-
ui.notify(`Task #${task.id} completed`, "info");
|
|
1329
|
-
} else if (action === "* Return to pending" || action === "* Reopen") {
|
|
1330
|
-
nativeTaskStore.update(task.id, { status: "pending" });
|
|
1331
|
-
ui.notify(`Task #${task.id} reopened`, "info");
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
widget.update();
|
|
1335
|
-
await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1336
|
-
return viewNativeTasks(ui);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
294
|
// ── Native task tools (only when pi-tasks is absent) ──
|
|
1340
295
|
|
|
1341
296
|
setTimeout(async () => {
|
|
1342
297
|
if (tasksAvailable || nativeTasksRegistered) return;
|
|
1343
|
-
nativeTaskStore = new TaskStore(resolveTaskStorePath(_sessionId));
|
|
298
|
+
nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
|
|
1344
299
|
nativeTasksRegistered = true;
|
|
1345
300
|
const taskStore = nativeTaskStore;
|
|
1346
301
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
if (trimmed) {
|
|
1356
|
-
const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
|
|
1357
|
-
pi.events.emit("tasks:created", {
|
|
1358
|
-
taskId: entry.id,
|
|
1359
|
-
subject: entry.subject,
|
|
1360
|
-
description: entry.description,
|
|
1361
|
-
status: entry.status,
|
|
1362
|
-
});
|
|
1363
|
-
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1364
|
-
widget.update();
|
|
1365
|
-
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
1366
|
-
if (backlog.created && backlog.entry) {
|
|
1367
|
-
ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
1368
|
-
}
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
await viewNativeTasks(ctx.ui);
|
|
1372
|
-
},
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
pi.registerTool({
|
|
1376
|
-
name: "TaskCreate",
|
|
1377
|
-
label: "TaskCreate",
|
|
1378
|
-
description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
|
|
1379
|
-
|
|
1380
|
-
Fields:
|
|
1381
|
-
- subject: brief actionable title
|
|
1382
|
-
- description: detailed requirements
|
|
1383
|
-
- metadata: optional tags/metadata`,
|
|
1384
|
-
promptGuidelines: [
|
|
1385
|
-
"Use TaskCreate to track complex multi-step work across turns.",
|
|
1386
|
-
"Break work into small, independently completable tasks. A task should be finishable in one focused session — if a task would take multiple turns, split it further.",
|
|
1387
|
-
"TaskCreate accepts `subject` and `description` parameters only — do not invent extra fields unless the schema explicitly adds them.",
|
|
1388
|
-
],
|
|
1389
|
-
parameters: Type.Object({
|
|
1390
|
-
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
1391
|
-
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
1392
|
-
}),
|
|
1393
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1394
|
-
const entry = taskStore.create(params.subject, params.description);
|
|
1395
|
-
pi.events.emit("tasks:created", {
|
|
1396
|
-
taskId: entry.id,
|
|
1397
|
-
subject: entry.subject,
|
|
1398
|
-
description: entry.description,
|
|
1399
|
-
status: entry.status,
|
|
1400
|
-
});
|
|
1401
|
-
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1402
|
-
widget.update();
|
|
1403
|
-
|
|
1404
|
-
const autoLoopMsg = backlog.created && backlog.entry
|
|
1405
|
-
? `\nWorker loop #${backlog.entry.id} auto-created`
|
|
1406
|
-
: "";
|
|
1407
|
-
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
|
|
1408
|
-
},
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
pi.registerTool({
|
|
1412
|
-
name: "TaskList",
|
|
1413
|
-
label: "TaskList",
|
|
1414
|
-
description: `List all tasks with status. Use to check progress and find available work.`,
|
|
1415
|
-
parameters: Type.Object({}),
|
|
1416
|
-
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1417
|
-
const tasks = taskStore.list();
|
|
1418
|
-
if (tasks.length === 0) return Promise.resolve(textResult("No tasks."));
|
|
1419
|
-
|
|
1420
|
-
const lines: string[] = [];
|
|
1421
|
-
const statuses: Record<"pending" | "in_progress" | "completed", number> = {
|
|
1422
|
-
pending: 0,
|
|
1423
|
-
in_progress: 0,
|
|
1424
|
-
completed: 0,
|
|
1425
|
-
};
|
|
1426
|
-
for (const t of tasks) {
|
|
1427
|
-
statuses[t.status]++;
|
|
1428
|
-
const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
|
|
1429
|
-
lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
|
|
1430
|
-
}
|
|
1431
|
-
lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
|
|
1432
|
-
return Promise.resolve(textResult(lines.join("\n")));
|
|
1433
|
-
},
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
pi.registerTool({
|
|
1437
|
-
name: "TaskUpdate",
|
|
1438
|
-
label: "TaskUpdate",
|
|
1439
|
-
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1440
|
-
|
|
1441
|
-
Statuses: pending → in_progress → completed
|
|
1442
|
-
Parameters: id (required), status, subject, description`,
|
|
1443
|
-
promptGuidelines: [
|
|
1444
|
-
"TaskUpdate uses parameter `id`, not `taskId`.",
|
|
1445
|
-
"Accepted parameters: `id` (required), `status`, `subject`, `description`.",
|
|
1446
|
-
"When validation fails with 'must have required properties id', you passed `taskId` instead of `id`. Correct silently and retry.",
|
|
1447
|
-
],
|
|
1448
|
-
parameters: Type.Object({
|
|
1449
|
-
id: Type.String({ description: "Task ID to update" }),
|
|
1450
|
-
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
|
1451
|
-
subject: Type.Optional(Type.String({ description: "New title" })),
|
|
1452
|
-
description: Type.Optional(Type.String({ description: "New description" })),
|
|
1453
|
-
}),
|
|
1454
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1455
|
-
const { id, status, subject, description } = params;
|
|
1456
|
-
const entry = taskStore.update(id, {
|
|
1457
|
-
status: status as "pending" | "in_progress" | "completed" | undefined,
|
|
1458
|
-
subject,
|
|
1459
|
-
description,
|
|
1460
|
-
});
|
|
1461
|
-
if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
|
|
302
|
+
registerTasksCommand({
|
|
303
|
+
pi,
|
|
304
|
+
getNativeTaskStore: () => nativeTaskStore,
|
|
305
|
+
evaluateTaskBacklog,
|
|
306
|
+
updateWidget: () => {
|
|
1462
307
|
widget.update();
|
|
1463
|
-
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1464
|
-
const statusMsg = status ? ` → ${status}` : "";
|
|
1465
|
-
const autoLoopMsg = backlog.created && backlog.entry
|
|
1466
|
-
? `\nWorker loop #${backlog.entry.id} auto-created`
|
|
1467
|
-
: "";
|
|
1468
|
-
return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
|
|
1469
308
|
},
|
|
1470
309
|
});
|
|
1471
310
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
id: Type.String({ description: "Task ID to delete" }),
|
|
1478
|
-
}),
|
|
1479
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1480
|
-
const deleted = taskStore.delete(params.id);
|
|
311
|
+
registerNativeTaskTools({
|
|
312
|
+
pi,
|
|
313
|
+
taskStore,
|
|
314
|
+
evaluateTaskBacklog,
|
|
315
|
+
updateWidget: () => {
|
|
1481
316
|
widget.update();
|
|
1482
|
-
if (deleted) {
|
|
1483
|
-
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1484
|
-
return Promise.resolve(textResult(`Task #${params.id} deleted`));
|
|
1485
|
-
}
|
|
1486
|
-
return Promise.resolve(textResult(`Task #${params.id} not found`));
|
|
1487
317
|
},
|
|
1488
318
|
});
|
|
1489
319
|
|