@trevonistrevon/pi-loop 0.2.4 → 0.2.6
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/dist/index.js +50 -12
- package/dist/store.d.ts +1 -0
- package/dist/store.js +6 -2
- package/dist/types.d.ts +1 -0
- package/dist/ui/widget.d.ts +2 -10
- package/package.json +1 -1
- package/src/index.ts +79 -27
- package/src/store.ts +4 -3
- package/src/types.ts +1 -0
- package/src/ui/widget.ts +9 -16
package/dist/index.js
CHANGED
|
@@ -30,10 +30,6 @@ function debug(...args) {
|
|
|
30
30
|
function textResult(msg) {
|
|
31
31
|
return { content: [{ type: "text", text: msg }], details: undefined };
|
|
32
32
|
}
|
|
33
|
-
const SYSTEM_REMINDER_TEMPLATE = `<system-reminder>
|
|
34
|
-
Loop "%prompt%" fired. Execute this instruction now.
|
|
35
|
-
Trigger: %trigger_info%. Loop: %loop_id%.
|
|
36
|
-
</system-reminder>`;
|
|
37
33
|
export default function (pi) {
|
|
38
34
|
const piLoopEnv = process.env.PI_LOOP;
|
|
39
35
|
const piLoopScope = process.env.PI_LOOP_SCOPE;
|
|
@@ -124,6 +120,7 @@ export default function (pi) {
|
|
|
124
120
|
prompt: entry.prompt,
|
|
125
121
|
trigger: entry.trigger,
|
|
126
122
|
timestamp: Date.now(),
|
|
123
|
+
readOnly: entry.readOnly,
|
|
127
124
|
});
|
|
128
125
|
}
|
|
129
126
|
// ── Session lifecycle ──
|
|
@@ -182,7 +179,9 @@ export default function (pi) {
|
|
|
182
179
|
showPersistedLoops(isResume);
|
|
183
180
|
});
|
|
184
181
|
// ── Loop fire handler — sends a user message to re-wake the agent ──
|
|
185
|
-
|
|
182
|
+
const pendingFollowUps = new Set();
|
|
183
|
+
pi.events.on("loop:fire", (event) => {
|
|
184
|
+
const data = event;
|
|
186
185
|
const triggerInfo = typeof data.trigger === "string"
|
|
187
186
|
? data.trigger
|
|
188
187
|
: data.trigger?.type === "cron"
|
|
@@ -190,12 +189,23 @@ export default function (pi) {
|
|
|
190
189
|
: data.trigger?.type === "event"
|
|
191
190
|
? `event: ${data.trigger.source}`
|
|
192
191
|
: `hybrid`;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
const loopId = data.loopId || "?";
|
|
193
|
+
if (pendingFollowUps.has(loopId)) {
|
|
194
|
+
debug(`loop:fire #${loopId} — follow-up already queued, skipping`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
pendingFollowUps.add(loopId);
|
|
198
|
+
const prompt = data.prompt || "loop fired";
|
|
199
|
+
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." : "";
|
|
200
|
+
const message = [
|
|
201
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
202
|
+
prompt,
|
|
203
|
+
].join("\n");
|
|
204
|
+
// deliverAs: "followUp" queues the message when the agent is busy;
|
|
205
|
+
// it delivers after the current turn finishes.
|
|
206
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
198
207
|
});
|
|
208
|
+
pi.on("turn_end", () => { pendingFollowUps.clear(); });
|
|
199
209
|
// ──────────────────────────────────────────────────
|
|
200
210
|
// Tool 1: LoopCreate
|
|
201
211
|
// ──────────────────────────────────────────────────
|
|
@@ -231,7 +241,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
231
241
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
232
242
|
- **recurring**: repeat or fire once (default: true)
|
|
233
243
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
234
|
-
- **
|
|
244
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
235
245
|
promptGuidelines: [
|
|
236
246
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
237
247
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -245,9 +255,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
245
255
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
246
256
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
247
257
|
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
258
|
+
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
248
259
|
}),
|
|
249
260
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
250
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
261
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
251
262
|
let trigger;
|
|
252
263
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
253
264
|
if (inferred === "cron") {
|
|
@@ -268,10 +279,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
268
279
|
debounceMs: debounceMs ?? 30000,
|
|
269
280
|
};
|
|
270
281
|
}
|
|
282
|
+
const validationError = validateTrigger(trigger);
|
|
283
|
+
if (validationError)
|
|
284
|
+
return Promise.resolve(textResult(validationError));
|
|
271
285
|
const entry = store.create(trigger, prompt, {
|
|
272
286
|
recurring: recurring ?? (inferred !== "event"),
|
|
273
287
|
autoTask,
|
|
274
288
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
289
|
+
readOnly,
|
|
275
290
|
});
|
|
276
291
|
triggerSystem.add(entry);
|
|
277
292
|
widget.update();
|
|
@@ -288,6 +303,29 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
288
303
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
289
304
|
},
|
|
290
305
|
});
|
|
306
|
+
function validateTrigger(trigger) {
|
|
307
|
+
if (trigger.type === "cron") {
|
|
308
|
+
const parts = trigger.schedule.trim().split(/\s+/);
|
|
309
|
+
if (parts.length !== 5) {
|
|
310
|
+
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.`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (trigger.type === "event") {
|
|
314
|
+
if (!trigger.source || trigger.source.trim().length === 0) {
|
|
315
|
+
return `Invalid event trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (trigger.type === "hybrid") {
|
|
319
|
+
const cronParts = trigger.cron.trim().split(/\s+/);
|
|
320
|
+
if (cronParts.length !== 5) {
|
|
321
|
+
return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
|
|
322
|
+
}
|
|
323
|
+
if (!trigger.event.source || trigger.event.source.trim().length === 0) {
|
|
324
|
+
return `Invalid hybrid trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
291
329
|
function inferTriggerType(input) {
|
|
292
330
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event")))
|
|
293
331
|
return "hybrid";
|
package/dist/store.d.ts
CHANGED
package/dist/store.js
CHANGED
|
@@ -16,8 +16,11 @@ function acquireLock(lockPath) {
|
|
|
16
16
|
if (e.code === "EEXIST") {
|
|
17
17
|
try {
|
|
18
18
|
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
19
|
-
if (pid
|
|
20
|
-
|
|
19
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
20
|
+
try {
|
|
21
|
+
unlinkSync(lockPath);
|
|
22
|
+
}
|
|
23
|
+
catch { /* ignore */ }
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
23
26
|
}
|
|
@@ -115,6 +118,7 @@ export class LoopStore {
|
|
|
115
118
|
recurring: opts.recurring,
|
|
116
119
|
autoTask: opts.autoTask,
|
|
117
120
|
selfPaced: opts.selfPaced,
|
|
121
|
+
readOnly: opts.readOnly,
|
|
118
122
|
createdAt: now,
|
|
119
123
|
updatedAt: now,
|
|
120
124
|
expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
|
package/dist/types.d.ts
CHANGED
package/dist/ui/widget.d.ts
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
2
3
|
import type { CronScheduler } from "../scheduler.js";
|
|
3
4
|
import type { LoopStore } from "../store.js";
|
|
4
|
-
export type UICtx = {
|
|
5
|
-
setStatus(key: string, text: string | undefined): void;
|
|
6
|
-
setWidget(key: string, content: undefined | ((tui: any, theme: any) => {
|
|
7
|
-
render(): string[];
|
|
8
|
-
invalidate(): void;
|
|
9
|
-
}), options?: {
|
|
10
|
-
placement?: "aboveEditor" | "belowEditor";
|
|
11
|
-
}): void;
|
|
12
|
-
};
|
|
13
5
|
export declare class LoopWidget {
|
|
14
6
|
private store;
|
|
15
7
|
private scheduler;
|
|
@@ -19,7 +11,7 @@ export declare class LoopWidget {
|
|
|
19
11
|
private widgetRegistered;
|
|
20
12
|
private interval;
|
|
21
13
|
constructor(store: LoopStore, scheduler: CronScheduler | undefined, monitorManager: MonitorManager);
|
|
22
|
-
setUICtx(ctx:
|
|
14
|
+
setUICtx(ctx: ExtensionUIContext): void;
|
|
23
15
|
setStore(store: LoopStore): void;
|
|
24
16
|
setScheduler(scheduler: CronScheduler): void;
|
|
25
17
|
update(): void;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
import { join, resolve } from "node:path";
|
|
19
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { parseInterval } from "./loop-parse.js";
|
|
22
22
|
import { MonitorManager } from "./monitor-manager.js";
|
|
@@ -24,7 +24,7 @@ import { CronScheduler } from "./scheduler.js";
|
|
|
24
24
|
import { LoopStore } from "./store.js";
|
|
25
25
|
import { TriggerSystem } from "./trigger-system.js";
|
|
26
26
|
import type { LoopEntry, Trigger } from "./types.js";
|
|
27
|
-
import { LoopWidget
|
|
27
|
+
import { LoopWidget } from "./ui/widget.js";
|
|
28
28
|
|
|
29
29
|
const DEBUG = !!process.env.PI_LOOP_DEBUG;
|
|
30
30
|
function debug(...args: unknown[]) {
|
|
@@ -35,10 +35,18 @@ function textResult(msg: string) {
|
|
|
35
35
|
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
interface LoopFireEvent {
|
|
39
|
+
loopId: string;
|
|
40
|
+
prompt: string;
|
|
41
|
+
trigger: Trigger | string;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
readOnly?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SessionSwitchEvent {
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
|
|
43
51
|
export default function (pi: ExtensionAPI) {
|
|
44
52
|
const piLoopEnv = process.env.PI_LOOP;
|
|
@@ -127,6 +135,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
135
|
prompt: entry.prompt,
|
|
128
136
|
trigger: entry.trigger,
|
|
129
137
|
timestamp: Date.now(),
|
|
138
|
+
readOnly: entry.readOnly,
|
|
130
139
|
});
|
|
131
140
|
}
|
|
132
141
|
|
|
@@ -165,20 +174,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
165
174
|
|
|
166
175
|
pi.on("turn_start", async (_event, ctx) => {
|
|
167
176
|
_latestCtx = ctx;
|
|
168
|
-
widget.setUICtx(ctx.ui
|
|
177
|
+
widget.setUICtx(ctx.ui);
|
|
169
178
|
upgradeStoreIfNeeded(ctx);
|
|
170
179
|
});
|
|
171
180
|
|
|
172
181
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
173
182
|
_latestCtx = ctx;
|
|
174
|
-
widget.setUICtx(ctx.ui
|
|
183
|
+
widget.setUICtx(ctx.ui);
|
|
175
184
|
upgradeStoreIfNeeded(ctx);
|
|
176
185
|
showPersistedLoops();
|
|
177
186
|
});
|
|
178
187
|
|
|
179
|
-
pi.on("session_switch" as any, async (event:
|
|
188
|
+
pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
|
|
180
189
|
_latestCtx = ctx;
|
|
181
|
-
widget.setUICtx(ctx.ui
|
|
190
|
+
widget.setUICtx(ctx.ui);
|
|
182
191
|
triggerSystem.stop();
|
|
183
192
|
|
|
184
193
|
const isResume = event?.reason === "resume";
|
|
@@ -195,7 +204,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
204
|
|
|
196
205
|
// ── Loop fire handler — sends a user message to re-wake the agent ──
|
|
197
206
|
|
|
198
|
-
|
|
207
|
+
const pendingFollowUps = new Set<string>();
|
|
208
|
+
|
|
209
|
+
pi.events.on("loop:fire", (event: unknown) => {
|
|
210
|
+
const data = event as LoopFireEvent;
|
|
199
211
|
const triggerInfo = typeof data.trigger === "string"
|
|
200
212
|
? data.trigger
|
|
201
213
|
: data.trigger?.type === "cron"
|
|
@@ -204,14 +216,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
204
216
|
? `event: ${data.trigger.source}`
|
|
205
217
|
: `hybrid`;
|
|
206
218
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
const loopId = data.loopId || "?";
|
|
220
|
+
if (pendingFollowUps.has(loopId)) {
|
|
221
|
+
debug(`loop:fire #${loopId} — follow-up already queued, skipping`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
pendingFollowUps.add(loopId);
|
|
225
|
+
|
|
226
|
+
const prompt = data.prompt || "loop fired";
|
|
227
|
+
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." : "";
|
|
228
|
+
const message = [
|
|
229
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
230
|
+
prompt,
|
|
231
|
+
].join("\n");
|
|
232
|
+
|
|
233
|
+
// deliverAs: "followUp" queues the message when the agent is busy;
|
|
234
|
+
// it delivers after the current turn finishes.
|
|
235
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
213
236
|
});
|
|
214
237
|
|
|
238
|
+
pi.on("turn_end", () => { pendingFollowUps.clear(); });
|
|
239
|
+
|
|
215
240
|
// ──────────────────────────────────────────────────
|
|
216
241
|
// Tool 1: LoopCreate
|
|
217
242
|
// ──────────────────────────────────────────────────
|
|
@@ -248,7 +273,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
248
273
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
249
274
|
- **recurring**: repeat or fire once (default: true)
|
|
250
275
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
251
|
-
- **
|
|
276
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
252
277
|
promptGuidelines: [
|
|
253
278
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
254
279
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -262,10 +287,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
262
287
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
263
288
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
264
289
|
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
290
|
+
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
265
291
|
}),
|
|
266
292
|
|
|
267
293
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
268
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
294
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
269
295
|
|
|
270
296
|
let trigger: Trigger;
|
|
271
297
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
@@ -287,10 +313,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
287
313
|
};
|
|
288
314
|
}
|
|
289
315
|
|
|
316
|
+
const validationError = validateTrigger(trigger);
|
|
317
|
+
if (validationError) return Promise.resolve(textResult(validationError));
|
|
318
|
+
|
|
290
319
|
const entry = store.create(trigger, prompt, {
|
|
291
320
|
recurring: recurring ?? (inferred !== "event"),
|
|
292
321
|
autoTask,
|
|
293
322
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
323
|
+
readOnly,
|
|
294
324
|
});
|
|
295
325
|
|
|
296
326
|
triggerSystem.add(entry);
|
|
@@ -313,6 +343,28 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
313
343
|
},
|
|
314
344
|
});
|
|
315
345
|
|
|
346
|
+
function validateTrigger(trigger: Trigger): string | null {
|
|
347
|
+
if (trigger.type === "cron") {
|
|
348
|
+
const parts = trigger.schedule.trim().split(/\s+/);
|
|
349
|
+
if (parts.length !== 5) {
|
|
350
|
+
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.`;
|
|
351
|
+
}
|
|
352
|
+
} else if (trigger.type === "event") {
|
|
353
|
+
if (!trigger.source || trigger.source.trim().length === 0) {
|
|
354
|
+
return `Invalid event trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
355
|
+
}
|
|
356
|
+
} else if (trigger.type === "hybrid") {
|
|
357
|
+
const cronParts = trigger.cron.trim().split(/\s+/);
|
|
358
|
+
if (cronParts.length !== 5) {
|
|
359
|
+
return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
|
|
360
|
+
}
|
|
361
|
+
if (!trigger.event.source || trigger.event.source.trim().length === 0) {
|
|
362
|
+
return `Invalid hybrid trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
316
368
|
function inferTriggerType(input: string): "cron" | "event" | "hybrid" {
|
|
317
369
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event"))) return "hybrid";
|
|
318
370
|
if (/^\d+\s*[smhd]$/i.test(input.trim())) return "cron";
|
|
@@ -555,8 +607,8 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
555
607
|
triggerSystem.add(entry);
|
|
556
608
|
widget.update();
|
|
557
609
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
558
|
-
} catch (err:
|
|
559
|
-
ui.notify(err.message, "error");
|
|
610
|
+
} catch (err: unknown) {
|
|
611
|
+
ui.notify((err as Error).message, "error");
|
|
560
612
|
}
|
|
561
613
|
return;
|
|
562
614
|
}
|
|
@@ -573,7 +625,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
573
625
|
},
|
|
574
626
|
});
|
|
575
627
|
|
|
576
|
-
async function scheduleLoop(ui:
|
|
628
|
+
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
577
629
|
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
578
630
|
if (!p) return;
|
|
579
631
|
|
|
@@ -587,12 +639,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
587
639
|
triggerSystem.add(entry);
|
|
588
640
|
widget.update();
|
|
589
641
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
590
|
-
} catch (err:
|
|
591
|
-
ui.notify(err.message, "error");
|
|
642
|
+
} catch (err: unknown) {
|
|
643
|
+
ui.notify((err as Error).message, "error");
|
|
592
644
|
}
|
|
593
645
|
}
|
|
594
646
|
|
|
595
|
-
async function eventLoop(ui:
|
|
647
|
+
async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
596
648
|
const p = prompt || await ui.input("Prompt");
|
|
597
649
|
if (!p) return;
|
|
598
650
|
|
|
@@ -606,7 +658,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
606
658
|
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
607
659
|
}
|
|
608
660
|
|
|
609
|
-
async function viewLoops(ui:
|
|
661
|
+
async function viewLoops(ui: ExtensionUIContext) {
|
|
610
662
|
const loops = store.list();
|
|
611
663
|
if (loops.length === 0) {
|
|
612
664
|
await ui.select("No active loops", ["← Back"]);
|
|
@@ -659,7 +711,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
659
711
|
return viewLoops(ui);
|
|
660
712
|
}
|
|
661
713
|
|
|
662
|
-
async function settings(ui:
|
|
714
|
+
async function settings(ui: ExtensionUIContext) {
|
|
663
715
|
const loops = store.list();
|
|
664
716
|
const active = loops.filter(l => l.status === "active").length;
|
|
665
717
|
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
package/src/store.ts
CHANGED
|
@@ -18,8 +18,8 @@ function acquireLock(lockPath: string): void {
|
|
|
18
18
|
if (e.code === "EEXIST") {
|
|
19
19
|
try {
|
|
20
20
|
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
21
|
-
if (pid
|
|
22
|
-
unlinkSync(lockPath);
|
|
21
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
22
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
25
|
} catch { /* ignore read errors */ }
|
|
@@ -95,7 +95,7 @@ export class LoopStore {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; selfPaced?: boolean }): LoopEntry {
|
|
98
|
+
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; selfPaced?: boolean; readOnly?: boolean }): LoopEntry {
|
|
99
99
|
return this.withLock(() => {
|
|
100
100
|
if (this.loops.size >= MAX_LOOPS) {
|
|
101
101
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
@@ -109,6 +109,7 @@ export class LoopStore {
|
|
|
109
109
|
recurring: opts.recurring,
|
|
110
110
|
autoTask: opts.autoTask,
|
|
111
111
|
selfPaced: opts.selfPaced,
|
|
112
|
+
readOnly: opts.readOnly,
|
|
112
113
|
createdAt: now,
|
|
113
114
|
updatedAt: now,
|
|
114
115
|
expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
|
package/src/types.ts
CHANGED
package/src/ui/widget.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
1
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
4
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
3
5
|
import type { CronScheduler } from "../scheduler.js";
|
|
@@ -5,18 +7,9 @@ import type { LoopStore } from "../store.js";
|
|
|
5
7
|
|
|
6
8
|
const MAX_VISIBLE = 6;
|
|
7
9
|
|
|
8
|
-
export type UICtx = {
|
|
9
|
-
setStatus(key: string, text: string | undefined): void;
|
|
10
|
-
setWidget(
|
|
11
|
-
key: string,
|
|
12
|
-
content: undefined | ((tui: any, theme: any) => { render(): string[]; invalidate(): void }),
|
|
13
|
-
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
14
|
-
): void;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
10
|
export class LoopWidget {
|
|
18
|
-
private uiCtx:
|
|
19
|
-
private tui:
|
|
11
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
12
|
+
private tui: TUI | undefined;
|
|
20
13
|
private widgetRegistered = false;
|
|
21
14
|
private interval: ReturnType<typeof setInterval> | undefined;
|
|
22
15
|
|
|
@@ -26,7 +19,7 @@ export class LoopWidget {
|
|
|
26
19
|
private monitorManager: MonitorManager,
|
|
27
20
|
) {}
|
|
28
21
|
|
|
29
|
-
setUICtx(ctx:
|
|
22
|
+
setUICtx(ctx: ExtensionUIContext) {
|
|
30
23
|
this.uiCtx = ctx;
|
|
31
24
|
}
|
|
32
25
|
|
|
@@ -61,17 +54,17 @@ export class LoopWidget {
|
|
|
61
54
|
}
|
|
62
55
|
|
|
63
56
|
if (!this.widgetRegistered) {
|
|
64
|
-
this.uiCtx.setWidget("loops", (tui, theme) => {
|
|
57
|
+
this.uiCtx.setWidget("loops", (tui: TUI, theme: Theme) => {
|
|
65
58
|
this.tui = tui;
|
|
66
|
-
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
|
|
59
|
+
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} } as Component & { dispose?(): void };
|
|
67
60
|
}, { placement: "aboveEditor" });
|
|
68
61
|
this.widgetRegistered = true;
|
|
69
62
|
} else if (this.tui) {
|
|
70
|
-
this.tui.requestRender();
|
|
63
|
+
(this.tui as any).requestRender();
|
|
71
64
|
}
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
private renderWidget(tui:
|
|
67
|
+
private renderWidget(tui: TUI, _theme: Theme): string[] {
|
|
75
68
|
const loops = this.store.list().filter(l => l.status === "active");
|
|
76
69
|
const monitors = this.monitorManager.list().filter(m => m.status === "running");
|
|
77
70
|
const w = tui.terminal.columns;
|