@hank-warren/pi-loop 1.0.0 → 1.2.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 +42 -0
- package/README.md +10 -4
- package/{skills/pi-loop/SKILL.md → docs/loop-craft.md} +0 -6
- package/package.json +2 -5
- package/src/command.ts +1 -1
- package/src/complete-tool.ts +2 -1
- package/src/decide.ts +2 -2
- package/src/docs.ts +21 -0
- package/src/errors.ts +1 -1
- package/src/fresh-launch.ts +2 -2
- package/src/index.ts +49 -8
- package/src/interval.ts +1 -1
- package/src/ledger.ts +6 -6
- package/src/loop-action-menus.ts +2 -2
- package/src/loop-env.ts +2 -2
- package/src/loop-launch-menu.ts +5 -5
- package/src/loop-manager-menu.ts +3 -3
- package/src/loop.ts +103 -8
- package/src/manager.ts +1 -1
- package/src/planning.ts +2 -1
- package/src/presentation.ts +55 -18
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +7 -2
- package/src/safety.ts +1 -1
- package/src/settings.ts +3 -3
- package/src/state.ts +7 -2
- package/src/widget.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1035138: Ship the craft documents by injected path instead of as skills.
|
|
8
|
+
|
|
9
|
+
The `pi-loop` and `pi-plan-mode` skills are gone. Their bodies now ship as
|
|
10
|
+
`docs/loop-craft.md` and `docs/plan-craft.md`, and the mode prompts inject the
|
|
11
|
+
file's absolute path (resolved from the installed package) at the moments the
|
|
12
|
+
guidance matters: while a loop is being drafted or completed, and while Plan
|
|
13
|
+
Mode is active. A skill's description line sits in every system prompt of every
|
|
14
|
+
session with the package loaded; across ~220 sessions after these two shipped,
|
|
15
|
+
every read of either file was triggered by the mode's own prompt and never by
|
|
16
|
+
the description, so the line was a tax on the ~95% of sessions that never
|
|
17
|
+
entered the mode. Same document, read at the same moments, at zero cost outside
|
|
18
|
+
them. Hosts that referenced the skills by name in settings should drop those
|
|
19
|
+
entries.
|
|
20
|
+
|
|
21
|
+
- 1035138: Deprecated in favour of `@hank-warren/pi-orchestrator`.
|
|
22
|
+
|
|
23
|
+
pi-loop is a pacemaker for one session that cannot be trusted to pace itself. The
|
|
24
|
+
`pi-orchestrator` skill is a supervising pi session watching real pi sessions in
|
|
25
|
+
Herdr panes — reading their state, answering their prompts within policy,
|
|
26
|
+
steering, and verifying their claims — which does by judgment what the loop
|
|
27
|
+
engine did by pacing and gates. This package stays published and continues to
|
|
28
|
+
work; it is no longer loaded by the git install of the `pi-extensions`
|
|
29
|
+
repository and receives no new features. The README opens with the notice.
|
|
30
|
+
|
|
31
|
+
## 1.1.0
|
|
32
|
+
|
|
33
|
+
### Minor Changes
|
|
34
|
+
|
|
35
|
+
- c079c51: Stage the loop tools behind the workflow that needs them, and stop a loop that outlives its deadline.
|
|
36
|
+
|
|
37
|
+
`loop_propose` activates when planning opens and the runtime trio (`loop_complete`, `loop_progress`, `loop_wait`) when a valid proposal exists, so a session that never touches `/loop` no longer carries four Loop-only schemas in its cached prompt prefix. Every tool stays registered, so a historical transcript still resolves it. An active loop re-asserts its runtime tools on every turn, which is what lets a paused loop restored in a later session be resumed and still finish itself.
|
|
38
|
+
|
|
39
|
+
An expiry watchdog armed on the exact `expiresAt` now ends a loop that has gone quiet past its deadline, independently of the fallback interval, and a bounded grace period stops a loop whose final expiry turn never starts — `sendUserMessage` is fire-and-forget, so an accepted wake is not a delivered one. Terminal states persist a `terminalReason`, and callbacks are loop-identity guarded.
|
|
40
|
+
|
|
41
|
+
Plan mode now owns the prompt outright: while it is enabled the loop injects neither its planning hint nor its objective, and resumes on the first turn after `/plan exit` without rewriting persisted state.
|
|
42
|
+
|
|
43
|
+
The approval card is a display-only session entry rendered through `registerEntryRenderer` rather than a message, so it stays visible and restorable while never entering model context or compaction. The internal `LOOP_PROPOSAL_MESSAGE_TYPE` constant is replaced by `LOOP_PROPOSAL_ENTRY_TYPE`; it was never exported from the package entry point.
|
|
44
|
+
|
|
3
45
|
## 1.0.0
|
|
4
46
|
|
|
5
47
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-loop — long-running work for the Pi coding agent
|
|
2
2
|
|
|
3
|
+
> **Deprecated.** pi-loop is no longer loaded by the git install of this repository and receives no new features. Its role — keeping autonomous work moving and gating completion on evidence — has moved to the [`pi-orchestrator`](../pi-orchestrator) skill package: a supervising pi session watching real pi sessions in Herdr panes, reading their state, answering their prompts within policy, steering them, and verifying their claims. A loop is a pacemaker for one session that cannot be trusted to pace itself; a supervisor with fresh context does that by judgment, and can see, attach to, and redirect each child. The package stays on npm and continues to install with `pi install npm:@hank-warren/pi-loop` for anyone who prefers it.
|
|
4
|
+
|
|
3
5
|
Inspired by Claude Code's `/loop`, adapted to Pi: keep work moving across many turns, and keep long loops coherent across context compaction.
|
|
4
6
|
|
|
5
7
|
A loop is a **pacemaker** that owns its own work: it carries an objective, completion criteria and ground rules, and ends when the model calls `loop_complete` with cited evidence, its expiry arrives, a cap you set is reached, or you stop it. Every loop is planned with you and started from an approval card. **No other extension is required.**
|
|
@@ -205,13 +207,13 @@ These were considered and cut, and the reasoning is recorded so they are not sil
|
|
|
205
207
|
- **No judge model.** Grading completion with a second model is a larger, more expensive change than the criteria/evidence gate; the gate is the rung that ships.
|
|
206
208
|
- **No `loop_blocked` tool.** `loop_wait` covers a real external dependency, and the no-progress breaker covers an impasse the model does not recognise as one. A third "I give up" tool mostly gives a model a way to stop early. `compaction.instructions` overrides the built-in template.
|
|
207
209
|
|
|
208
|
-
## The
|
|
210
|
+
## The loop-craft doc
|
|
209
211
|
|
|
210
|
-
The
|
|
212
|
+
The engine cannot encode the judgment that decides whether a loop is worth running — how an objective becomes falsifiable criteria, what the evidence gate accepts as a citation, when to declare a `loop_wait` instead of polling, what `PROGRESS.md` is worth, and when the work belongs in no loop at all. That lives in [`docs/loop-craft.md`](docs/loop-craft.md), which ships with the package.
|
|
211
213
|
|
|
212
|
-
It is **loaded on demand**: the planning hint and `loop_complete`'s prompt guidelines
|
|
214
|
+
It is **loaded on demand**: the planning hint and `loop_complete`'s prompt guidelines name it by absolute path (resolved from the installed package, so it works under any install layout) and the model reads it when it reaches those moments. Nothing about the guidance enters a stored loop message or the system append — those bytes are the cache prefix, and `test/bytes.test.ts` fails if any of them so much as mentions it.
|
|
213
215
|
|
|
214
|
-
|
|
216
|
+
It used to be a skill. A skill's description line is in every system prompt, which buys exactly one thing an injected pointer cannot: the model proposing a loop unprompted. Across ~220 sessions after it shipped, every read of the file was triggered by the planning hint or the completion tool, never by the description, and the model never suggested `/loop` on its own — so the line was a tax on every session that never ran a loop (about 95% of them) that bought nothing. A hard path injected only while a loop is being drafted or completed is the same document at zero cost outside those moments.
|
|
215
217
|
|
|
216
218
|
## Install
|
|
217
219
|
|
|
@@ -221,6 +223,10 @@ pi install npm:@hank-warren/pi-loop
|
|
|
221
223
|
|
|
222
224
|
**No other extension is required.** The only sibling state pi-loop reads is [pi-plan-mode](https://www.npmjs.com/package/@hank-warren/pi-plan-mode)'s, fail-open, so a loop never injects into a planning conversation — and it works fine without it.
|
|
223
225
|
|
|
226
|
+
## Changelog
|
|
227
|
+
|
|
228
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
229
|
+
|
|
224
230
|
## License
|
|
225
231
|
|
|
226
232
|
MIT
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: pi-loop
|
|
3
|
-
description: Draft loop objectives that become falsifiable criteria, set cadence and ground rules, and complete a loop with evidence the gate accepts. Use while drafting a loop in /loop planning, before calling loop_propose, before assembling loop_complete evidence, and when deciding whether work belongs in a loop at all.
|
|
4
|
-
license: MIT
|
|
5
|
-
---
|
|
6
|
-
|
|
7
1
|
# pi-loop
|
|
8
2
|
|
|
9
3
|
The engine handles pacing, wakes, compaction, and the ledger. It cannot handle the three things that decide whether a loop is worth running: **what end state the objective names**, **what constraints bound the work**, and **what evidence proves it is done**. That is this file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Long-running work for Pi: settle-paced loops with a durable ledger, adaptive waits, no-progress breakers, evidence-gated completion, and a task scheduler.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -29,15 +29,12 @@
|
|
|
29
29
|
"pi": {
|
|
30
30
|
"extensions": [
|
|
31
31
|
"./index.ts"
|
|
32
|
-
],
|
|
33
|
-
"skills": [
|
|
34
|
-
"./skills/pi-loop"
|
|
35
32
|
]
|
|
36
33
|
},
|
|
37
34
|
"files": [
|
|
38
35
|
"index.ts",
|
|
39
36
|
"src",
|
|
40
|
-
"
|
|
37
|
+
"docs/loop-craft.md",
|
|
41
38
|
"README.md",
|
|
42
39
|
"LICENSE",
|
|
43
40
|
"CHANGELOG.md"
|
package/src/command.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* of having exactly one way in, and the menu is one keystroke away.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
type LoopCommand =
|
|
21
21
|
/** Bare `/loop`: open whichever menu the current state calls for. */
|
|
22
22
|
| { kind: "menu" }
|
|
23
23
|
/** `/loop <text>`: enter planning and send the text as the first message. */
|
package/src/complete-tool.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { Type } from "typebox";
|
|
27
|
+
import { LOOP_CRAFT_DOC } from "./docs.js";
|
|
27
28
|
import type { LoopController } from "./loop.js";
|
|
28
29
|
import type { LoopCriterion } from "./ledger.js";
|
|
29
30
|
|
|
@@ -92,7 +93,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
92
93
|
"Pass the exact loop_id from the active /loop objective in the system prompt. A mismatched id means the loop changed and the call is refused.",
|
|
93
94
|
"loop_complete stops scheduled wakeups only. It does not mean an unrelated goal or task is complete.",
|
|
94
95
|
"If the criteria are not met, do not call it: keep working and expect another continuation.",
|
|
95
|
-
|
|
96
|
+
`Before your first loop_complete this session, read ${LOOP_CRAFT_DOC}: it describes what the evidence gate accepts as a citation and what it refuses.`,
|
|
96
97
|
],
|
|
97
98
|
parameters: Type.Object({
|
|
98
99
|
loop_id: Type.String({
|
package/src/decide.ts
CHANGED
|
@@ -37,9 +37,9 @@ function isWaiting(loop: LoopState, now: number): boolean {
|
|
|
37
37
|
* An expiring loop gets one last turn to write its state into the ledger
|
|
38
38
|
* before it stops; a loop already spending that turn stops immediately.
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
type ExpiryReason = "loop-expired" | "expiry-final-wake";
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
type SkipReason =
|
|
43
43
|
| "plan-mode-active"
|
|
44
44
|
| "agent-busy"
|
|
45
45
|
| "compaction-in-flight"
|
package/src/docs.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The loop-craft document: the judgment the engine cannot encode — how an
|
|
5
|
+
* objective becomes falsifiable criteria, what the evidence gate accepts as a
|
|
6
|
+
* citation, when to `loop_wait`, and when work belongs in no loop at all.
|
|
7
|
+
*
|
|
8
|
+
* It used to ship as a skill. A skill buys one thing an injected pointer
|
|
9
|
+
* cannot: a description line in every system prompt, so the model could
|
|
10
|
+
* propose a loop unprompted. Across ~220 sessions after it shipped, every read
|
|
11
|
+
* of the file was triggered by the planning hint or `loop_complete`'s
|
|
12
|
+
* guidelines — never by the description — and the model never suggested
|
|
13
|
+
* `/loop` on its own. So the line was a tax on every session that never ran a
|
|
14
|
+
* loop (~95% of them) and bought nothing. An absolute path, injected only
|
|
15
|
+
* while a loop is being drafted or completed, is the same document at zero
|
|
16
|
+
* cost outside those moments, and a hard path beats "if it is available".
|
|
17
|
+
*
|
|
18
|
+
* The path is resolved from this module's own location so it survives every
|
|
19
|
+
* install layout (git, npm, workspace symlink, `npm link`).
|
|
20
|
+
*/
|
|
21
|
+
export const LOOP_CRAFT_DOC = join(import.meta.dirname, "..", "docs", "loop-craft.md");
|
package/src/errors.ts
CHANGED
package/src/fresh-launch.ts
CHANGED
|
@@ -27,14 +27,14 @@ import { LOOP_STATE_ENTRY_TYPE } from "./state.js";
|
|
|
27
27
|
type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
|
|
28
28
|
type SessionManagerLike = Parameters<NonNullable<NewSessionOptions["setup"]>>[0];
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
type FreshLoopResult =
|
|
31
31
|
| { kind: "started" }
|
|
32
32
|
| { kind: "cancelled" }
|
|
33
33
|
/** The session exists and holds the loop, but it could not be kicked off. */
|
|
34
34
|
| { kind: "partial"; detail: string }
|
|
35
35
|
| { kind: "rejected"; detail: string };
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
interface FreshLoopRequest {
|
|
38
38
|
built: BuiltLoop;
|
|
39
39
|
/** Write the ledger for `built` before the handoff; returns a failure detail. */
|
|
40
40
|
prepareLedger(): string | undefined;
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { parseLoopCommand } from "./command.js";
|
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
13
|
import { registerLoopProgressTool } from "./progress-tool.js";
|
|
14
14
|
import { registerLoopProposeTool } from "./propose-tool.js";
|
|
15
|
+
import { registerLoopProposalRenderer } from "./presentation.js";
|
|
15
16
|
import { LOOP_PLANNING_HINT } from "./planning.js";
|
|
16
17
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
17
18
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from "./manager.js";
|
|
24
25
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
25
26
|
import { registerLoopMessageRendering } from "./render.js";
|
|
27
|
+
import { readPlanModeEnabled } from "./state.js";
|
|
26
28
|
|
|
27
29
|
/** What the planning menu's "Request proposal now" asks for. */
|
|
28
30
|
export const REQUEST_PROPOSAL_MESSAGE =
|
|
@@ -30,16 +32,36 @@ export const REQUEST_PROPOSAL_MESSAGE =
|
|
|
30
32
|
|
|
31
33
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
32
34
|
const controller = new LoopController(pi, options);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const proposeTools = ["loop_propose"];
|
|
36
|
+
const runtimeTools = ["loop_complete", "loop_progress", "loop_wait"];
|
|
37
|
+
let proposeActivated = false;
|
|
38
|
+
let runtimeActivated = false;
|
|
39
|
+
const reconcileTools = () => {
|
|
40
|
+
const active = pi.getActiveTools();
|
|
41
|
+
const wanted = new Set(active);
|
|
42
|
+
for (const name of proposeTools) proposeActivated ? wanted.add(name) : wanted.delete(name);
|
|
43
|
+
for (const name of runtimeTools) runtimeActivated ? wanted.add(name) : wanted.delete(name);
|
|
44
|
+
const next = [...wanted];
|
|
45
|
+
if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
|
|
46
|
+
pi.setActiveTools(next);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const activatePropose = () => {
|
|
50
|
+
proposeActivated = true;
|
|
51
|
+
reconcileTools();
|
|
52
|
+
};
|
|
53
|
+
const activateRuntime = () => {
|
|
54
|
+
runtimeActivated = true;
|
|
55
|
+
reconcileTools();
|
|
56
|
+
};
|
|
57
|
+
|
|
37
58
|
registerLoopCompleteTool(pi, controller);
|
|
38
|
-
// Registered on the same terms and for the same reason: the tool set is
|
|
39
|
-
// part of the cached prefix, so it never changes with loop state.
|
|
40
59
|
registerLoopWaitTool(pi, controller);
|
|
41
60
|
registerLoopProgressTool(pi, controller);
|
|
42
|
-
registerLoopProposeTool(pi, controller);
|
|
61
|
+
registerLoopProposeTool(pi, controller, activateRuntime);
|
|
62
|
+
registerLoopProposalRenderer(pi);
|
|
63
|
+
// Narrowing happens at session_start, never here: Pi refuses action methods
|
|
64
|
+
// (getActiveTools/setActiveTools among them) during extension loading.
|
|
43
65
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
44
66
|
// stored message and model context are untouched).
|
|
45
67
|
registerLoopMessageRendering(pi);
|
|
@@ -61,6 +83,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
61
83
|
|
|
62
84
|
const beginPlanning = (ctx: ExtensionCommandContext): void => {
|
|
63
85
|
if (controller.planning.active) return;
|
|
86
|
+
activatePropose();
|
|
64
87
|
controller.beginPlanning();
|
|
65
88
|
ctx.ui.notify(
|
|
66
89
|
"Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
|
|
@@ -110,7 +133,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
110
133
|
});
|
|
111
134
|
|
|
112
135
|
pi.on("session_start", async (_event, ctx) => {
|
|
136
|
+
proposeActivated = false;
|
|
137
|
+
runtimeActivated = false;
|
|
113
138
|
controller.onSessionStart(ctx);
|
|
139
|
+
if (controller.state?.status === "active") activateRuntime();
|
|
140
|
+
else reconcileTools();
|
|
114
141
|
});
|
|
115
142
|
pi.on("session_shutdown", async () => {
|
|
116
143
|
controller.onSessionShutdown();
|
|
@@ -129,7 +156,21 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
129
156
|
// A loop carries its own objective and injects it as a byte-stable system
|
|
130
157
|
// append, which is what lets the poke and continuation messages stay
|
|
131
158
|
// pointer-sized.
|
|
132
|
-
pi.on("before_agent_start", (event) => {
|
|
159
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
160
|
+
// Self-heal the runtime tool set every turn an active loop takes, not just
|
|
161
|
+
// at session_start. `resumeLoop` flips a restored *paused* loop to active
|
|
162
|
+
// and dispatches a continuation from the /loop menu, which has no way to
|
|
163
|
+
// reach activateRuntime — so without this the resumed loop would run with
|
|
164
|
+
// loop_complete stripped, be told by its own objective append to call it,
|
|
165
|
+
// and then be re-paused by enforceToolAvailability blaming --tools for
|
|
166
|
+
// something this extension did to itself. Activation is monotonic, so this
|
|
167
|
+
// covers resumeAfterEdit and the fresh-session handoff too, and costs a
|
|
168
|
+
// no-op set comparison on every other turn.
|
|
169
|
+
if (controller.state?.status === "active") activateRuntime();
|
|
170
|
+
// Plan mode owns the prompt while active. Loop scheduling is already held
|
|
171
|
+
// by the same persisted state; suppressing the append removes the remaining
|
|
172
|
+
// mixed-workflow instruction surface.
|
|
173
|
+
if (readPlanModeEnabled(ctx.sessionManager.getBranch())) return;
|
|
133
174
|
// Planning precedes any loop, so its guidance is injected on the same hook
|
|
134
175
|
// and is mutually exclusive with the objective append below.
|
|
135
176
|
if (controller.planning.active) {
|
package/src/interval.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function parseDuration(token: string): number | undefined {
|
|
|
27
27
|
return ms > MAX_INTERVAL_MS ? undefined : ms;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
interface ParsedInterval {
|
|
31
31
|
requestedMs: number;
|
|
32
32
|
/** Clamped to MIN_INTERVAL_MS; the caller must echo the effective value. */
|
|
33
33
|
effectiveMs: number;
|
package/src/ledger.ts
CHANGED
|
@@ -26,13 +26,13 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const LEDGER_DIR_NAME = "loop";
|
|
30
30
|
export const CRITERIA_FILE = "criteria.json";
|
|
31
31
|
export const PROGRESS_FILE = "PROGRESS.md";
|
|
32
32
|
|
|
33
33
|
/** Cap on a loop's criteria: an objective is a paragraph, not a backlog. */
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const MAX_CRITERIA = 12;
|
|
35
|
+
const MAX_DESCRIPTION_LENGTH = 500;
|
|
36
36
|
|
|
37
37
|
export interface LoopCriterion {
|
|
38
38
|
id: string;
|
|
@@ -75,7 +75,7 @@ export const MAX_EVIDENCE_LENGTH = 4000;
|
|
|
75
75
|
/** The template's placeholders, replaced rather than appended to on first write. */
|
|
76
76
|
const PLACEHOLDERS = new Set(["not started.", "- (nothing yet)"]);
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
79
79
|
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -234,7 +234,7 @@ export function createLedger(
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
function progressTemplate(objective: string): string {
|
|
238
238
|
return [
|
|
239
239
|
"# Loop progress ledger",
|
|
240
240
|
"",
|
|
@@ -320,7 +320,7 @@ function headingText(line: string | undefined): string | undefined {
|
|
|
320
320
|
return match ? match[1].toLowerCase() : undefined;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
323
|
+
interface MarkCriterionResult {
|
|
324
324
|
ok: boolean;
|
|
325
325
|
message: string;
|
|
326
326
|
criteria?: LoopCriterion[];
|
package/src/loop-action-menus.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { ActionsScreen } from "@narumitw/pi-tui-kit";
|
|
|
18
18
|
import { formatDuration } from "./interval.js";
|
|
19
19
|
import type { LoopProposal } from "./planning.js";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type LoopApprovalAction =
|
|
22
22
|
| "start-here"
|
|
23
23
|
| "start-fresh"
|
|
24
24
|
| "change-cadence"
|
|
@@ -27,7 +27,7 @@ export type LoopApprovalAction =
|
|
|
27
27
|
|
|
28
28
|
type Screen = "approval";
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
interface LoopApprovalMenuOptions {
|
|
31
31
|
proposal: LoopProposal;
|
|
32
32
|
signal?: AbortSignal;
|
|
33
33
|
isCurrent?(): boolean;
|
package/src/loop-env.ts
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import type { LoopState } from "./state.js";
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const LOOP_ACTIVE_ENV = "PI_LOOP_ACTIVE";
|
|
29
|
+
const LOOP_ID_ENV = "PI_LOOP_ID";
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Publish (or withdraw) the loop-active signal for `loop`.
|
package/src/loop-launch-menu.ts
CHANGED
|
@@ -15,9 +15,9 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
15
15
|
import type { ActionsScreen, DetailScreen } from "@narumitw/pi-tui-kit";
|
|
16
16
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
type LoopLaunchScreen = "main" | "how";
|
|
19
|
+
type LoopLaunchAction = "start-planning" | "settings";
|
|
20
|
+
type LoopPlanningAction = "request-proposal" | "cancel" | "settings";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* What a loop actually is, for someone who has never run one.
|
|
@@ -95,7 +95,7 @@ export function loopPlanningScreen(): ActionsScreen<LoopLaunchScreen, LoopPlanni
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
interface LoopLaunchMenuOptions {
|
|
99
99
|
signal?: AbortSignal;
|
|
100
100
|
isCurrent?(): boolean;
|
|
101
101
|
startPlanning(): void;
|
|
@@ -120,7 +120,7 @@ export async function showLoopLaunchMenu(ctx: ExtensionContext, options: LoopLau
|
|
|
120
120
|
return runMenu(ctx, menu, { getState: () => undefined, ...lifecycle(options) });
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
interface LoopPlanningMenuOptions {
|
|
124
124
|
signal?: AbortSignal;
|
|
125
125
|
isCurrent?(): boolean;
|
|
126
126
|
requestProposal(): void;
|
package/src/loop-manager-menu.ts
CHANGED
|
@@ -18,8 +18,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
18
18
|
import type { ActionsScreen, DetailScreen, InputScreen } from "@narumitw/pi-tui-kit";
|
|
19
19
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
type LoopManagerScreen = "main" | "status" | "focus" | "cadence";
|
|
22
|
+
type LoopManagerAction =
|
|
23
23
|
| "pause"
|
|
24
24
|
| "resume"
|
|
25
25
|
| "stop"
|
|
@@ -128,7 +128,7 @@ export function loopCadenceScreen(view: LoopManagerView): InputScreen<LoopManage
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
interface LoopManagerMenuOptions {
|
|
132
132
|
getView(): LoopManagerView | undefined;
|
|
133
133
|
signal?: AbortSignal;
|
|
134
134
|
isCurrent?(): boolean;
|
package/src/loop.ts
CHANGED
|
@@ -99,7 +99,7 @@ import {
|
|
|
99
99
|
updateLoopWidget,
|
|
100
100
|
} from "./widget.js";
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
const LOOP_STATUS_KEY = "loop";
|
|
103
103
|
|
|
104
104
|
/** Custom message type of the kickoff anchor. */
|
|
105
105
|
export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
@@ -109,7 +109,7 @@ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
|
109
109
|
* capped here. Deterministic and small: the point is to stop re-waking a loop
|
|
110
110
|
* that has nothing to do, not to invent an adaptive scheduler.
|
|
111
111
|
*/
|
|
112
|
-
|
|
112
|
+
const MAX_FALLBACK_BACKOFF = 4;
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* How long a single run may stay open before the widget calls it blocked.
|
|
@@ -121,9 +121,18 @@ export const MAX_FALLBACK_BACKOFF = 4;
|
|
|
121
121
|
* would otherwise be the first sign anything was wrong.
|
|
122
122
|
*/
|
|
123
123
|
export const STALL_ATTENTION_MS = 900_000;
|
|
124
|
+
/**
|
|
125
|
+
* How long the expiry wake has to become a turn before the loop stops anyway.
|
|
126
|
+
*
|
|
127
|
+
* `sendUserMessage` is fire-and-forget: Pi swallows an asynchronous delivery
|
|
128
|
+
* failure (an expired credential, a torn-down runner), so a successful return
|
|
129
|
+
* is not proof a turn will start. The ordinary dead-delivery counter cannot
|
|
130
|
+
* catch this one, because after expiry there is no next delivery to count.
|
|
131
|
+
*/
|
|
132
|
+
const EXPIRY_TURN_GRACE_MS = 60_000;
|
|
124
133
|
|
|
125
134
|
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
126
|
-
|
|
135
|
+
const MAX_DEAD_DELIVERIES = 3;
|
|
127
136
|
|
|
128
137
|
/** Why the loop caused the run that is currently in flight. */
|
|
129
138
|
type RunOrigin = "continuation" | "fallback";
|
|
@@ -136,7 +145,7 @@ type RunOrigin = "continuation" | "fallback";
|
|
|
136
145
|
* actions share that path — one installs the loop here, the other hands it to
|
|
137
146
|
* a fresh session — so the decision is returned and each caller renders it.
|
|
138
147
|
*/
|
|
139
|
-
|
|
148
|
+
type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
140
149
|
|
|
141
150
|
/**
|
|
142
151
|
* A loop that exists but is not running anywhere: everything `installLoop`
|
|
@@ -151,7 +160,7 @@ export interface BuiltLoop {
|
|
|
151
160
|
requestedMs: number;
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
|
|
163
|
+
type LoopBuildResult = { ok: true; built: BuiltLoop } | { ok: false; message: string };
|
|
155
164
|
|
|
156
165
|
interface ContinuationIntent {
|
|
157
166
|
loopId: string;
|
|
@@ -165,6 +174,8 @@ export interface LoopControllerOptions {
|
|
|
165
174
|
now?: () => number;
|
|
166
175
|
/** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
|
|
167
176
|
agentDir?: string;
|
|
177
|
+
/** How long the final expiry turn has to start before the loop gives up. */
|
|
178
|
+
expiryTurnGraceMs?: number;
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
export class LoopController {
|
|
@@ -177,6 +188,7 @@ export class LoopController {
|
|
|
177
188
|
private readonly now: () => number;
|
|
178
189
|
readonly settingsPath: string;
|
|
179
190
|
private timer: NodeJS.Timeout | undefined;
|
|
191
|
+
private expiryTimer: NodeJS.Timeout | undefined;
|
|
180
192
|
private nextWakeAt: number | undefined;
|
|
181
193
|
private wakePending = false;
|
|
182
194
|
private sessionCtx: ExtensionContext | undefined;
|
|
@@ -186,6 +198,7 @@ export class LoopController {
|
|
|
186
198
|
ledger: LedgerPaths | undefined;
|
|
187
199
|
private ledgerWarned = false;
|
|
188
200
|
private readonly agentDir: string | undefined;
|
|
201
|
+
private readonly expiryTurnGraceMs: number;
|
|
189
202
|
/** Consecutive fallback wakes that produced a no-op turn. */
|
|
190
203
|
noOpStreak = 0;
|
|
191
204
|
lastContinuation: (ContinuationDecision & { at: number }) | undefined;
|
|
@@ -229,12 +242,14 @@ export class LoopController {
|
|
|
229
242
|
this.now = options.now ?? Date.now;
|
|
230
243
|
this.settingsPath = options.settingsPath ?? loopSettingsPath();
|
|
231
244
|
this.agentDir = options.agentDir;
|
|
245
|
+
this.expiryTurnGraceMs = options.expiryTurnGraceMs ?? EXPIRY_TURN_GRACE_MS;
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
// --- lifecycle ---
|
|
235
249
|
|
|
236
250
|
onSessionStart(ctx: ExtensionContext): void {
|
|
237
251
|
this.clearTimer();
|
|
252
|
+
this.clearExpiryWatchdog();
|
|
238
253
|
this.wakePending = false;
|
|
239
254
|
this.compacting = false;
|
|
240
255
|
this.lastDecision = undefined;
|
|
@@ -274,6 +289,7 @@ export class LoopController {
|
|
|
274
289
|
// A wait whose deadline passed while the session was away is due now.
|
|
275
290
|
this.restoreWaitTimer();
|
|
276
291
|
this.armFallback();
|
|
292
|
+
this.armExpiryWatchdog();
|
|
277
293
|
// A loop handed over from another session has never had its first turn.
|
|
278
294
|
if (this.state.handoff) this.consumeHandoff(ctx);
|
|
279
295
|
}
|
|
@@ -306,6 +322,7 @@ export class LoopController {
|
|
|
306
322
|
// Withdraw the signal: the process may outlive this session.
|
|
307
323
|
publishLoopEnv(undefined);
|
|
308
324
|
this.clearTimer();
|
|
325
|
+
this.clearExpiryWatchdog();
|
|
309
326
|
this.waitTimer.clear();
|
|
310
327
|
this.wakePending = false;
|
|
311
328
|
this.continuationIntent = undefined;
|
|
@@ -835,6 +852,63 @@ export class LoopController {
|
|
|
835
852
|
this.nextWakeAt = undefined;
|
|
836
853
|
}
|
|
837
854
|
|
|
855
|
+
private armExpiryWatchdog(): void {
|
|
856
|
+
this.clearExpiryWatchdog();
|
|
857
|
+
const loop = this.state;
|
|
858
|
+
if (!loop || loop.status !== "active") return;
|
|
859
|
+
const loopId = loop.id;
|
|
860
|
+
const delay = Math.min(MAX_INTERVAL_MS, Math.max(0, loop.expiresAt - this.now()));
|
|
861
|
+
this.expiryTimer = setTimeout(() => {
|
|
862
|
+
this.expiryTimer = undefined;
|
|
863
|
+
const current = this.state;
|
|
864
|
+
if (!current || current.id !== loopId || current.status !== "active") return;
|
|
865
|
+
if (this.now() < current.expiresAt) {
|
|
866
|
+
this.armExpiryWatchdog();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const ctx = this.sessionCtx;
|
|
870
|
+
if (!ctx) {
|
|
871
|
+
this.transition("stopped", "loop expired without an active session context");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const planActive = readPlanModeEnabled(ctx.sessionManager.getBranch());
|
|
875
|
+
const busy = !ctx.isIdle() || ctx.hasPendingMessages();
|
|
876
|
+
if (planActive || busy) {
|
|
877
|
+
this.transition(
|
|
878
|
+
"stopped",
|
|
879
|
+
planActive
|
|
880
|
+
? "loop expired while Plan mode was active"
|
|
881
|
+
: "loop expired while the agent was busy",
|
|
882
|
+
);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
this.runTick(ctx);
|
|
886
|
+
}, delay);
|
|
887
|
+
this.expiryTimer.unref?.();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private clearExpiryWatchdog(): void {
|
|
891
|
+
if (this.expiryTimer) clearTimeout(this.expiryTimer);
|
|
892
|
+
this.expiryTimer = undefined;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The other half of expiry: the wake was handed to Pi, and this stops the
|
|
897
|
+
* loop if it never becomes a run. A run that *did* start leaves
|
|
898
|
+
* `awaitingRun` false, and the settle after it stops the loop normally.
|
|
899
|
+
*/
|
|
900
|
+
private armExpiryTurnGuard(loopId: string): void {
|
|
901
|
+
this.clearExpiryWatchdog();
|
|
902
|
+
this.expiryTimer = setTimeout(() => {
|
|
903
|
+
this.expiryTimer = undefined;
|
|
904
|
+
const current = this.state;
|
|
905
|
+
if (!current || current.id !== loopId || current.status !== "active") return;
|
|
906
|
+
if (!this.awaitingRun) return;
|
|
907
|
+
this.transition("stopped", "the final expiry turn never started");
|
|
908
|
+
}, this.expiryTurnGraceMs);
|
|
909
|
+
this.expiryTimer.unref?.();
|
|
910
|
+
}
|
|
911
|
+
|
|
838
912
|
private gatherEnvironment(ctx: ExtensionContext): TickEnvironment {
|
|
839
913
|
const branch = ctx.sessionManager.getBranch();
|
|
840
914
|
return {
|
|
@@ -922,6 +996,10 @@ export class LoopController {
|
|
|
922
996
|
}
|
|
923
997
|
this.runOrigin = "fallback";
|
|
924
998
|
this.continuationIntent = undefined;
|
|
999
|
+
// Marked directly rather than through noteDelivery(): the dead-delivery
|
|
1000
|
+
// counter pauses a loop that should keep trying, and this one is already
|
|
1001
|
+
// ending. The guard below is what acts on it.
|
|
1002
|
+
this.awaitingRun = true;
|
|
925
1003
|
this.state = {
|
|
926
1004
|
...this.consumeWait(loop),
|
|
927
1005
|
iteration: loop.iteration + 1,
|
|
@@ -930,6 +1008,10 @@ export class LoopController {
|
|
|
930
1008
|
expiring: true,
|
|
931
1009
|
};
|
|
932
1010
|
this.clearTimer();
|
|
1011
|
+
// The wake was accepted, not delivered. Hold one bounded guard so a final
|
|
1012
|
+
// turn that never starts still ends the loop instead of leaving it active
|
|
1013
|
+
// past its deadline with every timer cleared.
|
|
1014
|
+
this.armExpiryTurnGuard(loop.id);
|
|
933
1015
|
this.persist();
|
|
934
1016
|
this.updateWidget();
|
|
935
1017
|
this.sessionCtx?.ui.notify(
|
|
@@ -1048,9 +1130,20 @@ export class LoopController {
|
|
|
1048
1130
|
|
|
1049
1131
|
private transition(status: "paused" | "stopped", why: string, cause?: string): void {
|
|
1050
1132
|
if (!this.state) return;
|
|
1051
|
-
const {
|
|
1052
|
-
|
|
1133
|
+
const {
|
|
1134
|
+
waiting: _waiting,
|
|
1135
|
+
pauseCause: _pauseCause,
|
|
1136
|
+
terminalReason: _terminalReason,
|
|
1137
|
+
...rest
|
|
1138
|
+
} = this.state;
|
|
1139
|
+
this.state = {
|
|
1140
|
+
...rest,
|
|
1141
|
+
status,
|
|
1142
|
+
...(cause ? { pauseCause: cause } : {}),
|
|
1143
|
+
...(status === "stopped" ? { terminalReason: why } : {}),
|
|
1144
|
+
};
|
|
1053
1145
|
this.clearTimer();
|
|
1146
|
+
this.clearExpiryWatchdog();
|
|
1054
1147
|
this.waitTimer.clear();
|
|
1055
1148
|
this.wakePending = false;
|
|
1056
1149
|
this.continuationIntent = undefined;
|
|
@@ -1131,7 +1224,7 @@ export class LoopController {
|
|
|
1131
1224
|
const loop = this.state;
|
|
1132
1225
|
if (!loop) return ["No loop in this session. Run /loop to plan one."];
|
|
1133
1226
|
const lines = [
|
|
1134
|
-
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
1227
|
+
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : loop.terminalReason ? ` (${loop.terminalReason})` : ""}`,
|
|
1135
1228
|
...(loop.waiting
|
|
1136
1229
|
? [
|
|
1137
1230
|
`Waiting: ${loop.waiting.reason}${
|
|
@@ -1351,6 +1444,7 @@ export class LoopController {
|
|
|
1351
1444
|
this.openLedger(started, built.criteria);
|
|
1352
1445
|
this.persist();
|
|
1353
1446
|
this.scheduleTick(started.intervalMs);
|
|
1447
|
+
this.armExpiryWatchdog();
|
|
1354
1448
|
this.updateWidget();
|
|
1355
1449
|
const clampNote = built.clamped
|
|
1356
1450
|
? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
|
|
@@ -1439,6 +1533,7 @@ export class LoopController {
|
|
|
1439
1533
|
this.noOpStreak = 0;
|
|
1440
1534
|
this.persist();
|
|
1441
1535
|
this.scheduleTick(loop.intervalMs);
|
|
1536
|
+
this.armExpiryWatchdog();
|
|
1442
1537
|
this.updateWidget();
|
|
1443
1538
|
ctx.ui.notify(
|
|
1444
1539
|
`Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
|
package/src/manager.ts
CHANGED
package/src/planning.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* gone: both authored an acceptance gate in one line, unreviewed.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { LOOP_CRAFT_DOC } from "./docs.js";
|
|
20
21
|
import { deriveCriteria, type LoopCriterion } from "./ledger.js";
|
|
21
22
|
import { formatDuration } from "./interval.js";
|
|
22
23
|
|
|
@@ -132,7 +133,7 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
|
132
133
|
export const LOOP_PLANNING_HINT = [
|
|
133
134
|
"<system-reminder>",
|
|
134
135
|
"The user opened loop planning. You are drafting a loop with them; no loop is running and none starts until they approve one on the card.",
|
|
135
|
-
|
|
136
|
+
`Before drafting, read ${LOOP_CRAFT_DOC}: it carries the objective, criteria, cadence and evidence craft in depth.`,
|
|
136
137
|
"Cover three things in the conversation, then call loop_propose:",
|
|
137
138
|
"- The objective, written as an acceptance test. One requirement per bullet; a conjunction inside a sentence does not split, so 'fix the flaky test and update the docs' becomes one criterion whose evidence must cover both halves. Name the check in the requirement itself ('…, verified by npm test passing'). The two questions that fix most objectives: how will we know it is done, and what command proves it?",
|
|
138
139
|
"- The cadence: how long the loop may run before it expires, and the fallback heartbeat for a session that goes quiet. The loop advances whenever the session settles, so the heartbeat only matters when it is waiting on something.",
|
package/src/presentation.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The approval card, as a
|
|
2
|
+
* The approval card, as a display-only session entry.
|
|
3
3
|
*
|
|
4
4
|
* It used to go out twice and neither copy was a card: `loop_propose`
|
|
5
5
|
* returned it as tool-result text, and `/loop` re-printed it through
|
|
@@ -8,18 +8,59 @@
|
|
|
8
8
|
* artifact the whole planning flow exists to produce was the least legible
|
|
9
9
|
* thing on the screen, and duplicated.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* It is now a custom *entry* with a registered renderer, not a message. That
|
|
12
|
+
* is what buys the property a message could not: Pi maps a `custom` entry to
|
|
13
|
+
* no context messages at all and skips it during compaction, so the card stays
|
|
14
|
+
* visible and restorable in the transcript while never entering model context
|
|
15
|
+
* and never costing a compaction budget. The model is told a proposal exists
|
|
16
|
+
* by the tool result; it never re-reads the rendered card.
|
|
17
|
+
*
|
|
18
|
+
* pi-plan-mode's completed-plan card is the same mechanism for the same
|
|
19
|
+
* reason (`packages/pi-plan-mode/src/presentation.ts`), and `plan_mode_complete`
|
|
20
|
+
* likewise returns a one-line `Plan saved to <path>.` pointer instead of the
|
|
21
|
+
* plan body.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
|
-
import
|
|
24
|
+
import {
|
|
25
|
+
getMarkdownTheme,
|
|
26
|
+
type ExtensionAPI,
|
|
27
|
+
type ExtensionContext,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
20
30
|
import { type LoopProposal, renderProposalCard } from "./planning.js";
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The card is a custom *entry*, not a custom message, and it carries a new
|
|
34
|
+
* type name to say so. The old `LOOP_PROPOSAL_MESSAGE_TYPE` export is gone
|
|
35
|
+
* rather than aliased: an alias would keep consumers compiling while silently
|
|
36
|
+
* pointing them at a channel proposals no longer travel on, which is worse
|
|
37
|
+
* than the compile error that tells them to look.
|
|
38
|
+
*/
|
|
39
|
+
export const LOOP_PROPOSAL_ENTRY_TYPE = "loop-proposal-card";
|
|
40
|
+
|
|
41
|
+
type LoopProposalCardData = { markdown: string; criteria: number; proposedAt: number };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persisted entry data is input, not a guarantee.
|
|
45
|
+
*
|
|
46
|
+
* The renderer runs against whatever is on disk, which may predate a field, be
|
|
47
|
+
* truncated by a partial write, or have been hand-edited. Pi contains a
|
|
48
|
+
* renderer throw as an inline `[loop-proposal-card] renderer failed: …` box —
|
|
49
|
+
* survivable, but a needlessly ugly way to say "this card is old".
|
|
50
|
+
*/
|
|
51
|
+
function loopProposalCardData(value: unknown): LoopProposalCardData | undefined {
|
|
52
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
53
|
+
const { markdown } = value as { markdown?: unknown };
|
|
54
|
+
return typeof markdown === "string" ? (value as LoopProposalCardData) : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerLoopProposalRenderer(pi: ExtensionAPI): void {
|
|
58
|
+
pi.registerEntryRenderer(LOOP_PROPOSAL_ENTRY_TYPE, (entry) => {
|
|
59
|
+
const data = loopProposalCardData(entry.data);
|
|
60
|
+
if (!data) return new Text("Loop proposal card unavailable.", 0, 0);
|
|
61
|
+
return new Markdown(data.markdown, 0, 0, getMarkdownTheme());
|
|
62
|
+
});
|
|
63
|
+
}
|
|
23
64
|
|
|
24
65
|
/**
|
|
25
66
|
* Emit the card. Returns false when Pi refused it, in which case the caller
|
|
@@ -32,15 +73,11 @@ export function showLoopProposalCard(
|
|
|
32
73
|
proposal: LoopProposal,
|
|
33
74
|
): boolean {
|
|
34
75
|
try {
|
|
35
|
-
pi.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
details: { criteria: proposal.criteria.length, proposedAt: proposal.proposedAt },
|
|
41
|
-
},
|
|
42
|
-
{ triggerTurn: false },
|
|
43
|
-
);
|
|
76
|
+
pi.appendEntry<LoopProposalCardData>(LOOP_PROPOSAL_ENTRY_TYPE, {
|
|
77
|
+
markdown: renderProposalCard(proposal).join("\n"),
|
|
78
|
+
criteria: proposal.criteria.length,
|
|
79
|
+
proposedAt: proposal.proposedAt,
|
|
80
|
+
});
|
|
44
81
|
return true;
|
|
45
82
|
} catch (error) {
|
|
46
83
|
const detail = error instanceof Error ? error.message : String(error);
|
package/src/progress-tool.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from "./ledger.js";
|
|
34
34
|
import type { LoopController } from "./loop.js";
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const LOOP_PROGRESS_TOOL = "loop_progress";
|
|
37
37
|
|
|
38
38
|
export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopController) {
|
|
39
39
|
pi.registerTool(
|
package/src/propose-tool.ts
CHANGED
|
@@ -18,9 +18,13 @@ import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
|
18
18
|
import type { LoopController } from "./loop.js";
|
|
19
19
|
import { MAX_GROUND_RULE_LENGTH, MAX_GROUND_RULES } from "./planning.js";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
const LOOP_PROPOSE_TOOL = "loop_propose";
|
|
22
22
|
|
|
23
|
-
export function registerLoopProposeTool(
|
|
23
|
+
export function registerLoopProposeTool(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
controller: LoopController,
|
|
26
|
+
onProposed?: () => void,
|
|
27
|
+
) {
|
|
24
28
|
pi.registerTool(
|
|
25
29
|
defineTool({
|
|
26
30
|
name: LOOP_PROPOSE_TOOL,
|
|
@@ -116,6 +120,7 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
const proposal = controller.propose(objective, overrides);
|
|
123
|
+
onProposed?.();
|
|
119
124
|
// The card goes to the transcript as a framed block, not back through
|
|
120
125
|
// this tool result. Returning it here too would render the same
|
|
121
126
|
// artifact twice, once framed and once as a wall of markdown, and
|
package/src/safety.ts
CHANGED
package/src/settings.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { basename, dirname, join } from "node:path";
|
|
|
11
11
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { parseDuration } from "./interval.js";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const LOOP_SETTINGS_FILE = "pi-loop.json";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
interface LoopCompactionSettings {
|
|
17
17
|
enabled: boolean;
|
|
18
18
|
/** Fraction of the context window that triggers a proactive compact. */
|
|
19
19
|
threshold: number;
|
|
@@ -82,7 +82,7 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
type LoopSettingsLoadResult =
|
|
86
86
|
| { kind: "missing"; settings: LoopSettings }
|
|
87
87
|
| { kind: "invalid"; reason: string; settings: LoopSettings }
|
|
88
88
|
| { kind: "loaded"; settings: LoopSettings };
|
package/src/state.ts
CHANGED
|
@@ -11,8 +11,8 @@ export const PLAN_MODE_STATE_ENTRY_TYPE = "plan-mode-state";
|
|
|
11
11
|
|
|
12
12
|
import { type LoopWait, normalizeLoopWait } from "./wait.js";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const LOOP_STATUSES = ["active", "paused", "stopped"] as const;
|
|
15
|
+
type LoopStatus = (typeof LOOP_STATUSES)[number];
|
|
16
16
|
|
|
17
17
|
export interface LoopState {
|
|
18
18
|
id: string;
|
|
@@ -63,6 +63,8 @@ export interface LoopState {
|
|
|
63
63
|
lastFingerprint?: string;
|
|
64
64
|
/** Why a paused loop paused, for the widget and status after a restore. */
|
|
65
65
|
pauseCause?: string;
|
|
66
|
+
/** Durable reason recorded when the loop enters its terminal stopped state. */
|
|
67
|
+
terminalReason?: string;
|
|
66
68
|
/**
|
|
67
69
|
* Set once the expiry's final wake has been delivered. The loop is still
|
|
68
70
|
* active for exactly that one turn, so the objective append is present
|
|
@@ -144,6 +146,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
144
146
|
if (cancelledWaitReason === false) return undefined;
|
|
145
147
|
const pauseCause = optionalText(record.pauseCause);
|
|
146
148
|
if (pauseCause === false) return undefined;
|
|
149
|
+
const terminalReason = optionalText(record.terminalReason);
|
|
150
|
+
if (terminalReason === false) return undefined;
|
|
147
151
|
const toolFreeRepeatCount = record.toolFreeRepeatCount;
|
|
148
152
|
if (
|
|
149
153
|
toolFreeRepeatCount !== undefined &&
|
|
@@ -176,6 +180,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
176
180
|
...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
|
|
177
181
|
...(lastFingerprint === undefined ? {} : { lastFingerprint }),
|
|
178
182
|
...(pauseCause === undefined ? {} : { pauseCause }),
|
|
183
|
+
...(terminalReason === undefined ? {} : { terminalReason }),
|
|
179
184
|
...(record.expiring === true ? { expiring: true as const } : {}),
|
|
180
185
|
...(record.handoff === true ? { handoff: true as const } : {}),
|
|
181
186
|
};
|
package/src/widget.ts
CHANGED
|
@@ -45,13 +45,13 @@ interface WidgetTheme {
|
|
|
45
45
|
type WidgetHost = { setWidget?: unknown };
|
|
46
46
|
|
|
47
47
|
/** Criteria progress, absent when the loop has no readable ledger. */
|
|
48
|
-
|
|
48
|
+
interface CriteriaProgress {
|
|
49
49
|
met: number;
|
|
50
50
|
total: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** The loop is being drafted with the user and has not started. */
|
|
54
|
-
|
|
54
|
+
interface LoopPlanningView {
|
|
55
55
|
kind: "planning";
|
|
56
56
|
/** Criteria in the proposed draft, once one has been put up for approval. */
|
|
57
57
|
proposedCriteria?: number;
|