@hank-warren/pi-loop 0.5.0 → 0.7.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 +29 -0
- package/README.md +60 -49
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +112 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +56 -15
- package/src/decide.ts +30 -81
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +17 -4
- package/src/loop.ts +124 -200
- package/src/manager.ts +18 -28
- package/src/messages.ts +21 -38
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +83 -21
- package/src/start-tool.ts +199 -0
- package/src/state.ts +56 -116
- package/src/wait-tool.ts +2 -2
- package/src/widget.ts +5 -3
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,
|
|
@@ -45,16 +37,17 @@ import {
|
|
|
45
37
|
import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
46
38
|
import {
|
|
47
39
|
createLedger,
|
|
40
|
+
criteriaFromDescriptions,
|
|
48
41
|
deriveCriteria,
|
|
49
42
|
type LedgerPaths,
|
|
50
43
|
ledgerPaths,
|
|
44
|
+
type LoopCriterion,
|
|
51
45
|
readCriteria,
|
|
52
46
|
} from "./ledger.js";
|
|
53
47
|
import {
|
|
54
48
|
buildCompactionInstructions,
|
|
55
49
|
buildContinuation,
|
|
56
50
|
buildExpiryWake,
|
|
57
|
-
buildGoalPoke,
|
|
58
51
|
buildKickoffAnchor,
|
|
59
52
|
buildObjectivePoke,
|
|
60
53
|
type ContinuationKind,
|
|
@@ -67,10 +60,8 @@ import {
|
|
|
67
60
|
readLoopSettings,
|
|
68
61
|
} from "./settings.js";
|
|
69
62
|
import {
|
|
70
|
-
isStandaloneLoop,
|
|
71
63
|
LOOP_STATE_ENTRY_TYPE,
|
|
72
64
|
type LoopState,
|
|
73
|
-
readGoalSnapshot,
|
|
74
65
|
readPlanModeEnabled,
|
|
75
66
|
restoreLoopState,
|
|
76
67
|
} from "./state.js";
|
|
@@ -106,6 +97,17 @@ export const MAX_DEAD_DELIVERIES = 3;
|
|
|
106
97
|
/** Why the loop caused the run that is currently in flight. */
|
|
107
98
|
type RunOrigin = "continuation" | "fallback";
|
|
108
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The outcome of a start attempt.
|
|
102
|
+
*
|
|
103
|
+
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
104
|
+
* which tied the only start path to a UI. Two callers now share that path —
|
|
105
|
+
* the `/loop` command and the `loop_start` tool — and the tool has to turn
|
|
106
|
+
* the same refusal into tool content rather than a toast, so the decision is
|
|
107
|
+
* returned and each caller renders it.
|
|
108
|
+
*/
|
|
109
|
+
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
110
|
+
|
|
109
111
|
interface ContinuationIntent {
|
|
110
112
|
loopId: string;
|
|
111
113
|
kind: ContinuationKind;
|
|
@@ -189,7 +191,7 @@ export class LoopController {
|
|
|
189
191
|
this.transition("stopped", "loop expired while the session was away");
|
|
190
192
|
return;
|
|
191
193
|
}
|
|
192
|
-
if (
|
|
194
|
+
if (this.state.objective === undefined && this.adoptLegacyObjective(ctx)) return;
|
|
193
195
|
// A restored loop keeps its ledger: createLedger only ever creates
|
|
194
196
|
// PROGRESS.md, so days of agent-written state survive a restart.
|
|
195
197
|
this.openLedger(this.state);
|
|
@@ -211,11 +213,11 @@ export class LoopController {
|
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
/**
|
|
214
|
-
* A finished agent run with an active
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* A finished agent run with an active loop is the signal that paces it:
|
|
217
|
+
* record the *intent* to continue here and let the settled boundary decide
|
|
218
|
+
* whether it may be delivered. Recording at agent_end (not at settle) is
|
|
219
|
+
* what makes the intent survive Pi's own retries and auto-compaction, which
|
|
220
|
+
* run between the two events.
|
|
219
221
|
*/
|
|
220
222
|
/** A run started, so the delivery that caused it was not a dead one. */
|
|
221
223
|
onAgentStart(ctx: ExtensionContext): void {
|
|
@@ -230,7 +232,7 @@ export class LoopController {
|
|
|
230
232
|
const origin = this.runOrigin;
|
|
231
233
|
this.runOrigin = undefined;
|
|
232
234
|
const loop = this.state;
|
|
233
|
-
if (!loop || loop.status !== "active"
|
|
235
|
+
if (!loop || loop.status !== "active") return;
|
|
234
236
|
if (this.enforceToolAvailability(ctx)) return;
|
|
235
237
|
// The expiry's final turn is the last one: never queue a continuation
|
|
236
238
|
// behind it. The settle that follows stops the loop.
|
|
@@ -382,69 +384,52 @@ export class LoopController {
|
|
|
382
384
|
return;
|
|
383
385
|
}
|
|
384
386
|
// 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();
|
|
387
|
+
// interval of genuine idleness.
|
|
388
|
+
this.armFallback();
|
|
388
389
|
}
|
|
389
390
|
|
|
390
391
|
/**
|
|
391
392
|
* A settled boundary with no wake pending still evaluates the terminal
|
|
392
|
-
* decisions — expiry
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
393
|
+
* decisions — expiry and the caps — so the loop settles the moment the work
|
|
394
|
+
* does instead of up to one interval later. Poke and skip decisions are
|
|
395
|
+
* deliberately ignored here: only the timer pokes, and settling is not a
|
|
396
|
+
* schedule.
|
|
396
397
|
*/
|
|
397
398
|
private settleTerminalState(ctx: ExtensionContext): boolean {
|
|
398
399
|
const loop = this.state;
|
|
399
400
|
if (!loop) return false;
|
|
400
401
|
const env = this.gatherEnvironment(ctx);
|
|
401
402
|
const decision = decideTick(loop, env);
|
|
402
|
-
if (decision.action !== "expire" && decision.action !== "stop"
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
403
|
+
if (decision.action !== "expire" && decision.action !== "stop") return false;
|
|
405
404
|
this.lastDecision = { ...decision, at: env.now };
|
|
406
405
|
this.applyTerminalDecision(loop, decision);
|
|
407
406
|
return true;
|
|
408
407
|
}
|
|
409
408
|
|
|
410
409
|
/**
|
|
411
|
-
*
|
|
410
|
+
* The restore shim for a loop persisted before 0.6.0.
|
|
412
411
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
412
|
+
* Such a loop may carry no objective of its own: it delegated "is the work
|
|
413
|
+
* done" to a goal in another extension that no longer exists here. The only
|
|
414
|
+
* case that can still reach this code is a session persisted before 0.6.0
|
|
415
|
+
* and resumed after it, having never been restored under 0.5.0 — where it
|
|
416
|
+
* would already have been converted.
|
|
417
417
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* behaviour and gets the notice instead.
|
|
418
|
+
* Its focus text, when it has one, is the closest thing to an objective it
|
|
419
|
+
* has, so adopt that. With nothing to adopt there is no honest way to run
|
|
420
|
+
* it, so it pauses and says so.
|
|
422
421
|
*
|
|
423
|
-
* Returns true when the loop was
|
|
422
|
+
* Returns true when the loop was paused and needs no timer.
|
|
424
423
|
*/
|
|
425
|
-
private
|
|
424
|
+
private adoptLegacyObjective(ctx: ExtensionContext): boolean {
|
|
426
425
|
const loop = this.state;
|
|
427
426
|
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;
|
|
427
|
+
const objective = loop.prompt;
|
|
443
428
|
if (!objective) {
|
|
444
429
|
this.transition(
|
|
445
430
|
"paused",
|
|
446
|
-
"it was bound to a goal that is gone
|
|
447
|
-
"
|
|
431
|
+
"it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
|
|
432
|
+
"loop with no objective",
|
|
448
433
|
);
|
|
449
434
|
return true;
|
|
450
435
|
}
|
|
@@ -452,15 +437,15 @@ export class LoopController {
|
|
|
452
437
|
this.state = { ...rest, objective };
|
|
453
438
|
this.persist();
|
|
454
439
|
ctx.ui.notify(
|
|
455
|
-
`
|
|
440
|
+
`This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
|
|
456
441
|
"info",
|
|
457
442
|
);
|
|
458
443
|
return false;
|
|
459
444
|
}
|
|
460
445
|
|
|
461
446
|
/**
|
|
462
|
-
* A
|
|
463
|
-
*
|
|
447
|
+
* A loop that cannot call `loop_complete` cannot end itself: it will work,
|
|
448
|
+
* finish, and then be told to keep working until it hits a cap.
|
|
464
449
|
* That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
|
|
465
450
|
* a policy that drops extension tools), and it is invisible from inside the
|
|
466
451
|
* loop — so check the live tool set and pause instead of spinning.
|
|
@@ -469,7 +454,7 @@ export class LoopController {
|
|
|
469
454
|
*/
|
|
470
455
|
private enforceToolAvailability(ctx: ExtensionContext): boolean {
|
|
471
456
|
const loop = this.state;
|
|
472
|
-
if (!loop || loop.status !== "active"
|
|
457
|
+
if (!loop || loop.status !== "active") return false;
|
|
473
458
|
if (this.completeToolAvailable()) return false;
|
|
474
459
|
this.transition(
|
|
475
460
|
"paused",
|
|
@@ -523,7 +508,7 @@ export class LoopController {
|
|
|
523
508
|
*/
|
|
524
509
|
enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
|
|
525
510
|
const loop = this.state;
|
|
526
|
-
if (!loop || loop.status !== "active"
|
|
511
|
+
if (!loop || loop.status !== "active") return undefined;
|
|
527
512
|
const resolved = resolveWaitDelay(resumeAfterMs);
|
|
528
513
|
const waiting = createLoopWait(reason, resumeAfterMs, this.now());
|
|
529
514
|
// The wait replaces any continuation already recorded for this turn.
|
|
@@ -584,17 +569,24 @@ export class LoopController {
|
|
|
584
569
|
// --- ledger ---
|
|
585
570
|
|
|
586
571
|
/**
|
|
587
|
-
* Create (or adopt) the ledger
|
|
588
|
-
*
|
|
589
|
-
*
|
|
572
|
+
* Create (or adopt) the loop's ledger. Best-effort by design: a loop with
|
|
573
|
+
* no writable ledger still runs, it just loses the durable record, so the
|
|
574
|
+
* failure is warned once and never repeated.
|
|
575
|
+
*
|
|
576
|
+
* `criteria` is passed at start: the criteria proposed at `loop_start`, or
|
|
577
|
+
* the deterministic split of the objective. On restore it is omitted, and
|
|
578
|
+
* the criteria already on disk are authoritative — they are the ones the
|
|
579
|
+
* user saw echoed, and re-deriving them would both discard a proposed set
|
|
580
|
+
* and reset whatever `passes` flips the loop has earned.
|
|
590
581
|
*/
|
|
591
|
-
private openLedger(loop: LoopState): void {
|
|
582
|
+
private openLedger(loop: LoopState, criteria?: LoopCriterion[]): void {
|
|
592
583
|
if (loop.objective === undefined) {
|
|
593
584
|
this.ledger = undefined;
|
|
594
585
|
return;
|
|
595
586
|
}
|
|
596
587
|
const paths = ledgerPaths(loop.id, this.agentDir);
|
|
597
|
-
const
|
|
588
|
+
const contents = criteria ?? readCriteria(paths) ?? deriveCriteria(loop.objective);
|
|
589
|
+
const failure = createLedger(paths, loop.objective, contents);
|
|
598
590
|
if (failure) {
|
|
599
591
|
this.ledger = undefined;
|
|
600
592
|
if (!this.ledgerWarned) {
|
|
@@ -729,7 +721,6 @@ export class LoopController {
|
|
|
729
721
|
busy: !ctx.isIdle() || ctx.hasPendingMessages(),
|
|
730
722
|
compacting: this.compacting,
|
|
731
723
|
planModeEnabled: readPlanModeEnabled(branch),
|
|
732
|
-
goal: readGoalSnapshot(branch),
|
|
733
724
|
};
|
|
734
725
|
}
|
|
735
726
|
|
|
@@ -757,7 +748,7 @@ export class LoopController {
|
|
|
757
748
|
this.updateWidget();
|
|
758
749
|
return;
|
|
759
750
|
case "poke":
|
|
760
|
-
this.deliverPoke(env, decision.reason);
|
|
751
|
+
this.deliverPoke(env.now, decision.reason);
|
|
761
752
|
return;
|
|
762
753
|
default:
|
|
763
754
|
this.applyTerminalDecision(loop, decision);
|
|
@@ -767,7 +758,7 @@ export class LoopController {
|
|
|
767
758
|
|
|
768
759
|
private applyTerminalDecision(
|
|
769
760
|
loop: LoopState,
|
|
770
|
-
decision: Extract<TickDecision, { action: "expire" | "stop"
|
|
761
|
+
decision: Extract<TickDecision, { action: "expire" | "stop" }>,
|
|
771
762
|
): void {
|
|
772
763
|
switch (decision.action) {
|
|
773
764
|
case "expire":
|
|
@@ -775,22 +766,7 @@ export class LoopController {
|
|
|
775
766
|
this.transition("stopped", "loop expired (the expiry was reached)");
|
|
776
767
|
return;
|
|
777
768
|
case "stop":
|
|
778
|
-
this.transition(
|
|
779
|
-
"stopped",
|
|
780
|
-
decision.reason === "goal-complete"
|
|
781
|
-
? "the goal completed"
|
|
782
|
-
: decision.reason === "max-automatic-turns"
|
|
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`,
|
|
793
|
-
);
|
|
769
|
+
this.transition("stopped", `the ${loop.maxTurns}-turn cap was reached`);
|
|
794
770
|
return;
|
|
795
771
|
}
|
|
796
772
|
}
|
|
@@ -837,24 +813,15 @@ export class LoopController {
|
|
|
837
813
|
|
|
838
814
|
/**
|
|
839
815
|
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
840
|
-
* session), and
|
|
841
|
-
*
|
|
842
|
-
*
|
|
816
|
+
* session), and a turn persisted before the send would burn the cap on a
|
|
817
|
+
* poke that never arrived; on a throw the loop re-arms on the same cadence
|
|
818
|
+
* and retries at the next wake.
|
|
843
819
|
*/
|
|
844
|
-
private deliverPoke(
|
|
845
|
-
env: TickEnvironment,
|
|
846
|
-
reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
|
|
847
|
-
): void {
|
|
820
|
+
private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
|
|
848
821
|
const loop = this.state;
|
|
849
822
|
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
823
|
try {
|
|
855
|
-
this.pi.sendUserMessage(
|
|
856
|
-
standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
|
|
857
|
-
);
|
|
824
|
+
this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
|
|
858
825
|
} catch (error) {
|
|
859
826
|
this.sessionCtx?.ui.notify(
|
|
860
827
|
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
@@ -872,7 +839,7 @@ export class LoopController {
|
|
|
872
839
|
...this.consumeWait(loop),
|
|
873
840
|
iteration: loop.iteration + 1,
|
|
874
841
|
automaticTurns: loop.automaticTurns + 1,
|
|
875
|
-
lastWakeAt:
|
|
842
|
+
lastWakeAt: now,
|
|
876
843
|
};
|
|
877
844
|
this.persist();
|
|
878
845
|
this.armFallback();
|
|
@@ -894,15 +861,11 @@ export class LoopController {
|
|
|
894
861
|
if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
|
|
895
862
|
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
896
863
|
}
|
|
897
|
-
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
898
864
|
this.compacting = true;
|
|
899
865
|
try {
|
|
900
866
|
ctx.compact({
|
|
901
867
|
customInstructions: buildCompactionInstructions(
|
|
902
868
|
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
869
|
this.settings.compaction.instructions,
|
|
907
870
|
this.ledger,
|
|
908
871
|
),
|
|
@@ -937,7 +900,7 @@ export class LoopController {
|
|
|
937
900
|
*/
|
|
938
901
|
private requestReAnchor(result: unknown): void {
|
|
939
902
|
const loop = this.state;
|
|
940
|
-
if (!loop || loop.status !== "active"
|
|
903
|
+
if (!loop || loop.status !== "active") return;
|
|
941
904
|
const summary =
|
|
942
905
|
isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
|
|
943
906
|
this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
|
|
@@ -1002,7 +965,7 @@ export class LoopController {
|
|
|
1002
965
|
);
|
|
1003
966
|
return;
|
|
1004
967
|
}
|
|
1005
|
-
const cap = loop.
|
|
968
|
+
const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
|
|
1006
969
|
const next = this.wakePending
|
|
1007
970
|
? "next on idle"
|
|
1008
971
|
: this.nextWakeAt
|
|
@@ -1010,7 +973,7 @@ export class LoopController {
|
|
|
1010
973
|
: "next unscheduled";
|
|
1011
974
|
ui.setStatus(
|
|
1012
975
|
LOOP_STATUS_KEY,
|
|
1013
|
-
`loop ${formatDuration(loop.intervalMs)} · ${loop.
|
|
976
|
+
`loop ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`,
|
|
1014
977
|
);
|
|
1015
978
|
}
|
|
1016
979
|
|
|
@@ -1032,14 +995,13 @@ export class LoopController {
|
|
|
1032
995
|
? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
|
|
1033
996
|
: []),
|
|
1034
997
|
`Interval: every ${formatDuration(loop.intervalMs)}`,
|
|
1035
|
-
`
|
|
1036
|
-
`
|
|
998
|
+
`Loop turns: ${loop.automaticTurns}${loop.maxTurns === null ? " (unlimited)" : ` of ${loop.maxTurns}`}`,
|
|
999
|
+
`Fallback wakes delivered: ${loop.iteration}`,
|
|
1037
1000
|
`Started: ${new Date(loop.startedAt).toLocaleString()}`,
|
|
1038
1001
|
`Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
|
|
1039
1002
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
1040
1003
|
];
|
|
1041
1004
|
if (loop.objective) {
|
|
1042
|
-
lines.push("Mode: standalone (this loop owns its completion criteria)");
|
|
1043
1005
|
lines.push(`Objective: ${loop.objective}`);
|
|
1044
1006
|
if (this.ledger) {
|
|
1045
1007
|
const criteria = this.criteria();
|
|
@@ -1059,17 +1021,11 @@ export class LoopController {
|
|
|
1059
1021
|
} else {
|
|
1060
1022
|
lines.push("Ledger: unavailable (the loop runs without one)");
|
|
1061
1023
|
}
|
|
1062
|
-
} else {
|
|
1063
|
-
lines.push("Mode: goal-bound (pi-goal owns completion)");
|
|
1064
1024
|
}
|
|
1065
1025
|
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
1026
|
if (this.nextWakeAt && loop.status === "active") {
|
|
1071
1027
|
lines.push(
|
|
1072
|
-
|
|
1028
|
+
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1073
1029
|
this.noOpStreak > 0
|
|
1074
1030
|
? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
|
|
1075
1031
|
: ""
|
|
@@ -1090,36 +1046,29 @@ export class LoopController {
|
|
|
1090
1046
|
// --- command actions ---
|
|
1091
1047
|
|
|
1092
1048
|
/**
|
|
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.
|
|
1049
|
+
* Start a loop on its own objective, the only mode there is: the trailing
|
|
1050
|
+
* text *is* what the loop works on and what `loop_complete` answers for.
|
|
1051
|
+
* With no text there is nothing to work on, and the caller is told so.
|
|
1100
1052
|
*/
|
|
1101
|
-
startLoop(ctx:
|
|
1053
|
+
startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
|
|
1102
1054
|
this.sessionCtx = ctx;
|
|
1103
1055
|
const now = this.now();
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
);
|
|
1112
|
-
return;
|
|
1056
|
+
const objective = start.prompt?.trim();
|
|
1057
|
+
if (!objective) {
|
|
1058
|
+
return {
|
|
1059
|
+
ok: false,
|
|
1060
|
+
message:
|
|
1061
|
+
"A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
|
|
1062
|
+
};
|
|
1113
1063
|
}
|
|
1114
|
-
// A
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
if (
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
return;
|
|
1064
|
+
// A loop with no way to call loop_complete would work, finish, and then be
|
|
1065
|
+
// told to keep working until it hit a cap. Refuse at the door rather than
|
|
1066
|
+
// after the first turn.
|
|
1067
|
+
if (!this.completeToolAvailable()) {
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
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.`,
|
|
1071
|
+
};
|
|
1123
1072
|
}
|
|
1124
1073
|
const expiryMs =
|
|
1125
1074
|
start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
|
|
@@ -1129,44 +1078,35 @@ export class LoopController {
|
|
|
1129
1078
|
: this.settings.compaction.enabled
|
|
1130
1079
|
? this.settings.compaction.threshold
|
|
1131
1080
|
: null;
|
|
1132
|
-
|
|
1081
|
+
const started: LoopState = {
|
|
1133
1082
|
id: randomUUID().slice(0, 8),
|
|
1134
1083
|
status: "active",
|
|
1135
|
-
|
|
1136
|
-
// the authoritative objective for a standalone one; never both.
|
|
1137
|
-
...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
|
|
1138
|
-
...(objective ? { objective } : {}),
|
|
1084
|
+
objective,
|
|
1139
1085
|
intervalMs: start.intervalMs,
|
|
1140
|
-
|
|
1141
|
-
start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
|
|
1142
|
-
maxAutomaticTurns: this.settings.automaticTurns,
|
|
1086
|
+
maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
|
|
1143
1087
|
compactAt,
|
|
1144
1088
|
iteration: 0,
|
|
1145
1089
|
automaticTurns: 0,
|
|
1146
1090
|
startedAt: now,
|
|
1147
1091
|
expiresAt: now + expiryMs,
|
|
1148
1092
|
};
|
|
1093
|
+
this.state = started;
|
|
1149
1094
|
this.wakePending = false;
|
|
1150
1095
|
this.continuationIntent = undefined;
|
|
1151
1096
|
this.noOpStreak = 0;
|
|
1152
1097
|
this.ledgerWarned = false;
|
|
1153
|
-
this.openLedger(
|
|
1098
|
+
this.openLedger(
|
|
1099
|
+
this.state,
|
|
1100
|
+
start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
|
|
1101
|
+
);
|
|
1154
1102
|
this.persist();
|
|
1155
1103
|
this.scheduleTick(start.intervalMs);
|
|
1156
1104
|
this.updateWidget();
|
|
1157
1105
|
const clampNote = start.clamped
|
|
1158
1106
|
? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
|
|
1159
1107
|
: "";
|
|
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
1108
|
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).`,
|
|
1109
|
+
`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
1110
|
"info",
|
|
1171
1111
|
);
|
|
1172
1112
|
if (this.ledger) {
|
|
@@ -1183,13 +1123,12 @@ export class LoopController {
|
|
|
1183
1123
|
// The kickoff anchor: one stored message per loop holding the objective
|
|
1184
1124
|
// data, because the system append exists only while the loop is active.
|
|
1185
1125
|
this.sendKickoffAnchor(ctx);
|
|
1186
|
-
// Immediate kickoff:
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
}
|
|
1126
|
+
// Immediate kickoff: the loop starts working now instead of burning its
|
|
1127
|
+
// first interval idle. A busy session keeps the intent and delivers it at
|
|
1128
|
+
// the settle.
|
|
1129
|
+
this.requestContinuation(started, "kickoff");
|
|
1130
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1131
|
+
return { ok: true, loop: started };
|
|
1193
1132
|
}
|
|
1194
1133
|
|
|
1195
1134
|
/**
|
|
@@ -1202,7 +1141,7 @@ export class LoopController {
|
|
|
1202
1141
|
*/
|
|
1203
1142
|
private sendKickoffAnchor(ctx: ExtensionContext): void {
|
|
1204
1143
|
const loop = this.state;
|
|
1205
|
-
if (!loop ||
|
|
1144
|
+
if (!loop || loop.objective === undefined || !this.ledger) return;
|
|
1206
1145
|
const idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
1207
1146
|
try {
|
|
1208
1147
|
this.pi.sendMessage(
|
|
@@ -1241,19 +1180,6 @@ export class LoopController {
|
|
|
1241
1180
|
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
1242
1181
|
return;
|
|
1243
1182
|
}
|
|
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
1183
|
// Resuming starts a fresh safety epoch: the user has seen why it paused
|
|
1258
1184
|
// and chosen to continue, so the breaker must not trip on stale counters.
|
|
1259
1185
|
const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
|
|
@@ -1263,13 +1189,11 @@ export class LoopController {
|
|
|
1263
1189
|
this.scheduleTick(loop.intervalMs);
|
|
1264
1190
|
this.updateWidget();
|
|
1265
1191
|
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)}.`,
|
|
1192
|
+
`Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
|
|
1269
1193
|
"info",
|
|
1270
1194
|
);
|
|
1271
|
-
// Resuming
|
|
1272
|
-
if (this.state
|
|
1195
|
+
// Resuming resumes the work, not just the heartbeat.
|
|
1196
|
+
if (this.state) {
|
|
1273
1197
|
this.requestContinuation(this.state);
|
|
1274
1198
|
this.dispatchContinuationIfSettled(ctx);
|
|
1275
1199
|
}
|