@trevonistrevon/pi-loop 0.3.1 → 0.4.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/DIFFERENTIAL_REVIEW_REPORT.md +81 -0
- package/README.md +50 -6
- package/dist/index.js +404 -85
- package/dist/task-store.d.ts +22 -0
- package/dist/task-store.js +181 -0
- package/dist/task-types.d.ts +15 -0
- package/dist/task-types.js +1 -0
- package/dist/trigger-system.d.ts +2 -1
- package/dist/trigger-system.js +19 -16
- package/dist/ui/widget.d.ts +9 -7
- package/dist/ui/widget.js +29 -93
- package/package.json +1 -1
- package/src/index.ts +420 -83
- package/src/task-store.ts +171 -0
- package/src/task-types.ts +17 -0
- package/src/trigger-system.ts +20 -16
- package/src/ui/widget.ts +34 -91
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { parseInterval } from "./loop-parse.js";
|
|
|
20
20
|
import { MonitorManager } from "./monitor-manager.js";
|
|
21
21
|
import { CronScheduler } from "./scheduler.js";
|
|
22
22
|
import { LoopStore } from "./store.js";
|
|
23
|
+
import { TaskStore } from "./task-store.js";
|
|
23
24
|
import { TriggerSystem } from "./trigger-system.js";
|
|
24
25
|
import { LoopWidget } from "./ui/widget.js";
|
|
25
26
|
const DEBUG = !!process.env.PI_LOOP_DEBUG;
|
|
@@ -52,17 +53,35 @@ export default function (pi) {
|
|
|
52
53
|
return undefined;
|
|
53
54
|
return join(process.cwd(), ".pi", "loops", "loops.json");
|
|
54
55
|
}
|
|
56
|
+
function resolveTaskStorePath() {
|
|
57
|
+
if (loopScope === "memory")
|
|
58
|
+
return undefined;
|
|
59
|
+
return join(process.cwd(), ".pi", "tasks", "tasks.json");
|
|
60
|
+
}
|
|
55
61
|
let store = new LoopStore(resolveStorePath());
|
|
56
62
|
const monitorManager = new MonitorManager(pi);
|
|
57
63
|
let scheduler;
|
|
58
64
|
let triggerSystem;
|
|
59
|
-
const widget = new LoopWidget(store,
|
|
65
|
+
const widget = new LoopWidget(store, monitorManager);
|
|
66
|
+
widget.setTaskSummaryProvider(() => {
|
|
67
|
+
if (!nativeTaskStore)
|
|
68
|
+
return { count: 0 };
|
|
69
|
+
const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
|
|
70
|
+
const active = tasks.find(t => t.status === "in_progress");
|
|
71
|
+
const next = tasks.find(t => t.status === "pending");
|
|
72
|
+
const focus = active
|
|
73
|
+
? `active: ${active.subject.slice(0, 50)}`
|
|
74
|
+
: next
|
|
75
|
+
? `next: ${next.subject.slice(0, 50)}`
|
|
76
|
+
: undefined;
|
|
77
|
+
return { count: tasks.length, focusText: focus };
|
|
78
|
+
});
|
|
60
79
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
61
|
-
|
|
62
|
-
triggerSystem = new TriggerSystem(pi, scheduler, store);
|
|
80
|
+
triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
|
|
63
81
|
// ── pi-tasks integration ──
|
|
64
82
|
let tasksAvailable = false;
|
|
65
|
-
|
|
83
|
+
let nativeTaskStore;
|
|
84
|
+
let nativeTasksRegistered = false;
|
|
66
85
|
function checkTasksVersion() {
|
|
67
86
|
const requestId = randomUUID();
|
|
68
87
|
const timer = setTimeout(() => { unsub(); }, 5000);
|
|
@@ -78,54 +97,169 @@ export default function (pi) {
|
|
|
78
97
|
checkTasksVersion();
|
|
79
98
|
pi.events.on("tasks:ready", () => checkTasksVersion());
|
|
80
99
|
async function autoCreateTask(entry) {
|
|
81
|
-
if (!
|
|
100
|
+
if (!entry.autoTask)
|
|
82
101
|
return undefined;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
unsub()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
if (tasksAvailable) {
|
|
103
|
+
try {
|
|
104
|
+
const requestId = randomUUID();
|
|
105
|
+
const taskId = await new Promise((resolve, _reject) => {
|
|
106
|
+
const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
|
|
107
|
+
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw) => {
|
|
108
|
+
unsub();
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
const reply = raw;
|
|
111
|
+
if (reply.success && reply.data)
|
|
112
|
+
resolve(reply.data.id);
|
|
113
|
+
else
|
|
114
|
+
resolve(undefined);
|
|
115
|
+
});
|
|
116
|
+
pi.events.emit("tasks:rpc:create", {
|
|
117
|
+
requestId,
|
|
118
|
+
subject: entry.prompt.slice(0, 80),
|
|
119
|
+
description: `Auto-created from loop #${entry.id}`,
|
|
120
|
+
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
121
|
+
});
|
|
101
122
|
});
|
|
102
|
-
|
|
103
|
-
|
|
123
|
+
return taskId;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
104
128
|
}
|
|
105
|
-
|
|
129
|
+
if (!nativeTaskStore)
|
|
106
130
|
return undefined;
|
|
107
|
-
}
|
|
131
|
+
const task = nativeTaskStore.create(entry.prompt.slice(0, 80), `Auto-created from loop #${entry.id}`, {
|
|
132
|
+
loopId: entry.id,
|
|
133
|
+
trigger: entry.trigger,
|
|
134
|
+
});
|
|
135
|
+
widget.update();
|
|
136
|
+
return task.id;
|
|
108
137
|
}
|
|
109
138
|
async function hasPendingTasks() {
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
if (tasksAvailable) {
|
|
140
|
+
try {
|
|
141
|
+
const requestId = randomUUID();
|
|
142
|
+
const count = await new Promise((resolve) => {
|
|
143
|
+
const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
|
|
144
|
+
const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw) => {
|
|
145
|
+
unsub();
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
const reply = raw;
|
|
148
|
+
resolve(reply.success && reply.data ? reply.data.pending : -1);
|
|
149
|
+
});
|
|
150
|
+
pi.events.emit("tasks:rpc:pending", { requestId });
|
|
121
151
|
});
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
152
|
+
return count;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return -1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return nativeTaskStore ? nativeTaskStore.pendingCount() : -1;
|
|
159
|
+
}
|
|
160
|
+
async function cleanDoneTasks() {
|
|
161
|
+
if (tasksAvailable) {
|
|
162
|
+
try {
|
|
163
|
+
const requestId = randomUUID();
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
const timer = setTimeout(() => { unsub(); resolve(); }, 3000);
|
|
166
|
+
const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
|
|
167
|
+
unsub();
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
debug("tasks:rpc:clean — done tasks swept");
|
|
170
|
+
resolve();
|
|
171
|
+
});
|
|
172
|
+
pi.events.emit("tasks:rpc:clean", { requestId });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch { /* timeout or error, ignore */ }
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (nativeTaskStore) {
|
|
179
|
+
nativeTaskStore.sweepCompleted();
|
|
180
|
+
widget.update();
|
|
125
181
|
}
|
|
126
|
-
|
|
127
|
-
|
|
182
|
+
}
|
|
183
|
+
let agentRunning = false;
|
|
184
|
+
const pendingNotifications = new Map();
|
|
185
|
+
let flushPromise;
|
|
186
|
+
function buildLoopFireMessage(data) {
|
|
187
|
+
const triggerInfo = typeof data.trigger === "string"
|
|
188
|
+
? data.trigger
|
|
189
|
+
: data.trigger?.type === "cron"
|
|
190
|
+
? `schedule: ${data.trigger.schedule}`
|
|
191
|
+
: data.trigger?.type === "event"
|
|
192
|
+
? `event: ${data.trigger.source}`
|
|
193
|
+
: "hybrid";
|
|
194
|
+
const loopId = data.loopId || "?";
|
|
195
|
+
const prompt = data.prompt || "loop fired";
|
|
196
|
+
const constraint = data.readOnly
|
|
197
|
+
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
198
|
+
: "";
|
|
199
|
+
return [
|
|
200
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
201
|
+
prompt,
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
function buildPendingNotification(data) {
|
|
205
|
+
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
206
|
+
return {
|
|
207
|
+
...data,
|
|
208
|
+
key,
|
|
209
|
+
message: buildLoopFireMessage(data),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function deliverNotification(notification) {
|
|
213
|
+
if (notification.autoTask) {
|
|
214
|
+
const pending = await hasPendingTasks();
|
|
215
|
+
if (pending === 0) {
|
|
216
|
+
debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
|
|
217
|
+
await cleanDoneTasks();
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
128
220
|
}
|
|
221
|
+
agentRunning = true;
|
|
222
|
+
pi.sendMessage({
|
|
223
|
+
customType: "pi-loop",
|
|
224
|
+
content: notification.message,
|
|
225
|
+
display: false,
|
|
226
|
+
details: {
|
|
227
|
+
loopId: notification.loopId,
|
|
228
|
+
trigger: notification.trigger,
|
|
229
|
+
recurring: notification.recurring,
|
|
230
|
+
readOnly: notification.readOnly,
|
|
231
|
+
autoTask: notification.autoTask,
|
|
232
|
+
timestamp: notification.timestamp,
|
|
233
|
+
},
|
|
234
|
+
}, {
|
|
235
|
+
deliverAs: "steer",
|
|
236
|
+
triggerTurn: true,
|
|
237
|
+
});
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
async function flushPendingNotifications() {
|
|
241
|
+
if (flushPromise)
|
|
242
|
+
return flushPromise;
|
|
243
|
+
flushPromise = (async () => {
|
|
244
|
+
if (agentRunning || _latestCtx?.hasPendingMessages())
|
|
245
|
+
return;
|
|
246
|
+
const entries = [...pendingNotifications.entries()]
|
|
247
|
+
.sort(([, left], [, right]) => left.timestamp - right.timestamp);
|
|
248
|
+
for (const [key, notification] of entries) {
|
|
249
|
+
pendingNotifications.delete(key);
|
|
250
|
+
const delivered = await deliverNotification(notification);
|
|
251
|
+
if (delivered)
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
})().finally(() => {
|
|
255
|
+
flushPromise = undefined;
|
|
256
|
+
});
|
|
257
|
+
return flushPromise;
|
|
258
|
+
}
|
|
259
|
+
async function queueOrDeliverNotification(data) {
|
|
260
|
+
const notification = buildPendingNotification(data);
|
|
261
|
+
pendingNotifications.set(notification.key, notification);
|
|
262
|
+
await flushPendingNotifications();
|
|
129
263
|
}
|
|
130
264
|
// ── Loop fire handler ──
|
|
131
265
|
function onLoopFire(entry) {
|
|
@@ -165,8 +299,7 @@ export default function (pi) {
|
|
|
165
299
|
store = new LoopStore(path);
|
|
166
300
|
widget.setStore(store);
|
|
167
301
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
168
|
-
|
|
169
|
-
triggerSystem = new TriggerSystem(pi, scheduler, store);
|
|
302
|
+
triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
|
|
170
303
|
}
|
|
171
304
|
storeUpgraded = true;
|
|
172
305
|
}
|
|
@@ -187,17 +320,36 @@ export default function (pi) {
|
|
|
187
320
|
_latestCtx = ctx;
|
|
188
321
|
widget.setUICtx(ctx.ui);
|
|
189
322
|
upgradeStoreIfNeeded(ctx);
|
|
323
|
+
widget.update();
|
|
190
324
|
});
|
|
191
325
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
192
326
|
_latestCtx = ctx;
|
|
193
327
|
widget.setUICtx(ctx.ui);
|
|
194
328
|
upgradeStoreIfNeeded(ctx);
|
|
195
329
|
showPersistedLoops();
|
|
330
|
+
widget.update();
|
|
331
|
+
});
|
|
332
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
333
|
+
agentRunning = true;
|
|
334
|
+
_latestCtx = ctx;
|
|
335
|
+
widget.setUICtx(ctx.ui);
|
|
336
|
+
});
|
|
337
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
338
|
+
agentRunning = false;
|
|
339
|
+
_latestCtx = ctx;
|
|
340
|
+
widget.setUICtx(ctx.ui);
|
|
341
|
+
await flushPendingNotifications();
|
|
342
|
+
});
|
|
343
|
+
pi.on("session_shutdown", async () => {
|
|
344
|
+
agentRunning = false;
|
|
345
|
+
pendingNotifications.clear();
|
|
196
346
|
});
|
|
197
347
|
pi.on("session_switch", async (event, ctx) => {
|
|
198
348
|
_latestCtx = ctx;
|
|
199
349
|
widget.setUICtx(ctx.ui);
|
|
200
350
|
triggerSystem.stop();
|
|
351
|
+
agentRunning = false;
|
|
352
|
+
pendingNotifications.clear();
|
|
201
353
|
const isResume = event?.reason === "resume";
|
|
202
354
|
storeUpgraded = false;
|
|
203
355
|
persistedShown = false;
|
|
@@ -206,36 +358,20 @@ export default function (pi) {
|
|
|
206
358
|
}
|
|
207
359
|
upgradeStoreIfNeeded(ctx);
|
|
208
360
|
showPersistedLoops(isResume);
|
|
361
|
+
widget.update();
|
|
209
362
|
});
|
|
210
|
-
// ── Loop fire handler —
|
|
363
|
+
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
211
364
|
pi.events.on("loop:fire", async (event) => {
|
|
212
365
|
const data = event;
|
|
213
|
-
if (data.recurring && _latestCtx?.hasPendingMessages()) {
|
|
214
|
-
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
366
|
if (data.autoTask) {
|
|
218
367
|
const pending = await hasPendingTasks();
|
|
219
368
|
if (pending === 0) {
|
|
220
|
-
debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
|
|
369
|
+
debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
|
|
370
|
+
await cleanDoneTasks();
|
|
221
371
|
return;
|
|
222
372
|
}
|
|
223
373
|
}
|
|
224
|
-
|
|
225
|
-
? data.trigger
|
|
226
|
-
: data.trigger?.type === "cron"
|
|
227
|
-
? `schedule: ${data.trigger.schedule}`
|
|
228
|
-
: data.trigger?.type === "event"
|
|
229
|
-
? `event: ${data.trigger.source}`
|
|
230
|
-
: `hybrid`;
|
|
231
|
-
const loopId = data.loopId || "?";
|
|
232
|
-
const prompt = data.prompt || "loop fired";
|
|
233
|
-
const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
|
|
234
|
-
const message = [
|
|
235
|
-
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
236
|
-
prompt,
|
|
237
|
-
].join("\n");
|
|
238
|
-
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
374
|
+
await queueOrDeliverNotification(data);
|
|
239
375
|
});
|
|
240
376
|
// ──────────────────────────────────────────────────
|
|
241
377
|
// Tool 1: LoopCreate
|
|
@@ -271,7 +407,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
271
407
|
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
272
408
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
273
409
|
- **recurring**: repeat or fire once (default: true)
|
|
274
|
-
- **autoTask**:
|
|
410
|
+
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
275
411
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
276
412
|
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
|
|
277
413
|
promptGuidelines: [
|
|
@@ -290,7 +426,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
290
426
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
291
427
|
"## Task-driven workflows",
|
|
292
428
|
"After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
|
|
293
|
-
"When no tasks are pending, the loop skips the
|
|
429
|
+
"When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
|
|
294
430
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
295
431
|
],
|
|
296
432
|
parameters: Type.Object({
|
|
@@ -361,7 +497,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
361
497
|
`Trigger: ${triggerDesc}\n` +
|
|
362
498
|
`Recurring: ${entry.recurring}\n` +
|
|
363
499
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
364
|
-
(tasksAvailable ? "" : "(
|
|
500
|
+
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
365
501
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
366
502
|
},
|
|
367
503
|
});
|
|
@@ -430,7 +566,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
430
566
|
const nextFire = entry.trigger.type !== "event"
|
|
431
567
|
? scheduler.nextFire(entry.id)
|
|
432
568
|
: undefined;
|
|
433
|
-
const statusIcon = entry.status === "active" ? "
|
|
569
|
+
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
434
570
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
435
571
|
line += ` (${triggerDesc})`;
|
|
436
572
|
if (nextFire) {
|
|
@@ -492,7 +628,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
492
628
|
|
|
493
629
|
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).
|
|
494
630
|
|
|
495
|
-
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a
|
|
631
|
+
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.
|
|
496
632
|
|
|
497
633
|
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.
|
|
498
634
|
|
|
@@ -506,7 +642,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
506
642
|
|
|
507
643
|
## onDone — auto-notify on completion
|
|
508
644
|
|
|
509
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The
|
|
645
|
+
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"`,
|
|
510
646
|
promptGuidelines: [
|
|
511
647
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
512
648
|
"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.",
|
|
@@ -516,7 +652,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
516
652
|
command: Type.String({ description: "Shell command to run in background" }),
|
|
517
653
|
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
518
654
|
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
519
|
-
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion
|
|
655
|
+
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." })),
|
|
520
656
|
}),
|
|
521
657
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
522
658
|
if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
|
|
@@ -550,7 +686,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
550
686
|
return Promise.resolve(textResult("No monitors running."));
|
|
551
687
|
const lines = [];
|
|
552
688
|
for (const m of monitors) {
|
|
553
|
-
const icon = m.status === "running" ? "
|
|
689
|
+
const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
|
|
554
690
|
const age = Date.now() - m.startedAt;
|
|
555
691
|
const ageStr = formatRemaining(age);
|
|
556
692
|
let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
|
|
@@ -680,42 +816,42 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
680
816
|
async function viewLoops(ui) {
|
|
681
817
|
const loops = store.list();
|
|
682
818
|
if (loops.length === 0) {
|
|
683
|
-
await ui.select("No active loops", ["
|
|
819
|
+
await ui.select("No active loops", ["< Back"]);
|
|
684
820
|
return;
|
|
685
821
|
}
|
|
686
822
|
const choices = loops.map((l) => {
|
|
687
|
-
const icon = l.status === "active" ? "
|
|
823
|
+
const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
|
|
688
824
|
const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
|
|
689
825
|
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
690
826
|
});
|
|
691
|
-
choices.push("
|
|
827
|
+
choices.push("< Back");
|
|
692
828
|
const selected = await ui.select("Active Loops", choices);
|
|
693
|
-
if (!selected || selected === "
|
|
829
|
+
if (!selected || selected === "< Back")
|
|
694
830
|
return;
|
|
695
831
|
const match = selected.match(/#(\d+)/);
|
|
696
832
|
if (match) {
|
|
697
833
|
const entry = store.get(match[1]);
|
|
698
834
|
if (entry) {
|
|
699
|
-
const actions = ["
|
|
835
|
+
const actions = ["x Delete"];
|
|
700
836
|
if (entry.status === "active")
|
|
701
|
-
actions.unshift("
|
|
837
|
+
actions.unshift("- Pause");
|
|
702
838
|
else if (entry.status === "paused")
|
|
703
|
-
actions.unshift("
|
|
704
|
-
actions.push("
|
|
839
|
+
actions.unshift("* Resume");
|
|
840
|
+
actions.push("< Back");
|
|
705
841
|
const action = await ui.select(`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`, actions);
|
|
706
|
-
if (action === "
|
|
842
|
+
if (action === "x Delete") {
|
|
707
843
|
triggerSystem.remove(entry.id);
|
|
708
844
|
store.delete(entry.id);
|
|
709
845
|
widget.update();
|
|
710
846
|
ui.notify(`Loop #${entry.id} deleted`, "info");
|
|
711
847
|
}
|
|
712
|
-
else if (action === "
|
|
848
|
+
else if (action === "- Pause") {
|
|
713
849
|
store.update(entry.id, { status: "paused" });
|
|
714
850
|
triggerSystem.remove(entry.id);
|
|
715
851
|
widget.update();
|
|
716
852
|
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
717
853
|
}
|
|
718
|
-
else if (action === "
|
|
854
|
+
else if (action === "* Resume") {
|
|
719
855
|
store.update(entry.id, { status: "active" });
|
|
720
856
|
triggerSystem.add(entry);
|
|
721
857
|
widget.update();
|
|
@@ -730,4 +866,187 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
730
866
|
const active = loops.filter(l => l.status === "active").length;
|
|
731
867
|
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
732
868
|
}
|
|
869
|
+
async function createNativeTaskInteractively(ui) {
|
|
870
|
+
if (!nativeTaskStore) {
|
|
871
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const subject = await ui.input("Task subject");
|
|
875
|
+
if (!subject)
|
|
876
|
+
return;
|
|
877
|
+
const description = await ui.input("Task description") || subject;
|
|
878
|
+
const entry = nativeTaskStore.create(subject, description);
|
|
879
|
+
widget.update();
|
|
880
|
+
ui.notify(`Task #${entry.id} created`, "info");
|
|
881
|
+
}
|
|
882
|
+
async function viewNativeTasks(ui) {
|
|
883
|
+
if (!nativeTaskStore) {
|
|
884
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const tasks = nativeTaskStore.list();
|
|
888
|
+
const choices = tasks.map((task) => {
|
|
889
|
+
const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
|
|
890
|
+
return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
|
|
891
|
+
});
|
|
892
|
+
choices.unshift("+ Create task");
|
|
893
|
+
choices.push("< Back");
|
|
894
|
+
const selected = await ui.select("Native Tasks", choices);
|
|
895
|
+
if (!selected || selected === "< Back")
|
|
896
|
+
return;
|
|
897
|
+
if (selected === "+ Create task") {
|
|
898
|
+
await createNativeTaskInteractively(ui);
|
|
899
|
+
return viewNativeTasks(ui);
|
|
900
|
+
}
|
|
901
|
+
const match = selected.match(/#(\d+)/);
|
|
902
|
+
if (!match)
|
|
903
|
+
return viewNativeTasks(ui);
|
|
904
|
+
const task = nativeTaskStore.get(match[1]);
|
|
905
|
+
if (!task)
|
|
906
|
+
return viewNativeTasks(ui);
|
|
907
|
+
const actions = ["x Delete"];
|
|
908
|
+
if (task.status === "pending") {
|
|
909
|
+
actions.unshift("ok Complete");
|
|
910
|
+
actions.unshift("> Start");
|
|
911
|
+
}
|
|
912
|
+
else if (task.status === "in_progress") {
|
|
913
|
+
actions.unshift("ok Complete");
|
|
914
|
+
actions.unshift("* Return to pending");
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
actions.unshift("* Reopen");
|
|
918
|
+
}
|
|
919
|
+
actions.push("< Back");
|
|
920
|
+
const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
|
|
921
|
+
if (!action || action === "< Back")
|
|
922
|
+
return viewNativeTasks(ui);
|
|
923
|
+
if (action === "x Delete") {
|
|
924
|
+
nativeTaskStore.delete(task.id);
|
|
925
|
+
ui.notify(`Task #${task.id} deleted`, "info");
|
|
926
|
+
}
|
|
927
|
+
else if (action === "> Start") {
|
|
928
|
+
nativeTaskStore.update(task.id, { status: "in_progress" });
|
|
929
|
+
ui.notify(`Task #${task.id} started`, "info");
|
|
930
|
+
}
|
|
931
|
+
else if (action === "ok Complete") {
|
|
932
|
+
nativeTaskStore.update(task.id, { status: "completed" });
|
|
933
|
+
ui.notify(`Task #${task.id} completed`, "info");
|
|
934
|
+
}
|
|
935
|
+
else if (action === "* Return to pending" || action === "* Reopen") {
|
|
936
|
+
nativeTaskStore.update(task.id, { status: "pending" });
|
|
937
|
+
ui.notify(`Task #${task.id} reopened`, "info");
|
|
938
|
+
}
|
|
939
|
+
widget.update();
|
|
940
|
+
return viewNativeTasks(ui);
|
|
941
|
+
}
|
|
942
|
+
// ── Native task tools (only when pi-tasks is absent) ──
|
|
943
|
+
setTimeout(async () => {
|
|
944
|
+
if (tasksAvailable || nativeTasksRegistered)
|
|
945
|
+
return;
|
|
946
|
+
nativeTaskStore = new TaskStore(resolveTaskStorePath());
|
|
947
|
+
nativeTasksRegistered = true;
|
|
948
|
+
const taskStore = nativeTaskStore;
|
|
949
|
+
pi.registerCommand("tasks", {
|
|
950
|
+
description: "View or manage native pi-loop tasks when pi-tasks is not installed",
|
|
951
|
+
handler: async (args, ctx) => {
|
|
952
|
+
const trimmed = args.trim();
|
|
953
|
+
if (!nativeTaskStore) {
|
|
954
|
+
ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (trimmed) {
|
|
958
|
+
const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
|
|
959
|
+
widget.update();
|
|
960
|
+
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
await viewNativeTasks(ctx.ui);
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
pi.registerTool({
|
|
967
|
+
name: "TaskCreate",
|
|
968
|
+
label: "TaskCreate",
|
|
969
|
+
description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
|
|
970
|
+
|
|
971
|
+
Fields:
|
|
972
|
+
- subject: brief actionable title
|
|
973
|
+
- description: detailed requirements
|
|
974
|
+
- metadata: optional tags/metadata`,
|
|
975
|
+
parameters: Type.Object({
|
|
976
|
+
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
977
|
+
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
978
|
+
}),
|
|
979
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
980
|
+
const entry = taskStore.create(params.subject, params.description);
|
|
981
|
+
widget.update();
|
|
982
|
+
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
pi.registerTool({
|
|
986
|
+
name: "TaskList",
|
|
987
|
+
label: "TaskList",
|
|
988
|
+
description: `List all tasks with status. Use to check progress and find available work.`,
|
|
989
|
+
parameters: Type.Object({}),
|
|
990
|
+
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
991
|
+
const tasks = taskStore.list();
|
|
992
|
+
if (tasks.length === 0)
|
|
993
|
+
return Promise.resolve(textResult("No tasks."));
|
|
994
|
+
const lines = [];
|
|
995
|
+
const statuses = {
|
|
996
|
+
pending: 0,
|
|
997
|
+
in_progress: 0,
|
|
998
|
+
completed: 0,
|
|
999
|
+
};
|
|
1000
|
+
for (const t of tasks) {
|
|
1001
|
+
statuses[t.status]++;
|
|
1002
|
+
const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
|
|
1003
|
+
lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
|
|
1004
|
+
}
|
|
1005
|
+
lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
|
|
1006
|
+
return Promise.resolve(textResult(lines.join("\n")));
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
pi.registerTool({
|
|
1010
|
+
name: "TaskUpdate",
|
|
1011
|
+
label: "TaskUpdate",
|
|
1012
|
+
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1013
|
+
|
|
1014
|
+
Statuses: pending → in_progress → completed`,
|
|
1015
|
+
parameters: Type.Object({
|
|
1016
|
+
id: Type.String({ description: "Task ID to update" }),
|
|
1017
|
+
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
|
1018
|
+
subject: Type.Optional(Type.String({ description: "New title" })),
|
|
1019
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
1020
|
+
}),
|
|
1021
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1022
|
+
const { id, status, subject, description } = params;
|
|
1023
|
+
const entry = taskStore.update(id, {
|
|
1024
|
+
status: status,
|
|
1025
|
+
subject,
|
|
1026
|
+
description,
|
|
1027
|
+
});
|
|
1028
|
+
if (!entry)
|
|
1029
|
+
return Promise.resolve(textResult(`Task #${id} not found`));
|
|
1030
|
+
widget.update();
|
|
1031
|
+
const statusMsg = status ? ` → ${status}` : "";
|
|
1032
|
+
return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
pi.registerTool({
|
|
1036
|
+
name: "TaskDelete",
|
|
1037
|
+
label: "TaskDelete",
|
|
1038
|
+
description: `Delete a task by ID. Use for cleaning up completed or irrelevant tasks.`,
|
|
1039
|
+
parameters: Type.Object({
|
|
1040
|
+
id: Type.String({ description: "Task ID to delete" }),
|
|
1041
|
+
}),
|
|
1042
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1043
|
+
const deleted = taskStore.delete(params.id);
|
|
1044
|
+
widget.update();
|
|
1045
|
+
if (deleted)
|
|
1046
|
+
return Promise.resolve(textResult(`Task #${params.id} deleted`));
|
|
1047
|
+
return Promise.resolve(textResult(`Task #${params.id} not found`));
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
debug("native task tools registered (pi-tasks not detected)");
|
|
1051
|
+
}, 6000);
|
|
733
1052
|
}
|