@hank-warren/pi-loop 0.1.0

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/src/loop.ts ADDED
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The loop engine: timer lifecycle, tick evaluation, poke delivery, and
3
+ * loop-aware compaction, wired to Pi's extension events.
4
+ *
5
+ * Design invariants (approved plan):
6
+ * - Timers are armed in session_start or a command handler, never the factory,
7
+ * and cleared in an idempotent session_shutdown.
8
+ * - Pokes deliver only at a fully idle boundary; a tick that lands while the
9
+ * agent is busy coalesces into a single pending wake delivered at the next
10
+ * agent_settled. Missed ticks never stack.
11
+ * - pi-goal owns "whether the work is done": its safety states pause the loop,
12
+ * its completion stops it, and its thresholds ride along in the
13
+ * post-compaction continuation. Coupling is read-only session entries.
14
+ * - The loop's proactive compaction is the normal compaction path; Pi's
15
+ * reserve-token auto-compaction is the fault handler.
16
+ */
17
+
18
+ import { randomUUID } from "node:crypto";
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionCommandContext,
22
+ ExtensionContext,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import type { LoopStartArguments } from "./command.js";
25
+ import { decideTick, type TickDecision, type TickEnvironment } from "./decide.js";
26
+ import { formatClock, formatDuration, parseDuration } from "./interval.js";
27
+ import {
28
+ buildCompactionInstructions,
29
+ buildGoalPoke,
30
+ buildPostCompactContinuation,
31
+ buildPromptPoke,
32
+ } from "./messages.js";
33
+ import {
34
+ DEFAULT_LOOP_SETTINGS,
35
+ type LoopSettings,
36
+ loopSettingsPath,
37
+ readLoopSettings,
38
+ } from "./settings.js";
39
+ import {
40
+ LOOP_STATE_ENTRY_TYPE,
41
+ type LoopState,
42
+ readGoalSnapshot,
43
+ readPlanModeEnabled,
44
+ restoreLoopState,
45
+ } from "./state.js";
46
+
47
+ export const LOOP_STATUS_KEY = "loop";
48
+
49
+ export interface LoopControllerOptions {
50
+ settingsPath?: string;
51
+ now?: () => number;
52
+ }
53
+
54
+ export class LoopController {
55
+ settings: LoopSettings = structuredClone(DEFAULT_LOOP_SETTINGS);
56
+ state: LoopState | undefined;
57
+ compacting = false;
58
+ lastDecision: (TickDecision & { at: number }) | undefined;
59
+
60
+ private readonly pi: ExtensionAPI;
61
+ private readonly now: () => number;
62
+ readonly settingsPath: string;
63
+ private timer: NodeJS.Timeout | undefined;
64
+ private nextWakeAt: number | undefined;
65
+ private wakePending = false;
66
+ private pendingLoopId: string | undefined;
67
+ private sessionCtx: ExtensionContext | undefined;
68
+
69
+ constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
70
+ this.pi = pi;
71
+ this.now = options.now ?? Date.now;
72
+ this.settingsPath = options.settingsPath ?? loopSettingsPath();
73
+ }
74
+
75
+ // --- lifecycle ---
76
+
77
+ onSessionStart(ctx: ExtensionContext): void {
78
+ this.clearTimer();
79
+ this.wakePending = false;
80
+ this.pendingLoopId = undefined;
81
+ this.compacting = false;
82
+ this.lastDecision = undefined;
83
+ this.sessionCtx = ctx;
84
+
85
+ const loaded = readLoopSettings(this.settingsPath);
86
+ this.settings = loaded.settings;
87
+ if (loaded.kind === "invalid") {
88
+ ctx.ui.notify(`pi-loop settings ignored: ${loaded.reason}. Using defaults.`, "warning");
89
+ }
90
+
91
+ this.state = restoreLoopState(ctx.sessionManager.getBranch());
92
+ if (this.state && this.state.status === "active") {
93
+ if (this.now() >= this.state.expiresAt) {
94
+ this.transition("stopped", "loop expired while the session was away");
95
+ return;
96
+ }
97
+ this.scheduleTick(this.state.intervalMs);
98
+ }
99
+ this.updateWidget();
100
+ }
101
+
102
+ onSessionShutdown(): void {
103
+ this.clearTimer();
104
+ this.wakePending = false;
105
+ this.pendingLoopId = undefined;
106
+ this.sessionCtx = undefined;
107
+ }
108
+
109
+ onAgentSettled(ctx: ExtensionContext): void {
110
+ this.sessionCtx = ctx;
111
+ if (!this.state || this.state.status !== "active") return;
112
+ if (this.maybeStartCompaction(ctx)) return;
113
+ if (this.wakePending) {
114
+ if (this.pendingLoopId !== this.state.id) {
115
+ // Stale wake from a replaced loop: drop it (research: CC #57660).
116
+ this.wakePending = false;
117
+ this.pendingLoopId = undefined;
118
+ return;
119
+ }
120
+ this.wakePending = false;
121
+ this.pendingLoopId = undefined;
122
+ this.runTick(ctx);
123
+ }
124
+ }
125
+
126
+ onSessionCompact(ctx: ExtensionContext): void {
127
+ this.sessionCtx = ctx;
128
+ if (!this.state || this.state.status !== "active") return;
129
+ if (!this.settings.compaction.postCompactContinuation) return;
130
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
131
+ this.pi.sendUserMessage(buildPostCompactContinuation(this.state, goal), {
132
+ deliverAs: "followUp",
133
+ });
134
+ }
135
+
136
+ // --- tick machinery ---
137
+
138
+ private scheduleTick(delayMs: number): void {
139
+ this.clearTimer();
140
+ this.nextWakeAt = this.now() + delayMs;
141
+ this.timer = setTimeout(() => {
142
+ this.timer = undefined;
143
+ const ctx = this.sessionCtx;
144
+ if (!ctx) return;
145
+ this.runTick(ctx);
146
+ }, delayMs);
147
+ // Never hold the process open for a wakeup.
148
+ this.timer.unref?.();
149
+ }
150
+
151
+ private clearTimer(): void {
152
+ if (this.timer) clearTimeout(this.timer);
153
+ this.timer = undefined;
154
+ this.nextWakeAt = undefined;
155
+ }
156
+
157
+ private gatherEnvironment(ctx: ExtensionContext): TickEnvironment {
158
+ const branch = ctx.sessionManager.getBranch();
159
+ return {
160
+ now: this.now(),
161
+ busy: !ctx.isIdle() || ctx.hasPendingMessages(),
162
+ compacting: this.compacting,
163
+ planModeEnabled: readPlanModeEnabled(branch),
164
+ goal: readGoalSnapshot(branch),
165
+ };
166
+ }
167
+
168
+ runTick(ctx: ExtensionContext): void {
169
+ const loop = this.state;
170
+ if (!loop) return;
171
+ const env = this.gatherEnvironment(ctx);
172
+ const decision = decideTick(loop, env);
173
+ this.lastDecision = { ...decision, at: env.now };
174
+ switch (decision.action) {
175
+ case "none":
176
+ return;
177
+ case "expire":
178
+ this.transition("stopped", "loop expired (maxLoopDuration reached)");
179
+ return;
180
+ case "skip":
181
+ if (decision.reason === "plan-mode-active") {
182
+ // Plan mode may end without an agent_settled we can use, so
183
+ // keep ticking until it does.
184
+ this.scheduleTick(loop.intervalMs);
185
+ } else {
186
+ // Busy or compacting: coalesce into one pending wake that
187
+ // the next agent_settled (or compaction onComplete) delivers.
188
+ this.wakePending = true;
189
+ this.pendingLoopId = loop.id;
190
+ }
191
+ this.updateWidget();
192
+ return;
193
+ case "stop":
194
+ this.transition(
195
+ "stopped",
196
+ decision.reason === "goal-complete"
197
+ ? "the goal completed"
198
+ : `the ${loop.maxIterations}-iteration cap was reached`,
199
+ );
200
+ return;
201
+ case "pause":
202
+ this.transition(
203
+ "paused",
204
+ `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
205
+ );
206
+ return;
207
+ case "poke":
208
+ this.deliverPoke(ctx, env, decision.reason);
209
+ return;
210
+ }
211
+ }
212
+
213
+ private deliverPoke(
214
+ ctx: ExtensionContext,
215
+ env: TickEnvironment,
216
+ reason: "recurring-prompt" | "goal-stalled" | "goal-waiting",
217
+ ): void {
218
+ const loop = this.state;
219
+ if (!loop) return;
220
+ const message =
221
+ reason === "recurring-prompt" || !env.goal
222
+ ? buildPromptPoke(loop, this.settings.pokePreamble)
223
+ : buildGoalPoke(loop, env.goal, reason === "goal-waiting" ? "goal-waiting" : "goal-stalled");
224
+ this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
225
+ this.persist();
226
+ this.pi.sendUserMessage(message);
227
+ this.scheduleTick(this.state.intervalMs);
228
+ this.updateWidget();
229
+ }
230
+
231
+ private maybeStartCompaction(ctx: ExtensionContext): boolean {
232
+ const loop = this.state;
233
+ if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
234
+ if (!this.settings.compaction.enabled || this.compacting) return false;
235
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
236
+ const usage = ctx.getContextUsage();
237
+ if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
238
+ if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
239
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
240
+ this.compacting = true;
241
+ ctx.compact({
242
+ customInstructions: buildCompactionInstructions(
243
+ loop,
244
+ goal,
245
+ this.settings.compaction.instructions,
246
+ ),
247
+ onComplete: () => {
248
+ this.compacting = false;
249
+ const currentCtx = this.sessionCtx;
250
+ // A pending wake held during compaction delivers at the next
251
+ // settled boundary; nudge in case that boundary already passed.
252
+ if (currentCtx && this.wakePending) this.onAgentSettled(currentCtx);
253
+ },
254
+ onError: (error) => {
255
+ this.compacting = false;
256
+ this.sessionCtx?.ui.notify(
257
+ `pi-loop compaction failed: ${error instanceof Error ? error.message : String(error)}`,
258
+ "warning",
259
+ );
260
+ },
261
+ });
262
+ return true;
263
+ }
264
+
265
+ // --- state transitions & presentation ---
266
+
267
+ private transition(status: "paused" | "stopped", why: string): void {
268
+ if (!this.state) return;
269
+ this.state = { ...this.state, status };
270
+ this.clearTimer();
271
+ this.wakePending = false;
272
+ this.pendingLoopId = undefined;
273
+ this.persist();
274
+ this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
275
+ this.updateWidget();
276
+ }
277
+
278
+ persist(): void {
279
+ if (!this.state) return;
280
+ this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
281
+ }
282
+
283
+ updateWidget(): void {
284
+ const ui = this.sessionCtx?.ui;
285
+ if (!ui) return;
286
+ const loop = this.state;
287
+ if (!loop || loop.status === "stopped") {
288
+ ui.setStatus(LOOP_STATUS_KEY, undefined);
289
+ return;
290
+ }
291
+ if (loop.status === "paused") {
292
+ ui.setStatus(LOOP_STATUS_KEY, "loop paused");
293
+ return;
294
+ }
295
+ const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
296
+ const next = this.wakePending
297
+ ? "next on idle"
298
+ : this.nextWakeAt
299
+ ? `next ${formatClock(this.nextWakeAt)}`
300
+ : "next unscheduled";
301
+ ui.setStatus(
302
+ LOOP_STATUS_KEY,
303
+ `loop ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`,
304
+ );
305
+ }
306
+
307
+ statusLines(ctx: ExtensionContext): string[] {
308
+ const loop = this.state;
309
+ if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
310
+ const lines = [
311
+ `Status: ${loop.status}`,
312
+ `Interval: every ${formatDuration(loop.intervalMs)}`,
313
+ `Iterations: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
314
+ `Started: ${new Date(loop.startedAt).toLocaleString()}`,
315
+ `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
316
+ `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
317
+ ];
318
+ if (loop.prompt) lines.push(`Prompt: ${loop.prompt}`);
319
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
320
+ if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
321
+ if (this.nextWakeAt && loop.status === "active") {
322
+ lines.push(`Next wake: ${formatClock(this.nextWakeAt)}`);
323
+ }
324
+ if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
325
+ if (this.lastDecision) {
326
+ const { action, reason, at } = this.lastDecision;
327
+ lines.push(`Last tick: ${action} (${reason}) at ${formatClock(at)}`);
328
+ }
329
+ return lines;
330
+ }
331
+
332
+ // --- command actions ---
333
+
334
+ startLoop(ctx: ExtensionCommandContext, start: LoopStartArguments): void {
335
+ this.sessionCtx = ctx;
336
+ const now = this.now();
337
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
338
+ if (!start.prompt && goal?.status !== "active") {
339
+ ctx.ui.notify(
340
+ "A prompt is required unless a pi-goal goal is active: /loop <interval> <prompt>.",
341
+ "error",
342
+ );
343
+ return;
344
+ }
345
+ const expiryMs = parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
346
+ const compactAt =
347
+ start.compactAt !== undefined
348
+ ? start.compactAt
349
+ : this.settings.compaction.enabled
350
+ ? this.settings.compaction.threshold
351
+ : null;
352
+ this.state = {
353
+ id: randomUUID().slice(0, 8),
354
+ status: "active",
355
+ ...(start.prompt ? { prompt: start.prompt } : {}),
356
+ intervalMs: start.intervalMs,
357
+ maxIterations:
358
+ start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
359
+ compactAt,
360
+ iteration: 0,
361
+ startedAt: now,
362
+ expiresAt: now + expiryMs,
363
+ };
364
+ this.wakePending = false;
365
+ this.pendingLoopId = undefined;
366
+ this.persist();
367
+ this.scheduleTick(start.intervalMs);
368
+ this.updateWidget();
369
+ const clampNote = start.clamped
370
+ ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
371
+ : "";
372
+ const target = start.prompt ? "the loop prompt" : "the active goal";
373
+ ctx.ui.notify(
374
+ `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking ${target}. Stop with /loop stop.`,
375
+ "info",
376
+ );
377
+ }
378
+
379
+ pauseLoop(ctx: ExtensionContext): void {
380
+ this.sessionCtx = ctx;
381
+ if (!this.state || this.state.status !== "active") {
382
+ ctx.ui.notify("No active loop to pause.", "warning");
383
+ return;
384
+ }
385
+ this.transition("paused", "paused by user");
386
+ }
387
+
388
+ resumeLoop(ctx: ExtensionContext): void {
389
+ this.sessionCtx = ctx;
390
+ const loop = this.state;
391
+ if (!loop || loop.status !== "paused") {
392
+ ctx.ui.notify("No paused loop to resume.", "warning");
393
+ return;
394
+ }
395
+ if (this.now() >= loop.expiresAt) {
396
+ this.state = { ...loop, status: "active" };
397
+ this.transition("stopped", "loop expired (maxLoopDuration reached)");
398
+ return;
399
+ }
400
+ this.state = { ...loop, status: "active" };
401
+ this.persist();
402
+ this.scheduleTick(loop.intervalMs);
403
+ this.updateWidget();
404
+ ctx.ui.notify(
405
+ `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
406
+ "info",
407
+ );
408
+ }
409
+
410
+ /** Re-arm the timer after an interval edit while active. */
411
+ resumeAfterEdit(): void {
412
+ const loop = this.state;
413
+ if (!loop || loop.status !== "active") return;
414
+ this.scheduleTick(loop.intervalMs);
415
+ this.updateWidget();
416
+ }
417
+
418
+ stopLoop(ctx: ExtensionContext, why = "stopped by user"): void {
419
+ this.sessionCtx = ctx;
420
+ if (!this.state || this.state.status === "stopped") {
421
+ ctx.ui.notify("No loop to stop.", "warning");
422
+ return;
423
+ }
424
+ this.transition("stopped", why);
425
+ }
426
+ }
package/src/manager.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Bare-/loop manager and /loop settings, built on Pi's native dialog
3
+ * primitives (ui.select / ui.input / ui.confirm). Non-TUI modes get status
4
+ * notifications instead of menus.
5
+ */
6
+
7
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import { formatDuration, parseInterval } from "./interval.js";
9
+ import type { LoopController } from "./loop.js";
10
+ import { readGoalSnapshot } from "./state.js";
11
+ import {
12
+ DEFAULT_LOOP_SETTINGS,
13
+ type LoopSettings,
14
+ normalizeLoopSettings,
15
+ saveLoopSettings,
16
+ } from "./settings.js";
17
+ import { parseDuration } from "./interval.js";
18
+
19
+ export async function showLoopManager(
20
+ controller: LoopController,
21
+ ctx: ExtensionCommandContext,
22
+ ): Promise<void> {
23
+ if (ctx.mode !== "tui") {
24
+ notifyStatus(controller, ctx);
25
+ return;
26
+ }
27
+ for (;;) {
28
+ const loop = controller.state;
29
+ const options: string[] = ["Status"];
30
+ if (loop?.status === "active") options.push("Pause", "Edit prompt", "Edit interval", "Stop");
31
+ else if (loop?.status === "paused") options.push("Resume", "Edit prompt", "Edit interval", "Stop");
32
+ else options.push("Start a loop…");
33
+ options.push("Settings");
34
+ const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
35
+ if (choice === undefined) return;
36
+ switch (choice) {
37
+ case "Status":
38
+ notifyStatus(controller, ctx);
39
+ break;
40
+ case "Pause":
41
+ controller.pauseLoop(ctx);
42
+ break;
43
+ case "Resume":
44
+ controller.resumeLoop(ctx);
45
+ break;
46
+ case "Stop":
47
+ controller.stopLoop(ctx);
48
+ break;
49
+ case "Start a loop…":
50
+ await startFromMenu(controller, ctx);
51
+ break;
52
+ case "Edit prompt":
53
+ await editPrompt(controller, ctx);
54
+ break;
55
+ case "Edit interval":
56
+ await editInterval(controller, ctx);
57
+ break;
58
+ case "Settings":
59
+ await showLoopSettings(controller, ctx);
60
+ break;
61
+ default:
62
+ return;
63
+ }
64
+ }
65
+ }
66
+
67
+ function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
68
+ ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
69
+ }
70
+
71
+ async function startFromMenu(
72
+ controller: LoopController,
73
+ ctx: ExtensionCommandContext,
74
+ ): Promise<void> {
75
+ const intervalText = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", "5m");
76
+ if (intervalText === undefined) return;
77
+ const interval = parseInterval(intervalText.trim() || "5m");
78
+ if (!interval) {
79
+ ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
80
+ return;
81
+ }
82
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
83
+ const promptText = await ctx.ui.input(
84
+ goal?.status === "active"
85
+ ? "Loop prompt (optional: empty pokes the active goal)"
86
+ : "Loop prompt",
87
+ );
88
+ if (promptText === undefined) return;
89
+ const prompt = promptText.trim();
90
+ controller.startLoop(ctx, {
91
+ kind: "start",
92
+ requestedMs: interval.requestedMs,
93
+ intervalMs: interval.effectiveMs,
94
+ clamped: interval.clamped,
95
+ ...(prompt ? { prompt } : {}),
96
+ });
97
+ }
98
+
99
+ async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
100
+ const loop = controller.state;
101
+ if (!loop || loop.status === "stopped") return;
102
+ const next = await ctx.ui.input("Loop prompt", loop.prompt ?? "");
103
+ if (next === undefined) return;
104
+ const prompt = next.trim();
105
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
106
+ if (!prompt && goal?.status !== "active") {
107
+ ctx.ui.notify("A prompt is required unless a pi-goal goal is active.", "error");
108
+ return;
109
+ }
110
+ if (prompt) controller.state = { ...loop, prompt };
111
+ else {
112
+ const { prompt: _dropped, ...rest } = loop;
113
+ controller.state = rest;
114
+ }
115
+ controller.persist();
116
+ ctx.ui.notify("Loop prompt updated.", "info");
117
+ }
118
+
119
+ async function editInterval(
120
+ controller: LoopController,
121
+ ctx: ExtensionCommandContext,
122
+ ): Promise<void> {
123
+ const loop = controller.state;
124
+ if (!loop || loop.status === "stopped") return;
125
+ const next = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", formatDuration(loop.intervalMs));
126
+ if (next === undefined) return;
127
+ const interval = parseInterval(next.trim());
128
+ if (!interval) {
129
+ ctx.ui.notify(`Invalid interval: ${next}. Use <number><unit>, e.g. 5m.`, "error");
130
+ return;
131
+ }
132
+ controller.state = { ...loop, intervalMs: interval.effectiveMs };
133
+ controller.persist();
134
+ if (loop.status === "active") {
135
+ // Re-arm on the new cadence from now.
136
+ controller.resumeAfterEdit();
137
+ }
138
+ ctx.ui.notify(
139
+ `Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
140
+ "info",
141
+ );
142
+ }
143
+
144
+ export async function showLoopSettings(
145
+ controller: LoopController,
146
+ ctx: ExtensionCommandContext,
147
+ ): Promise<void> {
148
+ if (ctx.mode !== "tui") {
149
+ ctx.ui.notify(`Edit pi-loop settings manually: ${controller.settingsPath}`, "info");
150
+ return;
151
+ }
152
+ for (;;) {
153
+ const s = controller.settings;
154
+ const items = [
155
+ `Max iterations: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
156
+ `Max loop duration: ${s.maxLoopDuration}`,
157
+ `Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
158
+ `Post-compact continuation: ${s.compaction.postCompactContinuation ? "On" : "Off"}`,
159
+ `Inline /loop: ${s.inlineInvocation ? "On" : "Off"}`,
160
+ ];
161
+ const choice = await ctx.ui.select("Pi Loop Settings", items);
162
+ if (choice === undefined) return;
163
+ const index = items.indexOf(choice);
164
+ const next = structuredClone(s);
165
+ if (index === 0) {
166
+ const value = await ctx.ui.input(
167
+ "Max iterations (positive number, or unlimited)",
168
+ s.maxIterations === null ? "unlimited" : `${s.maxIterations}`,
169
+ );
170
+ if (value === undefined) continue;
171
+ const trimmed = value.trim();
172
+ if (trimmed === "unlimited") next.maxIterations = null;
173
+ else {
174
+ const parsed = Number(trimmed);
175
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
176
+ ctx.ui.notify(`Invalid value: ${value}.`, "error");
177
+ continue;
178
+ }
179
+ next.maxIterations = parsed;
180
+ }
181
+ } else if (index === 1) {
182
+ const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
183
+ if (value === undefined) continue;
184
+ if (parseDuration(value.trim()) === undefined) {
185
+ ctx.ui.notify(`Invalid duration: ${value}. Use <number><unit>, e.g. 7d.`, "error");
186
+ continue;
187
+ }
188
+ next.maxLoopDuration = value.trim();
189
+ } else if (index === 2) {
190
+ if (s.compaction.enabled) next.compaction.enabled = false;
191
+ else {
192
+ const value = await ctx.ui.input(
193
+ "Compaction threshold (percent of context window)",
194
+ `${Math.round((s.compaction.threshold || DEFAULT_LOOP_SETTINGS.compaction.threshold) * 100)}%`,
195
+ );
196
+ if (value === undefined) continue;
197
+ const raw = value.trim().replace(/%$/, "");
198
+ const fraction = Number(raw) / 100;
199
+ if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
200
+ ctx.ui.notify(`Invalid threshold: ${value}. Use a percentage between 1 and 99.`, "error");
201
+ continue;
202
+ }
203
+ next.compaction.enabled = true;
204
+ next.compaction.threshold = fraction;
205
+ }
206
+ } else if (index === 3) {
207
+ next.compaction.postCompactContinuation = !s.compaction.postCompactContinuation;
208
+ } else if (index === 4) {
209
+ next.inlineInvocation = !s.inlineInvocation;
210
+ } else {
211
+ continue;
212
+ }
213
+ if (!applySettings(controller, ctx, next)) continue;
214
+ }
215
+ }
216
+
217
+ function applySettings(
218
+ controller: LoopController,
219
+ ctx: ExtensionCommandContext,
220
+ next: LoopSettings,
221
+ ): boolean {
222
+ if (!normalizeLoopSettings(next)) {
223
+ ctx.ui.notify("Refusing to save invalid pi-loop settings.", "error");
224
+ return false;
225
+ }
226
+ try {
227
+ saveLoopSettings(next, controller.settingsPath);
228
+ } catch (error) {
229
+ ctx.ui.notify(
230
+ `Could not save settings (${error instanceof Error ? error.message : String(error)}); the previous values remain active.`,
231
+ "error",
232
+ );
233
+ return false;
234
+ }
235
+ controller.settings = next;
236
+ return true;
237
+ }
package/src/markers.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Provenance markers for loop-injected messages, following pi-goal's marker
3
+ * pattern: an HTML comment the model and transcript can see but that reads as
4
+ * metadata, letting stale or duplicate deliveries be detected (research:
5
+ * Claude Code's untagged auto-fires made wakeups indistinguishable from user
6
+ * prompts, issue #57660).
7
+ */
8
+
9
+ const POKE_MARKER_PREFIX = "pi-loop-poke:";
10
+ const CONTINUATION_MARKER_PREFIX = "pi-loop-continuation:";
11
+
12
+ const POKE_MARKER_PATTERN = new RegExp(
13
+ `<!--\\s*${escapeRegExpText(POKE_MARKER_PREFIX)}([^\\s:>]+):(\\d+)\\s*-->`,
14
+ );
15
+ const CONTINUATION_MARKER_PATTERN = new RegExp(
16
+ `<!--\\s*${escapeRegExpText(CONTINUATION_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
17
+ );
18
+
19
+ export function appendPokeMarker(prompt: string, loopId: string, iteration: number): string {
20
+ return `${prompt}\n\n<!-- ${POKE_MARKER_PREFIX}${loopId}:${iteration} -->`;
21
+ }
22
+
23
+ export function extractPokeMarker(
24
+ prompt: string,
25
+ ): { loopId: string; iteration: number } | undefined {
26
+ const match = POKE_MARKER_PATTERN.exec(prompt);
27
+ if (!match || match[1] === undefined || match[2] === undefined) return undefined;
28
+ return { loopId: match[1], iteration: Number(match[2]) };
29
+ }
30
+
31
+ export function appendContinuationMarker(prompt: string, loopId: string): string {
32
+ return `${prompt}\n\n<!-- ${CONTINUATION_MARKER_PREFIX}${loopId} -->`;
33
+ }
34
+
35
+ export function extractContinuationMarker(prompt: string): string | undefined {
36
+ return CONTINUATION_MARKER_PATTERN.exec(prompt)?.[1];
37
+ }
38
+
39
+ function escapeRegExpText(value: string) {
40
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+ }