@hank-warren/pi-loop 0.2.0 → 0.3.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/CHANGELOG.md +38 -0
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/index.ts +0 -3
- package/src/loop.ts +111 -63
- package/src/manager.ts +0 -3
- package/src/markers.ts +8 -16
- package/src/messages.ts +7 -41
- package/src/settings.ts +4 -9
- package/src/state.ts +35 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3e94f24: drop the post-compaction continuation and harden the loop engine
|
|
8
|
+
|
|
9
|
+
**Removed:** the `postCompactContinuation` setting, the message it sent, and the
|
|
10
|
+
`session_compact` handler behind it. pi-goal already re-prompts the session after
|
|
11
|
+
a compaction for an active goal, and a loop requires an active goal, so pi-loop's
|
|
12
|
+
follow-up only ever doubled the queued messages and the tokens they cost. pi-goal
|
|
13
|
+
owns that message; pi-loop owns the compaction trigger and its instructions.
|
|
14
|
+
Threshold compaction is unchanged. An existing `pi-loop.json` carrying the removed
|
|
15
|
+
key keeps working — it is ignored, never rejected — but the settings row and the
|
|
16
|
+
session surface are gone, hence the minor bump.
|
|
17
|
+
|
|
18
|
+
**Terminal decisions at settle.** A settled boundary with no pending wake now
|
|
19
|
+
evaluates expiry, completion, and pi-goal's safety states, so a loop stops the
|
|
20
|
+
moment its goal does instead of up to one interval later. Poke and skip decisions
|
|
21
|
+
are ignored there: only the timer pokes.
|
|
22
|
+
|
|
23
|
+
**Delivery and compaction no longer lose or wedge state.** A poke is sent before
|
|
24
|
+
it is accounted for, so a refused delivery re-arms on the same cadence instead of
|
|
25
|
+
burning an iteration on a message that never arrived. A synchronous throw from
|
|
26
|
+
`ctx.compact` resets the in-flight flag rather than leaving every later tick
|
|
27
|
+
skipping as compaction-in-flight, and a failed compaction releases a held wake
|
|
28
|
+
just as a successful one does.
|
|
29
|
+
|
|
30
|
+
**Reading pi-goal through its completion clear** is now a bounded backward scan
|
|
31
|
+
over consecutive clears, so a completion still stops the loop when other entries
|
|
32
|
+
land on top of it. `resumeLoop` refuses a loop whose goal is gone, matching
|
|
33
|
+
`startLoop`. Compaction instructions receive the goal only while it is active.
|
|
34
|
+
|
|
35
|
+
## 0.2.1
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- 2b22273: Stop the loop with "goal completed" when pi-goal clears its state entry after completion. pi-goal persists the finished goal (status complete) and then writes a clear (goal: null), so by the loop's next tick the last goal-state entry was the clear and the loop paused as goal-missing instead of stopping as goal-complete. readGoalSnapshot now reads a completed goal through its completion clear; a clear over any non-complete goal (user /goal clear mid-flight) still pauses the loop.
|
|
40
|
+
|
|
3
41
|
## 0.2.0
|
|
4
42
|
|
|
5
43
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-loop — interval wakeups for the Pi coding agent
|
|
2
2
|
|
|
3
|
-
Inspired by Claude Code's `/loop`, adapted to Pi: wake the session on an interval to keep an active [pi-goal](
|
|
3
|
+
Inspired by Claude Code's `/loop`, adapted to Pi: wake the session on an interval to keep an active [pi-goal](../pi-goal) goal moving, and keep long loops coherent across context compaction. pi-loop reads the fork's `goal-state` entries, so pair it with [`@hank-warren/pi-goal`](../pi-goal) rather than [upstream](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal).
|
|
4
4
|
|
|
5
5
|
pi-loop is a **pacemaker, not an evaluator**: it owns *when* the session wakes; pi-goal owns *whether the work is done*. **Loops require an active goal to operate** — the goal evaluator is the stop criterion, so a loop without a goal has nothing to decide when it is done. Coupling is read-only, fail-open reads of pi-goal's `goal-state` and pi-plan-mode's `plan-mode-state` session entries.
|
|
6
6
|
|
|
@@ -25,9 +25,11 @@ Each tick evaluates, in order:
|
|
|
25
25
|
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
|
|
26
26
|
2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
|
|
27
27
|
3. **Agent busy?** Never interrupt: coalesce into a single pending wake delivered at the next fully-settled idle boundary. N missed ticks collapse into one poke.
|
|
28
|
-
4. **Goal state**: a missing goal (cleared mid-loop) **pauses** the loop; completion **stops** it; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`, or any unknown status) **pauses** it — pi-loop never pokes past pi-goal's circuit breakers. An `active` or `goal_wait`-waiting goal in an idle session is exactly the stall this extension exists for, so it pokes toward the goal (a tick is the external wake `goal_wait` arranges).
|
|
28
|
+
4. **Goal state**: a missing goal (cleared mid-loop) **pauses** the loop; completion **stops** it — including through the clear that follows it, since pi-goal persists the finished goal and *then* clears the entry, so the newest entry at completion is a clear and the loop reads back past it; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`, or any unknown status) **pauses** it — pi-loop never pokes past pi-goal's circuit breakers. An `active` or `goal_wait`-waiting goal in an idle session is exactly the stall this extension exists for, so it pokes toward the goal (a tick is the external wake `goal_wait` arranges).
|
|
29
29
|
5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
|
|
30
|
-
6. **Poke**: a goal wake message restating the goal (plus the loop focus, when set). Every
|
|
30
|
+
6. **Poke**: a goal wake message restating the goal (plus the loop focus, when set). Every poke carries a marker (`<!-- pi-loop-poke:<id>:<n> -->`) so a wakeup is identifiable as loop-injected rather than user-typed. The marker is **provenance only** — pi-loop coalesces wakes in its own state and never reads the marker back to drop a delivery.
|
|
31
|
+
|
|
32
|
+
Expiry, completion, and pi-goal's safety states are also evaluated whenever the session settles, so a loop stops as soon as its goal does rather than at the next scheduled tick. Only the timer pokes.
|
|
31
33
|
|
|
32
34
|
The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows the full card including the last tick's decision and reason.
|
|
33
35
|
|
|
@@ -36,7 +38,7 @@ The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows th
|
|
|
36
38
|
Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
|
|
37
39
|
|
|
38
40
|
- **Proactive compact at a threshold** (default 70% of the context window, `--compact-at` / settings): at an idle boundary, pi-loop triggers `/compact` itself with loop-specific instructions — preserve the objective and acceptance criteria verbatim, decisions and dead-ends, files modified, commands and unresolved errors, the next 1-3 actions, and carry prior summaries forward cumulatively. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
|
|
39
|
-
- **
|
|
41
|
+
- **No post-compaction continuation of its own**: pi-goal already re-prompts the session after a compaction for an active goal, and a loop requires an active goal, so a second follow-up from pi-loop would only duplicate it — doubling queued messages and tokens. pi-goal owns that message; pi-loop owns the compaction trigger and its instructions.
|
|
40
42
|
- Loop state itself lives in custom session entries, which compaction never touches, and survives session restarts (the timer re-arms on resume; expired loops are dropped with a notice).
|
|
41
43
|
|
|
42
44
|
## Settings
|
|
@@ -50,7 +52,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
|
|
|
50
52
|
"compaction": {
|
|
51
53
|
"enabled": true,
|
|
52
54
|
"threshold": 0.7,
|
|
53
|
-
"postCompactContinuation": true,
|
|
54
55
|
"instructions": null
|
|
55
56
|
}
|
|
56
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Interval wakeups for Pi: recurring prompt re-runs, stall rescue toward an active pi-goal goal, and loop-aware compaction that survives long sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/index.ts
CHANGED
|
@@ -77,7 +77,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
77
77
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
78
78
|
controller.onAgentSettled(ctx);
|
|
79
79
|
});
|
|
80
|
-
pi.on("session_compact", async (_event, ctx) => {
|
|
81
|
-
controller.onSessionCompact(ctx);
|
|
82
|
-
});
|
|
83
80
|
}
|
package/src/loop.ts
CHANGED
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
* agent_settled. Missed ticks never stack.
|
|
11
11
|
* - Loops require an active pi-goal goal to operate: pi-goal owns "whether
|
|
12
12
|
* the work is done". Its safety states pause the loop, its completion stops
|
|
13
|
-
* it, a missing goal pauses the loop
|
|
14
|
-
*
|
|
13
|
+
* it, and a missing goal pauses the loop. Coupling is read-only session
|
|
14
|
+
* entries.
|
|
15
|
+
* - Terminal decisions (expiry, completion, safety) also land at a settled
|
|
16
|
+
* boundary, so the loop settles as soon as the goal does; only the timer
|
|
17
|
+
* ever pokes.
|
|
15
18
|
* - The loop's proactive compaction is the normal compaction path; Pi's
|
|
16
|
-
* reserve-token auto-compaction is the fault handler.
|
|
19
|
+
* reserve-token auto-compaction is the fault handler. pi-goal owns the
|
|
20
|
+
* post-compaction re-prompt: the loop sends none of its own.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import { randomUUID } from "node:crypto";
|
|
@@ -25,11 +29,7 @@ import type {
|
|
|
25
29
|
import type { LoopStartArguments } from "./command.js";
|
|
26
30
|
import { decideTick, type TickDecision, type TickEnvironment } from "./decide.js";
|
|
27
31
|
import { formatClock, formatDuration, parseDuration } from "./interval.js";
|
|
28
|
-
import {
|
|
29
|
-
buildCompactionInstructions,
|
|
30
|
-
buildGoalPoke,
|
|
31
|
-
buildPostCompactContinuation,
|
|
32
|
-
} from "./messages.js";
|
|
32
|
+
import { buildCompactionInstructions, buildGoalPoke } from "./messages.js";
|
|
33
33
|
import {
|
|
34
34
|
DEFAULT_LOOP_SETTINGS,
|
|
35
35
|
type LoopSettings,
|
|
@@ -63,7 +63,6 @@ export class LoopController {
|
|
|
63
63
|
private timer: NodeJS.Timeout | undefined;
|
|
64
64
|
private nextWakeAt: number | undefined;
|
|
65
65
|
private wakePending = false;
|
|
66
|
-
private pendingLoopId: string | undefined;
|
|
67
66
|
private sessionCtx: ExtensionContext | undefined;
|
|
68
67
|
|
|
69
68
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
@@ -77,7 +76,6 @@ export class LoopController {
|
|
|
77
76
|
onSessionStart(ctx: ExtensionContext): void {
|
|
78
77
|
this.clearTimer();
|
|
79
78
|
this.wakePending = false;
|
|
80
|
-
this.pendingLoopId = undefined;
|
|
81
79
|
this.compacting = false;
|
|
82
80
|
this.lastDecision = undefined;
|
|
83
81
|
this.sessionCtx = ctx;
|
|
@@ -102,7 +100,6 @@ export class LoopController {
|
|
|
102
100
|
onSessionShutdown(): void {
|
|
103
101
|
this.clearTimer();
|
|
104
102
|
this.wakePending = false;
|
|
105
|
-
this.pendingLoopId = undefined;
|
|
106
103
|
this.sessionCtx = undefined;
|
|
107
104
|
}
|
|
108
105
|
|
|
@@ -111,26 +108,30 @@ export class LoopController {
|
|
|
111
108
|
if (!this.state || this.state.status !== "active") return;
|
|
112
109
|
if (this.maybeStartCompaction(ctx)) return;
|
|
113
110
|
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
111
|
this.wakePending = false;
|
|
121
|
-
this.pendingLoopId = undefined;
|
|
122
112
|
this.runTick(ctx);
|
|
113
|
+
return;
|
|
123
114
|
}
|
|
115
|
+
this.settleTerminalState(ctx);
|
|
124
116
|
}
|
|
125
117
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
118
|
+
/**
|
|
119
|
+
* A settled boundary with no wake pending still evaluates the terminal
|
|
120
|
+
* decisions — expiry, a completed or missing goal, a pi-goal safety state —
|
|
121
|
+
* so the loop settles the moment the goal does instead of up to one interval
|
|
122
|
+
* later. Poke and skip decisions are deliberately ignored here: only the
|
|
123
|
+
* timer pokes, and settling is not a schedule.
|
|
124
|
+
*/
|
|
125
|
+
private settleTerminalState(ctx: ExtensionContext): void {
|
|
126
|
+
const loop = this.state;
|
|
127
|
+
if (!loop) return;
|
|
128
|
+
const env = this.gatherEnvironment(ctx);
|
|
129
|
+
const decision = decideTick(loop, env);
|
|
130
|
+
if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.lastDecision = { ...decision, at: env.now };
|
|
134
|
+
this.applyTerminalDecision(loop, decision);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
// --- tick machinery ---
|
|
@@ -174,9 +175,6 @@ export class LoopController {
|
|
|
174
175
|
switch (decision.action) {
|
|
175
176
|
case "none":
|
|
176
177
|
return;
|
|
177
|
-
case "expire":
|
|
178
|
-
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
179
|
-
return;
|
|
180
178
|
case "skip":
|
|
181
179
|
if (decision.reason === "plan-mode-active") {
|
|
182
180
|
// Plan mode may end without an agent_settled we can use, so
|
|
@@ -186,10 +184,26 @@ export class LoopController {
|
|
|
186
184
|
// Busy or compacting: coalesce into one pending wake that
|
|
187
185
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
188
186
|
this.wakePending = true;
|
|
189
|
-
this.pendingLoopId = loop.id;
|
|
190
187
|
}
|
|
191
188
|
this.updateWidget();
|
|
192
189
|
return;
|
|
190
|
+
case "poke":
|
|
191
|
+
this.deliverPoke(env, decision.reason);
|
|
192
|
+
return;
|
|
193
|
+
default:
|
|
194
|
+
this.applyTerminalDecision(loop, decision);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private applyTerminalDecision(
|
|
200
|
+
loop: LoopState,
|
|
201
|
+
decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
|
|
202
|
+
): void {
|
|
203
|
+
switch (decision.action) {
|
|
204
|
+
case "expire":
|
|
205
|
+
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
206
|
+
return;
|
|
193
207
|
case "stop":
|
|
194
208
|
this.transition(
|
|
195
209
|
"stopped",
|
|
@@ -206,23 +220,31 @@ export class LoopController {
|
|
|
206
220
|
: `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
|
|
207
221
|
);
|
|
208
222
|
return;
|
|
209
|
-
case "poke":
|
|
210
|
-
this.deliverPoke(ctx, env, decision.reason);
|
|
211
|
-
return;
|
|
212
223
|
}
|
|
213
224
|
}
|
|
214
225
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
228
|
+
* session), and an iteration persisted before the send would burn the
|
|
229
|
+
* maxIterations cap on a poke that never arrived; on a throw the loop re-arms
|
|
230
|
+
* on the same cadence and retries at the next wake.
|
|
231
|
+
*/
|
|
232
|
+
private deliverPoke(env: TickEnvironment, reason: "goal-stalled" | "goal-waiting"): void {
|
|
220
233
|
const loop = this.state;
|
|
221
234
|
if (!loop || !env.goal) return;
|
|
222
|
-
|
|
235
|
+
try {
|
|
236
|
+
this.pi.sendUserMessage(buildGoalPoke(loop, env.goal, reason));
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.sessionCtx?.ui.notify(
|
|
239
|
+
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
240
|
+
"warning",
|
|
241
|
+
);
|
|
242
|
+
this.scheduleTick(loop.intervalMs);
|
|
243
|
+
this.updateWidget();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
223
246
|
this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
|
|
224
247
|
this.persist();
|
|
225
|
-
this.pi.sendUserMessage(message);
|
|
226
248
|
this.scheduleTick(this.state.intervalMs);
|
|
227
249
|
this.updateWidget();
|
|
228
250
|
}
|
|
@@ -237,30 +259,45 @@ export class LoopController {
|
|
|
237
259
|
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
238
260
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
239
261
|
this.compacting = true;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
262
|
+
try {
|
|
263
|
+
ctx.compact({
|
|
264
|
+
customInstructions: buildCompactionInstructions(
|
|
265
|
+
loop,
|
|
266
|
+
// A completed or otherwise finished goal is no longer the
|
|
267
|
+
// objective the summary must preserve.
|
|
268
|
+
goal?.status === "active" ? goal : undefined,
|
|
269
|
+
this.settings.compaction.instructions,
|
|
270
|
+
),
|
|
271
|
+
onComplete: () => {
|
|
272
|
+
this.compacting = false;
|
|
273
|
+
this.nudgeHeldWake();
|
|
274
|
+
},
|
|
275
|
+
onError: (error) => {
|
|
276
|
+
this.compacting = false;
|
|
277
|
+
this.sessionCtx?.ui.notify(`pi-loop compaction failed: ${formatError(error)}`, "warning");
|
|
278
|
+
this.nudgeHeldWake();
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// A synchronous refusal (a torn-down runner) must not leave the loop
|
|
283
|
+
// skipping every tick as compaction-in-flight for the rest of the
|
|
284
|
+
// session.
|
|
285
|
+
this.compacting = false;
|
|
286
|
+
ctx.ui.notify(`pi-loop compaction could not start: ${formatError(error)}`, "warning");
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
261
289
|
return true;
|
|
262
290
|
}
|
|
263
291
|
|
|
292
|
+
/**
|
|
293
|
+
* A wake held during compaction delivers at the next settled boundary;
|
|
294
|
+
* nudge in case that boundary already passed while compaction ran.
|
|
295
|
+
*/
|
|
296
|
+
private nudgeHeldWake(): void {
|
|
297
|
+
const ctx = this.sessionCtx;
|
|
298
|
+
if (ctx && this.wakePending) this.onAgentSettled(ctx);
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
// --- state transitions & presentation ---
|
|
265
302
|
|
|
266
303
|
private transition(status: "paused" | "stopped", why: string): void {
|
|
@@ -268,7 +305,6 @@ export class LoopController {
|
|
|
268
305
|
this.state = { ...this.state, status };
|
|
269
306
|
this.clearTimer();
|
|
270
307
|
this.wakePending = false;
|
|
271
|
-
this.pendingLoopId = undefined;
|
|
272
308
|
this.persist();
|
|
273
309
|
this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
|
|
274
310
|
this.updateWidget();
|
|
@@ -361,7 +397,6 @@ export class LoopController {
|
|
|
361
397
|
expiresAt: now + expiryMs,
|
|
362
398
|
};
|
|
363
399
|
this.wakePending = false;
|
|
364
|
-
this.pendingLoopId = undefined;
|
|
365
400
|
this.persist();
|
|
366
401
|
this.scheduleTick(start.intervalMs);
|
|
367
402
|
this.updateWidget();
|
|
@@ -395,6 +430,15 @@ export class LoopController {
|
|
|
395
430
|
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
396
431
|
return;
|
|
397
432
|
}
|
|
433
|
+
// Same guard as startLoop: resuming into a finished or missing goal would
|
|
434
|
+
// only stop or pause again at the first tick.
|
|
435
|
+
if (readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active") {
|
|
436
|
+
ctx.ui.notify(
|
|
437
|
+
"Loops require an active goal to operate. Start one first: /goal <objective>, then /loop resume.",
|
|
438
|
+
"error",
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
398
442
|
this.state = { ...loop, status: "active" };
|
|
399
443
|
this.persist();
|
|
400
444
|
this.scheduleTick(loop.intervalMs);
|
|
@@ -422,3 +466,7 @@ export class LoopController {
|
|
|
422
466
|
this.transition("stopped", why);
|
|
423
467
|
}
|
|
424
468
|
}
|
|
469
|
+
|
|
470
|
+
function formatError(error: unknown): string {
|
|
471
|
+
return error instanceof Error ? error.message : String(error);
|
|
472
|
+
}
|
package/src/manager.ts
CHANGED
|
@@ -153,7 +153,6 @@ export async function showLoopSettings(
|
|
|
153
153
|
`Max iterations: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
|
|
154
154
|
`Max loop duration: ${s.maxLoopDuration}`,
|
|
155
155
|
`Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
|
|
156
|
-
`Post-compact continuation: ${s.compaction.postCompactContinuation ? "On" : "Off"}`,
|
|
157
156
|
];
|
|
158
157
|
const choice = await ctx.ui.select("Pi Loop Settings", items);
|
|
159
158
|
if (choice === undefined) return;
|
|
@@ -200,8 +199,6 @@ export async function showLoopSettings(
|
|
|
200
199
|
next.compaction.enabled = true;
|
|
201
200
|
next.compaction.threshold = fraction;
|
|
202
201
|
}
|
|
203
|
-
} else if (index === 3) {
|
|
204
|
-
next.compaction.postCompactContinuation = !s.compaction.postCompactContinuation;
|
|
205
202
|
} else {
|
|
206
203
|
continue;
|
|
207
204
|
}
|
package/src/markers.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provenance
|
|
2
|
+
* Provenance marker for loop-injected pokes, following pi-goal's marker
|
|
3
3
|
* pattern: an HTML comment the model and transcript can see but that reads as
|
|
4
|
-
* metadata,
|
|
5
|
-
* Claude Code's untagged auto-fires made wakeups
|
|
6
|
-
* prompts, issue #57660).
|
|
4
|
+
* metadata, so a wakeup is identifiable as loop-injected rather than
|
|
5
|
+
* user-typed (research: Claude Code's untagged auto-fires made wakeups
|
|
6
|
+
* indistinguishable from user prompts, issue #57660).
|
|
7
|
+
*
|
|
8
|
+
* Provenance only. Nothing in pi-loop reads the marker back to drop stale or
|
|
9
|
+
* duplicate deliveries — the engine coalesces wakes in its own state, and the
|
|
10
|
+
* marker exists for the model, the transcript, and sibling extensions.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
const POKE_MARKER_PREFIX = "pi-loop-poke:";
|
|
10
|
-
const CONTINUATION_MARKER_PREFIX = "pi-loop-continuation:";
|
|
11
14
|
|
|
12
15
|
const POKE_MARKER_PATTERN = new RegExp(
|
|
13
16
|
`<!--\\s*${escapeRegExpText(POKE_MARKER_PREFIX)}([^\\s:>]+):(\\d+)\\s*-->`,
|
|
14
17
|
);
|
|
15
|
-
const CONTINUATION_MARKER_PATTERN = new RegExp(
|
|
16
|
-
`<!--\\s*${escapeRegExpText(CONTINUATION_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
|
|
17
|
-
);
|
|
18
18
|
|
|
19
19
|
export function appendPokeMarker(prompt: string, loopId: string, iteration: number): string {
|
|
20
20
|
return `${prompt}\n\n<!-- ${POKE_MARKER_PREFIX}${loopId}:${iteration} -->`;
|
|
@@ -28,14 +28,6 @@ export function extractPokeMarker(
|
|
|
28
28
|
return { loopId: match[1], iteration: Number(match[2]) };
|
|
29
29
|
}
|
|
30
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
31
|
function escapeRegExpText(value: string) {
|
|
40
32
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
33
|
}
|
package/src/messages.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Builders for every loop-injected message: pokes
|
|
3
|
-
* instructions
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Builders for every loop-injected message: pokes and the proactive compaction
|
|
3
|
+
* instructions. Pure string functions so tests can pin their contracts — most
|
|
4
|
+
* importantly that no loop-injected message embeds a runnable /loop command
|
|
5
|
+
* (research: Claude Code compaction summaries re-executed scheduling commands,
|
|
6
|
+
* issue #50554).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { formatDuration } from "./interval.js";
|
|
10
|
-
import {
|
|
10
|
+
import { appendPokeMarker } from "./markers.js";
|
|
11
11
|
import type { GoalSnapshot, LoopState } from "./state.js";
|
|
12
12
|
|
|
13
13
|
function formatIteration(loop: LoopState): string {
|
|
@@ -41,6 +41,7 @@ export function buildGoalPoke(
|
|
|
41
41
|
*/
|
|
42
42
|
export function buildCompactionInstructions(
|
|
43
43
|
loop: LoopState,
|
|
44
|
+
/** The goal only when it is still active; a finished goal is not the objective. */
|
|
44
45
|
goal: GoalSnapshot | undefined,
|
|
45
46
|
override: string | null,
|
|
46
47
|
): string {
|
|
@@ -62,38 +63,3 @@ export function buildCompactionInstructions(
|
|
|
62
63
|
"Discard raw tool output, file contents that live on disk, and duplicate exploration.",
|
|
63
64
|
].join("\n");
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The post-compaction continuation message. Restates the loop and goal state
|
|
68
|
-
* (including pi-goal's thresholds) so work resumes coherently. Deliberately
|
|
69
|
-
* references /loop only as inert prose-free metadata: no line of this message
|
|
70
|
-
* is a dispatchable command.
|
|
71
|
-
*/
|
|
72
|
-
export function buildPostCompactContinuation(
|
|
73
|
-
loop: LoopState,
|
|
74
|
-
goal: GoalSnapshot | undefined,
|
|
75
|
-
): string {
|
|
76
|
-
const lines = [
|
|
77
|
-
"Context was just compacted. Loop status, restored from outside the context window:",
|
|
78
|
-
`- loop iteration: ${loop.iteration}${loop.maxIterations === null ? "" : ` of ${loop.maxIterations}`}, waking every ${formatDuration(loop.intervalMs)}`,
|
|
79
|
-
];
|
|
80
|
-
if (loop.prompt) lines.push(`- loop focus: ${loop.prompt}`);
|
|
81
|
-
if (goal) {
|
|
82
|
-
lines.push(`- active goal (status ${goal.status}): ${goal.text}`);
|
|
83
|
-
const thresholds: string[] = [];
|
|
84
|
-
if (goal.iteration !== undefined) thresholds.push(`goal iteration ${goal.iteration}`);
|
|
85
|
-
if (goal.automaticModelTurns !== undefined) {
|
|
86
|
-
thresholds.push(`${goal.automaticModelTurns} automatic turns used`);
|
|
87
|
-
}
|
|
88
|
-
if (goal.tokensUsed !== undefined) {
|
|
89
|
-
thresholds.push(
|
|
90
|
-
`${goal.tokensUsed} tokens used${goal.tokenBudget !== undefined ? ` of a ${goal.tokenBudget} budget` : ""}`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
if (thresholds.length > 0) lines.push(`- goal accounting: ${thresholds.join(", ")}`);
|
|
94
|
-
}
|
|
95
|
-
lines.push(
|
|
96
|
-
"Re-read any plan, progress, or state files the work relies on before continuing, then resume from the next concrete action.",
|
|
97
|
-
);
|
|
98
|
-
return appendContinuationMarker(lines.join("\n"), loop.id);
|
|
99
|
-
}
|
package/src/settings.ts
CHANGED
|
@@ -17,7 +17,6 @@ export interface LoopCompactionSettings {
|
|
|
17
17
|
enabled: boolean;
|
|
18
18
|
/** Fraction of the context window that triggers a proactive compact. */
|
|
19
19
|
threshold: number;
|
|
20
|
-
postCompactContinuation: boolean;
|
|
21
20
|
/** Override for the built-in compaction instruction template. */
|
|
22
21
|
instructions: string | null;
|
|
23
22
|
}
|
|
@@ -36,7 +35,6 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
|
36
35
|
compaction: {
|
|
37
36
|
enabled: true,
|
|
38
37
|
threshold: 0.7,
|
|
39
|
-
postCompactContinuation: true,
|
|
40
38
|
instructions: null,
|
|
41
39
|
},
|
|
42
40
|
};
|
|
@@ -63,12 +61,10 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
63
61
|
const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
|
|
64
62
|
if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
|
|
65
63
|
const compactionRecord = ownRecord(compactionValue) ?? {};
|
|
64
|
+
// `postCompactContinuation` was removed in favour of pi-goal owning the
|
|
65
|
+
// post-compaction re-prompt; a file still carrying it is preserved as an
|
|
66
|
+
// unknown field and ignored, never rejected.
|
|
66
67
|
const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
|
|
67
|
-
const postCompactContinuation = readBoolean(
|
|
68
|
-
compactionRecord,
|
|
69
|
-
"postCompactContinuation",
|
|
70
|
-
DEFAULT_LOOP_SETTINGS.compaction.postCompactContinuation,
|
|
71
|
-
);
|
|
72
68
|
const threshold = Object.hasOwn(compactionRecord, "threshold")
|
|
73
69
|
? compactionRecord.threshold
|
|
74
70
|
: DEFAULT_LOOP_SETTINGS.compaction.threshold;
|
|
@@ -79,7 +75,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
79
75
|
);
|
|
80
76
|
if (
|
|
81
77
|
typeof enabled !== "boolean" ||
|
|
82
|
-
typeof postCompactContinuation !== "boolean" ||
|
|
83
78
|
instructions === false ||
|
|
84
79
|
typeof threshold !== "number" ||
|
|
85
80
|
!Number.isFinite(threshold) ||
|
|
@@ -92,7 +87,7 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
92
87
|
return {
|
|
93
88
|
maxIterations,
|
|
94
89
|
maxLoopDuration,
|
|
95
|
-
compaction: { enabled, threshold,
|
|
90
|
+
compaction: { enabled, threshold, instructions },
|
|
96
91
|
};
|
|
97
92
|
}
|
|
98
93
|
|
package/src/state.ts
CHANGED
|
@@ -87,11 +87,17 @@ interface SessionEntryLike {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function lastCustomEntryData(entries: unknown[], customType: string): unknown {
|
|
90
|
-
|
|
90
|
+
return lastCustomEntryDatas(entries, customType, 1)[0];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Newest-first data of the last `limit` custom entries of `customType`. */
|
|
94
|
+
function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
|
|
95
|
+
const datas: unknown[] = [];
|
|
96
|
+
for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
|
|
91
97
|
const entry = entries[index] as SessionEntryLike | undefined;
|
|
92
|
-
if (entry?.type === "custom" && entry.customType === customType)
|
|
98
|
+
if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
|
|
93
99
|
}
|
|
94
|
-
return
|
|
100
|
+
return datas;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/** Restore the persisted loop state from a session branch, fail-open. */
|
|
@@ -104,6 +110,9 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
|
|
|
104
110
|
|
|
105
111
|
export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
|
|
106
112
|
|
|
113
|
+
/** How many `goal-state` entries a clear may be scanned back through. */
|
|
114
|
+
const GOAL_CLEAR_SCAN_LIMIT = 8;
|
|
115
|
+
|
|
107
116
|
export interface GoalSnapshot {
|
|
108
117
|
status: string;
|
|
109
118
|
text: string;
|
|
@@ -120,11 +129,31 @@ export interface GoalSnapshot {
|
|
|
120
129
|
* entry shape is not recognizably a goal. Only fields pi-loop consumes are
|
|
121
130
|
* extracted; unknown statuses are preserved verbatim so the caller can treat
|
|
122
131
|
* anything outside its known sets conservatively.
|
|
132
|
+
*
|
|
133
|
+
* Completion race: pi-goal persists the finished goal (status "complete") and
|
|
134
|
+
* then clears the entry (goal: null), so by the loop's next tick the last
|
|
135
|
+
* entry is the clear. When the newest entry is a clear (or unreadable), scan
|
|
136
|
+
* back over the consecutive run of clears for the goal they cleared: a
|
|
137
|
+
* complete goal is reported, so the loop stops with "goal completed" instead
|
|
138
|
+
* of pausing as goal-missing. A clear over any other status (user /goal clear
|
|
139
|
+
* mid-flight) still reads as no goal. The scan is bounded so a long history of
|
|
140
|
+
* clears cannot make the read walk the branch.
|
|
123
141
|
*/
|
|
124
142
|
export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, GOAL_CLEAR_SCAN_LIMIT);
|
|
144
|
+
const newest = parseGoalSnapshot(ownRecord(datas[0])?.goal);
|
|
145
|
+
if (newest) return newest;
|
|
146
|
+
for (let index = 1; index < datas.length; index += 1) {
|
|
147
|
+
const cleared = parseGoalSnapshot(ownRecord(datas[index])?.goal);
|
|
148
|
+
// Another clear or an unreadable entry: keep scanning back.
|
|
149
|
+
if (!cleared) continue;
|
|
150
|
+
return cleared.status === "complete" ? cleared : undefined;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
|
|
156
|
+
const goal = ownRecord(value);
|
|
128
157
|
if (!goal) return undefined;
|
|
129
158
|
const status = typeof goal.status === "string" ? goal.status : undefined;
|
|
130
159
|
const text = typeof goal.text === "string" ? goal.text.trim() : "";
|