@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/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { parseInterval } from "./loop-parse.js";
|
|
|
22
22
|
import { MonitorManager } from "./monitor-manager.js";
|
|
23
23
|
import { CronScheduler } from "./scheduler.js";
|
|
24
24
|
import { LoopStore } from "./store.js";
|
|
25
|
+
import { TaskStore } from "./task-store.js";
|
|
25
26
|
import { TriggerSystem } from "./trigger-system.js";
|
|
26
27
|
import type { LoopEntry, Trigger } from "./types.js";
|
|
27
28
|
import { LoopWidget } from "./ui/widget.js";
|
|
@@ -49,6 +50,10 @@ interface SessionSwitchEvent {
|
|
|
49
50
|
reason?: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
interface PendingNotification extends LoopFireEvent {
|
|
54
|
+
key: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
52
57
|
|
|
53
58
|
export default function (pi: ExtensionAPI) {
|
|
54
59
|
const piLoopEnv = process.env.PI_LOOP;
|
|
@@ -68,19 +73,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
68
73
|
return join(process.cwd(), ".pi", "loops", "loops.json");
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
function resolveTaskStorePath(): string | undefined {
|
|
77
|
+
if (loopScope === "memory") return undefined;
|
|
78
|
+
return join(process.cwd(), ".pi", "tasks", "tasks.json");
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
let store = new LoopStore(resolveStorePath());
|
|
72
82
|
const monitorManager = new MonitorManager(pi);
|
|
73
83
|
let scheduler: CronScheduler;
|
|
74
84
|
let triggerSystem: TriggerSystem;
|
|
75
|
-
const widget = new LoopWidget(store,
|
|
85
|
+
const widget = new LoopWidget(store, monitorManager);
|
|
86
|
+
widget.setTaskSummaryProvider(() => {
|
|
87
|
+
if (!nativeTaskStore) return { count: 0 };
|
|
88
|
+
const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
|
|
89
|
+
const active = tasks.find(t => t.status === "in_progress");
|
|
90
|
+
const next = tasks.find(t => t.status === "pending");
|
|
91
|
+
const focus = active
|
|
92
|
+
? `active: ${active.subject.slice(0, 50)}`
|
|
93
|
+
: next
|
|
94
|
+
? `next: ${next.subject.slice(0, 50)}`
|
|
95
|
+
: undefined;
|
|
96
|
+
return { count: tasks.length, focusText: focus };
|
|
97
|
+
});
|
|
76
98
|
|
|
77
99
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
78
|
-
|
|
79
|
-
triggerSystem = new TriggerSystem(pi, scheduler, store);
|
|
100
|
+
triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
|
|
80
101
|
|
|
81
102
|
// ── pi-tasks integration ──
|
|
82
103
|
let tasksAvailable = false;
|
|
83
|
-
|
|
104
|
+
let nativeTaskStore: TaskStore | undefined;
|
|
105
|
+
let nativeTasksRegistered = false;
|
|
84
106
|
|
|
85
107
|
function checkTasksVersion() {
|
|
86
108
|
const requestId = randomUUID();
|
|
@@ -97,47 +119,171 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
119
|
pi.events.on("tasks:ready", () => checkTasksVersion());
|
|
98
120
|
|
|
99
121
|
async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
unsub(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
if (!entry.autoTask) return undefined;
|
|
123
|
+
if (tasksAvailable) {
|
|
124
|
+
try {
|
|
125
|
+
const requestId = randomUUID();
|
|
126
|
+
const taskId = await new Promise<string | undefined>((resolve, _reject) => {
|
|
127
|
+
const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
|
|
128
|
+
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
|
|
129
|
+
unsub(); clearTimeout(timer);
|
|
130
|
+
const reply = raw as { success: boolean; data?: { id: string }; error?: string };
|
|
131
|
+
if (reply.success && reply.data) resolve(reply.data.id);
|
|
132
|
+
else resolve(undefined);
|
|
133
|
+
});
|
|
134
|
+
pi.events.emit("tasks:rpc:create", {
|
|
135
|
+
requestId,
|
|
136
|
+
subject: entry.prompt.slice(0, 80),
|
|
137
|
+
description: `Auto-created from loop #${entry.id}`,
|
|
138
|
+
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
139
|
+
});
|
|
116
140
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
141
|
+
return taskId;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
121
145
|
}
|
|
146
|
+
if (!nativeTaskStore) return undefined;
|
|
147
|
+
const task = nativeTaskStore.create(entry.prompt.slice(0, 80), `Auto-created from loop #${entry.id}`, {
|
|
148
|
+
loopId: entry.id,
|
|
149
|
+
trigger: entry.trigger,
|
|
150
|
+
});
|
|
151
|
+
widget.update();
|
|
152
|
+
return task.id;
|
|
122
153
|
}
|
|
123
154
|
|
|
124
155
|
async function hasPendingTasks(): Promise<number> {
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
if (tasksAvailable) {
|
|
157
|
+
try {
|
|
158
|
+
const requestId = randomUUID();
|
|
159
|
+
const count = await new Promise<number>((resolve) => {
|
|
160
|
+
const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
|
|
161
|
+
const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
|
|
162
|
+
unsub(); clearTimeout(timer);
|
|
163
|
+
const reply = raw as { success: boolean; data?: { pending: number }; error?: string };
|
|
164
|
+
resolve(reply.success && reply.data ? reply.data.pending : -1);
|
|
165
|
+
});
|
|
166
|
+
pi.events.emit("tasks:rpc:pending", { requestId });
|
|
134
167
|
});
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
168
|
+
return count;
|
|
169
|
+
} catch {
|
|
170
|
+
return -1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return nativeTaskStore ? nativeTaskStore.pendingCount() : -1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function cleanDoneTasks(): Promise<void> {
|
|
177
|
+
if (tasksAvailable) {
|
|
178
|
+
try {
|
|
179
|
+
const requestId = randomUUID();
|
|
180
|
+
await new Promise<void>((resolve) => {
|
|
181
|
+
const timer = setTimeout(() => { unsub(); resolve(); }, 3000);
|
|
182
|
+
const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
|
|
183
|
+
unsub(); clearTimeout(timer);
|
|
184
|
+
debug("tasks:rpc:clean — done tasks swept");
|
|
185
|
+
resolve();
|
|
186
|
+
});
|
|
187
|
+
pi.events.emit("tasks:rpc:clean", { requestId });
|
|
188
|
+
});
|
|
189
|
+
} catch { /* timeout or error, ignore */ }
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (nativeTaskStore) {
|
|
193
|
+
nativeTaskStore.sweepCompleted();
|
|
194
|
+
widget.update();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let agentRunning = false;
|
|
199
|
+
const pendingNotifications = new Map<string, PendingNotification>();
|
|
200
|
+
let flushPromise: Promise<void> | undefined;
|
|
201
|
+
|
|
202
|
+
function buildLoopFireMessage(data: LoopFireEvent): string {
|
|
203
|
+
const triggerInfo = typeof data.trigger === "string"
|
|
204
|
+
? data.trigger
|
|
205
|
+
: data.trigger?.type === "cron"
|
|
206
|
+
? `schedule: ${data.trigger.schedule}`
|
|
207
|
+
: data.trigger?.type === "event"
|
|
208
|
+
? `event: ${data.trigger.source}`
|
|
209
|
+
: "hybrid";
|
|
210
|
+
|
|
211
|
+
const loopId = data.loopId || "?";
|
|
212
|
+
const prompt = data.prompt || "loop fired";
|
|
213
|
+
const constraint = data.readOnly
|
|
214
|
+
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
215
|
+
: "";
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
219
|
+
prompt,
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildPendingNotification(data: LoopFireEvent): PendingNotification {
|
|
224
|
+
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
225
|
+
return {
|
|
226
|
+
...data,
|
|
227
|
+
key,
|
|
228
|
+
message: buildLoopFireMessage(data),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function deliverNotification(notification: PendingNotification): Promise<boolean> {
|
|
233
|
+
if (notification.autoTask) {
|
|
234
|
+
const pending = await hasPendingTasks();
|
|
235
|
+
if (pending === 0) {
|
|
236
|
+
debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
|
|
237
|
+
await cleanDoneTasks();
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
140
240
|
}
|
|
241
|
+
|
|
242
|
+
agentRunning = true;
|
|
243
|
+
pi.sendMessage({
|
|
244
|
+
customType: "pi-loop",
|
|
245
|
+
content: notification.message,
|
|
246
|
+
display: false,
|
|
247
|
+
details: {
|
|
248
|
+
loopId: notification.loopId,
|
|
249
|
+
trigger: notification.trigger,
|
|
250
|
+
recurring: notification.recurring,
|
|
251
|
+
readOnly: notification.readOnly,
|
|
252
|
+
autoTask: notification.autoTask,
|
|
253
|
+
timestamp: notification.timestamp,
|
|
254
|
+
},
|
|
255
|
+
}, {
|
|
256
|
+
deliverAs: "steer",
|
|
257
|
+
triggerTurn: true,
|
|
258
|
+
});
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function flushPendingNotifications(): Promise<void> {
|
|
263
|
+
if (flushPromise) return flushPromise;
|
|
264
|
+
|
|
265
|
+
flushPromise = (async () => {
|
|
266
|
+
if (agentRunning || _latestCtx?.hasPendingMessages()) return;
|
|
267
|
+
|
|
268
|
+
const entries = [...pendingNotifications.entries()]
|
|
269
|
+
.sort(([, left], [, right]) => left.timestamp - right.timestamp);
|
|
270
|
+
|
|
271
|
+
for (const [key, notification] of entries) {
|
|
272
|
+
pendingNotifications.delete(key);
|
|
273
|
+
const delivered = await deliverNotification(notification);
|
|
274
|
+
if (delivered) return;
|
|
275
|
+
}
|
|
276
|
+
})().finally(() => {
|
|
277
|
+
flushPromise = undefined;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return flushPromise;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
|
|
284
|
+
const notification = buildPendingNotification(data);
|
|
285
|
+
pendingNotifications.set(notification.key, notification);
|
|
286
|
+
await flushPendingNotifications();
|
|
141
287
|
}
|
|
142
288
|
|
|
143
289
|
// ── Loop fire handler ──
|
|
@@ -183,8 +329,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
183
329
|
store = new LoopStore(path);
|
|
184
330
|
widget.setStore(store);
|
|
185
331
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
186
|
-
|
|
187
|
-
triggerSystem = new TriggerSystem(pi, scheduler, store);
|
|
332
|
+
triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
|
|
188
333
|
}
|
|
189
334
|
storeUpgraded = true;
|
|
190
335
|
}
|
|
@@ -206,6 +351,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
351
|
_latestCtx = ctx;
|
|
207
352
|
widget.setUICtx(ctx.ui);
|
|
208
353
|
upgradeStoreIfNeeded(ctx);
|
|
354
|
+
widget.update();
|
|
209
355
|
});
|
|
210
356
|
|
|
211
357
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
@@ -213,12 +359,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
213
359
|
widget.setUICtx(ctx.ui);
|
|
214
360
|
upgradeStoreIfNeeded(ctx);
|
|
215
361
|
showPersistedLoops();
|
|
362
|
+
widget.update();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
366
|
+
agentRunning = true;
|
|
367
|
+
_latestCtx = ctx;
|
|
368
|
+
widget.setUICtx(ctx.ui);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
372
|
+
agentRunning = false;
|
|
373
|
+
_latestCtx = ctx;
|
|
374
|
+
widget.setUICtx(ctx.ui);
|
|
375
|
+
await flushPendingNotifications();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.on("session_shutdown", async () => {
|
|
379
|
+
agentRunning = false;
|
|
380
|
+
pendingNotifications.clear();
|
|
216
381
|
});
|
|
217
382
|
|
|
218
383
|
pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
|
|
219
384
|
_latestCtx = ctx;
|
|
220
385
|
widget.setUICtx(ctx.ui);
|
|
221
386
|
triggerSystem.stop();
|
|
387
|
+
agentRunning = false;
|
|
388
|
+
pendingNotifications.clear();
|
|
222
389
|
|
|
223
390
|
const isResume = event?.reason === "resume";
|
|
224
391
|
storeUpgraded = false;
|
|
@@ -230,43 +397,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
230
397
|
|
|
231
398
|
upgradeStoreIfNeeded(ctx);
|
|
232
399
|
showPersistedLoops(isResume);
|
|
400
|
+
widget.update();
|
|
233
401
|
});
|
|
234
402
|
|
|
235
|
-
// ── Loop fire handler —
|
|
403
|
+
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
236
404
|
|
|
237
405
|
pi.events.on("loop:fire", async (event: unknown) => {
|
|
238
406
|
const data = event as LoopFireEvent;
|
|
239
407
|
|
|
240
|
-
if (data.recurring && _latestCtx?.hasPendingMessages()) {
|
|
241
|
-
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
408
|
if (data.autoTask) {
|
|
246
409
|
const pending = await hasPendingTasks();
|
|
247
410
|
if (pending === 0) {
|
|
248
|
-
debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
|
|
411
|
+
debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
|
|
412
|
+
await cleanDoneTasks();
|
|
249
413
|
return;
|
|
250
414
|
}
|
|
251
415
|
}
|
|
252
416
|
|
|
253
|
-
|
|
254
|
-
? data.trigger
|
|
255
|
-
: data.trigger?.type === "cron"
|
|
256
|
-
? `schedule: ${data.trigger.schedule}`
|
|
257
|
-
: data.trigger?.type === "event"
|
|
258
|
-
? `event: ${data.trigger.source}`
|
|
259
|
-
: `hybrid`;
|
|
260
|
-
|
|
261
|
-
const loopId = data.loopId || "?";
|
|
262
|
-
const prompt = data.prompt || "loop fired";
|
|
263
|
-
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." : "";
|
|
264
|
-
const message = [
|
|
265
|
-
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
266
|
-
prompt,
|
|
267
|
-
].join("\n");
|
|
268
|
-
|
|
269
|
-
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
417
|
+
await queueOrDeliverNotification(data);
|
|
270
418
|
});
|
|
271
419
|
|
|
272
420
|
// ──────────────────────────────────────────────────
|
|
@@ -304,7 +452,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
304
452
|
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
305
453
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
306
454
|
- **recurring**: repeat or fire once (default: true)
|
|
307
|
-
- **autoTask**:
|
|
455
|
+
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
308
456
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
309
457
|
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
|
|
310
458
|
promptGuidelines: [
|
|
@@ -323,7 +471,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
323
471
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
324
472
|
"## Task-driven workflows",
|
|
325
473
|
"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.'",
|
|
326
|
-
"When no tasks are pending, the loop skips the
|
|
474
|
+
"When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
|
|
327
475
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
328
476
|
],
|
|
329
477
|
parameters: Type.Object({
|
|
@@ -401,7 +549,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
401
549
|
`Trigger: ${triggerDesc}\n` +
|
|
402
550
|
`Recurring: ${entry.recurring}\n` +
|
|
403
551
|
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
404
|
-
(tasksAvailable ? "" : "(
|
|
552
|
+
((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
|
|
405
553
|
`ID: ${entry.id} (use LoopDelete to cancel)`
|
|
406
554
|
));
|
|
407
555
|
},
|
|
@@ -474,7 +622,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
474
622
|
? scheduler.nextFire(entry.id)
|
|
475
623
|
: undefined;
|
|
476
624
|
|
|
477
|
-
const statusIcon = entry.status === "active" ? "
|
|
625
|
+
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
478
626
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
479
627
|
line += ` (${triggerDesc})`;
|
|
480
628
|
if (nextFire) {
|
|
@@ -540,7 +688,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
540
688
|
|
|
541
689
|
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).
|
|
542
690
|
|
|
543
|
-
If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a
|
|
691
|
+
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.
|
|
544
692
|
|
|
545
693
|
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.
|
|
546
694
|
|
|
@@ -554,7 +702,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
|
|
|
554
702
|
|
|
555
703
|
## onDone — auto-notify on completion
|
|
556
704
|
|
|
557
|
-
Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The
|
|
705
|
+
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"`,
|
|
558
706
|
promptGuidelines: [
|
|
559
707
|
"Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
|
|
560
708
|
"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.",
|
|
@@ -564,7 +712,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
564
712
|
command: Type.String({ description: "Shell command to run in background" }),
|
|
565
713
|
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
566
714
|
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
567
|
-
onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion
|
|
715
|
+
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." })),
|
|
568
716
|
}),
|
|
569
717
|
|
|
570
718
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
@@ -607,7 +755,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
607
755
|
|
|
608
756
|
const lines: string[] = [];
|
|
609
757
|
for (const m of monitors) {
|
|
610
|
-
const icon = m.status === "running" ? "
|
|
758
|
+
const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
|
|
611
759
|
const age = Date.now() - m.startedAt;
|
|
612
760
|
const ageStr = formatRemaining(age);
|
|
613
761
|
let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
|
|
@@ -744,45 +892,45 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
744
892
|
async function viewLoops(ui: ExtensionUIContext) {
|
|
745
893
|
const loops = store.list();
|
|
746
894
|
if (loops.length === 0) {
|
|
747
|
-
await ui.select("No active loops", ["
|
|
895
|
+
await ui.select("No active loops", ["< Back"]);
|
|
748
896
|
return;
|
|
749
897
|
}
|
|
750
898
|
|
|
751
899
|
const choices = loops.map((l: LoopEntry) => {
|
|
752
|
-
const icon = l.status === "active" ? "
|
|
900
|
+
const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
|
|
753
901
|
const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
|
|
754
902
|
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
755
903
|
});
|
|
756
|
-
choices.push("
|
|
904
|
+
choices.push("< Back");
|
|
757
905
|
|
|
758
906
|
const selected = await ui.select("Active Loops", choices);
|
|
759
|
-
if (!selected || selected === "
|
|
907
|
+
if (!selected || selected === "< Back") return;
|
|
760
908
|
|
|
761
909
|
const match = selected.match(/#(\d+)/);
|
|
762
910
|
if (match) {
|
|
763
911
|
const entry = store.get(match[1]);
|
|
764
912
|
if (entry) {
|
|
765
|
-
const actions = ["
|
|
766
|
-
if (entry.status === "active") actions.unshift("
|
|
767
|
-
else if (entry.status === "paused") actions.unshift("
|
|
768
|
-
actions.push("
|
|
913
|
+
const actions = ["x Delete"];
|
|
914
|
+
if (entry.status === "active") actions.unshift("- Pause");
|
|
915
|
+
else if (entry.status === "paused") actions.unshift("* Resume");
|
|
916
|
+
actions.push("< Back");
|
|
769
917
|
|
|
770
918
|
const action = await ui.select(
|
|
771
919
|
`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
|
|
772
920
|
actions,
|
|
773
921
|
);
|
|
774
922
|
|
|
775
|
-
if (action === "
|
|
923
|
+
if (action === "x Delete") {
|
|
776
924
|
triggerSystem.remove(entry.id);
|
|
777
925
|
store.delete(entry.id);
|
|
778
926
|
widget.update();
|
|
779
927
|
ui.notify(`Loop #${entry.id} deleted`, "info");
|
|
780
|
-
} else if (action === "
|
|
928
|
+
} else if (action === "- Pause") {
|
|
781
929
|
store.update(entry.id, { status: "paused" });
|
|
782
930
|
triggerSystem.remove(entry.id);
|
|
783
931
|
widget.update();
|
|
784
932
|
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
785
|
-
} else if (action === "
|
|
933
|
+
} else if (action === "* Resume") {
|
|
786
934
|
store.update(entry.id, { status: "active" });
|
|
787
935
|
triggerSystem.add(entry);
|
|
788
936
|
widget.update();
|
|
@@ -799,4 +947,193 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
799
947
|
const active = loops.filter(l => l.status === "active").length;
|
|
800
948
|
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
801
949
|
}
|
|
950
|
+
|
|
951
|
+
async function createNativeTaskInteractively(ui: ExtensionUIContext) {
|
|
952
|
+
if (!nativeTaskStore) {
|
|
953
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const subject = await ui.input("Task subject");
|
|
958
|
+
if (!subject) return;
|
|
959
|
+
const description = await ui.input("Task description") || subject;
|
|
960
|
+
const entry = nativeTaskStore.create(subject, description);
|
|
961
|
+
widget.update();
|
|
962
|
+
ui.notify(`Task #${entry.id} created`, "info");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function viewNativeTasks(ui: ExtensionUIContext): Promise<void> {
|
|
966
|
+
if (!nativeTaskStore) {
|
|
967
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const tasks = nativeTaskStore.list();
|
|
972
|
+
const choices = tasks.map((task) => {
|
|
973
|
+
const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
|
|
974
|
+
return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
|
|
975
|
+
});
|
|
976
|
+
choices.unshift("+ Create task");
|
|
977
|
+
choices.push("< Back");
|
|
978
|
+
|
|
979
|
+
const selected = await ui.select("Native Tasks", choices);
|
|
980
|
+
if (!selected || selected === "< Back") return;
|
|
981
|
+
if (selected === "+ Create task") {
|
|
982
|
+
await createNativeTaskInteractively(ui);
|
|
983
|
+
return viewNativeTasks(ui);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const match = selected.match(/#(\d+)/);
|
|
987
|
+
if (!match) return viewNativeTasks(ui);
|
|
988
|
+
|
|
989
|
+
const task = nativeTaskStore.get(match[1]);
|
|
990
|
+
if (!task) return viewNativeTasks(ui);
|
|
991
|
+
|
|
992
|
+
const actions = ["x Delete"];
|
|
993
|
+
if (task.status === "pending") {
|
|
994
|
+
actions.unshift("ok Complete");
|
|
995
|
+
actions.unshift("> Start");
|
|
996
|
+
} else if (task.status === "in_progress") {
|
|
997
|
+
actions.unshift("ok Complete");
|
|
998
|
+
actions.unshift("* Return to pending");
|
|
999
|
+
} else {
|
|
1000
|
+
actions.unshift("* Reopen");
|
|
1001
|
+
}
|
|
1002
|
+
actions.push("< Back");
|
|
1003
|
+
|
|
1004
|
+
const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
|
|
1005
|
+
if (!action || action === "< Back") return viewNativeTasks(ui);
|
|
1006
|
+
|
|
1007
|
+
if (action === "x Delete") {
|
|
1008
|
+
nativeTaskStore.delete(task.id);
|
|
1009
|
+
ui.notify(`Task #${task.id} deleted`, "info");
|
|
1010
|
+
} else if (action === "> Start") {
|
|
1011
|
+
nativeTaskStore.update(task.id, { status: "in_progress" });
|
|
1012
|
+
ui.notify(`Task #${task.id} started`, "info");
|
|
1013
|
+
} else if (action === "ok Complete") {
|
|
1014
|
+
nativeTaskStore.update(task.id, { status: "completed" });
|
|
1015
|
+
ui.notify(`Task #${task.id} completed`, "info");
|
|
1016
|
+
} else if (action === "* Return to pending" || action === "* Reopen") {
|
|
1017
|
+
nativeTaskStore.update(task.id, { status: "pending" });
|
|
1018
|
+
ui.notify(`Task #${task.id} reopened`, "info");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
widget.update();
|
|
1022
|
+
return viewNativeTasks(ui);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ── Native task tools (only when pi-tasks is absent) ──
|
|
1026
|
+
|
|
1027
|
+
setTimeout(async () => {
|
|
1028
|
+
if (tasksAvailable || nativeTasksRegistered) return;
|
|
1029
|
+
nativeTaskStore = new TaskStore(resolveTaskStorePath());
|
|
1030
|
+
nativeTasksRegistered = true;
|
|
1031
|
+
const taskStore = nativeTaskStore;
|
|
1032
|
+
|
|
1033
|
+
pi.registerCommand("tasks", {
|
|
1034
|
+
description: "View or manage native pi-loop tasks when pi-tasks is not installed",
|
|
1035
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1036
|
+
const trimmed = args.trim();
|
|
1037
|
+
if (!nativeTaskStore) {
|
|
1038
|
+
ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (trimmed) {
|
|
1042
|
+
const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
|
|
1043
|
+
widget.update();
|
|
1044
|
+
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
await viewNativeTasks(ctx.ui);
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
pi.registerTool({
|
|
1052
|
+
name: "TaskCreate",
|
|
1053
|
+
label: "TaskCreate",
|
|
1054
|
+
description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
|
|
1055
|
+
|
|
1056
|
+
Fields:
|
|
1057
|
+
- subject: brief actionable title
|
|
1058
|
+
- description: detailed requirements
|
|
1059
|
+
- metadata: optional tags/metadata`,
|
|
1060
|
+
parameters: Type.Object({
|
|
1061
|
+
subject: Type.String({ description: "Brief actionable title for the task" }),
|
|
1062
|
+
description: Type.String({ description: "Detailed description of what needs to be done" }),
|
|
1063
|
+
}),
|
|
1064
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1065
|
+
const entry = taskStore.create(params.subject, params.description);
|
|
1066
|
+
widget.update();
|
|
1067
|
+
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
pi.registerTool({
|
|
1072
|
+
name: "TaskList",
|
|
1073
|
+
label: "TaskList",
|
|
1074
|
+
description: `List all tasks with status. Use to check progress and find available work.`,
|
|
1075
|
+
parameters: Type.Object({}),
|
|
1076
|
+
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1077
|
+
const tasks = taskStore.list();
|
|
1078
|
+
if (tasks.length === 0) return Promise.resolve(textResult("No tasks."));
|
|
1079
|
+
|
|
1080
|
+
const lines: string[] = [];
|
|
1081
|
+
const statuses: Record<"pending" | "in_progress" | "completed", number> = {
|
|
1082
|
+
pending: 0,
|
|
1083
|
+
in_progress: 0,
|
|
1084
|
+
completed: 0,
|
|
1085
|
+
};
|
|
1086
|
+
for (const t of tasks) {
|
|
1087
|
+
statuses[t.status]++;
|
|
1088
|
+
const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
|
|
1089
|
+
lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
|
|
1090
|
+
}
|
|
1091
|
+
lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
|
|
1092
|
+
return Promise.resolve(textResult(lines.join("\n")));
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
pi.registerTool({
|
|
1097
|
+
name: "TaskUpdate",
|
|
1098
|
+
label: "TaskUpdate",
|
|
1099
|
+
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
1100
|
+
|
|
1101
|
+
Statuses: pending → in_progress → completed`,
|
|
1102
|
+
parameters: Type.Object({
|
|
1103
|
+
id: Type.String({ description: "Task ID to update" }),
|
|
1104
|
+
status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
|
|
1105
|
+
subject: Type.Optional(Type.String({ description: "New title" })),
|
|
1106
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
1107
|
+
}),
|
|
1108
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1109
|
+
const { id, status, subject, description } = params;
|
|
1110
|
+
const entry = taskStore.update(id, {
|
|
1111
|
+
status: status as "pending" | "in_progress" | "completed" | undefined,
|
|
1112
|
+
subject,
|
|
1113
|
+
description,
|
|
1114
|
+
});
|
|
1115
|
+
if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
|
|
1116
|
+
widget.update();
|
|
1117
|
+
const statusMsg = status ? ` → ${status}` : "";
|
|
1118
|
+
return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
pi.registerTool({
|
|
1123
|
+
name: "TaskDelete",
|
|
1124
|
+
label: "TaskDelete",
|
|
1125
|
+
description: `Delete a task by ID. Use for cleaning up completed or irrelevant tasks.`,
|
|
1126
|
+
parameters: Type.Object({
|
|
1127
|
+
id: Type.String({ description: "Task ID to delete" }),
|
|
1128
|
+
}),
|
|
1129
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1130
|
+
const deleted = taskStore.delete(params.id);
|
|
1131
|
+
widget.update();
|
|
1132
|
+
if (deleted) return Promise.resolve(textResult(`Task #${params.id} deleted`));
|
|
1133
|
+
return Promise.resolve(textResult(`Task #${params.id} not found`));
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
debug("native task tools registered (pi-tasks not detected)");
|
|
1138
|
+
}, 6000);
|
|
802
1139
|
}
|