@hank-warren/pi-loop 0.5.0 → 0.6.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 +18 -0
- package/README.md +37 -35
- package/package.json +1 -1
- package/src/complete-tool.ts +2 -2
- package/src/decide.ts +20 -68
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/loop.ts +103 -184
- package/src/manager.ts +12 -17
- package/src/messages.ts +12 -36
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +35 -4
- package/src/start-tool.ts +145 -0
- package/src/state.ts +16 -96
- package/src/wait-tool.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @hank-warren/pi-loop
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b642391: Removing goal-bound loops is breaking for anyone holding one; it lands as a 0.x minor per this repo's convention.
|
|
8
|
+
|
|
9
|
+
**Start a loop from an inline `/loop` invocation.** Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` used to arrive as prose and vanish. A new `loop_start` tool, pointed at by a one-turn `<system-reminder>`, now starts the loop and begins working in the same turn. `loop: <objective>` at line start works the same way. The message itself is never rewritten, split, re-sent, or annotated: an `input` handler only records it, and `before_agent_start` appends the hint when the starting prompt is that same message.
|
|
10
|
+
|
|
11
|
+
`loop_start` **refuses unless the inline hint armed for that turn.** A loop is self-continuing, so a spurious start does not produce one unwanted answer, it produces turns until a cap — that gate is enforcement rather than prompt guidance. It also refuses when a loop is already active, when `loop_complete` is missing from the active tool set, and on an unparsable objective, interval, or expiry.
|
|
12
|
+
|
|
13
|
+
New settings: `inlineInvocation` (default `true`) and `defaultInterval` (default `"10m"`, used when an invocation names no interval).
|
|
14
|
+
|
|
15
|
+
**Goal-bound loops are gone,** along with every `goal-state` reader, the completion-race clear-scan, and the goal outcomes in the tick and continuation decisions. A loop owns its objective, ledger, post-compaction re-anchor, and evidence-gated completion, so delegating completion to a second extension bought nothing and cost a coupling. `plan-mode-state` is now the only sibling entry pi-loop reads.
|
|
16
|
+
|
|
17
|
+
A loop persisted before this release with no objective of its own adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
|
|
18
|
+
|
|
19
|
+
`@hank-warren/pi-goal` is deprecated in favour of this package. Its published versions stay installable.
|
|
20
|
+
|
|
3
21
|
## 0.5.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -2,30 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Inspired by Claude Code's `/loop`, adapted to Pi: keep work moving across many turns, and keep long loops coherent across context compaction.
|
|
4
4
|
|
|
5
|
-
A loop is a **pacemaker
|
|
5
|
+
A loop is a **pacemaker** that owns its own work: it carries an objective and completion criteria, and ends when the model calls `loop_complete` with cited evidence, a cap is reached, its expiry arrives, or you stop it. **No other extension is required.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Goal-bound** (**deprecated**) — an active [pi-goal](../pi-goal) goal is present, so the loop binds to it and pi-goal owns *whether the work is done*. Coupling stays read-only: fail-open reads of pi-goal's `goal-state` entries.
|
|
7
|
+
Earlier versions could instead bind a loop to a [pi-goal](https://www.npmjs.com/package/@hank-warren/pi-goal) goal and let that extension own *whether the work is done*. That mode is gone as of 0.6.0, along with every `goal-state` reader: a loop now does everything the pairing did — objective, durable ledger, post-compaction re-anchor, evidence-gated completion — so the coupling bought nothing. A loop persisted before 0.6.0 that carries no objective adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
### Goal-bound loops are going away
|
|
13
|
-
|
|
14
|
-
A standalone loop now does everything the pairing did — it owns its objective, keeps a durable ledger, re-anchors itself after compaction, and gates completion on cited evidence — so delegating "is the work done" to a second extension buys nothing and costs a coupling. [pi-goal](../pi-goal) carries the matching deprecation banner.
|
|
15
|
-
|
|
16
|
-
This release keeps the goal-bound path working, warns when you start one, and **migrates a restored goal-bound loop to standalone** by adopting the goal's objective text (falling back to the loop's own focus text). The one case it deliberately does not migrate is a **still-active** goal: pi-goal is driving that session's continuations, and a standalone loop driving them too would send two messages at every settle — that loop keeps its old behaviour and gets the warning instead. If there is nothing to adopt at all, the loop pauses and says so rather than pretending.
|
|
17
|
-
|
|
18
|
-
The following release removes the goal-bound branch, the `goal-state` readers, and their fixtures.
|
|
19
|
-
|
|
20
|
-
pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, so a loop never injects into a planning conversation.
|
|
9
|
+
pi-plan-mode's `plan-mode-state` is read fail-open — the only sibling state left — so a loop never injects into a planning conversation.
|
|
21
10
|
|
|
22
11
|
## Usage
|
|
23
12
|
|
|
24
13
|
```
|
|
25
|
-
/loop 30m until all tests pass and CI is green #
|
|
26
|
-
/goal get CI green # ...or bind to a goal
|
|
27
|
-
/loop 30m # poke that goal every 30m if the session stalls
|
|
28
|
-
/loop 10m recheck the pipeline # trailing text is a per-wake focus when goal-bound
|
|
14
|
+
/loop 30m until all tests pass and CI is green # the trailing text is the objective
|
|
29
15
|
/loop # manager TUI (status, pause/resume, edit, settings, stop)
|
|
30
16
|
/loop status | pause | resume | stop | settings
|
|
31
17
|
/loop --max 20 --compact-at 60% --expires 3d 10m # per-loop overrides
|
|
@@ -33,7 +19,26 @@ pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, s
|
|
|
33
19
|
```
|
|
34
20
|
|
|
35
21
|
- **Intervals** are `<number><unit>` with unit `s`/`m`/`h`/`d`, parsed by the extension (never the model), minimum 1 minute (smaller values clamp, and the effective value is echoed).
|
|
36
|
-
-
|
|
22
|
+
- Loops are **user-driven only**: the model may start one *you* invoked inline (below), and never on its own initiative.
|
|
23
|
+
|
|
24
|
+
### Inline invocation
|
|
25
|
+
|
|
26
|
+
Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` arrives as ordinary prose and nothing happens. That is the common way to lose an invocation, so pi-loop detects it:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
quick check /loop 10m get CI green on main, verified by a passing run
|
|
30
|
+
loop: get CI green on main, verified by a passing run
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both make the model call the `loop_start` tool with the objective and then begin working, in the same turn.
|
|
34
|
+
|
|
35
|
+
**Your message is never touched** — not cut, split, re-sent, or annotated. An `input` handler only *records* the text; `before_agent_start` appends a one-turn `<system-reminder>` when the starting prompt is that same message. The hint is a per-turn system-prompt append, never a stored message, so "call loop_start now" cannot linger and fire on a later turn.
|
|
36
|
+
|
|
37
|
+
Detection is deliberately narrow. A token at position 0 is ignored (that is Pi's own dispatch), as is one inside backticks or a quoted span — `` `/loop` `` and `"/loop 5m ship it"` are how people *discuss* the command — and a bare trailing `/loop` with no remainder. The `loop:` form needs to start a line, so "my loop: is unclear" is prose. Extension-sent prompts never arm, and neither does steered or queued input, whose delivery order would leave a stale arm behind.
|
|
38
|
+
|
|
39
|
+
**`loop_start` refuses unless the turn was armed.** This is enforcement, not guidance. A loop is *self-continuing*: a spurious start does not produce one unwanted answer, it produces turns until a cap. So the tool checks a flag the inline hooks set for exactly one turn, and refuses outright otherwise, along with a loop already being active, a missing `loop_complete`, or an unparsable interval or expiry.
|
|
40
|
+
|
|
41
|
+
An invocation that names no interval uses the `defaultInterval` setting (`10m`). In a settle-paced loop the interval is only a fallback heartbeat, so the value matters far less than it looks. Turn the whole feature off with `"inlineInvocation": false`.
|
|
37
42
|
|
|
38
43
|
## What paces a loop
|
|
39
44
|
|
|
@@ -55,8 +60,6 @@ A reply that starts or ends with `LOOP_OK` and carries at most **300 characters*
|
|
|
55
60
|
|
|
56
61
|
The chip is **display-only**. The stored message keeps its exact bytes, because rewriting them would break the prompt cache this whole design is built around.
|
|
57
62
|
|
|
58
|
-
A **goal-bound** loop is unchanged: pi-goal drives its own settle continuations, so pi-loop would only double every turn, and the interval stays that loop's only driver.
|
|
59
|
-
|
|
60
63
|
### Two counters
|
|
61
64
|
|
|
62
65
|
One wake now yields many turns, so a single counter cannot bound a loop:
|
|
@@ -70,28 +73,25 @@ Either cap trips independently and stops the loop; `null` on either means unlimi
|
|
|
70
73
|
|
|
71
74
|
Each tick — fallback heartbeat or settled boundary — evaluates, in order:
|
|
72
75
|
|
|
73
|
-
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded.
|
|
76
|
+
1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded. The loop gets **one final turn** first: "write the current state into the ledger, start no new work, claim no completion", and the settle after it stops the loop. A loop that simply vanished at its deadline would leave its most recent state only in a conversation about to be closed. If that final wake cannot be delivered, the loop stops immediately rather than living past its deadline.
|
|
74
77
|
2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
|
|
75
78
|
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.
|
|
76
|
-
4. **
|
|
79
|
+
4. **A declared wait?** A `loop_wait` whose deadline has not passed holds both drivers: the loop is not stalled, it is waiting on the world.
|
|
77
80
|
5. **Caps** (see [Two counters](#two-counters)): stop.
|
|
78
|
-
6. **Settled boundary
|
|
79
|
-
|
|
80
|
-
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.
|
|
81
|
-
|
|
82
|
-
**A poke never restates the objective**, in either mode — the objective always reaches the model through a byte-stable system append on the same turn, and duplicating it in the message would store another copy on every wake. The two modes differ only in who provides that append: pi-goal's, on every active goal turn, for a goal-bound loop; this extension's own, for a standalone one. Both work because pokes are delivered as ordinary user messages that pass through `before_agent_start`.
|
|
81
|
+
6. **Settled boundary:** dispatch the recorded continuation — a pointer-sized message (`⟳ loop continue #6`) that points at the system prompt for the objective, exactly as the pokes do.
|
|
82
|
+
7. **Fallback heartbeat: poke.** The wake header, why it fired (stalled, or a wait that has elapsed), 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.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
**A poke never restates the objective.** The objective reaches the model through a byte-stable system append on the same turn, and duplicating it in the message would store another copy on every wake. That works because pokes are delivered as ordinary user messages, which pass through `before_agent_start`; a delivery path that bypassed it (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly) would arrive with no append and would have to carry the objective again. The token-lean contract is pinned in `test/messages.test.ts`.
|
|
85
85
|
|
|
86
86
|
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.
|
|
87
87
|
|
|
88
|
-
Expiry
|
|
88
|
+
Expiry and the caps are evaluated whenever the session settles, so a loop ends as soon as the work does rather than at the next scheduled tick. Only the fallback heartbeat pokes; a settle continues.
|
|
89
89
|
|
|
90
90
|
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.
|
|
91
91
|
|
|
92
|
-
##
|
|
92
|
+
## Objective, injection, and `loop_complete`
|
|
93
93
|
|
|
94
|
-
A
|
|
94
|
+
A loop puts its own objective in front of the model, under a cache-safe split:
|
|
95
95
|
|
|
96
96
|
- **Static per loop — the system prompt.** The objective, `loop_id`, ledger contract, and loop-mode rules are appended to the system prompt, **byte-identically on every turn of that loop**. Anthropic caches `tools → system → messages` as one prefix, so a moving value there (iteration, next wake) would invalidate the cache for the whole conversation every wake. It changes only when the loop does.
|
|
97
97
|
|
|
@@ -151,7 +151,7 @@ The system append carries the objective only while the loop is *active*, and con
|
|
|
151
151
|
Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
|
|
152
152
|
|
|
153
153
|
- **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, **every failed approach and the reason it failed**, decisions and rationale, files modified, commands and unresolved errors, and the next 1-3 actions. The instructions explicitly **stop carrying prior summaries forward wholesale** and tell the next turn to re-derive status from the ledger and authoritative state instead: cumulative carry-forward grows the text while the information in it decays. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
|
|
154
|
-
- **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle — re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation.
|
|
154
|
+
- **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle — re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation. The loop no longer goes quiet until the next wake. A re-anchor supersedes an ordinary continuation already queued: after a compaction, "re-read the ledger" is strictly the better instruction.
|
|
155
155
|
- 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).
|
|
156
156
|
|
|
157
157
|
## `/schedule`: recurring prompts and headless runs
|
|
@@ -200,6 +200,8 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
|
|
|
200
200
|
"automaticTurns": 25,
|
|
201
201
|
"noProgressTurns": 3,
|
|
202
202
|
"maxLoopDuration": "7d",
|
|
203
|
+
"inlineInvocation": true,
|
|
204
|
+
"defaultInterval": "10m",
|
|
203
205
|
"compaction": {
|
|
204
206
|
"enabled": true,
|
|
205
207
|
"threshold": 0.7,
|
|
@@ -208,7 +210,7 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
|
|
|
208
210
|
}
|
|
209
211
|
```
|
|
210
212
|
|
|
211
|
-
`maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker.
|
|
213
|
+
`maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker. `inlineInvocation: false` turns off mid-prompt `/loop` detection; `defaultInterval` is the fallback heartbeat an inline invocation gets when it names none.
|
|
212
214
|
|
|
213
215
|
## Deliberate omissions
|
|
214
216
|
|
|
@@ -224,7 +226,7 @@ These were considered and cut, and the reasoning is recorded so they are not sil
|
|
|
224
226
|
pi install npm:@hank-warren/pi-loop
|
|
225
227
|
```
|
|
226
228
|
|
|
227
|
-
**No other extension is required.**
|
|
229
|
+
**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.
|
|
228
230
|
|
|
229
231
|
## License
|
|
230
232
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": [
|
package/src/complete-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `loop_complete`: the
|
|
2
|
+
* `loop_complete`: the loop's terminal tool.
|
|
3
3
|
*
|
|
4
4
|
* It used to be deliberately thin, on the argument that stopping a pacemaker
|
|
5
5
|
* has a small blast radius: the user just restarts it. That argument does not
|
|
@@ -94,7 +94,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
94
94
|
if (!loop || loop.objective === undefined) {
|
|
95
95
|
return {
|
|
96
96
|
content: toolContent(
|
|
97
|
-
"No
|
|
97
|
+
"No /loop with an objective is active, so there is nothing to complete. Start one with /loop <interval> <objective>.",
|
|
98
98
|
),
|
|
99
99
|
details: { loopId: params.loop_id },
|
|
100
100
|
isError: true,
|
package/src/decide.ts
CHANGED
|
@@ -9,17 +9,13 @@
|
|
|
9
9
|
* handler for a lost continuation or an external wait, not the pacemaker.
|
|
10
10
|
*
|
|
11
11
|
* Both share one precedence prefix: loop liveness → expiry → plan mode →
|
|
12
|
-
* compaction → busy →
|
|
12
|
+
* compaction → busy → wait → caps → act.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* its `goal-state`, so a missing goal pauses it and a safety state holds it;
|
|
17
|
-
* pi-goal also owns its settle continuations, so `decideContinuation` never
|
|
18
|
-
* acts for one. A standalone loop owns its own objective, reads no goal state
|
|
19
|
-
* at all, and ends only through `loop_complete`, a cap, or the user.
|
|
14
|
+
* A loop owns its own objective and reads no other extension's state: it ends
|
|
15
|
+
* only through `loop_complete`, a cap, an expiry, or the user.
|
|
20
16
|
*/
|
|
21
17
|
|
|
22
|
-
import
|
|
18
|
+
import type { LoopState } from "./state.js";
|
|
23
19
|
|
|
24
20
|
export interface TickEnvironment {
|
|
25
21
|
now: number;
|
|
@@ -28,7 +24,6 @@ export interface TickEnvironment {
|
|
|
28
24
|
/** A loop-owned proactive compaction is in flight; hold pokes. */
|
|
29
25
|
compacting: boolean;
|
|
30
26
|
planModeEnabled: boolean;
|
|
31
|
-
goal: GoalSnapshot | undefined;
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
/** A `loop_wait` whose deadline has passed is due, not waiting. */
|
|
@@ -39,8 +34,8 @@ function isWaiting(loop: LoopState, now: number): boolean {
|
|
|
39
34
|
}
|
|
40
35
|
|
|
41
36
|
/**
|
|
42
|
-
* An expiring
|
|
43
|
-
*
|
|
37
|
+
* An expiring loop gets one last turn to write its state into the ledger
|
|
38
|
+
* before it stops; a loop already spending that turn stops immediately.
|
|
44
39
|
*/
|
|
45
40
|
export type ExpiryReason = "loop-expired" | "expiry-final-wake";
|
|
46
41
|
|
|
@@ -54,36 +49,26 @@ export type TickDecision =
|
|
|
54
49
|
| { action: "none"; reason: "loop-not-active" }
|
|
55
50
|
| { action: "expire"; reason: ExpiryReason }
|
|
56
51
|
| { action: "skip"; reason: SkipReason }
|
|
57
|
-
| { action: "stop"; reason: "
|
|
58
|
-
| { action: "
|
|
59
|
-
| { action: "pause"; reason: "goal-missing" }
|
|
60
|
-
| {
|
|
61
|
-
action: "poke";
|
|
62
|
-
reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed";
|
|
63
|
-
};
|
|
52
|
+
| { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
|
|
53
|
+
| { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
|
|
64
54
|
|
|
65
55
|
export type ContinuationDecision =
|
|
66
|
-
| { action: "none"; reason: "loop-not-active"
|
|
56
|
+
| { action: "none"; reason: "loop-not-active" }
|
|
67
57
|
| { action: "expire"; reason: ExpiryReason }
|
|
68
58
|
| { action: "skip"; reason: SkipReason }
|
|
69
59
|
| { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
|
|
70
60
|
| { action: "continue"; reason: "settled-idle" };
|
|
71
61
|
|
|
72
|
-
/** Shared prefix: everything that holds or ends a loop before
|
|
62
|
+
/** Shared prefix: everything that holds or ends a loop before caps matter. */
|
|
73
63
|
function decideCommonPrefix(
|
|
74
64
|
loop: LoopState,
|
|
75
65
|
env: TickEnvironment,
|
|
76
66
|
): Extract<TickDecision, { action: "none" | "expire" | "skip" }> | undefined {
|
|
77
67
|
if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
|
|
78
68
|
if (env.now >= loop.expiresAt) {
|
|
79
|
-
// The final wake is
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
action: "expire",
|
|
84
|
-
reason:
|
|
85
|
-
isStandaloneLoop(loop) && !loop.expiring ? "expiry-final-wake" : "loop-expired",
|
|
86
|
-
};
|
|
69
|
+
// The final wake is the loop's own summarise-and-stop turn; a loop already
|
|
70
|
+
// spending it has nothing left to buy.
|
|
71
|
+
return { action: "expire", reason: loop.expiring ? "loop-expired" : "expiry-final-wake" };
|
|
87
72
|
}
|
|
88
73
|
if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
|
|
89
74
|
if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
|
|
@@ -111,16 +96,13 @@ function decideCaps(
|
|
|
111
96
|
}
|
|
112
97
|
|
|
113
98
|
/**
|
|
114
|
-
* The settled-idle boundary
|
|
115
|
-
*
|
|
116
|
-
*
|
|
99
|
+
* The settled-idle boundary: the session finished a turn with the objective
|
|
100
|
+
* unfinished, so the loop continues immediately instead of waiting out an
|
|
101
|
+
* interval of idle wall time.
|
|
117
102
|
*/
|
|
118
103
|
export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
|
|
119
104
|
const prefix = decideCommonPrefix(loop, env);
|
|
120
105
|
if (prefix) return prefix;
|
|
121
|
-
// pi-goal drives its own settle continuations for a goal-bound loop; two
|
|
122
|
-
// extensions continuing the same session would double every turn.
|
|
123
|
-
if (!isStandaloneLoop(loop)) return { action: "none", reason: "goal-bound" };
|
|
124
106
|
const capped = decideCaps(loop);
|
|
125
107
|
if (capped) return capped;
|
|
126
108
|
return { action: "continue", reason: "settled-idle" };
|
|
@@ -129,40 +111,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
|
|
|
129
111
|
export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
|
|
130
112
|
const prefix = decideCommonPrefix(loop, env);
|
|
131
113
|
if (prefix) return prefix;
|
|
132
|
-
|
|
133
|
-
// A standalone loop carries its own objective, so it never consults
|
|
134
|
-
// pi-goal: it runs until loop_complete stops it, a cap is reached, or the
|
|
135
|
-
// user intervenes.
|
|
136
|
-
if (isStandaloneLoop(loop)) {
|
|
137
|
-
const capped = decideCaps(loop);
|
|
138
|
-
if (capped) return capped;
|
|
139
|
-
// The prefix already let a still-waiting loop skip, so a wait surviving
|
|
140
|
-
// to here is one whose deadline has come due: this wake is the wake it
|
|
141
|
-
// asked for, and it counts against the wake cap like any other.
|
|
142
|
-
return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const goal = env.goal;
|
|
146
|
-
// Goal-bound loops require a goal to operate: the goal evaluator owns the
|
|
147
|
-
// stop criterion, so such a loop with no goal has nothing to decide when it
|
|
148
|
-
// is done.
|
|
149
|
-
if (!goal) return { action: "pause", reason: "goal-missing" };
|
|
150
|
-
if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
|
|
151
|
-
if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
|
|
152
|
-
return { action: "pause", reason: "goal-safety", cause: goal.status };
|
|
153
|
-
}
|
|
154
|
-
if (goal.status !== "active") {
|
|
155
|
-
// Unknown status from a newer pi-goal: treat like a safety state
|
|
156
|
-
// rather than poking past a guard we do not understand.
|
|
157
|
-
return { action: "pause", reason: "goal-safety", cause: goal.status };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
114
|
const capped = decideCaps(loop);
|
|
161
115
|
if (capped) return capped;
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
// an external event — which this wakeup is.
|
|
167
|
-
return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
|
|
116
|
+
// The prefix already let a still-waiting loop skip, so a wait surviving to
|
|
117
|
+
// here is one whose deadline has come due: this wake is the wake it asked
|
|
118
|
+
// for, and it counts against the wake cap like any other.
|
|
119
|
+
return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
|
|
168
120
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-loop: Claude-Code-/loop-inspired
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* pi-loop: Claude-Code-/loop-inspired long-running work for Pi. A loop
|
|
3
|
+
* carries its own objective and completion criteria, is paced by the session
|
|
4
|
+
* settling, keeps a durable ledger, compacts itself, and ends through
|
|
5
|
+
* `loop_complete`, a cap, its expiry, or the user. It depends on no other
|
|
6
|
+
* extension; the only sibling state it reads is pi-plan-mode's, fail-open, so
|
|
7
|
+
* a loop never injects into a planning conversation.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
|
+
import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
|
|
14
|
+
import { registerLoopStartTool } from "./start-tool.js";
|
|
13
15
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
14
16
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
15
17
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
@@ -28,12 +30,19 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
28
30
|
});
|
|
29
31
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
30
32
|
// part of the cached request prefix, so mutating the tool set mid-session
|
|
31
|
-
// would invalidate the whole conversation cache. It refuses when no
|
|
32
|
-
//
|
|
33
|
+
// would invalidate the whole conversation cache. It refuses when no loop is
|
|
34
|
+
// active.
|
|
33
35
|
registerLoopCompleteTool(pi, controller);
|
|
34
36
|
// Registered on the same terms and for the same reason: the tool set is
|
|
35
37
|
// part of the cached prefix, so it never changes with loop state.
|
|
36
38
|
registerLoopWaitTool(pi, controller);
|
|
39
|
+
// Inline invocation: an `input` handler arms a one-turn system-prompt hint
|
|
40
|
+
// for a mid-prompt `/loop` token, `before_agent_start` appends it, and
|
|
41
|
+
// loop_start is the model-invoked start it points at — refused on any turn
|
|
42
|
+
// the hint did not arm. The user's message is never transformed.
|
|
43
|
+
const invocation = new InlineInvocationState();
|
|
44
|
+
registerInlineInvocation(pi, controller, invocation);
|
|
45
|
+
registerLoopStartTool(pi, controller, invocation);
|
|
37
46
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
38
47
|
// stored message and model context are untouched).
|
|
39
48
|
registerLoopMessageRendering(pi);
|
|
@@ -84,7 +93,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
84
93
|
return;
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
|
-
controller.startLoop(ctx, command);
|
|
96
|
+
const result = controller.startLoop(ctx, command);
|
|
97
|
+
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
88
98
|
return;
|
|
89
99
|
}
|
|
90
100
|
}
|
|
@@ -169,8 +179,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
169
179
|
controller.onSessionShutdown();
|
|
170
180
|
scheduler.onSessionShutdown();
|
|
171
181
|
});
|
|
172
|
-
// The pacemaker
|
|
173
|
-
//
|
|
182
|
+
// The pacemaker: agent_end records the intent to continue, agent_settled
|
|
183
|
+
// delivers it once Pi will accept a message.
|
|
174
184
|
pi.on("agent_start", async (_event, ctx) => {
|
|
175
185
|
controller.onAgentStart(ctx);
|
|
176
186
|
});
|
|
@@ -181,10 +191,9 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
181
191
|
controller.onAgentSettled(ctx);
|
|
182
192
|
scheduler.onAgentSettled(ctx);
|
|
183
193
|
});
|
|
184
|
-
// A
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// nothing here — pi-goal already owns that turn's append.
|
|
194
|
+
// A loop carries its own objective and injects it as a byte-stable system
|
|
195
|
+
// append, which is what lets the poke and continuation messages stay
|
|
196
|
+
// pointer-sized.
|
|
188
197
|
pi.on("before_agent_start", (event) => {
|
|
189
198
|
const loop = controller.state;
|
|
190
199
|
if (!loop || loop.status !== "active") return;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline slash-command detection.
|
|
3
|
+
*
|
|
4
|
+
* Pi dispatches extension commands only when a message *starts* with the
|
|
5
|
+
* command token; a command mentioned mid-prompt arrives as plain text. This
|
|
6
|
+
* helper finds inline occurrences of a `/cmd` token (and `cmd:` prefix lines)
|
|
7
|
+
* so the caller can react — here, by appending a reminder that tells the model
|
|
8
|
+
* to invoke the corresponding tool. It never rewrites or re-sends the user's
|
|
9
|
+
* message.
|
|
10
|
+
*
|
|
11
|
+
* Ported from @hank-warren/pi-goal, which proved the mechanism before pi-loop
|
|
12
|
+
* absorbed it; the helper was always generic over the command name.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface InlineCommandSplit {
|
|
16
|
+
/** Text before the command token, trailing whitespace removed. */
|
|
17
|
+
prose: string;
|
|
18
|
+
/** Everything after the command token to end of message, trimmed. */
|
|
19
|
+
commandArgs: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find the first inline `/<commandName>` occurrence in `text`.
|
|
24
|
+
*
|
|
25
|
+
* Detection rules:
|
|
26
|
+
* - The token must be preceded by start-of-line or whitespace and followed by
|
|
27
|
+
* whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
|
|
28
|
+
* path-like `foo/cmd` never matches.
|
|
29
|
+
* - A token at position 0 is ignored: that is Pi's native dispatch position.
|
|
30
|
+
* - Occurrences inside backtick code (inline spans and fenced blocks) or
|
|
31
|
+
* inside a single/double-quoted span are ignored. Code regions are
|
|
32
|
+
* approximated by the CommonMark backtick-run rule: a run of N backticks
|
|
33
|
+
* opens a region closed by the next run of exactly N backticks.
|
|
34
|
+
*
|
|
35
|
+
* Returns undefined when no qualifying occurrence exists.
|
|
36
|
+
*/
|
|
37
|
+
export function extractInlineCommand(
|
|
38
|
+
text: string,
|
|
39
|
+
commandName: string,
|
|
40
|
+
): InlineCommandSplit | undefined {
|
|
41
|
+
const token = `/${commandName}`;
|
|
42
|
+
const ignored = ignoredRegions(text);
|
|
43
|
+
let searchFrom = 0;
|
|
44
|
+
while (searchFrom < text.length) {
|
|
45
|
+
const index = text.indexOf(token, searchFrom);
|
|
46
|
+
if (index === -1) return undefined;
|
|
47
|
+
searchFrom = index + 1;
|
|
48
|
+
if (index === 0) continue;
|
|
49
|
+
const before = text[index - 1];
|
|
50
|
+
if (before !== undefined && !/\s/.test(before)) continue;
|
|
51
|
+
const afterToken = text[index + token.length];
|
|
52
|
+
if (afterToken === undefined || !/\s/.test(afterToken)) continue;
|
|
53
|
+
if (insideRegion(ignored, index)) continue;
|
|
54
|
+
const commandArgs = text.slice(index + token.length).trim();
|
|
55
|
+
if (!commandArgs) continue;
|
|
56
|
+
return {
|
|
57
|
+
prose: text.slice(0, index).trimEnd(),
|
|
58
|
+
commandArgs,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when the message inline-invokes the command: a mid-message `/cmd`
|
|
66
|
+
* token with a remainder, or a `cmd:` prefix at the start of any line — both
|
|
67
|
+
* outside backtick code and quoted spans.
|
|
68
|
+
*/
|
|
69
|
+
export function detectsInlineInvocation(text: string, commandName: string): boolean {
|
|
70
|
+
if (extractInlineCommand(text, commandName) !== undefined) return true;
|
|
71
|
+
const ignored = ignoredRegions(text);
|
|
72
|
+
const prefixPattern = new RegExp(`^[ \\t]*${escapeRegExpText(commandName)}:[ \\t]+\\S`, "gim");
|
|
73
|
+
for (
|
|
74
|
+
let match = prefixPattern.exec(text);
|
|
75
|
+
match;
|
|
76
|
+
match = prefixPattern.exec(text)
|
|
77
|
+
) {
|
|
78
|
+
if (!insideRegion(ignored, match.index)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Region = readonly [start: number, end: number];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Backtick code regions plus single/double-quoted spans. Quoting a command is
|
|
87
|
+
* how people discuss one (`use "/loop 10m ship it" to start`), so a quoted
|
|
88
|
+
* token is a mention, not an invocation.
|
|
89
|
+
*/
|
|
90
|
+
function ignoredRegions(text: string): Region[] {
|
|
91
|
+
return [...codeRegions(text), ...quoteRegions(text)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function codeRegions(text: string): Region[] {
|
|
95
|
+
const regions: Region[] = [];
|
|
96
|
+
const runs: Array<{ index: number; length: number }> = [];
|
|
97
|
+
const runPattern = /`+/g;
|
|
98
|
+
for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
|
|
99
|
+
runs.push({ index: match.index, length: match[0].length });
|
|
100
|
+
}
|
|
101
|
+
for (let open = 0; open < runs.length; open += 1) {
|
|
102
|
+
const opener = runs[open];
|
|
103
|
+
if (opener === undefined) continue;
|
|
104
|
+
for (let close = open + 1; close < runs.length; close += 1) {
|
|
105
|
+
const closer = runs[close];
|
|
106
|
+
if (closer === undefined || closer.length !== opener.length) continue;
|
|
107
|
+
regions.push([opener.index, closer.index + closer.length]);
|
|
108
|
+
open = close;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return regions;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Single- and double-quoted spans, matched conservatively so an apostrophe
|
|
117
|
+
* inside a word (`don't`) never opens one: an opening quote follows nothing,
|
|
118
|
+
* whitespace, or an opening bracket and precedes a non-space; its closing
|
|
119
|
+
* quote is on the same line, follows a non-space, and precedes end-of-line,
|
|
120
|
+
* whitespace, or closing punctuation.
|
|
121
|
+
*/
|
|
122
|
+
function quoteRegions(text: string): Region[] {
|
|
123
|
+
const regions: Region[] = [];
|
|
124
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
125
|
+
const quote = text[index];
|
|
126
|
+
if (quote !== '"' && quote !== "'") continue;
|
|
127
|
+
const before = text[index - 1];
|
|
128
|
+
const after = text[index + 1];
|
|
129
|
+
if (before !== undefined && !/[\s([{<]/.test(before)) continue;
|
|
130
|
+
if (after === undefined || /\s/.test(after)) continue;
|
|
131
|
+
const close = closingQuoteIndex(text, index, quote);
|
|
132
|
+
if (close === undefined) continue;
|
|
133
|
+
regions.push([index, close + 1]);
|
|
134
|
+
index = close;
|
|
135
|
+
}
|
|
136
|
+
return regions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function closingQuoteIndex(text: string, openIndex: number, quote: string): number | undefined {
|
|
140
|
+
for (let index = openIndex + 1; index < text.length; index += 1) {
|
|
141
|
+
const char = text[index];
|
|
142
|
+
if (char === "\n") return undefined;
|
|
143
|
+
if (char !== quote) continue;
|
|
144
|
+
const before = text[index - 1];
|
|
145
|
+
const after = text[index + 1];
|
|
146
|
+
if (before === undefined || /\s/.test(before)) continue;
|
|
147
|
+
if (after !== undefined && !/[\s.,;:!?)\]}>]/.test(after)) continue;
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function insideRegion(regions: readonly Region[], index: number): boolean {
|
|
154
|
+
return regions.some(([start, end]) => index >= start && index < end);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeRegExpText(value: string) {
|
|
158
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
|
+
}
|