@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 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
- pi.events.on("loop:fire", (data) => {
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 reminder = SYSTEM_REMINDER_TEMPLATE
194
- .replace("%prompt%", data.prompt || "loop fired")
195
- .replace("%trigger_info%", triggerInfo)
196
- .replace("%loop_id%", data.loopId || "unknown");
197
- pi.sendUserMessage(reminder);
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
- - **triggerType**: "cron", "event", or "hybrid" (inferred if omitted)`,
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
@@ -12,6 +12,7 @@ export declare class LoopStore {
12
12
  recurring: boolean;
13
13
  autoTask?: boolean;
14
14
  selfPaced?: boolean;
15
+ readOnly?: boolean;
15
16
  }): LoopEntry;
16
17
  get(id: string): LoopEntry | undefined;
17
18
  list(): LoopEntry[];
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 && !isProcessRunning(pid)) {
20
- unlinkSync(lockPath);
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
@@ -29,6 +29,7 @@ export interface LoopEntry {
29
29
  expiresAt: number;
30
30
  autoTask?: boolean;
31
31
  selfPaced?: boolean;
32
+ readOnly?: boolean;
32
33
  }
33
34
  export interface LoopStoreData {
34
35
  nextId: number;
@@ -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: UICtx): void;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
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, type UICtx } from "./ui/widget.js";
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
- const SYSTEM_REMINDER_TEMPLATE = `<system-reminder>
39
- Loop "%prompt%" fired. Execute this instruction now.
40
- Trigger: %trigger_info%. Loop: %loop_id%.
41
- </system-reminder>`;
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 as UICtx);
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 as UICtx);
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: any, ctx: ExtensionContext) => {
188
+ pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
180
189
  _latestCtx = ctx;
181
- widget.setUICtx(ctx.ui as UICtx);
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
- pi.events.on("loop:fire", (data: any) => {
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 reminder = SYSTEM_REMINDER_TEMPLATE
208
- .replace("%prompt%", data.prompt || "loop fired")
209
- .replace("%trigger_info%", triggerInfo)
210
- .replace("%loop_id%", data.loopId || "unknown");
211
-
212
- pi.sendUserMessage(reminder);
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
- - **triggerType**: "cron", "event", or "hybrid" (inferred if omitted)`,
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: any) {
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: any, prompt?: string) {
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: any) {
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: any, prompt?: string) {
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: any) {
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: any) {
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 && !isProcessRunning(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
@@ -31,6 +31,7 @@ export interface LoopEntry {
31
31
  expiresAt: number;
32
32
  autoTask?: boolean;
33
33
  selfPaced?: boolean;
34
+ readOnly?: boolean;
34
35
  }
35
36
 
36
37
  export interface LoopStoreData {
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: UICtx | undefined;
19
- private tui: any | undefined;
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: UICtx) {
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: any, _theme: any): string[] {
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;