@hank-warren/pi-loop 0.2.1 → 0.3.1
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 +79 -0
- package/README.md +11 -6
- package/package.json +3 -2
- package/src/index.ts +4 -3
- package/src/loop.ts +117 -63
- package/src/manager.ts +0 -3
- package/src/markers.ts +8 -16
- package/src/messages.ts +18 -51
- package/src/render.ts +33 -0
- package/src/settings.ts +4 -9
- package/src/state.ts +19 -11
- package/src/widget.ts +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 910f77a: stop invalidating the prompt cache, and stop re-sending the goal block every turn
|
|
8
|
+
|
|
9
|
+
Goal mode has to keep the objective and its rules in front of the model on every
|
|
10
|
+
turn. It did that twice: a full block appended to the system prompt with the
|
|
11
|
+
running budget counter embedded in it, and a near-complete copy re-sent as a
|
|
12
|
+
stored user message on every automatic continuation. pi-loop restated the
|
|
13
|
+
objective a third time in every poke.
|
|
14
|
+
|
|
15
|
+
Anthropic caches `tools → system → messages` as one prefix, so the moving budget
|
|
16
|
+
counter invalidated the system _and_ conversation cache on every turn of a
|
|
17
|
+
budgeted goal. Verified against the installed pi-ai Anthropic adapter
|
|
18
|
+
(`buildParams` places `cache_control` on the system block and the last user
|
|
19
|
+
message, and the `before_agent_start` append lands inside that cached block).
|
|
20
|
+
|
|
21
|
+
**Static per goal.** The system append is now byte-identical across turns of the
|
|
22
|
+
same goal and states only the budget _total_, so it changes only when the goal
|
|
23
|
+
does — start, edit, clear — which is one accepted cache rewrite per boundary.
|
|
24
|
+
Pinned as the cache-stability contract: same goal, different tokens used,
|
|
25
|
+
iteration, and elapsed time produce identical output.
|
|
26
|
+
|
|
27
|
+
**Dynamic per wake.** Kickoff, continuation, and poke messages shrink to
|
|
28
|
+
pointer-sized triggers carrying the continuation number, budget usage, and wake
|
|
29
|
+
reason, and point at the system prompt for the rest. Measured at ~4 chars/token:
|
|
30
|
+
kickoff 805 → 53, continuation 840 → 54, poke 86 → 64, system append 807 → 820
|
|
31
|
+
(+13 once per goal, the price of stability). Stored conversation tokens for a
|
|
32
|
+
25-iteration goal: 20,965 → 1,349. The resume, edit, and waiting-resume prompts
|
|
33
|
+
keep the full block — they are rare, user-initiated, and already a cache
|
|
34
|
+
boundary.
|
|
35
|
+
|
|
36
|
+
**Legibility.** Those messages now collapse to one-line transcript chips
|
|
37
|
+
(`⟳ goal continuation #4 · budget 12k/100k`, `⏰ loop wake 4/25 · stalled`) via
|
|
38
|
+
markdown transformers, which are display-only by Pi's contract: the stored
|
|
39
|
+
message and the model's context are untouched. New goal and loop widgets above
|
|
40
|
+
the editor carry the counters that left the system prompt — objective, budget
|
|
41
|
+
fraction, iteration, automatic turns for the goal; interval, iteration/cap, next
|
|
42
|
+
wake, and focus for the loop.
|
|
43
|
+
|
|
44
|
+
**Cross-extension assumption.** pi-loop's poke no longer restates the objective,
|
|
45
|
+
because loops require an active goal and pokes are ordinary user messages that
|
|
46
|
+
go through `before_agent_start`, so every poke turn already carries pi-goal's
|
|
47
|
+
system append. Documented in both READMEs; if pokes are ever delivered by a path
|
|
48
|
+
that bypasses that hook, the poke must carry the objective again.
|
|
49
|
+
|
|
50
|
+
## 0.3.0
|
|
51
|
+
|
|
52
|
+
### Minor Changes
|
|
53
|
+
|
|
54
|
+
- 3e94f24: drop the post-compaction continuation and harden the loop engine
|
|
55
|
+
|
|
56
|
+
**Removed:** the `postCompactContinuation` setting, the message it sent, and the
|
|
57
|
+
`session_compact` handler behind it. pi-goal already re-prompts the session after
|
|
58
|
+
a compaction for an active goal, and a loop requires an active goal, so pi-loop's
|
|
59
|
+
follow-up only ever doubled the queued messages and the tokens they cost. pi-goal
|
|
60
|
+
owns that message; pi-loop owns the compaction trigger and its instructions.
|
|
61
|
+
Threshold compaction is unchanged. An existing `pi-loop.json` carrying the removed
|
|
62
|
+
key keeps working — it is ignored, never rejected — but the settings row and the
|
|
63
|
+
session surface are gone, hence the minor bump.
|
|
64
|
+
|
|
65
|
+
**Terminal decisions at settle.** A settled boundary with no pending wake now
|
|
66
|
+
evaluates expiry, completion, and pi-goal's safety states, so a loop stops the
|
|
67
|
+
moment its goal does instead of up to one interval later. Poke and skip decisions
|
|
68
|
+
are ignored there: only the timer pokes.
|
|
69
|
+
|
|
70
|
+
**Delivery and compaction no longer lose or wedge state.** A poke is sent before
|
|
71
|
+
it is accounted for, so a refused delivery re-arms on the same cadence instead of
|
|
72
|
+
burning an iteration on a message that never arrived. A synchronous throw from
|
|
73
|
+
`ctx.compact` resets the in-flight flag rather than leaving every later tick
|
|
74
|
+
skipping as compaction-in-flight, and a failed compaction releases a held wake
|
|
75
|
+
just as a successful one does.
|
|
76
|
+
|
|
77
|
+
**Reading pi-goal through its completion clear** is now a bounded backward scan
|
|
78
|
+
over consecutive clears, so a completion still stops the loop when other entries
|
|
79
|
+
land on top of it. `resumeLoop` refuses a loop whose goal is gone, matching
|
|
80
|
+
`startLoop`. Compaction instructions receive the goal only while it is active.
|
|
81
|
+
|
|
3
82
|
## 0.2.1
|
|
4
83
|
|
|
5
84
|
### Patch 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,18 +25,24 @@ 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
|
|
30
|
+
6. **Poke**: a goal wake message — the wake header, why it fired (stalled or the external wake for a waiting goal), and 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
31
|
|
|
32
|
-
The
|
|
32
|
+
**A poke does not restate the objective**, and this is a deliberate cross-extension assumption. Loops require an active goal, and pi-goal appends the objective, `goal_id`, and goal-mode rules to the system prompt of *every* goal turn — including poke turns, because pokes are delivered as ordinary user messages that go through `before_agent_start`. Restating the goal in the poke would store a duplicate copy in the conversation on every wake for no benefit. The token-lean contract is pinned in `test/messages.test.ts`; the matching cache-stability contract lives in [pi-goal](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal#fork-feature-cache-safe-token-lean-injections). If pokes are ever delivered by a path that bypasses `before_agent_start` (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly), this assumption breaks and the poke must carry the objective again.
|
|
33
|
+
|
|
34
|
+
In the transcript, a poke renders as a one-line chip (`⏰ loop wake 4/25 · stalled`) via a markdown transformer. That hook is display-only by Pi's contract — the stored message and the model's context are untouched.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
The footer status shows `loop 5m · 3/25 · next 14:32`, and a widget above the editor shows the same state with the loop focus beneath it; `/loop status` shows the full card including the last tick's decision and reason.
|
|
33
39
|
|
|
34
40
|
## Loop-aware compaction
|
|
35
41
|
|
|
36
42
|
Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
|
|
37
43
|
|
|
38
44
|
- **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
|
-
- **
|
|
45
|
+
- **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
46
|
- 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
47
|
|
|
42
48
|
## Settings
|
|
@@ -50,7 +56,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
|
|
|
50
56
|
"compaction": {
|
|
51
57
|
"enabled": true,
|
|
52
58
|
"threshold": 0.7,
|
|
53
|
-
"postCompactContinuation": true,
|
|
54
59
|
"instructions": null
|
|
55
60
|
}
|
|
56
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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": [
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"CHANGELOG.md"
|
|
39
39
|
],
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-tui": "*"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,9 +11,13 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
13
13
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
14
|
+
import { registerLoopMessageRendering } from "./render.js";
|
|
14
15
|
|
|
15
16
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
16
17
|
const controller = new LoopController(pi, options);
|
|
18
|
+
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
19
|
+
// stored message and model context are untouched).
|
|
20
|
+
registerLoopMessageRendering(pi);
|
|
17
21
|
|
|
18
22
|
pi.registerCommand("loop", {
|
|
19
23
|
description:
|
|
@@ -77,7 +81,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
77
81
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
78
82
|
controller.onAgentSettled(ctx);
|
|
79
83
|
});
|
|
80
|
-
pi.on("session_compact", async (_event, ctx) => {
|
|
81
|
-
controller.onSessionCompact(ctx);
|
|
82
|
-
});
|
|
83
84
|
}
|
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,
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
readPlanModeEnabled,
|
|
44
44
|
restoreLoopState,
|
|
45
45
|
} from "./state.js";
|
|
46
|
+
import { clearLoopWidget, updateLoopWidget } from "./widget.js";
|
|
46
47
|
|
|
47
48
|
export const LOOP_STATUS_KEY = "loop";
|
|
48
49
|
|
|
@@ -63,7 +64,6 @@ export class LoopController {
|
|
|
63
64
|
private timer: NodeJS.Timeout | undefined;
|
|
64
65
|
private nextWakeAt: number | undefined;
|
|
65
66
|
private wakePending = false;
|
|
66
|
-
private pendingLoopId: string | undefined;
|
|
67
67
|
private sessionCtx: ExtensionContext | undefined;
|
|
68
68
|
|
|
69
69
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
@@ -77,7 +77,6 @@ export class LoopController {
|
|
|
77
77
|
onSessionStart(ctx: ExtensionContext): void {
|
|
78
78
|
this.clearTimer();
|
|
79
79
|
this.wakePending = false;
|
|
80
|
-
this.pendingLoopId = undefined;
|
|
81
80
|
this.compacting = false;
|
|
82
81
|
this.lastDecision = undefined;
|
|
83
82
|
this.sessionCtx = ctx;
|
|
@@ -102,7 +101,7 @@ export class LoopController {
|
|
|
102
101
|
onSessionShutdown(): void {
|
|
103
102
|
this.clearTimer();
|
|
104
103
|
this.wakePending = false;
|
|
105
|
-
this.
|
|
104
|
+
if (this.sessionCtx) clearLoopWidget(this.sessionCtx.ui);
|
|
106
105
|
this.sessionCtx = undefined;
|
|
107
106
|
}
|
|
108
107
|
|
|
@@ -111,26 +110,30 @@ export class LoopController {
|
|
|
111
110
|
if (!this.state || this.state.status !== "active") return;
|
|
112
111
|
if (this.maybeStartCompaction(ctx)) return;
|
|
113
112
|
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
113
|
this.wakePending = false;
|
|
121
|
-
this.pendingLoopId = undefined;
|
|
122
114
|
this.runTick(ctx);
|
|
115
|
+
return;
|
|
123
116
|
}
|
|
117
|
+
this.settleTerminalState(ctx);
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
/**
|
|
121
|
+
* A settled boundary with no wake pending still evaluates the terminal
|
|
122
|
+
* decisions — expiry, a completed or missing goal, a pi-goal safety state —
|
|
123
|
+
* so the loop settles the moment the goal does instead of up to one interval
|
|
124
|
+
* later. Poke and skip decisions are deliberately ignored here: only the
|
|
125
|
+
* timer pokes, and settling is not a schedule.
|
|
126
|
+
*/
|
|
127
|
+
private settleTerminalState(ctx: ExtensionContext): void {
|
|
128
|
+
const loop = this.state;
|
|
129
|
+
if (!loop) return;
|
|
130
|
+
const env = this.gatherEnvironment(ctx);
|
|
131
|
+
const decision = decideTick(loop, env);
|
|
132
|
+
if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.lastDecision = { ...decision, at: env.now };
|
|
136
|
+
this.applyTerminalDecision(loop, decision);
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// --- tick machinery ---
|
|
@@ -174,9 +177,6 @@ export class LoopController {
|
|
|
174
177
|
switch (decision.action) {
|
|
175
178
|
case "none":
|
|
176
179
|
return;
|
|
177
|
-
case "expire":
|
|
178
|
-
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
179
|
-
return;
|
|
180
180
|
case "skip":
|
|
181
181
|
if (decision.reason === "plan-mode-active") {
|
|
182
182
|
// Plan mode may end without an agent_settled we can use, so
|
|
@@ -186,10 +186,26 @@ export class LoopController {
|
|
|
186
186
|
// Busy or compacting: coalesce into one pending wake that
|
|
187
187
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
188
188
|
this.wakePending = true;
|
|
189
|
-
this.pendingLoopId = loop.id;
|
|
190
189
|
}
|
|
191
190
|
this.updateWidget();
|
|
192
191
|
return;
|
|
192
|
+
case "poke":
|
|
193
|
+
this.deliverPoke(env, decision.reason);
|
|
194
|
+
return;
|
|
195
|
+
default:
|
|
196
|
+
this.applyTerminalDecision(loop, decision);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private applyTerminalDecision(
|
|
202
|
+
loop: LoopState,
|
|
203
|
+
decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
|
|
204
|
+
): void {
|
|
205
|
+
switch (decision.action) {
|
|
206
|
+
case "expire":
|
|
207
|
+
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
208
|
+
return;
|
|
193
209
|
case "stop":
|
|
194
210
|
this.transition(
|
|
195
211
|
"stopped",
|
|
@@ -206,23 +222,31 @@ export class LoopController {
|
|
|
206
222
|
: `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
|
|
207
223
|
);
|
|
208
224
|
return;
|
|
209
|
-
case "poke":
|
|
210
|
-
this.deliverPoke(ctx, env, decision.reason);
|
|
211
|
-
return;
|
|
212
225
|
}
|
|
213
226
|
}
|
|
214
227
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
230
|
+
* session), and an iteration persisted before the send would burn the
|
|
231
|
+
* maxIterations cap on a poke that never arrived; on a throw the loop re-arms
|
|
232
|
+
* on the same cadence and retries at the next wake.
|
|
233
|
+
*/
|
|
234
|
+
private deliverPoke(env: TickEnvironment, reason: "goal-stalled" | "goal-waiting"): void {
|
|
220
235
|
const loop = this.state;
|
|
221
236
|
if (!loop || !env.goal) return;
|
|
222
|
-
|
|
237
|
+
try {
|
|
238
|
+
this.pi.sendUserMessage(buildGoalPoke(loop, reason));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.sessionCtx?.ui.notify(
|
|
241
|
+
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
242
|
+
"warning",
|
|
243
|
+
);
|
|
244
|
+
this.scheduleTick(loop.intervalMs);
|
|
245
|
+
this.updateWidget();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
223
248
|
this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
|
|
224
249
|
this.persist();
|
|
225
|
-
this.pi.sendUserMessage(message);
|
|
226
250
|
this.scheduleTick(this.state.intervalMs);
|
|
227
251
|
this.updateWidget();
|
|
228
252
|
}
|
|
@@ -237,30 +261,45 @@ export class LoopController {
|
|
|
237
261
|
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
238
262
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
239
263
|
this.compacting = true;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
264
|
+
try {
|
|
265
|
+
ctx.compact({
|
|
266
|
+
customInstructions: buildCompactionInstructions(
|
|
267
|
+
loop,
|
|
268
|
+
// A completed or otherwise finished goal is no longer the
|
|
269
|
+
// objective the summary must preserve.
|
|
270
|
+
goal?.status === "active" ? goal : undefined,
|
|
271
|
+
this.settings.compaction.instructions,
|
|
272
|
+
),
|
|
273
|
+
onComplete: () => {
|
|
274
|
+
this.compacting = false;
|
|
275
|
+
this.nudgeHeldWake();
|
|
276
|
+
},
|
|
277
|
+
onError: (error) => {
|
|
278
|
+
this.compacting = false;
|
|
279
|
+
this.sessionCtx?.ui.notify(`pi-loop compaction failed: ${formatError(error)}`, "warning");
|
|
280
|
+
this.nudgeHeldWake();
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// A synchronous refusal (a torn-down runner) must not leave the loop
|
|
285
|
+
// skipping every tick as compaction-in-flight for the rest of the
|
|
286
|
+
// session.
|
|
287
|
+
this.compacting = false;
|
|
288
|
+
ctx.ui.notify(`pi-loop compaction could not start: ${formatError(error)}`, "warning");
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
261
291
|
return true;
|
|
262
292
|
}
|
|
263
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A wake held during compaction delivers at the next settled boundary;
|
|
296
|
+
* nudge in case that boundary already passed while compaction ran.
|
|
297
|
+
*/
|
|
298
|
+
private nudgeHeldWake(): void {
|
|
299
|
+
const ctx = this.sessionCtx;
|
|
300
|
+
if (ctx && this.wakePending) this.onAgentSettled(ctx);
|
|
301
|
+
}
|
|
302
|
+
|
|
264
303
|
// --- state transitions & presentation ---
|
|
265
304
|
|
|
266
305
|
private transition(status: "paused" | "stopped", why: string): void {
|
|
@@ -268,7 +307,6 @@ export class LoopController {
|
|
|
268
307
|
this.state = { ...this.state, status };
|
|
269
308
|
this.clearTimer();
|
|
270
309
|
this.wakePending = false;
|
|
271
|
-
this.pendingLoopId = undefined;
|
|
272
310
|
this.persist();
|
|
273
311
|
this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
|
|
274
312
|
this.updateWidget();
|
|
@@ -283,6 +321,10 @@ export class LoopController {
|
|
|
283
321
|
const ui = this.sessionCtx?.ui;
|
|
284
322
|
if (!ui) return;
|
|
285
323
|
const loop = this.state;
|
|
324
|
+
updateLoopWidget(
|
|
325
|
+
ui,
|
|
326
|
+
loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
|
|
327
|
+
);
|
|
286
328
|
if (!loop || loop.status === "stopped") {
|
|
287
329
|
ui.setStatus(LOOP_STATUS_KEY, undefined);
|
|
288
330
|
return;
|
|
@@ -361,7 +403,6 @@ export class LoopController {
|
|
|
361
403
|
expiresAt: now + expiryMs,
|
|
362
404
|
};
|
|
363
405
|
this.wakePending = false;
|
|
364
|
-
this.pendingLoopId = undefined;
|
|
365
406
|
this.persist();
|
|
366
407
|
this.scheduleTick(start.intervalMs);
|
|
367
408
|
this.updateWidget();
|
|
@@ -395,6 +436,15 @@ export class LoopController {
|
|
|
395
436
|
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
396
437
|
return;
|
|
397
438
|
}
|
|
439
|
+
// Same guard as startLoop: resuming into a finished or missing goal would
|
|
440
|
+
// only stop or pause again at the first tick.
|
|
441
|
+
if (readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active") {
|
|
442
|
+
ctx.ui.notify(
|
|
443
|
+
"Loops require an active goal to operate. Start one first: /goal <objective>, then /loop resume.",
|
|
444
|
+
"error",
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
398
448
|
this.state = { ...loop, status: "active" };
|
|
399
449
|
this.persist();
|
|
400
450
|
this.scheduleTick(loop.intervalMs);
|
|
@@ -422,3 +472,7 @@ export class LoopController {
|
|
|
422
472
|
this.transition("stopped", why);
|
|
423
473
|
}
|
|
424
474
|
}
|
|
475
|
+
|
|
476
|
+
function formatError(error: unknown): string {
|
|
477
|
+
return error instanceof Error ? error.message : String(error);
|
|
478
|
+
}
|
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 {
|
|
@@ -15,22 +15,23 @@ function formatIteration(loop: LoopState): string {
|
|
|
15
15
|
return `${loop.iteration + 1}/${cap}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The goal-bound poke: wake the session and point at the goal without
|
|
20
|
+
* restating it. Loops require an active pi-goal goal, so every poke turn
|
|
21
|
+
* already carries the objective, goal_id, and goal-mode rules through
|
|
22
|
+
* pi-goal's system prompt append; restating them here would store duplicate
|
|
23
|
+
* tokens in the conversation on every wake (see README: cross-extension
|
|
24
|
+
* assumption). Only the dynamic per-wake state (iteration, reason) belongs in
|
|
25
|
+
* this tail message.
|
|
26
|
+
*/
|
|
27
|
+
export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-waiting"): string {
|
|
24
28
|
const lines = [
|
|
25
29
|
`Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
26
30
|
reason === "goal-waiting"
|
|
27
31
|
? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
|
|
28
|
-
: "The session went idle but the active goal is not complete. Continue working toward it.",
|
|
29
|
-
"",
|
|
30
|
-
`Active goal: ${goal.text}`,
|
|
32
|
+
: "The session went idle but the active goal is not complete. Continue working toward it — the objective and goal-mode rules are in the system prompt.",
|
|
31
33
|
];
|
|
32
34
|
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
33
|
-
lines.push("", "Use the goal tools (goal_complete, goal_blocked, goal_wait) when their conditions are met.");
|
|
34
35
|
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -41,6 +42,7 @@ export function buildGoalPoke(
|
|
|
41
42
|
*/
|
|
42
43
|
export function buildCompactionInstructions(
|
|
43
44
|
loop: LoopState,
|
|
45
|
+
/** The goal only when it is still active; a finished goal is not the objective. */
|
|
44
46
|
goal: GoalSnapshot | undefined,
|
|
45
47
|
override: string | null,
|
|
46
48
|
): string {
|
|
@@ -62,38 +64,3 @@ export function buildCompactionInstructions(
|
|
|
62
64
|
"Discard raw tool output, file contents that live on disk, and duplicate exploration.",
|
|
63
65
|
].join("\n");
|
|
64
66
|
}
|
|
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/render.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only compaction of loop-injected pokes.
|
|
3
|
+
*
|
|
4
|
+
* Pokes are already token-lean, but they still render as multi-line prompt
|
|
5
|
+
* text plus a provenance marker comment. This transformer collapses each into
|
|
6
|
+
* a one-line themed chip in the transcript. Display-only by Pi contract: the
|
|
7
|
+
* stored message and model context are untouched, and pokes keep being
|
|
8
|
+
* delivered through sendUserMessage so pi-goal's before_agent_start hook
|
|
9
|
+
* (which appends the goal system prompt) still fires for every poke turn.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { extractPokeMarker } from "./markers.js";
|
|
14
|
+
|
|
15
|
+
const POKE_HEAD_PATTERN = /^Scheduled loop wakeup (\S+) \(every ([^)]+)\)\./u;
|
|
16
|
+
const POKE_FOCUS_PATTERN = /^Loop focus: (.+)$/mu;
|
|
17
|
+
|
|
18
|
+
export function registerLoopMessageRendering(pi: ExtensionAPI) {
|
|
19
|
+
pi.registerMarkdownTransformer((markdown, { messageType }) => {
|
|
20
|
+
if (messageType !== "user") return markdown;
|
|
21
|
+
return compactPokeMessage(markdown) ?? markdown;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Exported for tests: the poke chip, or undefined when not ours. */
|
|
26
|
+
export function compactPokeMessage(markdown: string) {
|
|
27
|
+
if (!extractPokeMarker(markdown)) return undefined;
|
|
28
|
+
const head = POKE_HEAD_PATTERN.exec(markdown);
|
|
29
|
+
if (!head) return undefined;
|
|
30
|
+
const reason = markdown.includes("external wake") ? "waiting" : "stalled";
|
|
31
|
+
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
32
|
+
return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
|
|
33
|
+
}
|
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
|
@@ -110,6 +110,9 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
|
|
|
110
110
|
|
|
111
111
|
export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
|
|
112
112
|
|
|
113
|
+
/** How many `goal-state` entries a clear may be scanned back through. */
|
|
114
|
+
const GOAL_CLEAR_SCAN_LIMIT = 8;
|
|
115
|
+
|
|
113
116
|
export interface GoalSnapshot {
|
|
114
117
|
status: string;
|
|
115
118
|
text: string;
|
|
@@ -129,19 +132,24 @@ export interface GoalSnapshot {
|
|
|
129
132
|
*
|
|
130
133
|
* Completion race: pi-goal persists the finished goal (status "complete") and
|
|
131
134
|
* then clears the entry (goal: null), so by the loop's next tick the last
|
|
132
|
-
* entry is the clear.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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.
|
|
136
141
|
*/
|
|
137
142
|
export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
|
|
138
|
-
const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE,
|
|
139
|
-
const newest = ownRecord(datas[0]);
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop widget: a compact themed line above the editor mirroring the
|
|
3
|
+
* footer status (interval · iteration/cap · next wake), with the loop focus
|
|
4
|
+
* dimmed below it when set.
|
|
5
|
+
*
|
|
6
|
+
* Presentation only: every entry point tolerates a host without setWidget
|
|
7
|
+
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
8
|
+
* widget must never interrupt loop state transitions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { formatClock, formatDuration } from "./interval.js";
|
|
13
|
+
import type { LoopState } from "./state.js";
|
|
14
|
+
|
|
15
|
+
export const LOOP_WIDGET_KEY = "loop";
|
|
16
|
+
|
|
17
|
+
interface WidgetTheme {
|
|
18
|
+
bold?: (text: string) => string;
|
|
19
|
+
fg?: (color: string, text: string) => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type WidgetHost = { setWidget?: unknown };
|
|
23
|
+
|
|
24
|
+
export interface LoopWidgetView {
|
|
25
|
+
loop: LoopState;
|
|
26
|
+
/** A wake is held for the next idle boundary. */
|
|
27
|
+
wakePending: boolean;
|
|
28
|
+
/** Epoch ms of the next scheduled tick, when armed. */
|
|
29
|
+
nextWakeAt: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
|
|
33
|
+
const setWidget = resolveSetWidget(ui);
|
|
34
|
+
if (!setWidget) return;
|
|
35
|
+
try {
|
|
36
|
+
if (!view || view.loop.status === "stopped") {
|
|
37
|
+
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
|
|
41
|
+
const bold = theme.bold ?? identity;
|
|
42
|
+
const dim = (text: string) => theme.fg?.("dim", text) ?? text;
|
|
43
|
+
const focus = view.loop.prompt ? `\n${dim(` focus: ${view.loop.prompt}`)}` : "";
|
|
44
|
+
return new Text(`${bold(loopWidgetLine(view))}${focus}`);
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
// Presentation only; a widget failure must never break a loop transition.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearLoopWidget(ui: WidgetHost) {
|
|
52
|
+
const setWidget = resolveSetWidget(ui);
|
|
53
|
+
if (!setWidget) return;
|
|
54
|
+
try {
|
|
55
|
+
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
56
|
+
} catch {
|
|
57
|
+
// Presentation only.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loopWidgetLine(view: LoopWidgetView) {
|
|
62
|
+
const loop = view.loop;
|
|
63
|
+
if (loop.status === "paused") return "⏸ loop paused";
|
|
64
|
+
const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
|
|
65
|
+
const next = view.wakePending
|
|
66
|
+
? "next on idle"
|
|
67
|
+
: view.nextWakeAt !== undefined
|
|
68
|
+
? `next ${formatClock(view.nextWakeAt)}`
|
|
69
|
+
: "next unscheduled";
|
|
70
|
+
return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function identity(text: string) {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveSetWidget(ui: WidgetHost) {
|
|
78
|
+
return typeof ui.setWidget === "function"
|
|
79
|
+
? (ui.setWidget as (key: string, content: unknown) => void).bind(ui)
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|