@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline `/loop` invocation, tool-mediated.
|
|
3
|
+
*
|
|
4
|
+
* Pi only dispatches `/loop` when it starts the message. When the user writes
|
|
5
|
+
* `quick check /loop 10m get CI green` or a `loop:` prefixed line mid-prompt,
|
|
6
|
+
* this module appends a one-turn reminder to the system prompt so the model
|
|
7
|
+
* reliably calls the `loop_start` tool with the objective. The user's message
|
|
8
|
+
* itself is never touched — no cutting, splitting, re-sending, or visible
|
|
9
|
+
* annotation — so there are no delivery races, no message loss, and no
|
|
10
|
+
* transcript noise: guidance is injected at agent-start time, not by
|
|
11
|
+
* rewriting input.
|
|
12
|
+
*
|
|
13
|
+
* Two hooks cooperate because neither alone is safe:
|
|
14
|
+
* - `input` carries a `source` and so can tell user-typed text from
|
|
15
|
+
* extension-sent prompts, but any transform it returns rewrites the stored,
|
|
16
|
+
* visible user message. It only records the armed text here.
|
|
17
|
+
* - `before_agent_start` can extend the system prompt, but fires for
|
|
18
|
+
* extension-sent prompts too, and pi-loop's own kickoff and continuation
|
|
19
|
+
* prompts contain phrases like "the active /loop objective" that the
|
|
20
|
+
* detector would match. It injects only when the starting prompt *is* the
|
|
21
|
+
* armed user message, and disarms on every start, matched or not.
|
|
22
|
+
*
|
|
23
|
+
* Two deliberate limits keep the armed window one turn wide:
|
|
24
|
+
* - Streaming-typed input (`streamingBehavior` set, i.e. steered or queued)
|
|
25
|
+
* never arms. Pi returns from `prompt()` before `before_agent_start` for
|
|
26
|
+
* those, so an armed flag would survive to a later, unrelated turn — the
|
|
27
|
+
* window through which pi-loop's own continuation prompts could be matched.
|
|
28
|
+
* - The hint is a per-turn `systemPrompt` append, not a stored message, so
|
|
29
|
+
* "call loop_start now" cannot linger in the conversation and fire on a
|
|
30
|
+
* later turn.
|
|
31
|
+
*
|
|
32
|
+
* The armed flag is also the `loop_start` tool's gate: unlike pi-goal, which
|
|
33
|
+
* relied on prompt guidelines alone, a loop is self-continuing, so this
|
|
34
|
+
* extension *enforces* that the tool only runs on a turn the user explicitly
|
|
35
|
+
* invoked. See `InlineInvocationState.invokedThisTurn`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import { detectsInlineInvocation } from "./inline-command.js";
|
|
40
|
+
import type { LoopController } from "./loop.js";
|
|
41
|
+
|
|
42
|
+
export const INLINE_LOOP_COMMAND = "loop";
|
|
43
|
+
|
|
44
|
+
export const INLINE_LOOP_HINT =
|
|
45
|
+
"<system-reminder>The user's message this turn inline-invoked a loop (/loop or loop:). Call the loop_start tool now with the objective text that follows the token in that message, then begin working toward it. Do not answer the objective as prose without starting the loop. If the message is discussing, quoting, or documenting the /loop command rather than invoking it, do not call loop_start. This reminder applies only to the user's message this turn.</system-reminder>";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The armed state, shared between the hooks and the `loop_start` tool.
|
|
49
|
+
*
|
|
50
|
+
* `invokedThisTurn` is the hard gate: set when `before_agent_start` matched
|
|
51
|
+
* the armed user message, cleared at `agent_end` and at every session
|
|
52
|
+
* boundary. `loop_start` refuses whenever it is false, so no amount of prompt
|
|
53
|
+
* drift, transcript replay, or model initiative can start a self-continuing
|
|
54
|
+
* loop the user did not ask for.
|
|
55
|
+
*/
|
|
56
|
+
export class InlineInvocationState {
|
|
57
|
+
invokedThisTurn = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerInlineInvocation(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
controller: LoopController,
|
|
63
|
+
state: InlineInvocationState,
|
|
64
|
+
) {
|
|
65
|
+
let armedText: string | undefined;
|
|
66
|
+
|
|
67
|
+
pi.on("input", (event) => {
|
|
68
|
+
// Extension-sourced messages (loop kickoffs, continuations, pokes, other
|
|
69
|
+
// extensions' injections) never arm the hint.
|
|
70
|
+
if (event.source === "extension") return;
|
|
71
|
+
// Steered or queued input returns from prompt() without ever reaching
|
|
72
|
+
// before_agent_start, so arming it would leave stale text armed.
|
|
73
|
+
if (event.streamingBehavior !== undefined) return;
|
|
74
|
+
if (!controller.settings.inlineInvocation) return;
|
|
75
|
+
if (!detectsInlineInvocation(event.text, INLINE_LOOP_COMMAND)) return;
|
|
76
|
+
armedText = event.text;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("before_agent_start", (event) => {
|
|
80
|
+
const armed = armedText;
|
|
81
|
+
armedText = undefined;
|
|
82
|
+
// Every start closes the previous turn's window, so a turn that ends
|
|
83
|
+
// without an agent_end still cannot leave the tool unlocked.
|
|
84
|
+
state.invokedThisTurn = false;
|
|
85
|
+
if (armed === undefined) return;
|
|
86
|
+
if (!controller.settings.inlineInvocation) return;
|
|
87
|
+
if (!promptCarriesArmedMessage(event.prompt, armed)) return;
|
|
88
|
+
state.invokedThisTurn = true;
|
|
89
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${INLINE_LOOP_HINT}` };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const disarm = () => {
|
|
93
|
+
armedText = undefined;
|
|
94
|
+
state.invokedThisTurn = false;
|
|
95
|
+
};
|
|
96
|
+
pi.on("agent_end", disarm);
|
|
97
|
+
pi.on("session_start", disarm);
|
|
98
|
+
pi.on("session_shutdown", disarm);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True when the starting prompt is the armed user message. Pi may wrap the
|
|
103
|
+
* text with expanded prefixes or suffixes, so the armed text has to be the
|
|
104
|
+
* whole prompt or one of its ends — mirroring how upstream recognises its own
|
|
105
|
+
* owned prompts at a terminal boundary.
|
|
106
|
+
*/
|
|
107
|
+
function promptCarriesArmedMessage(prompt: string, armed: string) {
|
|
108
|
+
return prompt === armed || prompt.startsWith(armed) || prompt.endsWith(armed);
|
|
109
|
+
}
|
package/src/loop.ts
CHANGED
|
@@ -3,37 +3,29 @@
|
|
|
3
3
|
* loop-aware compaction, wired to Pi's extension events.
|
|
4
4
|
*
|
|
5
5
|
* Design invariants (approved plan):
|
|
6
|
-
* - The settled idle boundary is the pacemaker
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* external wait). A goal-bound loop is unchanged: pi-goal owns its settle
|
|
12
|
-
* continuations, and the interval is still that loop's only driver.
|
|
6
|
+
* - The settled idle boundary is the pacemaker: an agent_end records a
|
|
7
|
+
* continuation *intent*, and the next fully settled boundary dispatches it.
|
|
8
|
+
* The interval is a fallback heartbeat, re-armed from the last settle, that
|
|
9
|
+
* fires only when the session has been idle a whole interval with the
|
|
10
|
+
* objective unfinished (a lost continuation, or an external wait).
|
|
13
11
|
* - Timers are armed in session_start, a settle, or a command handler, never
|
|
14
12
|
* the factory, and cleared in an idempotent session_shutdown.
|
|
15
13
|
* - Pokes deliver only at a fully idle boundary; a tick that lands while the
|
|
16
14
|
* agent is busy coalesces into a single pending wake delivered at the next
|
|
17
15
|
* agent_settled. Missed ticks never stack, and a continuation supersedes a
|
|
18
16
|
* coalesced wake rather than delivering both.
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* boundary, so the loop settles as soon as the goal does; only the timer
|
|
25
|
-
* ever pokes.
|
|
17
|
+
* - A loop owns "whether the work is done" itself: it ends through
|
|
18
|
+
* `loop_complete`, a cap, its expiry, or the user, and reads no other
|
|
19
|
+
* extension's state to decide that.
|
|
20
|
+
* - Terminal decisions (expiry, caps) also land at a settled boundary, so the
|
|
21
|
+
* loop settles as soon as the work does; only the timer ever pokes.
|
|
26
22
|
* - The loop's proactive compaction is the normal compaction path; Pi's
|
|
27
|
-
* reserve-token auto-compaction is the fault handler.
|
|
28
|
-
* post-compaction re-
|
|
23
|
+
* reserve-token auto-compaction is the fault handler. The loop owns the
|
|
24
|
+
* post-compaction re-anchor.
|
|
29
25
|
*/
|
|
30
26
|
|
|
31
27
|
import { randomUUID } from "node:crypto";
|
|
32
|
-
import type {
|
|
33
|
-
ExtensionAPI,
|
|
34
|
-
ExtensionCommandContext,
|
|
35
|
-
ExtensionContext,
|
|
36
|
-
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
29
|
import type { LoopStartArguments } from "./command.js";
|
|
38
30
|
import {
|
|
39
31
|
type ContinuationDecision,
|
|
@@ -54,7 +46,6 @@ import {
|
|
|
54
46
|
buildCompactionInstructions,
|
|
55
47
|
buildContinuation,
|
|
56
48
|
buildExpiryWake,
|
|
57
|
-
buildGoalPoke,
|
|
58
49
|
buildKickoffAnchor,
|
|
59
50
|
buildObjectivePoke,
|
|
60
51
|
type ContinuationKind,
|
|
@@ -67,10 +58,8 @@ import {
|
|
|
67
58
|
readLoopSettings,
|
|
68
59
|
} from "./settings.js";
|
|
69
60
|
import {
|
|
70
|
-
isStandaloneLoop,
|
|
71
61
|
LOOP_STATE_ENTRY_TYPE,
|
|
72
62
|
type LoopState,
|
|
73
|
-
readGoalSnapshot,
|
|
74
63
|
readPlanModeEnabled,
|
|
75
64
|
restoreLoopState,
|
|
76
65
|
} from "./state.js";
|
|
@@ -106,6 +95,17 @@ export const MAX_DEAD_DELIVERIES = 3;
|
|
|
106
95
|
/** Why the loop caused the run that is currently in flight. */
|
|
107
96
|
type RunOrigin = "continuation" | "fallback";
|
|
108
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The outcome of a start attempt.
|
|
100
|
+
*
|
|
101
|
+
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
102
|
+
* which tied the only start path to a UI. Two callers now share that path —
|
|
103
|
+
* the `/loop` command and the `loop_start` tool — and the tool has to turn
|
|
104
|
+
* the same refusal into tool content rather than a toast, so the decision is
|
|
105
|
+
* returned and each caller renders it.
|
|
106
|
+
*/
|
|
107
|
+
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
108
|
+
|
|
109
109
|
interface ContinuationIntent {
|
|
110
110
|
loopId: string;
|
|
111
111
|
kind: ContinuationKind;
|
|
@@ -189,7 +189,7 @@ export class LoopController {
|
|
|
189
189
|
this.transition("stopped", "loop expired while the session was away");
|
|
190
190
|
return;
|
|
191
191
|
}
|
|
192
|
-
if (
|
|
192
|
+
if (this.state.objective === undefined && this.adoptLegacyObjective(ctx)) return;
|
|
193
193
|
// A restored loop keeps its ledger: createLedger only ever creates
|
|
194
194
|
// PROGRESS.md, so days of agent-written state survive a restart.
|
|
195
195
|
this.openLedger(this.state);
|
|
@@ -211,11 +211,11 @@ export class LoopController {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
|
-
* A finished agent run with an active
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
214
|
+
* A finished agent run with an active loop is the signal that paces it:
|
|
215
|
+
* record the *intent* to continue here and let the settled boundary decide
|
|
216
|
+
* whether it may be delivered. Recording at agent_end (not at settle) is
|
|
217
|
+
* what makes the intent survive Pi's own retries and auto-compaction, which
|
|
218
|
+
* run between the two events.
|
|
219
219
|
*/
|
|
220
220
|
/** A run started, so the delivery that caused it was not a dead one. */
|
|
221
221
|
onAgentStart(ctx: ExtensionContext): void {
|
|
@@ -230,7 +230,7 @@ export class LoopController {
|
|
|
230
230
|
const origin = this.runOrigin;
|
|
231
231
|
this.runOrigin = undefined;
|
|
232
232
|
const loop = this.state;
|
|
233
|
-
if (!loop || loop.status !== "active"
|
|
233
|
+
if (!loop || loop.status !== "active") return;
|
|
234
234
|
if (this.enforceToolAvailability(ctx)) return;
|
|
235
235
|
// The expiry's final turn is the last one: never queue a continuation
|
|
236
236
|
// behind it. The settle that follows stops the loop.
|
|
@@ -382,69 +382,52 @@ export class LoopController {
|
|
|
382
382
|
return;
|
|
383
383
|
}
|
|
384
384
|
// Re-arm the heartbeat from this settle, so it can only fire after a full
|
|
385
|
-
// interval of genuine idleness.
|
|
386
|
-
|
|
387
|
-
if (isStandaloneLoop(this.state)) this.armFallback();
|
|
385
|
+
// interval of genuine idleness.
|
|
386
|
+
this.armFallback();
|
|
388
387
|
}
|
|
389
388
|
|
|
390
389
|
/**
|
|
391
390
|
* A settled boundary with no wake pending still evaluates the terminal
|
|
392
|
-
* decisions — expiry
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
391
|
+
* decisions — expiry and the caps — so the loop settles the moment the work
|
|
392
|
+
* does instead of up to one interval later. Poke and skip decisions are
|
|
393
|
+
* deliberately ignored here: only the timer pokes, and settling is not a
|
|
394
|
+
* schedule.
|
|
396
395
|
*/
|
|
397
396
|
private settleTerminalState(ctx: ExtensionContext): boolean {
|
|
398
397
|
const loop = this.state;
|
|
399
398
|
if (!loop) return false;
|
|
400
399
|
const env = this.gatherEnvironment(ctx);
|
|
401
400
|
const decision = decideTick(loop, env);
|
|
402
|
-
if (decision.action !== "expire" && decision.action !== "stop"
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
401
|
+
if (decision.action !== "expire" && decision.action !== "stop") return false;
|
|
405
402
|
this.lastDecision = { ...decision, at: env.now };
|
|
406
403
|
this.applyTerminalDecision(loop, decision);
|
|
407
404
|
return true;
|
|
408
405
|
}
|
|
409
406
|
|
|
410
407
|
/**
|
|
411
|
-
*
|
|
408
|
+
* The restore shim for a loop persisted before 0.6.0.
|
|
412
409
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
410
|
+
* Such a loop may carry no objective of its own: it delegated "is the work
|
|
411
|
+
* done" to a goal in another extension that no longer exists here. The only
|
|
412
|
+
* case that can still reach this code is a session persisted before 0.6.0
|
|
413
|
+
* and resumed after it, having never been restored under 0.5.0 — where it
|
|
414
|
+
* would already have been converted.
|
|
417
415
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* behaviour and gets the notice instead.
|
|
416
|
+
* Its focus text, when it has one, is the closest thing to an objective it
|
|
417
|
+
* has, so adopt that. With nothing to adopt there is no honest way to run
|
|
418
|
+
* it, so it pauses and says so.
|
|
422
419
|
*
|
|
423
|
-
* Returns true when the loop was
|
|
420
|
+
* Returns true when the loop was paused and needs no timer.
|
|
424
421
|
*/
|
|
425
|
-
private
|
|
422
|
+
private adoptLegacyObjective(ctx: ExtensionContext): boolean {
|
|
426
423
|
const loop = this.state;
|
|
427
424
|
if (!loop) return true;
|
|
428
|
-
const
|
|
429
|
-
if (goal?.status === "active") {
|
|
430
|
-
ctx.ui.notify(
|
|
431
|
-
"This loop is bound to a /goal, which is deprecated: pi-loop now owns long-running work on its own. It keeps working as before for now — start future loops with /loop <interval> <objective>.",
|
|
432
|
-
"warning",
|
|
433
|
-
);
|
|
434
|
-
return false;
|
|
435
|
-
}
|
|
436
|
-
if (goal?.status === "complete") {
|
|
437
|
-
this.transition("stopped", "the goal completed");
|
|
438
|
-
return true;
|
|
439
|
-
}
|
|
440
|
-
// The goal is gone or held: nothing is driving this loop any more, so
|
|
441
|
-
// adopt whatever objective text is still readable and carry on.
|
|
442
|
-
const objective = goal?.text ?? loop.prompt;
|
|
425
|
+
const objective = loop.prompt;
|
|
443
426
|
if (!objective) {
|
|
444
427
|
this.transition(
|
|
445
428
|
"paused",
|
|
446
|
-
"it was bound to a goal that is gone
|
|
447
|
-
"
|
|
429
|
+
"it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
|
|
430
|
+
"loop with no objective",
|
|
448
431
|
);
|
|
449
432
|
return true;
|
|
450
433
|
}
|
|
@@ -452,15 +435,15 @@ export class LoopController {
|
|
|
452
435
|
this.state = { ...rest, objective };
|
|
453
436
|
this.persist();
|
|
454
437
|
ctx.ui.notify(
|
|
455
|
-
`
|
|
438
|
+
`This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
|
|
456
439
|
"info",
|
|
457
440
|
);
|
|
458
441
|
return false;
|
|
459
442
|
}
|
|
460
443
|
|
|
461
444
|
/**
|
|
462
|
-
* A
|
|
463
|
-
*
|
|
445
|
+
* A loop that cannot call `loop_complete` cannot end itself: it will work,
|
|
446
|
+
* finish, and then be told to keep working until it hits a cap.
|
|
464
447
|
* That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
|
|
465
448
|
* a policy that drops extension tools), and it is invisible from inside the
|
|
466
449
|
* loop — so check the live tool set and pause instead of spinning.
|
|
@@ -469,7 +452,7 @@ export class LoopController {
|
|
|
469
452
|
*/
|
|
470
453
|
private enforceToolAvailability(ctx: ExtensionContext): boolean {
|
|
471
454
|
const loop = this.state;
|
|
472
|
-
if (!loop || loop.status !== "active"
|
|
455
|
+
if (!loop || loop.status !== "active") return false;
|
|
473
456
|
if (this.completeToolAvailable()) return false;
|
|
474
457
|
this.transition(
|
|
475
458
|
"paused",
|
|
@@ -523,7 +506,7 @@ export class LoopController {
|
|
|
523
506
|
*/
|
|
524
507
|
enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
|
|
525
508
|
const loop = this.state;
|
|
526
|
-
if (!loop || loop.status !== "active"
|
|
509
|
+
if (!loop || loop.status !== "active") return undefined;
|
|
527
510
|
const resolved = resolveWaitDelay(resumeAfterMs);
|
|
528
511
|
const waiting = createLoopWait(reason, resumeAfterMs, this.now());
|
|
529
512
|
// The wait replaces any continuation already recorded for this turn.
|
|
@@ -584,9 +567,9 @@ export class LoopController {
|
|
|
584
567
|
// --- ledger ---
|
|
585
568
|
|
|
586
569
|
/**
|
|
587
|
-
* Create (or adopt) the ledger
|
|
588
|
-
*
|
|
589
|
-
*
|
|
570
|
+
* Create (or adopt) the loop's ledger. Best-effort by design: a loop with
|
|
571
|
+
* no writable ledger still runs, it just loses the durable record, so the
|
|
572
|
+
* failure is warned once and never repeated.
|
|
590
573
|
*/
|
|
591
574
|
private openLedger(loop: LoopState): void {
|
|
592
575
|
if (loop.objective === undefined) {
|
|
@@ -729,7 +712,6 @@ export class LoopController {
|
|
|
729
712
|
busy: !ctx.isIdle() || ctx.hasPendingMessages(),
|
|
730
713
|
compacting: this.compacting,
|
|
731
714
|
planModeEnabled: readPlanModeEnabled(branch),
|
|
732
|
-
goal: readGoalSnapshot(branch),
|
|
733
715
|
};
|
|
734
716
|
}
|
|
735
717
|
|
|
@@ -757,7 +739,7 @@ export class LoopController {
|
|
|
757
739
|
this.updateWidget();
|
|
758
740
|
return;
|
|
759
741
|
case "poke":
|
|
760
|
-
this.deliverPoke(env, decision.reason);
|
|
742
|
+
this.deliverPoke(env.now, decision.reason);
|
|
761
743
|
return;
|
|
762
744
|
default:
|
|
763
745
|
this.applyTerminalDecision(loop, decision);
|
|
@@ -767,7 +749,7 @@ export class LoopController {
|
|
|
767
749
|
|
|
768
750
|
private applyTerminalDecision(
|
|
769
751
|
loop: LoopState,
|
|
770
|
-
decision: Extract<TickDecision, { action: "expire" | "stop"
|
|
752
|
+
decision: Extract<TickDecision, { action: "expire" | "stop" }>,
|
|
771
753
|
): void {
|
|
772
754
|
switch (decision.action) {
|
|
773
755
|
case "expire":
|
|
@@ -777,19 +759,9 @@ export class LoopController {
|
|
|
777
759
|
case "stop":
|
|
778
760
|
this.transition(
|
|
779
761
|
"stopped",
|
|
780
|
-
decision.reason === "
|
|
781
|
-
?
|
|
782
|
-
:
|
|
783
|
-
? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
|
|
784
|
-
: `the ${loop.maxIterations}-iteration cap was reached`,
|
|
785
|
-
);
|
|
786
|
-
return;
|
|
787
|
-
case "pause":
|
|
788
|
-
this.transition(
|
|
789
|
-
"paused",
|
|
790
|
-
decision.reason === "goal-missing"
|
|
791
|
-
? "loops require an active goal; start one with /goal <objective>, then /loop resume"
|
|
792
|
-
: `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
|
|
762
|
+
decision.reason === "max-automatic-turns"
|
|
763
|
+
? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
|
|
764
|
+
: `the ${loop.maxIterations}-iteration cap was reached`,
|
|
793
765
|
);
|
|
794
766
|
return;
|
|
795
767
|
}
|
|
@@ -841,20 +813,11 @@ export class LoopController {
|
|
|
841
813
|
* maxIterations cap on a poke that never arrived; on a throw the loop re-arms
|
|
842
814
|
* on the same cadence and retries at the next wake.
|
|
843
815
|
*/
|
|
844
|
-
private deliverPoke(
|
|
845
|
-
env: TickEnvironment,
|
|
846
|
-
reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
|
|
847
|
-
): void {
|
|
816
|
+
private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
|
|
848
817
|
const loop = this.state;
|
|
849
818
|
if (!loop) return;
|
|
850
|
-
const standalone = reason === "objective-stalled" || reason === "wait-elapsed";
|
|
851
|
-
// A goal-bound poke restates nothing, so it is only meaningful while the
|
|
852
|
-
// goal it points at is readable; a standalone poke needs no goal at all.
|
|
853
|
-
if (!standalone && !env.goal) return;
|
|
854
819
|
try {
|
|
855
|
-
this.pi.sendUserMessage(
|
|
856
|
-
standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
|
|
857
|
-
);
|
|
820
|
+
this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
|
|
858
821
|
} catch (error) {
|
|
859
822
|
this.sessionCtx?.ui.notify(
|
|
860
823
|
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
@@ -872,7 +835,7 @@ export class LoopController {
|
|
|
872
835
|
...this.consumeWait(loop),
|
|
873
836
|
iteration: loop.iteration + 1,
|
|
874
837
|
automaticTurns: loop.automaticTurns + 1,
|
|
875
|
-
lastWakeAt:
|
|
838
|
+
lastWakeAt: now,
|
|
876
839
|
};
|
|
877
840
|
this.persist();
|
|
878
841
|
this.armFallback();
|
|
@@ -894,15 +857,11 @@ export class LoopController {
|
|
|
894
857
|
if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
|
|
895
858
|
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
896
859
|
}
|
|
897
|
-
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
898
860
|
this.compacting = true;
|
|
899
861
|
try {
|
|
900
862
|
ctx.compact({
|
|
901
863
|
customInstructions: buildCompactionInstructions(
|
|
902
864
|
loop,
|
|
903
|
-
// A completed or otherwise finished goal is no longer the
|
|
904
|
-
// objective the summary must preserve.
|
|
905
|
-
goal?.status === "active" ? goal : undefined,
|
|
906
865
|
this.settings.compaction.instructions,
|
|
907
866
|
this.ledger,
|
|
908
867
|
),
|
|
@@ -937,7 +896,7 @@ export class LoopController {
|
|
|
937
896
|
*/
|
|
938
897
|
private requestReAnchor(result: unknown): void {
|
|
939
898
|
const loop = this.state;
|
|
940
|
-
if (!loop || loop.status !== "active"
|
|
899
|
+
if (!loop || loop.status !== "active") return;
|
|
941
900
|
const summary =
|
|
942
901
|
isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
|
|
943
902
|
this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
|
|
@@ -1039,7 +998,6 @@ export class LoopController {
|
|
|
1039
998
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
1040
999
|
];
|
|
1041
1000
|
if (loop.objective) {
|
|
1042
|
-
lines.push("Mode: standalone (this loop owns its completion criteria)");
|
|
1043
1001
|
lines.push(`Objective: ${loop.objective}`);
|
|
1044
1002
|
if (this.ledger) {
|
|
1045
1003
|
const criteria = this.criteria();
|
|
@@ -1059,17 +1017,11 @@ export class LoopController {
|
|
|
1059
1017
|
} else {
|
|
1060
1018
|
lines.push("Ledger: unavailable (the loop runs without one)");
|
|
1061
1019
|
}
|
|
1062
|
-
} else {
|
|
1063
|
-
lines.push("Mode: goal-bound (pi-goal owns completion)");
|
|
1064
1020
|
}
|
|
1065
1021
|
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
1066
|
-
const goal = isStandaloneLoop(loop)
|
|
1067
|
-
? undefined
|
|
1068
|
-
: readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
1069
|
-
if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
|
|
1070
1022
|
if (this.nextWakeAt && loop.status === "active") {
|
|
1071
1023
|
lines.push(
|
|
1072
|
-
|
|
1024
|
+
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1073
1025
|
this.noOpStreak > 0
|
|
1074
1026
|
? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
|
|
1075
1027
|
: ""
|
|
@@ -1090,36 +1042,29 @@ export class LoopController {
|
|
|
1090
1042
|
// --- command actions ---
|
|
1091
1043
|
|
|
1092
1044
|
/**
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
* behaves exactly as it always has, and the trailing text stays a per-wake
|
|
1097
|
-
* focus. With no active goal the trailing text becomes this loop's own
|
|
1098
|
-
* objective and the loop is standalone. With neither, there is nothing to
|
|
1099
|
-
* work on, and the caller is told what to supply.
|
|
1045
|
+
* Start a loop on its own objective, the only mode there is: the trailing
|
|
1046
|
+
* text *is* what the loop works on and what `loop_complete` answers for.
|
|
1047
|
+
* With no text there is nothing to work on, and the caller is told so.
|
|
1100
1048
|
*/
|
|
1101
|
-
startLoop(ctx:
|
|
1049
|
+
startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
|
|
1102
1050
|
this.sessionCtx = ctx;
|
|
1103
1051
|
const now = this.now();
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
);
|
|
1112
|
-
return;
|
|
1052
|
+
const objective = start.prompt?.trim();
|
|
1053
|
+
if (!objective) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
1056
|
+
message:
|
|
1057
|
+
"A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
|
|
1058
|
+
};
|
|
1113
1059
|
}
|
|
1114
|
-
// A
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
if (
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
return;
|
|
1060
|
+
// A loop with no way to call loop_complete would work, finish, and then be
|
|
1061
|
+
// told to keep working until it hit a cap. Refuse at the door rather than
|
|
1062
|
+
// after the first turn.
|
|
1063
|
+
if (!this.completeToolAvailable()) {
|
|
1064
|
+
return {
|
|
1065
|
+
ok: false,
|
|
1066
|
+
message: `This session has no ${LOOP_COMPLETE_TOOL} tool, so a loop could never end itself. Re-enable it (it is excluded by --tools/--no-tools or a tool policy) and start the loop again.`,
|
|
1067
|
+
};
|
|
1123
1068
|
}
|
|
1124
1069
|
const expiryMs =
|
|
1125
1070
|
start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
|
|
@@ -1129,13 +1074,10 @@ export class LoopController {
|
|
|
1129
1074
|
: this.settings.compaction.enabled
|
|
1130
1075
|
? this.settings.compaction.threshold
|
|
1131
1076
|
: null;
|
|
1132
|
-
|
|
1077
|
+
const started: LoopState = {
|
|
1133
1078
|
id: randomUUID().slice(0, 8),
|
|
1134
1079
|
status: "active",
|
|
1135
|
-
|
|
1136
|
-
// the authoritative objective for a standalone one; never both.
|
|
1137
|
-
...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
|
|
1138
|
-
...(objective ? { objective } : {}),
|
|
1080
|
+
objective,
|
|
1139
1081
|
intervalMs: start.intervalMs,
|
|
1140
1082
|
maxIterations:
|
|
1141
1083
|
start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
|
|
@@ -1146,6 +1088,7 @@ export class LoopController {
|
|
|
1146
1088
|
startedAt: now,
|
|
1147
1089
|
expiresAt: now + expiryMs,
|
|
1148
1090
|
};
|
|
1091
|
+
this.state = started;
|
|
1149
1092
|
this.wakePending = false;
|
|
1150
1093
|
this.continuationIntent = undefined;
|
|
1151
1094
|
this.noOpStreak = 0;
|
|
@@ -1157,16 +1100,8 @@ export class LoopController {
|
|
|
1157
1100
|
const clampNote = start.clamped
|
|
1158
1101
|
? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
|
|
1159
1102
|
: "";
|
|
1160
|
-
if (goalBound) {
|
|
1161
|
-
ctx.ui.notify(
|
|
1162
|
-
"Binding a loop to an active /goal is deprecated and will be removed. pi-loop now owns long-running work on its own: stop the goal and run /loop <interval> <objective> instead.",
|
|
1163
|
-
"warning",
|
|
1164
|
-
);
|
|
1165
|
-
}
|
|
1166
1103
|
ctx.ui.notify(
|
|
1167
|
-
|
|
1168
|
-
? `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`
|
|
1169
|
-
: `Loop started: working its own objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop. Fallback wake every ${formatDuration(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
1104
|
+
`Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop. Fallback wake every ${formatDuration(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
1170
1105
|
"info",
|
|
1171
1106
|
);
|
|
1172
1107
|
if (this.ledger) {
|
|
@@ -1183,13 +1118,12 @@ export class LoopController {
|
|
|
1183
1118
|
// The kickoff anchor: one stored message per loop holding the objective
|
|
1184
1119
|
// data, because the system append exists only while the loop is active.
|
|
1185
1120
|
this.sendKickoffAnchor(ctx);
|
|
1186
|
-
// Immediate kickoff:
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
}
|
|
1121
|
+
// Immediate kickoff: the loop starts working now instead of burning its
|
|
1122
|
+
// first interval idle. A busy session keeps the intent and delivers it at
|
|
1123
|
+
// the settle.
|
|
1124
|
+
this.requestContinuation(started, "kickoff");
|
|
1125
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1126
|
+
return { ok: true, loop: started };
|
|
1193
1127
|
}
|
|
1194
1128
|
|
|
1195
1129
|
/**
|
|
@@ -1202,7 +1136,7 @@ export class LoopController {
|
|
|
1202
1136
|
*/
|
|
1203
1137
|
private sendKickoffAnchor(ctx: ExtensionContext): void {
|
|
1204
1138
|
const loop = this.state;
|
|
1205
|
-
if (!loop ||
|
|
1139
|
+
if (!loop || loop.objective === undefined || !this.ledger) return;
|
|
1206
1140
|
const idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
1207
1141
|
try {
|
|
1208
1142
|
this.pi.sendMessage(
|
|
@@ -1241,19 +1175,6 @@ export class LoopController {
|
|
|
1241
1175
|
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
1242
1176
|
return;
|
|
1243
1177
|
}
|
|
1244
|
-
// Same guard as startLoop, and for the same reason: resuming a goal-bound
|
|
1245
|
-
// loop into a finished or missing goal would only stop or pause again at
|
|
1246
|
-
// the first tick. A standalone loop owns its objective and needs no goal.
|
|
1247
|
-
if (
|
|
1248
|
-
!isStandaloneLoop(loop) &&
|
|
1249
|
-
readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active"
|
|
1250
|
-
) {
|
|
1251
|
-
ctx.ui.notify(
|
|
1252
|
-
"This loop is bound to a goal, which is no longer active. Start one with /goal <objective>, then /loop resume.",
|
|
1253
|
-
"error",
|
|
1254
|
-
);
|
|
1255
|
-
return;
|
|
1256
|
-
}
|
|
1257
1178
|
// Resuming starts a fresh safety epoch: the user has seen why it paused
|
|
1258
1179
|
// and chosen to continue, so the breaker must not trip on stale counters.
|
|
1259
1180
|
const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
|
|
@@ -1263,13 +1184,11 @@ export class LoopController {
|
|
|
1263
1184
|
this.scheduleTick(loop.intervalMs);
|
|
1264
1185
|
this.updateWidget();
|
|
1265
1186
|
ctx.ui.notify(
|
|
1266
|
-
|
|
1267
|
-
? `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`
|
|
1268
|
-
: `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
|
|
1187
|
+
`Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
|
|
1269
1188
|
"info",
|
|
1270
1189
|
);
|
|
1271
|
-
// Resuming
|
|
1272
|
-
if (this.state
|
|
1190
|
+
// Resuming resumes the work, not just the heartbeat.
|
|
1191
|
+
if (this.state) {
|
|
1273
1192
|
this.requestContinuation(this.state);
|
|
1274
1193
|
this.dispatchContinuationIfSettled(ctx);
|
|
1275
1194
|
}
|