@trevonistrevon/pi-loop 0.2.5 → 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 +43 -4
- 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 +71 -18
- package/src/store.ts +4 -3
- package/src/types.ts +1 -0
- package/src/ui/widget.ts +9 -16
package/dist/index.js
CHANGED
|
@@ -120,6 +120,7 @@ export default function (pi) {
|
|
|
120
120
|
prompt: entry.prompt,
|
|
121
121
|
trigger: entry.trigger,
|
|
122
122
|
timestamp: Date.now(),
|
|
123
|
+
readOnly: entry.readOnly,
|
|
123
124
|
});
|
|
124
125
|
}
|
|
125
126
|
// ── Session lifecycle ──
|
|
@@ -178,7 +179,9 @@ export default function (pi) {
|
|
|
178
179
|
showPersistedLoops(isResume);
|
|
179
180
|
});
|
|
180
181
|
// ── Loop fire handler — sends a user message to re-wake the agent ──
|
|
181
|
-
|
|
182
|
+
const pendingFollowUps = new Set();
|
|
183
|
+
pi.events.on("loop:fire", (event) => {
|
|
184
|
+
const data = event;
|
|
182
185
|
const triggerInfo = typeof data.trigger === "string"
|
|
183
186
|
? data.trigger
|
|
184
187
|
: data.trigger?.type === "cron"
|
|
@@ -186,15 +189,23 @@ export default function (pi) {
|
|
|
186
189
|
: data.trigger?.type === "event"
|
|
187
190
|
? `event: ${data.trigger.source}`
|
|
188
191
|
: `hybrid`;
|
|
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);
|
|
189
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." : "";
|
|
190
200
|
const message = [
|
|
191
|
-
`[pi-loop] Loop #${
|
|
201
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
192
202
|
prompt,
|
|
193
203
|
].join("\n");
|
|
194
204
|
// deliverAs: "followUp" queues the message when the agent is busy;
|
|
195
205
|
// it delivers after the current turn finishes.
|
|
196
206
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
197
207
|
});
|
|
208
|
+
pi.on("turn_end", () => { pendingFollowUps.clear(); });
|
|
198
209
|
// ──────────────────────────────────────────────────
|
|
199
210
|
// Tool 1: LoopCreate
|
|
200
211
|
// ──────────────────────────────────────────────────
|
|
@@ -230,7 +241,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
230
241
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
231
242
|
- **recurring**: repeat or fire once (default: true)
|
|
232
243
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
233
|
-
- **
|
|
244
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
234
245
|
promptGuidelines: [
|
|
235
246
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
236
247
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -244,9 +255,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
244
255
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
245
256
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
246
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 })),
|
|
247
259
|
}),
|
|
248
260
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
249
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
261
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
250
262
|
let trigger;
|
|
251
263
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
252
264
|
if (inferred === "cron") {
|
|
@@ -267,10 +279,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
267
279
|
debounceMs: debounceMs ?? 30000,
|
|
268
280
|
};
|
|
269
281
|
}
|
|
282
|
+
const validationError = validateTrigger(trigger);
|
|
283
|
+
if (validationError)
|
|
284
|
+
return Promise.resolve(textResult(validationError));
|
|
270
285
|
const entry = store.create(trigger, prompt, {
|
|
271
286
|
recurring: recurring ?? (inferred !== "event"),
|
|
272
287
|
autoTask,
|
|
273
288
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
289
|
+
readOnly,
|
|
274
290
|
});
|
|
275
291
|
triggerSystem.add(entry);
|
|
276
292
|
widget.update();
|
|
@@ -287,6 +303,29 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
287
303
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
288
304
|
},
|
|
289
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
|
+
}
|
|
290
329
|
function inferTriggerType(input) {
|
|
291
330
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event")))
|
|
292
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,6 +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
|
+
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
|
+
|
|
38
50
|
|
|
39
51
|
export default function (pi: ExtensionAPI) {
|
|
40
52
|
const piLoopEnv = process.env.PI_LOOP;
|
|
@@ -123,6 +135,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
135
|
prompt: entry.prompt,
|
|
124
136
|
trigger: entry.trigger,
|
|
125
137
|
timestamp: Date.now(),
|
|
138
|
+
readOnly: entry.readOnly,
|
|
126
139
|
});
|
|
127
140
|
}
|
|
128
141
|
|
|
@@ -161,20 +174,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
174
|
|
|
162
175
|
pi.on("turn_start", async (_event, ctx) => {
|
|
163
176
|
_latestCtx = ctx;
|
|
164
|
-
widget.setUICtx(ctx.ui
|
|
177
|
+
widget.setUICtx(ctx.ui);
|
|
165
178
|
upgradeStoreIfNeeded(ctx);
|
|
166
179
|
});
|
|
167
180
|
|
|
168
181
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
169
182
|
_latestCtx = ctx;
|
|
170
|
-
widget.setUICtx(ctx.ui
|
|
183
|
+
widget.setUICtx(ctx.ui);
|
|
171
184
|
upgradeStoreIfNeeded(ctx);
|
|
172
185
|
showPersistedLoops();
|
|
173
186
|
});
|
|
174
187
|
|
|
175
|
-
pi.on("session_switch" as any, async (event:
|
|
188
|
+
pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
|
|
176
189
|
_latestCtx = ctx;
|
|
177
|
-
widget.setUICtx(ctx.ui
|
|
190
|
+
widget.setUICtx(ctx.ui);
|
|
178
191
|
triggerSystem.stop();
|
|
179
192
|
|
|
180
193
|
const isResume = event?.reason === "resume";
|
|
@@ -191,7 +204,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
204
|
|
|
192
205
|
// ── Loop fire handler — sends a user message to re-wake the agent ──
|
|
193
206
|
|
|
194
|
-
|
|
207
|
+
const pendingFollowUps = new Set<string>();
|
|
208
|
+
|
|
209
|
+
pi.events.on("loop:fire", (event: unknown) => {
|
|
210
|
+
const data = event as LoopFireEvent;
|
|
195
211
|
const triggerInfo = typeof data.trigger === "string"
|
|
196
212
|
? data.trigger
|
|
197
213
|
: data.trigger?.type === "cron"
|
|
@@ -200,9 +216,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
216
|
? `event: ${data.trigger.source}`
|
|
201
217
|
: `hybrid`;
|
|
202
218
|
|
|
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
|
+
|
|
203
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." : "";
|
|
204
228
|
const message = [
|
|
205
|
-
`[pi-loop] Loop #${
|
|
229
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
206
230
|
prompt,
|
|
207
231
|
].join("\n");
|
|
208
232
|
|
|
@@ -211,6 +235,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
235
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
212
236
|
});
|
|
213
237
|
|
|
238
|
+
pi.on("turn_end", () => { pendingFollowUps.clear(); });
|
|
239
|
+
|
|
214
240
|
// ──────────────────────────────────────────────────
|
|
215
241
|
// Tool 1: LoopCreate
|
|
216
242
|
// ──────────────────────────────────────────────────
|
|
@@ -247,7 +273,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
247
273
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
248
274
|
- **recurring**: repeat or fire once (default: true)
|
|
249
275
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
250
|
-
- **
|
|
276
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
251
277
|
promptGuidelines: [
|
|
252
278
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
253
279
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -261,10 +287,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
261
287
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
262
288
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
263
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 })),
|
|
264
291
|
}),
|
|
265
292
|
|
|
266
293
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
267
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
294
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
268
295
|
|
|
269
296
|
let trigger: Trigger;
|
|
270
297
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
@@ -286,10 +313,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
286
313
|
};
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
const validationError = validateTrigger(trigger);
|
|
317
|
+
if (validationError) return Promise.resolve(textResult(validationError));
|
|
318
|
+
|
|
289
319
|
const entry = store.create(trigger, prompt, {
|
|
290
320
|
recurring: recurring ?? (inferred !== "event"),
|
|
291
321
|
autoTask,
|
|
292
322
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
323
|
+
readOnly,
|
|
293
324
|
});
|
|
294
325
|
|
|
295
326
|
triggerSystem.add(entry);
|
|
@@ -312,6 +343,28 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
312
343
|
},
|
|
313
344
|
});
|
|
314
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
|
+
|
|
315
368
|
function inferTriggerType(input: string): "cron" | "event" | "hybrid" {
|
|
316
369
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event"))) return "hybrid";
|
|
317
370
|
if (/^\d+\s*[smhd]$/i.test(input.trim())) return "cron";
|
|
@@ -554,8 +607,8 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
554
607
|
triggerSystem.add(entry);
|
|
555
608
|
widget.update();
|
|
556
609
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
557
|
-
} catch (err:
|
|
558
|
-
ui.notify(err.message, "error");
|
|
610
|
+
} catch (err: unknown) {
|
|
611
|
+
ui.notify((err as Error).message, "error");
|
|
559
612
|
}
|
|
560
613
|
return;
|
|
561
614
|
}
|
|
@@ -572,7 +625,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
572
625
|
},
|
|
573
626
|
});
|
|
574
627
|
|
|
575
|
-
async function scheduleLoop(ui:
|
|
628
|
+
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
576
629
|
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
577
630
|
if (!p) return;
|
|
578
631
|
|
|
@@ -586,12 +639,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
586
639
|
triggerSystem.add(entry);
|
|
587
640
|
widget.update();
|
|
588
641
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
589
|
-
} catch (err:
|
|
590
|
-
ui.notify(err.message, "error");
|
|
642
|
+
} catch (err: unknown) {
|
|
643
|
+
ui.notify((err as Error).message, "error");
|
|
591
644
|
}
|
|
592
645
|
}
|
|
593
646
|
|
|
594
|
-
async function eventLoop(ui:
|
|
647
|
+
async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
595
648
|
const p = prompt || await ui.input("Prompt");
|
|
596
649
|
if (!p) return;
|
|
597
650
|
|
|
@@ -605,7 +658,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
605
658
|
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
606
659
|
}
|
|
607
660
|
|
|
608
|
-
async function viewLoops(ui:
|
|
661
|
+
async function viewLoops(ui: ExtensionUIContext) {
|
|
609
662
|
const loops = store.list();
|
|
610
663
|
if (loops.length === 0) {
|
|
611
664
|
await ui.select("No active loops", ["← Back"]);
|
|
@@ -658,7 +711,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
658
711
|
return viewLoops(ui);
|
|
659
712
|
}
|
|
660
713
|
|
|
661
|
-
async function settings(ui:
|
|
714
|
+
async function settings(ui: ExtensionUIContext) {
|
|
662
715
|
const loops = store.list();
|
|
663
716
|
const active = loops.filter(l => l.status === "active").length;
|
|
664
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;
|