@hank-warren/pi-loop 0.9.0 → 1.1.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 +26 -0
- package/README.md +35 -32
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +64 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/index.ts +119 -87
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +2 -1
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +120 -25
- package/src/manager.ts +118 -104
- package/src/messages.ts +1 -1
- package/src/objective.ts +25 -0
- package/src/planning.ts +67 -23
- package/src/presentation.ts +55 -18
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +35 -14
- package/src/settings.ts +27 -22
- package/src/state.ts +33 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/loop.ts
CHANGED
|
@@ -89,6 +89,8 @@ import {
|
|
|
89
89
|
buildProposal,
|
|
90
90
|
type LoopPlanningState,
|
|
91
91
|
type LoopProposal,
|
|
92
|
+
type LoopProposalOverrides,
|
|
93
|
+
normalizeGroundRules,
|
|
92
94
|
} from "./planning.js";
|
|
93
95
|
import {
|
|
94
96
|
clearLoopWidget,
|
|
@@ -119,6 +121,15 @@ export const MAX_FALLBACK_BACKOFF = 4;
|
|
|
119
121
|
* would otherwise be the first sign anything was wrong.
|
|
120
122
|
*/
|
|
121
123
|
export const STALL_ATTENTION_MS = 900_000;
|
|
124
|
+
/**
|
|
125
|
+
* How long the expiry wake has to become a turn before the loop stops anyway.
|
|
126
|
+
*
|
|
127
|
+
* `sendUserMessage` is fire-and-forget: Pi swallows an asynchronous delivery
|
|
128
|
+
* failure (an expired credential, a torn-down runner), so a successful return
|
|
129
|
+
* is not proof a turn will start. The ordinary dead-delivery counter cannot
|
|
130
|
+
* catch this one, because after expiry there is no next delivery to count.
|
|
131
|
+
*/
|
|
132
|
+
export const EXPIRY_TURN_GRACE_MS = 60_000;
|
|
122
133
|
|
|
123
134
|
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
124
135
|
export const MAX_DEAD_DELIVERIES = 3;
|
|
@@ -130,10 +141,9 @@ type RunOrigin = "continuation" | "fallback";
|
|
|
130
141
|
* The outcome of a start attempt.
|
|
131
142
|
*
|
|
132
143
|
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
133
|
-
* which tied the only start path to a UI.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* returned and each caller renders it.
|
|
144
|
+
* which tied the only start path to a UI. The approval card's two start
|
|
145
|
+
* actions share that path — one installs the loop here, the other hands it to
|
|
146
|
+
* a fresh session — so the decision is returned and each caller renders it.
|
|
137
147
|
*/
|
|
138
148
|
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
139
149
|
|
|
@@ -164,6 +174,8 @@ export interface LoopControllerOptions {
|
|
|
164
174
|
now?: () => number;
|
|
165
175
|
/** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
|
|
166
176
|
agentDir?: string;
|
|
177
|
+
/** How long the final expiry turn has to start before the loop gives up. */
|
|
178
|
+
expiryTurnGraceMs?: number;
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
export class LoopController {
|
|
@@ -176,6 +188,7 @@ export class LoopController {
|
|
|
176
188
|
private readonly now: () => number;
|
|
177
189
|
readonly settingsPath: string;
|
|
178
190
|
private timer: NodeJS.Timeout | undefined;
|
|
191
|
+
private expiryTimer: NodeJS.Timeout | undefined;
|
|
179
192
|
private nextWakeAt: number | undefined;
|
|
180
193
|
private wakePending = false;
|
|
181
194
|
private sessionCtx: ExtensionContext | undefined;
|
|
@@ -185,6 +198,7 @@ export class LoopController {
|
|
|
185
198
|
ledger: LedgerPaths | undefined;
|
|
186
199
|
private ledgerWarned = false;
|
|
187
200
|
private readonly agentDir: string | undefined;
|
|
201
|
+
private readonly expiryTurnGraceMs: number;
|
|
188
202
|
/** Consecutive fallback wakes that produced a no-op turn. */
|
|
189
203
|
noOpStreak = 0;
|
|
190
204
|
lastContinuation: (ContinuationDecision & { at: number }) | undefined;
|
|
@@ -228,12 +242,14 @@ export class LoopController {
|
|
|
228
242
|
this.now = options.now ?? Date.now;
|
|
229
243
|
this.settingsPath = options.settingsPath ?? loopSettingsPath();
|
|
230
244
|
this.agentDir = options.agentDir;
|
|
245
|
+
this.expiryTurnGraceMs = options.expiryTurnGraceMs ?? EXPIRY_TURN_GRACE_MS;
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
// --- lifecycle ---
|
|
234
249
|
|
|
235
250
|
onSessionStart(ctx: ExtensionContext): void {
|
|
236
251
|
this.clearTimer();
|
|
252
|
+
this.clearExpiryWatchdog();
|
|
237
253
|
this.wakePending = false;
|
|
238
254
|
this.compacting = false;
|
|
239
255
|
this.lastDecision = undefined;
|
|
@@ -273,6 +289,7 @@ export class LoopController {
|
|
|
273
289
|
// A wait whose deadline passed while the session was away is due now.
|
|
274
290
|
this.restoreWaitTimer();
|
|
275
291
|
this.armFallback();
|
|
292
|
+
this.armExpiryWatchdog();
|
|
276
293
|
// A loop handed over from another session has never had its first turn.
|
|
277
294
|
if (this.state.handoff) this.consumeHandoff(ctx);
|
|
278
295
|
}
|
|
@@ -293,7 +310,7 @@ export class LoopController {
|
|
|
293
310
|
this.state = rest;
|
|
294
311
|
this.persist();
|
|
295
312
|
ctx.ui.notify(
|
|
296
|
-
"Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you
|
|
313
|
+
"Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
|
|
297
314
|
"info",
|
|
298
315
|
);
|
|
299
316
|
this.sendKickoffAnchor(ctx);
|
|
@@ -305,6 +322,7 @@ export class LoopController {
|
|
|
305
322
|
// Withdraw the signal: the process may outlive this session.
|
|
306
323
|
publishLoopEnv(undefined);
|
|
307
324
|
this.clearTimer();
|
|
325
|
+
this.clearExpiryWatchdog();
|
|
308
326
|
this.waitTimer.clear();
|
|
309
327
|
this.wakePending = false;
|
|
310
328
|
this.continuationIntent = undefined;
|
|
@@ -371,14 +389,14 @@ export class LoopController {
|
|
|
371
389
|
case "usage-limited":
|
|
372
390
|
this.transition(
|
|
373
391
|
"paused",
|
|
374
|
-
"the provider reports the usage limit is reached; resume
|
|
392
|
+
"the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
|
|
375
393
|
"usage limit reached",
|
|
376
394
|
);
|
|
377
395
|
return true;
|
|
378
396
|
case "fatal":
|
|
379
397
|
this.transition(
|
|
380
398
|
"paused",
|
|
381
|
-
"the turn failed with an error a retry cannot fix; resolve it, then /loop
|
|
399
|
+
"the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
|
|
382
400
|
"unrecoverable provider error",
|
|
383
401
|
);
|
|
384
402
|
return true;
|
|
@@ -386,7 +404,7 @@ export class LoopController {
|
|
|
386
404
|
// Esc, or another extension stopping the turn. A loop-caused run
|
|
387
405
|
// that the user interrupted must not be immediately re-sent.
|
|
388
406
|
if (origin === undefined) return false;
|
|
389
|
-
this.transition("paused", "the turn was interrupted; resume
|
|
407
|
+
this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
|
|
390
408
|
return true;
|
|
391
409
|
case "context-overflow":
|
|
392
410
|
// The request no longer fits: compact first, then continue. The
|
|
@@ -408,7 +426,7 @@ export class LoopController {
|
|
|
408
426
|
* The no-progress breaker: consecutive tool-free loop turns with identical
|
|
409
427
|
* visible output pause the loop instead of waking it again forever. It
|
|
410
428
|
* pauses rather than stops, so the loop stays configured and one
|
|
411
|
-
*
|
|
429
|
+
* Resuming from the /loop menu (or the next user prompt) puts it back to work.
|
|
412
430
|
*/
|
|
413
431
|
private enforceNoProgress(
|
|
414
432
|
ctx: ExtensionContext,
|
|
@@ -451,7 +469,7 @@ export class LoopController {
|
|
|
451
469
|
void ctx;
|
|
452
470
|
this.transition(
|
|
453
471
|
"paused",
|
|
454
|
-
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop
|
|
472
|
+
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
|
|
455
473
|
"no progress",
|
|
456
474
|
);
|
|
457
475
|
return true;
|
|
@@ -534,7 +552,7 @@ export class LoopController {
|
|
|
534
552
|
if (!objective) {
|
|
535
553
|
this.transition(
|
|
536
554
|
"paused",
|
|
537
|
-
"it was bound to a goal that is gone and has no objective of its own;
|
|
555
|
+
"it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
|
|
538
556
|
"loop with no objective",
|
|
539
557
|
);
|
|
540
558
|
return true;
|
|
@@ -564,7 +582,7 @@ export class LoopController {
|
|
|
564
582
|
if (this.completeToolAvailable()) return false;
|
|
565
583
|
this.transition(
|
|
566
584
|
"paused",
|
|
567
|
-
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop
|
|
585
|
+
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
|
|
568
586
|
"loop_complete unavailable",
|
|
569
587
|
);
|
|
570
588
|
void ctx;
|
|
@@ -598,7 +616,7 @@ export class LoopController {
|
|
|
598
616
|
if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
|
|
599
617
|
this.transition(
|
|
600
618
|
"paused",
|
|
601
|
-
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop
|
|
619
|
+
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
|
|
602
620
|
"deliveries produce no turns",
|
|
603
621
|
);
|
|
604
622
|
return true;
|
|
@@ -679,7 +697,7 @@ export class LoopController {
|
|
|
679
697
|
* no writable ledger still runs, it just loses the durable record, so the
|
|
680
698
|
* failure is warned once and never repeated.
|
|
681
699
|
*
|
|
682
|
-
* `criteria` is passed at start: the criteria
|
|
700
|
+
* `criteria` is passed at start: the criteria approved with the draft, or
|
|
683
701
|
* the deterministic split of the objective. On restore it is omitted, and
|
|
684
702
|
* the criteria already on disk are authoritative — they are the ones the
|
|
685
703
|
* user saw echoed, and re-deriving them would both discard a proposed set
|
|
@@ -817,7 +835,7 @@ export class LoopController {
|
|
|
817
835
|
// Nothing is scheduled once the timer has fired. `runTick` re-arms it
|
|
818
836
|
// through `scheduleTick` when it pokes, but a busy or compacting
|
|
819
837
|
// session coalesces into `wakePending` instead — and leaving the old
|
|
820
|
-
// deadline here made
|
|
838
|
+
// deadline here made the /loop status screen report a clock time that had
|
|
821
839
|
// already passed.
|
|
822
840
|
this.nextWakeAt = undefined;
|
|
823
841
|
const ctx = this.sessionCtx;
|
|
@@ -834,6 +852,63 @@ export class LoopController {
|
|
|
834
852
|
this.nextWakeAt = undefined;
|
|
835
853
|
}
|
|
836
854
|
|
|
855
|
+
private armExpiryWatchdog(): void {
|
|
856
|
+
this.clearExpiryWatchdog();
|
|
857
|
+
const loop = this.state;
|
|
858
|
+
if (!loop || loop.status !== "active") return;
|
|
859
|
+
const loopId = loop.id;
|
|
860
|
+
const delay = Math.min(MAX_INTERVAL_MS, Math.max(0, loop.expiresAt - this.now()));
|
|
861
|
+
this.expiryTimer = setTimeout(() => {
|
|
862
|
+
this.expiryTimer = undefined;
|
|
863
|
+
const current = this.state;
|
|
864
|
+
if (!current || current.id !== loopId || current.status !== "active") return;
|
|
865
|
+
if (this.now() < current.expiresAt) {
|
|
866
|
+
this.armExpiryWatchdog();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const ctx = this.sessionCtx;
|
|
870
|
+
if (!ctx) {
|
|
871
|
+
this.transition("stopped", "loop expired without an active session context");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const planActive = readPlanModeEnabled(ctx.sessionManager.getBranch());
|
|
875
|
+
const busy = !ctx.isIdle() || ctx.hasPendingMessages();
|
|
876
|
+
if (planActive || busy) {
|
|
877
|
+
this.transition(
|
|
878
|
+
"stopped",
|
|
879
|
+
planActive
|
|
880
|
+
? "loop expired while Plan mode was active"
|
|
881
|
+
: "loop expired while the agent was busy",
|
|
882
|
+
);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
this.runTick(ctx);
|
|
886
|
+
}, delay);
|
|
887
|
+
this.expiryTimer.unref?.();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private clearExpiryWatchdog(): void {
|
|
891
|
+
if (this.expiryTimer) clearTimeout(this.expiryTimer);
|
|
892
|
+
this.expiryTimer = undefined;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The other half of expiry: the wake was handed to Pi, and this stops the
|
|
897
|
+
* loop if it never becomes a run. A run that *did* start leaves
|
|
898
|
+
* `awaitingRun` false, and the settle after it stops the loop normally.
|
|
899
|
+
*/
|
|
900
|
+
private armExpiryTurnGuard(loopId: string): void {
|
|
901
|
+
this.clearExpiryWatchdog();
|
|
902
|
+
this.expiryTimer = setTimeout(() => {
|
|
903
|
+
this.expiryTimer = undefined;
|
|
904
|
+
const current = this.state;
|
|
905
|
+
if (!current || current.id !== loopId || current.status !== "active") return;
|
|
906
|
+
if (!this.awaitingRun) return;
|
|
907
|
+
this.transition("stopped", "the final expiry turn never started");
|
|
908
|
+
}, this.expiryTurnGraceMs);
|
|
909
|
+
this.expiryTimer.unref?.();
|
|
910
|
+
}
|
|
911
|
+
|
|
837
912
|
private gatherEnvironment(ctx: ExtensionContext): TickEnvironment {
|
|
838
913
|
const branch = ctx.sessionManager.getBranch();
|
|
839
914
|
return {
|
|
@@ -921,6 +996,10 @@ export class LoopController {
|
|
|
921
996
|
}
|
|
922
997
|
this.runOrigin = "fallback";
|
|
923
998
|
this.continuationIntent = undefined;
|
|
999
|
+
// Marked directly rather than through noteDelivery(): the dead-delivery
|
|
1000
|
+
// counter pauses a loop that should keep trying, and this one is already
|
|
1001
|
+
// ending. The guard below is what acts on it.
|
|
1002
|
+
this.awaitingRun = true;
|
|
924
1003
|
this.state = {
|
|
925
1004
|
...this.consumeWait(loop),
|
|
926
1005
|
iteration: loop.iteration + 1,
|
|
@@ -929,6 +1008,10 @@ export class LoopController {
|
|
|
929
1008
|
expiring: true,
|
|
930
1009
|
};
|
|
931
1010
|
this.clearTimer();
|
|
1011
|
+
// The wake was accepted, not delivered. Hold one bounded guard so a final
|
|
1012
|
+
// turn that never starts still ends the loop instead of leaving it active
|
|
1013
|
+
// past its deadline with every timer cleared.
|
|
1014
|
+
this.armExpiryTurnGuard(loop.id);
|
|
932
1015
|
this.persist();
|
|
933
1016
|
this.updateWidget();
|
|
934
1017
|
this.sessionCtx?.ui.notify(
|
|
@@ -1047,9 +1130,20 @@ export class LoopController {
|
|
|
1047
1130
|
|
|
1048
1131
|
private transition(status: "paused" | "stopped", why: string, cause?: string): void {
|
|
1049
1132
|
if (!this.state) return;
|
|
1050
|
-
const {
|
|
1051
|
-
|
|
1133
|
+
const {
|
|
1134
|
+
waiting: _waiting,
|
|
1135
|
+
pauseCause: _pauseCause,
|
|
1136
|
+
terminalReason: _terminalReason,
|
|
1137
|
+
...rest
|
|
1138
|
+
} = this.state;
|
|
1139
|
+
this.state = {
|
|
1140
|
+
...rest,
|
|
1141
|
+
status,
|
|
1142
|
+
...(cause ? { pauseCause: cause } : {}),
|
|
1143
|
+
...(status === "stopped" ? { terminalReason: why } : {}),
|
|
1144
|
+
};
|
|
1052
1145
|
this.clearTimer();
|
|
1146
|
+
this.clearExpiryWatchdog();
|
|
1053
1147
|
this.waitTimer.clear();
|
|
1054
1148
|
this.wakePending = false;
|
|
1055
1149
|
this.continuationIntent = undefined;
|
|
@@ -1128,9 +1222,9 @@ export class LoopController {
|
|
|
1128
1222
|
|
|
1129
1223
|
statusLines(ctx: ExtensionContext): string[] {
|
|
1130
1224
|
const loop = this.state;
|
|
1131
|
-
if (!loop) return ["No loop in this session.
|
|
1225
|
+
if (!loop) return ["No loop in this session. Run /loop to plan one."];
|
|
1132
1226
|
const lines = [
|
|
1133
|
-
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
1227
|
+
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : loop.terminalReason ? ` (${loop.terminalReason})` : ""}`,
|
|
1134
1228
|
...(loop.waiting
|
|
1135
1229
|
? [
|
|
1136
1230
|
`Waiting: ${loop.waiting.reason}${
|
|
@@ -1208,10 +1302,7 @@ export class LoopController {
|
|
|
1208
1302
|
}
|
|
1209
1303
|
|
|
1210
1304
|
/** Record a drafted loop for approval, replacing any previous draft. */
|
|
1211
|
-
propose(
|
|
1212
|
-
objective: string,
|
|
1213
|
-
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
1214
|
-
): LoopProposal {
|
|
1305
|
+
propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
|
|
1215
1306
|
const proposal = buildProposal(
|
|
1216
1307
|
objective,
|
|
1217
1308
|
{
|
|
@@ -1287,7 +1378,7 @@ export class LoopController {
|
|
|
1287
1378
|
return {
|
|
1288
1379
|
ok: false,
|
|
1289
1380
|
message:
|
|
1290
|
-
"A loop needs something to work on.
|
|
1381
|
+
"A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
|
|
1291
1382
|
};
|
|
1292
1383
|
}
|
|
1293
1384
|
// A loop with no way to call loop_complete would work, finish, and then be
|
|
@@ -1307,10 +1398,12 @@ export class LoopController {
|
|
|
1307
1398
|
: this.settings.compaction.enabled
|
|
1308
1399
|
? this.settings.compaction.threshold
|
|
1309
1400
|
: null;
|
|
1401
|
+
const groundRules = normalizeGroundRules(start.groundRules);
|
|
1310
1402
|
const loop: LoopState = {
|
|
1311
1403
|
id: randomUUID().slice(0, 8),
|
|
1312
1404
|
status: "active",
|
|
1313
1405
|
objective,
|
|
1406
|
+
...(groundRules ? { groundRules } : {}),
|
|
1314
1407
|
intervalMs: start.intervalMs,
|
|
1315
1408
|
maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
|
|
1316
1409
|
compactAt,
|
|
@@ -1351,12 +1444,13 @@ export class LoopController {
|
|
|
1351
1444
|
this.openLedger(started, built.criteria);
|
|
1352
1445
|
this.persist();
|
|
1353
1446
|
this.scheduleTick(started.intervalMs);
|
|
1447
|
+
this.armExpiryWatchdog();
|
|
1354
1448
|
this.updateWidget();
|
|
1355
1449
|
const clampNote = built.clamped
|
|
1356
1450
|
? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
|
|
1357
1451
|
: "";
|
|
1358
1452
|
ctx.ui.notify(
|
|
1359
|
-
`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
|
|
1453
|
+
`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 stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
1360
1454
|
"info",
|
|
1361
1455
|
);
|
|
1362
1456
|
if (this.ledger) {
|
|
@@ -1439,6 +1533,7 @@ export class LoopController {
|
|
|
1439
1533
|
this.noOpStreak = 0;
|
|
1440
1534
|
this.persist();
|
|
1441
1535
|
this.scheduleTick(loop.intervalMs);
|
|
1536
|
+
this.armExpiryWatchdog();
|
|
1442
1537
|
this.updateWidget();
|
|
1443
1538
|
ctx.ui.notify(
|
|
1444
1539
|
`Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
|