@trevonistrevon/pi-loop 0.2.5 → 0.2.7
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 +40 -6
- 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 +67 -20
- 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,12 @@ 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
|
-
pi.events.on("loop:fire", (
|
|
182
|
+
pi.events.on("loop:fire", (event) => {
|
|
183
|
+
const data = event;
|
|
184
|
+
if (_latestCtx?.hasPendingMessages()) {
|
|
185
|
+
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
182
188
|
const triggerInfo = typeof data.trigger === "string"
|
|
183
189
|
? data.trigger
|
|
184
190
|
: data.trigger?.type === "cron"
|
|
@@ -186,13 +192,13 @@ export default function (pi) {
|
|
|
186
192
|
: data.trigger?.type === "event"
|
|
187
193
|
? `event: ${data.trigger.source}`
|
|
188
194
|
: `hybrid`;
|
|
195
|
+
const loopId = data.loopId || "?";
|
|
189
196
|
const prompt = data.prompt || "loop fired";
|
|
197
|
+
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
198
|
const message = [
|
|
191
|
-
`[pi-loop] Loop #${
|
|
199
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
192
200
|
prompt,
|
|
193
201
|
].join("\n");
|
|
194
|
-
// deliverAs: "followUp" queues the message when the agent is busy;
|
|
195
|
-
// it delivers after the current turn finishes.
|
|
196
202
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
197
203
|
});
|
|
198
204
|
// ──────────────────────────────────────────────────
|
|
@@ -230,7 +236,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
230
236
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
231
237
|
- **recurring**: repeat or fire once (default: true)
|
|
232
238
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
233
|
-
- **
|
|
239
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
234
240
|
promptGuidelines: [
|
|
235
241
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
236
242
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -244,9 +250,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
244
250
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
245
251
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
246
252
|
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
253
|
+
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
247
254
|
}),
|
|
248
255
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
249
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
256
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
250
257
|
let trigger;
|
|
251
258
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
252
259
|
if (inferred === "cron") {
|
|
@@ -267,10 +274,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
267
274
|
debounceMs: debounceMs ?? 30000,
|
|
268
275
|
};
|
|
269
276
|
}
|
|
277
|
+
const validationError = validateTrigger(trigger);
|
|
278
|
+
if (validationError)
|
|
279
|
+
return Promise.resolve(textResult(validationError));
|
|
270
280
|
const entry = store.create(trigger, prompt, {
|
|
271
281
|
recurring: recurring ?? (inferred !== "event"),
|
|
272
282
|
autoTask,
|
|
273
283
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
284
|
+
readOnly,
|
|
274
285
|
});
|
|
275
286
|
triggerSystem.add(entry);
|
|
276
287
|
widget.update();
|
|
@@ -287,6 +298,29 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
287
298
|
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
288
299
|
},
|
|
289
300
|
});
|
|
301
|
+
function validateTrigger(trigger) {
|
|
302
|
+
if (trigger.type === "cron") {
|
|
303
|
+
const parts = trigger.schedule.trim().split(/\s+/);
|
|
304
|
+
if (parts.length !== 5) {
|
|
305
|
+
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.`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (trigger.type === "event") {
|
|
309
|
+
if (!trigger.source || trigger.source.trim().length === 0) {
|
|
310
|
+
return `Invalid event trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (trigger.type === "hybrid") {
|
|
314
|
+
const cronParts = trigger.cron.trim().split(/\s+/);
|
|
315
|
+
if (cronParts.length !== 5) {
|
|
316
|
+
return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
|
|
317
|
+
}
|
|
318
|
+
if (!trigger.event.source || trigger.event.source.trim().length === 0) {
|
|
319
|
+
return `Invalid hybrid trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
290
324
|
function inferTriggerType(input) {
|
|
291
325
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event")))
|
|
292
326
|
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,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
204
|
|
|
192
205
|
// ── Loop fire handler — sends a user message to re-wake the agent ──
|
|
193
206
|
|
|
194
|
-
pi.events.on("loop:fire", (
|
|
207
|
+
pi.events.on("loop:fire", (event: unknown) => {
|
|
208
|
+
const data = event as LoopFireEvent;
|
|
209
|
+
|
|
210
|
+
if (_latestCtx?.hasPendingMessages()) {
|
|
211
|
+
debug(`loop:fire #${data.loopId} — agent has pending messages, skipping`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
195
215
|
const triggerInfo = typeof data.trigger === "string"
|
|
196
216
|
? data.trigger
|
|
197
217
|
: data.trigger?.type === "cron"
|
|
@@ -200,14 +220,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
220
|
? `event: ${data.trigger.source}`
|
|
201
221
|
: `hybrid`;
|
|
202
222
|
|
|
223
|
+
const loopId = data.loopId || "?";
|
|
203
224
|
const prompt = data.prompt || "loop fired";
|
|
225
|
+
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
226
|
const message = [
|
|
205
|
-
`[pi-loop] Loop #${
|
|
227
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
206
228
|
prompt,
|
|
207
229
|
].join("\n");
|
|
208
230
|
|
|
209
|
-
// deliverAs: "followUp" queues the message when the agent is busy;
|
|
210
|
-
// it delivers after the current turn finishes.
|
|
211
231
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
212
232
|
});
|
|
213
233
|
|
|
@@ -247,7 +267,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
247
267
|
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
248
268
|
- **recurring**: repeat or fire once (default: true)
|
|
249
269
|
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
250
|
-
- **
|
|
270
|
+
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
|
|
251
271
|
promptGuidelines: [
|
|
252
272
|
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
253
273
|
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
@@ -261,10 +281,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
261
281
|
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
262
282
|
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
263
283
|
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
284
|
+
readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
|
|
264
285
|
}),
|
|
265
286
|
|
|
266
287
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
267
|
-
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
288
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
|
|
268
289
|
|
|
269
290
|
let trigger: Trigger;
|
|
270
291
|
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
@@ -286,10 +307,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
286
307
|
};
|
|
287
308
|
}
|
|
288
309
|
|
|
310
|
+
const validationError = validateTrigger(trigger);
|
|
311
|
+
if (validationError) return Promise.resolve(textResult(validationError));
|
|
312
|
+
|
|
289
313
|
const entry = store.create(trigger, prompt, {
|
|
290
314
|
recurring: recurring ?? (inferred !== "event"),
|
|
291
315
|
autoTask,
|
|
292
316
|
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
317
|
+
readOnly,
|
|
293
318
|
});
|
|
294
319
|
|
|
295
320
|
triggerSystem.add(entry);
|
|
@@ -312,6 +337,28 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
312
337
|
},
|
|
313
338
|
});
|
|
314
339
|
|
|
340
|
+
function validateTrigger(trigger: Trigger): string | null {
|
|
341
|
+
if (trigger.type === "cron") {
|
|
342
|
+
const parts = trigger.schedule.trim().split(/\s+/);
|
|
343
|
+
if (parts.length !== 5) {
|
|
344
|
+
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.`;
|
|
345
|
+
}
|
|
346
|
+
} else if (trigger.type === "event") {
|
|
347
|
+
if (!trigger.source || trigger.source.trim().length === 0) {
|
|
348
|
+
return `Invalid event trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
349
|
+
}
|
|
350
|
+
} else if (trigger.type === "hybrid") {
|
|
351
|
+
const cronParts = trigger.cron.trim().split(/\s+/);
|
|
352
|
+
if (cronParts.length !== 5) {
|
|
353
|
+
return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
|
|
354
|
+
}
|
|
355
|
+
if (!trigger.event.source || trigger.event.source.trim().length === 0) {
|
|
356
|
+
return `Invalid hybrid trigger. Event source must be non-empty (e.g., "tool_execution_start").`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
315
362
|
function inferTriggerType(input: string): "cron" | "event" | "hybrid" {
|
|
316
363
|
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event"))) return "hybrid";
|
|
317
364
|
if (/^\d+\s*[smhd]$/i.test(input.trim())) return "cron";
|
|
@@ -554,8 +601,8 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
554
601
|
triggerSystem.add(entry);
|
|
555
602
|
widget.update();
|
|
556
603
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
557
|
-
} catch (err:
|
|
558
|
-
ui.notify(err.message, "error");
|
|
604
|
+
} catch (err: unknown) {
|
|
605
|
+
ui.notify((err as Error).message, "error");
|
|
559
606
|
}
|
|
560
607
|
return;
|
|
561
608
|
}
|
|
@@ -572,7 +619,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
572
619
|
},
|
|
573
620
|
});
|
|
574
621
|
|
|
575
|
-
async function scheduleLoop(ui:
|
|
622
|
+
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
576
623
|
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
577
624
|
if (!p) return;
|
|
578
625
|
|
|
@@ -586,12 +633,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
586
633
|
triggerSystem.add(entry);
|
|
587
634
|
widget.update();
|
|
588
635
|
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
589
|
-
} catch (err:
|
|
590
|
-
ui.notify(err.message, "error");
|
|
636
|
+
} catch (err: unknown) {
|
|
637
|
+
ui.notify((err as Error).message, "error");
|
|
591
638
|
}
|
|
592
639
|
}
|
|
593
640
|
|
|
594
|
-
async function eventLoop(ui:
|
|
641
|
+
async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
595
642
|
const p = prompt || await ui.input("Prompt");
|
|
596
643
|
if (!p) return;
|
|
597
644
|
|
|
@@ -605,7 +652,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
605
652
|
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
606
653
|
}
|
|
607
654
|
|
|
608
|
-
async function viewLoops(ui:
|
|
655
|
+
async function viewLoops(ui: ExtensionUIContext) {
|
|
609
656
|
const loops = store.list();
|
|
610
657
|
if (loops.length === 0) {
|
|
611
658
|
await ui.select("No active loops", ["← Back"]);
|
|
@@ -658,7 +705,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
658
705
|
return viewLoops(ui);
|
|
659
706
|
}
|
|
660
707
|
|
|
661
|
-
async function settings(ui:
|
|
708
|
+
async function settings(ui: ExtensionUIContext) {
|
|
662
709
|
const loops = store.list();
|
|
663
710
|
const active = loops.filter(l => l.status === "active").length;
|
|
664
711
|
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;
|