@hank-warren/pi-loop 0.6.0 → 0.8.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 +60 -0
- package/README.md +25 -52
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +155 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +54 -13
- package/src/decide.ts +13 -16
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +204 -10
- package/src/loop.ts +185 -55
- package/src/manager.ts +80 -11
- package/src/messages.ts +12 -5
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/settings.ts +48 -17
- package/src/start-tool.ts +56 -2
- package/src/state.ts +40 -20
- package/src/widget.ts +103 -11
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/loop.ts
CHANGED
|
@@ -34,12 +34,20 @@ import {
|
|
|
34
34
|
type TickDecision,
|
|
35
35
|
type TickEnvironment,
|
|
36
36
|
} from "./decide.js";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
formatClock,
|
|
39
|
+
formatDuration,
|
|
40
|
+
formatElapsed,
|
|
41
|
+
MAX_INTERVAL_MS,
|
|
42
|
+
parseDuration,
|
|
43
|
+
} from "./interval.js";
|
|
38
44
|
import {
|
|
39
45
|
createLedger,
|
|
46
|
+
criteriaFromDescriptions,
|
|
40
47
|
deriveCriteria,
|
|
41
48
|
type LedgerPaths,
|
|
42
49
|
ledgerPaths,
|
|
50
|
+
type LoopCriterion,
|
|
43
51
|
readCriteria,
|
|
44
52
|
} from "./ledger.js";
|
|
45
53
|
import {
|
|
@@ -75,7 +83,17 @@ import {
|
|
|
75
83
|
type ResolvedWaitDelay,
|
|
76
84
|
} from "./wait.js";
|
|
77
85
|
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
78
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
buildProposal,
|
|
88
|
+
type LoopPlanningState,
|
|
89
|
+
type LoopProposal,
|
|
90
|
+
} from "./planning.js";
|
|
91
|
+
import {
|
|
92
|
+
clearLoopWidget,
|
|
93
|
+
loopWidgetLine,
|
|
94
|
+
type LoopWidgetView,
|
|
95
|
+
updateLoopWidget,
|
|
96
|
+
} from "./widget.js";
|
|
79
97
|
|
|
80
98
|
export const LOOP_STATUS_KEY = "loop";
|
|
81
99
|
|
|
@@ -89,6 +107,17 @@ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
|
89
107
|
*/
|
|
90
108
|
export const MAX_FALLBACK_BACKOFF = 4;
|
|
91
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How long a single run may stay open before the widget calls it blocked.
|
|
112
|
+
*
|
|
113
|
+
* Deliberately generous: a long build, a big test suite, or a deep subagent
|
|
114
|
+
* fan-out are all legitimately busy for a while, and crying blocked on honest
|
|
115
|
+
* work would train the signal to be ignored. Fifteen minutes with no completed
|
|
116
|
+
* turn is well past ordinary work and well short of the seven-day expiry that
|
|
117
|
+
* would otherwise be the first sign anything was wrong.
|
|
118
|
+
*/
|
|
119
|
+
export const STALL_ATTENTION_MS = 900_000;
|
|
120
|
+
|
|
92
121
|
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
93
122
|
export const MAX_DEAD_DELIVERIES = 3;
|
|
94
123
|
|
|
@@ -152,6 +181,30 @@ export class LoopController {
|
|
|
152
181
|
private awaitingRun = false;
|
|
153
182
|
/** Set when an interrupted turn needs a compaction before the loop continues. */
|
|
154
183
|
private compactionRequested = false;
|
|
184
|
+
/**
|
|
185
|
+
* When the in-flight run started, or undefined between runs.
|
|
186
|
+
*
|
|
187
|
+
* A session blocked on a modal prompt — a permission approval, a question,
|
|
188
|
+
* anything that waits for a human — is `busy`, and `busy` makes every
|
|
189
|
+
* continuation and every fallback tick skip. No turn completes, so the cap
|
|
190
|
+
* never trips and the no-progress breaker (which counts turns) never fires.
|
|
191
|
+
* Expiry is the only thing left, up to seven days later. The engine cannot
|
|
192
|
+
* see the prompt, but it can see that a run has been open far longer than
|
|
193
|
+
* work usually takes, which is enough to put an attention state on the
|
|
194
|
+
* widget instead of a cheerful next-wake time.
|
|
195
|
+
*/
|
|
196
|
+
private busySince: number | undefined;
|
|
197
|
+
/** How long the current run has been open, once past the stall threshold. */
|
|
198
|
+
private blockedForMs: number | undefined;
|
|
199
|
+
/**
|
|
200
|
+
* Loop planning: the drafting conversation that precedes a loop.
|
|
201
|
+
*
|
|
202
|
+
* In-memory on purpose. A draft is a short interactive flow, and persisting
|
|
203
|
+
* it would mean a half-written objective could outlive the conversation that
|
|
204
|
+
* produced it and be approved later out of context. The loop it becomes is
|
|
205
|
+
* persisted; the draft is not.
|
|
206
|
+
*/
|
|
207
|
+
planning: LoopPlanningState = { active: false };
|
|
155
208
|
|
|
156
209
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
157
210
|
this.pi = pi;
|
|
@@ -173,6 +226,9 @@ export class LoopController {
|
|
|
173
226
|
this.noOpStreak = 0;
|
|
174
227
|
this.deadDeliveries = 0;
|
|
175
228
|
this.awaitingRun = false;
|
|
229
|
+
this.busySince = undefined;
|
|
230
|
+
this.blockedForMs = undefined;
|
|
231
|
+
this.planning = { active: false };
|
|
176
232
|
this.sessionCtx = ctx;
|
|
177
233
|
|
|
178
234
|
const loaded = readLoopSettings(this.settingsPath);
|
|
@@ -222,11 +278,16 @@ export class LoopController {
|
|
|
222
278
|
this.sessionCtx = ctx;
|
|
223
279
|
this.awaitingRun = false;
|
|
224
280
|
this.deadDeliveries = 0;
|
|
281
|
+
this.busySince = this.now();
|
|
282
|
+
this.blockedForMs = undefined;
|
|
225
283
|
}
|
|
226
284
|
|
|
227
285
|
onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
|
|
228
286
|
this.sessionCtx = ctx;
|
|
229
287
|
this.awaitingRun = false;
|
|
288
|
+
// A completed turn is proof the session was not blocked on a human.
|
|
289
|
+
this.busySince = undefined;
|
|
290
|
+
this.blockedForMs = undefined;
|
|
230
291
|
const origin = this.runOrigin;
|
|
231
292
|
this.runOrigin = undefined;
|
|
232
293
|
const loop = this.state;
|
|
@@ -570,14 +631,21 @@ export class LoopController {
|
|
|
570
631
|
* Create (or adopt) the loop's ledger. Best-effort by design: a loop with
|
|
571
632
|
* no writable ledger still runs, it just loses the durable record, so the
|
|
572
633
|
* failure is warned once and never repeated.
|
|
634
|
+
*
|
|
635
|
+
* `criteria` is passed at start: the criteria proposed at `loop_start`, or
|
|
636
|
+
* the deterministic split of the objective. On restore it is omitted, and
|
|
637
|
+
* the criteria already on disk are authoritative — they are the ones the
|
|
638
|
+
* user saw echoed, and re-deriving them would both discard a proposed set
|
|
639
|
+
* and reset whatever `passes` flips the loop has earned.
|
|
573
640
|
*/
|
|
574
|
-
private openLedger(loop: LoopState): void {
|
|
641
|
+
private openLedger(loop: LoopState, criteria?: LoopCriterion[]): void {
|
|
575
642
|
if (loop.objective === undefined) {
|
|
576
643
|
this.ledger = undefined;
|
|
577
644
|
return;
|
|
578
645
|
}
|
|
579
646
|
const paths = ledgerPaths(loop.id, this.agentDir);
|
|
580
|
-
const
|
|
647
|
+
const contents = criteria ?? readCriteria(paths) ?? deriveCriteria(loop.objective);
|
|
648
|
+
const failure = createLedger(paths, loop.objective, contents);
|
|
581
649
|
if (failure) {
|
|
582
650
|
this.ledger = undefined;
|
|
583
651
|
if (!this.ledgerWarned) {
|
|
@@ -735,6 +803,13 @@ export class LoopController {
|
|
|
735
803
|
// Busy or compacting: coalesce into one pending wake that
|
|
736
804
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
737
805
|
this.wakePending = true;
|
|
806
|
+
if (decision.reason === "agent-busy") this.noteBusyTick(env.now);
|
|
807
|
+
// Keep the heartbeat armed while busy. Coalescing is about
|
|
808
|
+
// deliveries, not about the timer: wakePending is a boolean, so a
|
|
809
|
+
// further tick cannot produce a second wake. Without re-arming,
|
|
810
|
+
// the first busy tick would be the last one ever taken, and a
|
|
811
|
+
// session blocked on a prompt would never be noticed at all.
|
|
812
|
+
this.armFallback();
|
|
738
813
|
}
|
|
739
814
|
this.updateWidget();
|
|
740
815
|
return;
|
|
@@ -757,12 +832,7 @@ export class LoopController {
|
|
|
757
832
|
this.transition("stopped", "loop expired (the expiry was reached)");
|
|
758
833
|
return;
|
|
759
834
|
case "stop":
|
|
760
|
-
this.transition(
|
|
761
|
-
"stopped",
|
|
762
|
-
decision.reason === "max-automatic-turns"
|
|
763
|
-
? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
|
|
764
|
-
: `the ${loop.maxIterations}-iteration cap was reached`,
|
|
765
|
-
);
|
|
835
|
+
this.transition("stopped", `the ${loop.maxTurns}-turn cap was reached`);
|
|
766
836
|
return;
|
|
767
837
|
}
|
|
768
838
|
}
|
|
@@ -809,9 +879,9 @@ export class LoopController {
|
|
|
809
879
|
|
|
810
880
|
/**
|
|
811
881
|
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
812
|
-
* session), and
|
|
813
|
-
*
|
|
814
|
-
*
|
|
882
|
+
* session), and a turn persisted before the send would burn the cap on a
|
|
883
|
+
* poke that never arrived; on a throw the loop re-arms on the same cadence
|
|
884
|
+
* and retries at the next wake.
|
|
815
885
|
*/
|
|
816
886
|
private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
|
|
817
887
|
const loop = this.state;
|
|
@@ -933,44 +1003,63 @@ export class LoopController {
|
|
|
933
1003
|
this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
|
|
934
1004
|
}
|
|
935
1005
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1006
|
+
/**
|
|
1007
|
+
* A run has been open past the stall threshold, so the most likely
|
|
1008
|
+
* explanation is a prompt waiting for a human. Reported, never acted on:
|
|
1009
|
+
* approving on the user's behalf is exactly the boundary pi-auto-permissions
|
|
1010
|
+
* exists to hold.
|
|
1011
|
+
*/
|
|
1012
|
+
private noteBusyTick(now: number): void {
|
|
1013
|
+
const since = this.busySince;
|
|
1014
|
+
if (since === undefined) return;
|
|
1015
|
+
const openFor = now - since;
|
|
1016
|
+
this.blockedForMs = openFor >= STALL_ATTENTION_MS ? openFor : undefined;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The view both surfaces render, or undefined when there is nothing to show.
|
|
1021
|
+
*
|
|
1022
|
+
* One function feeds the widget and the footer because two hand-rolled
|
|
1023
|
+
* formatters drifted: the footer handled `loop.waiting` and the widget did
|
|
1024
|
+
* not, so a loop blocked on CI read as an ordinary scheduled loop above the
|
|
1025
|
+
* editor while the footer said it was waiting.
|
|
1026
|
+
*/
|
|
1027
|
+
widgetView(): LoopWidgetView | undefined {
|
|
939
1028
|
const loop = this.state;
|
|
940
|
-
updateLoopWidget(
|
|
941
|
-
ui,
|
|
942
|
-
loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
|
|
943
|
-
);
|
|
944
1029
|
if (!loop || loop.status === "stopped") {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (loop.status === "paused") {
|
|
949
|
-
ui.setStatus(
|
|
950
|
-
LOOP_STATUS_KEY,
|
|
951
|
-
loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
|
|
952
|
-
);
|
|
953
|
-
return;
|
|
1030
|
+
if (!this.planning.active) return undefined;
|
|
1031
|
+
const proposed = this.planning.proposal?.criteria.length;
|
|
1032
|
+
return { kind: "planning", ...(proposed === undefined ? {} : { proposedCriteria: proposed }) };
|
|
954
1033
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
);
|
|
1034
|
+
const criteria = this.criteriaProgress();
|
|
1035
|
+
return {
|
|
1036
|
+
kind: "loop",
|
|
1037
|
+
loop,
|
|
1038
|
+
wakePending: this.wakePending,
|
|
1039
|
+
nextWakeAt: this.nextWakeAt,
|
|
1040
|
+
...(criteria ? { criteria } : {}),
|
|
1041
|
+
...(this.blockedForMs === undefined ? {} : { blockedForMs: this.blockedForMs }),
|
|
1042
|
+
now: this.now(),
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Criteria progress for the widget. Re-read rather than cached: this runs on
|
|
1048
|
+
* discrete state transitions, not per frame, and a stale count right after a
|
|
1049
|
+
* criterion is marked would undercut the one number the line exists to show.
|
|
1050
|
+
*/
|
|
1051
|
+
private criteriaProgress(): { met: number; total: number } | undefined {
|
|
1052
|
+
const criteria = this.criteria();
|
|
1053
|
+
if (!criteria || criteria.length === 0) return undefined;
|
|
1054
|
+
return { met: criteria.filter((criterion) => criterion.passes).length, total: criteria.length };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
updateWidget(): void {
|
|
1058
|
+
const ui = this.sessionCtx?.ui;
|
|
1059
|
+
if (!ui) return;
|
|
1060
|
+
const view = this.widgetView();
|
|
1061
|
+
updateLoopWidget(ui, view);
|
|
1062
|
+
ui.setStatus(LOOP_STATUS_KEY, view ? loopWidgetLine(view) : undefined);
|
|
974
1063
|
}
|
|
975
1064
|
|
|
976
1065
|
statusLines(ctx: ExtensionContext): string[] {
|
|
@@ -991,8 +1080,8 @@ export class LoopController {
|
|
|
991
1080
|
? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
|
|
992
1081
|
: []),
|
|
993
1082
|
`Interval: every ${formatDuration(loop.intervalMs)}`,
|
|
994
|
-
`
|
|
995
|
-
`
|
|
1083
|
+
`Loop turns: ${loop.automaticTurns}${loop.maxTurns === null ? " (unlimited)" : ` of ${loop.maxTurns}`}`,
|
|
1084
|
+
`Fallback wakes delivered: ${loop.iteration}`,
|
|
996
1085
|
`Started: ${new Date(loop.startedAt).toLocaleString()}`,
|
|
997
1086
|
`Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
|
|
998
1087
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
@@ -1019,7 +1108,11 @@ export class LoopController {
|
|
|
1019
1108
|
}
|
|
1020
1109
|
}
|
|
1021
1110
|
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
1022
|
-
|
|
1111
|
+
// A pending wake is delivered at the next settle, whatever the timer says.
|
|
1112
|
+
// The heartbeat stays armed while the agent is busy so a stalled session is
|
|
1113
|
+
// still noticed, which means nextWakeAt can be set here even though no wake
|
|
1114
|
+
// will fire at that time — claiming it would be a stale clock time.
|
|
1115
|
+
if (this.nextWakeAt && !this.wakePending && loop.status === "active") {
|
|
1023
1116
|
lines.push(
|
|
1024
1117
|
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1025
1118
|
this.noOpStreak > 0
|
|
@@ -1039,6 +1132,42 @@ export class LoopController {
|
|
|
1039
1132
|
return lines;
|
|
1040
1133
|
}
|
|
1041
1134
|
|
|
1135
|
+
// --- planning ---
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Open the drafting conversation. Idempotent: running /loop again while
|
|
1139
|
+
* planning shows the current draft rather than restarting the flow.
|
|
1140
|
+
*/
|
|
1141
|
+
beginPlanning(): void {
|
|
1142
|
+
this.planning = { active: true };
|
|
1143
|
+
this.updateWidget();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Record a drafted loop for approval, replacing any previous draft. */
|
|
1147
|
+
propose(
|
|
1148
|
+
objective: string,
|
|
1149
|
+
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
1150
|
+
): LoopProposal {
|
|
1151
|
+
const proposal = buildProposal(
|
|
1152
|
+
objective,
|
|
1153
|
+
{
|
|
1154
|
+
intervalMs: parseDuration(this.settings.defaultInterval) ?? 600_000,
|
|
1155
|
+
maxTurns: this.settings.maxTurns,
|
|
1156
|
+
expiresInMs: parseDuration(this.settings.maxLoopDuration) ?? 604_800_000,
|
|
1157
|
+
},
|
|
1158
|
+
this.now(),
|
|
1159
|
+
overrides,
|
|
1160
|
+
);
|
|
1161
|
+
this.planning = { active: true, proposal };
|
|
1162
|
+
this.updateWidget();
|
|
1163
|
+
return proposal;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
endPlanning(): void {
|
|
1167
|
+
this.planning = { active: false };
|
|
1168
|
+
this.updateWidget();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1042
1171
|
// --- command actions ---
|
|
1043
1172
|
|
|
1044
1173
|
/**
|
|
@@ -1079,9 +1208,7 @@ export class LoopController {
|
|
|
1079
1208
|
status: "active",
|
|
1080
1209
|
objective,
|
|
1081
1210
|
intervalMs: start.intervalMs,
|
|
1082
|
-
|
|
1083
|
-
start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
|
|
1084
|
-
maxAutomaticTurns: this.settings.automaticTurns,
|
|
1211
|
+
maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
|
|
1085
1212
|
compactAt,
|
|
1086
1213
|
iteration: 0,
|
|
1087
1214
|
automaticTurns: 0,
|
|
@@ -1093,7 +1220,10 @@ export class LoopController {
|
|
|
1093
1220
|
this.continuationIntent = undefined;
|
|
1094
1221
|
this.noOpStreak = 0;
|
|
1095
1222
|
this.ledgerWarned = false;
|
|
1096
|
-
this.openLedger(
|
|
1223
|
+
this.openLedger(
|
|
1224
|
+
this.state,
|
|
1225
|
+
start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
|
|
1226
|
+
);
|
|
1097
1227
|
this.persist();
|
|
1098
1228
|
this.scheduleTick(start.intervalMs);
|
|
1099
1229
|
this.updateWidget();
|
package/src/manager.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
saveLoopSettings,
|
|
15
15
|
} from "./settings.js";
|
|
16
16
|
import { parseDuration } from "./interval.js";
|
|
17
|
+
import { renderProposalCard } from "./planning.js";
|
|
17
18
|
|
|
18
19
|
export async function showLoopManager(
|
|
19
20
|
controller: LoopController,
|
|
@@ -154,8 +155,7 @@ export async function showLoopSettings(
|
|
|
154
155
|
for (;;) {
|
|
155
156
|
const s = controller.settings;
|
|
156
157
|
const items = [
|
|
157
|
-
`Max
|
|
158
|
-
`Max automatic turns: ${s.automaticTurns === null ? "Unlimited" : s.automaticTurns}`,
|
|
158
|
+
`Max loop turns: ${s.maxTurns === null ? "Unlimited" : s.maxTurns}`,
|
|
159
159
|
`No-progress breaker: ${s.noProgressTurns === null ? "Off" : `after ${s.noProgressTurns} repeats`}`,
|
|
160
160
|
`Max loop duration: ${s.maxLoopDuration}`,
|
|
161
161
|
`Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
|
|
@@ -167,14 +167,10 @@ export async function showLoopSettings(
|
|
|
167
167
|
if (index === 0) {
|
|
168
168
|
// Unlimited is a first-class choice, not a magic word typed into a free
|
|
169
169
|
// text box: it is only reachable by discovery otherwise.
|
|
170
|
-
const cap = await editCap(ctx, "Max
|
|
170
|
+
const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
|
|
171
171
|
if (cap === undefined) continue;
|
|
172
|
-
next.
|
|
172
|
+
next.maxTurns = cap === "unlimited" ? null : cap;
|
|
173
173
|
} else if (index === 1) {
|
|
174
|
-
const cap = await editCap(ctx, "Max automatic turns", "no turn cap", s.automaticTurns);
|
|
175
|
-
if (cap === undefined) continue;
|
|
176
|
-
next.automaticTurns = cap === "unlimited" ? null : cap;
|
|
177
|
-
} else if (index === 2) {
|
|
178
174
|
const cap = await editCap(
|
|
179
175
|
ctx,
|
|
180
176
|
"No-progress breaker",
|
|
@@ -183,7 +179,7 @@ export async function showLoopSettings(
|
|
|
183
179
|
);
|
|
184
180
|
if (cap === undefined) continue;
|
|
185
181
|
next.noProgressTurns = cap === "unlimited" ? null : cap;
|
|
186
|
-
} else if (index ===
|
|
182
|
+
} else if (index === 2) {
|
|
187
183
|
const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
|
|
188
184
|
if (value === undefined) continue;
|
|
189
185
|
if (parseDuration(value.trim()) === undefined) {
|
|
@@ -191,7 +187,7 @@ export async function showLoopSettings(
|
|
|
191
187
|
continue;
|
|
192
188
|
}
|
|
193
189
|
next.maxLoopDuration = value.trim();
|
|
194
|
-
} else if (index ===
|
|
190
|
+
} else if (index === 3) {
|
|
195
191
|
if (s.compaction.enabled) next.compaction.enabled = false;
|
|
196
192
|
else {
|
|
197
193
|
const value = await ctx.ui.input(
|
|
@@ -216,7 +212,7 @@ export async function showLoopSettings(
|
|
|
216
212
|
}
|
|
217
213
|
|
|
218
214
|
/**
|
|
219
|
-
* One cap editor for
|
|
215
|
+
* One cap editor for every cap. Unlimited is a first-class choice, not a
|
|
220
216
|
* magic word typed into a free text box: it is only reachable by discovery
|
|
221
217
|
* otherwise. The typed word still works, so the /loop --max vocabulary and
|
|
222
218
|
* muscle memory keep working.
|
|
@@ -271,3 +267,76 @@ function applySettings(
|
|
|
271
267
|
controller.settings = next;
|
|
272
268
|
return true;
|
|
273
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The approval card and its actions.
|
|
273
|
+
*
|
|
274
|
+
* This is where a planned loop starts, and the approval is what authorises it.
|
|
275
|
+
* `loop_start`'s gate exists because a loop is self-continuing and must never
|
|
276
|
+
* begin on model initiative; an explicit choice here, on a card showing the
|
|
277
|
+
* objective, the derived criteria, the cadence and the caps, is stronger
|
|
278
|
+
* evidence of intent than a typed token, so it starts the loop directly rather
|
|
279
|
+
* than routing back through a tool the model could reach on its own.
|
|
280
|
+
*/
|
|
281
|
+
export async function showLoopApproval(
|
|
282
|
+
controller: LoopController,
|
|
283
|
+
ctx: ExtensionCommandContext,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const proposal = controller.planning.proposal;
|
|
286
|
+
if (!proposal) return;
|
|
287
|
+
ctx.ui.notify(renderProposalCard(proposal).join("\n"), "info");
|
|
288
|
+
if (ctx.mode !== "tui") {
|
|
289
|
+
ctx.ui.notify(
|
|
290
|
+
"Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
|
|
291
|
+
"info",
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const choice = await ctx.ui.select("Start this loop?", [
|
|
296
|
+
"Start loop",
|
|
297
|
+
"Change cadence",
|
|
298
|
+
"Keep editing",
|
|
299
|
+
"Cancel",
|
|
300
|
+
]);
|
|
301
|
+
switch (choice) {
|
|
302
|
+
case "Start loop": {
|
|
303
|
+
const result = controller.startLoop(ctx, {
|
|
304
|
+
kind: "start",
|
|
305
|
+
requestedMs: proposal.intervalMs,
|
|
306
|
+
intervalMs: proposal.intervalMs,
|
|
307
|
+
clamped: false,
|
|
308
|
+
maxTurns: proposal.maxTurns,
|
|
309
|
+
expiresInMs: proposal.expiresInMs,
|
|
310
|
+
prompt: proposal.objective,
|
|
311
|
+
});
|
|
312
|
+
if (result.ok) controller.endPlanning();
|
|
313
|
+
else ctx.ui.notify(result.message, "error");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
case "Change cadence": {
|
|
317
|
+
const text = await ctx.ui.input(
|
|
318
|
+
"Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
|
|
319
|
+
formatDuration(proposal.intervalMs),
|
|
320
|
+
);
|
|
321
|
+
if (text === undefined) return;
|
|
322
|
+
const interval = parseInterval(text.trim());
|
|
323
|
+
if (!interval) {
|
|
324
|
+
ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
controller.propose(proposal.objective, {
|
|
328
|
+
intervalMs: interval.effectiveMs,
|
|
329
|
+
maxTurns: proposal.maxTurns,
|
|
330
|
+
expiresInMs: proposal.expiresInMs,
|
|
331
|
+
});
|
|
332
|
+
await showLoopApproval(controller, ctx);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
case "Keep editing":
|
|
336
|
+
ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
|
|
337
|
+
return;
|
|
338
|
+
default:
|
|
339
|
+
controller.endPlanning();
|
|
340
|
+
ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -15,23 +15,30 @@ import type { LoopState } from "./state.js";
|
|
|
15
15
|
/** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
|
|
16
16
|
export type ContinuationKind = "kickoff" | "continue" | "reanchor";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The wake ordinal, and only the ordinal. It used to read `4/25`, the wake
|
|
20
|
+
* counter against the delivered-wake cap; that cap is gone, collapsed into
|
|
21
|
+
* the single loop-turn cap, and pairing a wake number with a turn cap would
|
|
22
|
+
* have been a number that reads as a budget and is not one. The cap is shown
|
|
23
|
+
* to the *user*, in the widget and `/loop status`, which is who it is for.
|
|
24
|
+
*/
|
|
25
|
+
function formatWakeOrdinal(loop: LoopState): string {
|
|
26
|
+
return `${loop.iteration + 1}`;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* The poke. Deliberately slim: the loop's own objective injection puts the
|
|
25
31
|
* objective and loop-mode rules in the system prompt of every turn, so
|
|
26
32
|
* restating them here would store a duplicate copy on every wake. Only the
|
|
27
|
-
* dynamic per-wake state (
|
|
33
|
+
* dynamic per-wake state (the wake ordinal, the reason) belongs in this tail
|
|
34
|
+
* message.
|
|
28
35
|
*/
|
|
29
36
|
export function buildObjectivePoke(
|
|
30
37
|
loop: LoopState,
|
|
31
38
|
reason: "objective-stalled" | "wait-elapsed" = "objective-stalled",
|
|
32
39
|
): string {
|
|
33
40
|
const lines = [
|
|
34
|
-
`Scheduled loop wakeup ${
|
|
41
|
+
`Scheduled loop wakeup ${formatWakeOrdinal(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
35
42
|
reason === "wait-elapsed"
|
|
36
43
|
? "The wait you asked for has elapsed. Re-check the external state it depended on and continue — the objective and loop-mode rules are in the system prompt."
|
|
37
44
|
: "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
|
package/src/objective.ts
CHANGED
|
@@ -44,6 +44,16 @@ export function buildLoopObjectivePrompt(
|
|
|
44
44
|
"- Effort exhaustion is not completion. Running long, running out of ideas, or approaching a cap is never a reason to call loop_complete.",
|
|
45
45
|
"- Call loop_complete with this exact loop_id only when every completion criterion is proven, passing one cited piece of evidence per criterion id. It stops the wakeups; it does not assert that unrelated work is finished.",
|
|
46
46
|
"- If the criteria are not met, keep working and expect another wake.",
|
|
47
|
+
// Autonomy. Stated as mechanics rather than as a rule to obey, because the
|
|
48
|
+
// mechanics are the reason: a blocked session is `busy`, and `busy` makes
|
|
49
|
+
// every continuation and every fallback tick skip. Nothing enumerates
|
|
50
|
+
// tools here on purpose — the user's other extensions are unknowable, and
|
|
51
|
+
// a blacklist would go stale the moment one of them ships a new prompt.
|
|
52
|
+
"- You are running unattended. A prompt that blocks on a human — a permission approval, a clarifying question, any tool that waits for an answer — does not pause this loop, it deadlocks it: the session stays busy, so no continuation fires, no wake lands, and no cap trips. Nothing ends the loop until it expires. Plan to work without prompting.",
|
|
53
|
+
"- Decide rather than ask. Take the reversible option, record the decision and the reasoning behind it in the ledger, and keep going. A decision written down is worth more than a question nobody is there to answer.",
|
|
54
|
+
"- When you genuinely need a human, call loop_wait: it is the only way to ask that does not deadlock the session. Put the options in the ledger first, so the answer can be one word.",
|
|
55
|
+
"- Never reshape a command to get past a permission prompt. A blocked command means stop and ask through loop_wait, never find another way around it.",
|
|
56
|
+
"- Prefer the undoable. Nobody is watching to catch a bad call, so when two paths are close, take the one that is cheap to reverse.",
|
|
47
57
|
...(ledger ? ledgerRules(ledger) : []),
|
|
48
58
|
`${focus}`,
|
|
49
59
|
]
|
|
@@ -63,8 +73,9 @@ function ledgerRules(ledger: LedgerPaths): string[] {
|
|
|
63
73
|
return [
|
|
64
74
|
"",
|
|
65
75
|
`Loop ledger (durable state for this loop, at ${ledger.dir}):`,
|
|
66
|
-
`- ${PROGRESS_FILE} is yours to maintain
|
|
67
|
-
`-
|
|
76
|
+
`- ${PROGRESS_FILE} is yours to maintain, keeping its four sections: current status, completed, failed approaches and why, next actions. Record failures and their reasons as you go — nothing else remembers them once the conversation is compacted.`,
|
|
77
|
+
`- Write it with the loop_progress tool and never with the file or shell tools: loop_progress edits one section and leaves every other byte alone, where a whole-file write destroys the objective line and the other three sections along with the one you meant to update.`,
|
|
78
|
+
`- ${CRITERIA_FILE} holds this loop's completion criteria. Mark one met with loop_progress, which stores the citation next to it; only the \`passes\` field ever changes. Never edit an id, description, or check, never add or remove entries, and never write this file by hand.`,
|
|
68
79
|
"- After a compaction, re-read both files before acting. They are the record; a summary is not.",
|
|
69
80
|
];
|
|
70
81
|
}
|
package/src/planning.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop planning: authoring an objective with the user before any loop exists.
|
|
3
|
+
*
|
|
4
|
+
* The criteria are frozen the moment a loop starts, and until now the first
|
|
5
|
+
* time anyone saw them was after that point. That is the wrong order. The
|
|
6
|
+
* objective's wording is the single leverage point on a loop's whole life —
|
|
7
|
+
* it becomes the acceptance gate — and the moment it is decided is a
|
|
8
|
+
* conversation, not a typed command.
|
|
9
|
+
*
|
|
10
|
+
* So `/loop` with nothing running opens a drafting conversation instead of an
|
|
11
|
+
* error about a missing interval, and the loop starts from an approval card
|
|
12
|
+
* that shows the exact criteria the split will produce. The card is the design
|
|
13
|
+
* language: because the cadence and the caps are on it and editable there, the
|
|
14
|
+
* command grammar does not have to be natural, and none of the
|
|
15
|
+
* optional-interval, `every`-prefix, adverb or dry-run machinery needs to
|
|
16
|
+
* exist. A concept removed rather than a knob added.
|
|
17
|
+
*
|
|
18
|
+
* The typed form (`/loop 30m <objective>`) is untouched, as is inline `loop:`
|
|
19
|
+
* invocation. Planning is the front door, not the only door.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { deriveCriteria, type LoopCriterion } from "./ledger.js";
|
|
23
|
+
import { formatDuration } from "./interval.js";
|
|
24
|
+
|
|
25
|
+
/** A drafted loop, put up for approval and not yet started. */
|
|
26
|
+
export interface LoopProposal {
|
|
27
|
+
objective: string;
|
|
28
|
+
/** Exactly what `deriveCriteria` will produce, computed here so the card cannot lie. */
|
|
29
|
+
criteria: LoopCriterion[];
|
|
30
|
+
intervalMs: number;
|
|
31
|
+
maxTurns: number | null;
|
|
32
|
+
expiresInMs: number;
|
|
33
|
+
proposedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoopPlanningState {
|
|
37
|
+
/** The user opened planning and no loop has started yet. */
|
|
38
|
+
active: boolean;
|
|
39
|
+
/** The current draft awaiting approval, when one has been proposed. */
|
|
40
|
+
proposal?: LoopProposal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildProposal(
|
|
44
|
+
objective: string,
|
|
45
|
+
defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
|
|
46
|
+
now: number,
|
|
47
|
+
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
48
|
+
): LoopProposal {
|
|
49
|
+
return {
|
|
50
|
+
objective: objective.trim(),
|
|
51
|
+
// Derived, never authored: the card has to show the criteria the engine
|
|
52
|
+
// will actually freeze, or approving it means approving something else.
|
|
53
|
+
criteria: deriveCriteria(objective),
|
|
54
|
+
intervalMs: overrides.intervalMs ?? defaults.intervalMs,
|
|
55
|
+
maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
|
|
56
|
+
expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
|
|
57
|
+
proposedAt: now,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The approval card, as transcript lines. */
|
|
62
|
+
export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
63
|
+
return [
|
|
64
|
+
"**◆ Loop ready to start**",
|
|
65
|
+
"",
|
|
66
|
+
"**Objective**",
|
|
67
|
+
...proposal.objective.split("\n").map((line) => `> ${line}`),
|
|
68
|
+
"",
|
|
69
|
+
`**Criteria the gate will hold you to** (${proposal.criteria.length})`,
|
|
70
|
+
...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
|
|
71
|
+
"",
|
|
72
|
+
`**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
|
|
73
|
+
`**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
|
|
74
|
+
"",
|
|
75
|
+
"Run `/loop` to approve and start, keep editing, or cancel.",
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const LOOP_PLANNING_HINT = [
|
|
80
|
+
"<system-reminder>",
|
|
81
|
+
"The user opened loop planning. You are drafting a loop objective with them; no loop is running and none starts until they approve one.",
|
|
82
|
+
"A loop's objective becomes its acceptance gate, so draft it as an acceptance test, not as a prompt:",
|
|
83
|
+
"- One requirement per line, as a bullet. A conjunction inside a sentence does not split, so 'fix the flaky test and update the docs' becomes one criterion whose evidence must cover both halves.",
|
|
84
|
+
"- Name the check in the requirement itself ('…, verified by npm test passing'), so completion is a lookup instead of an argument.",
|
|
85
|
+
"- The two questions that fix most objectives: how will we know it is done, and what command proves it?",
|
|
86
|
+
"When the draft is ready, call loop_propose with it. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
|
|
87
|
+
// Without this the model reaches for the loop_start prohibition instead. It
|
|
88
|
+
// is stated emphatically and repeatedly ('never start a loop without that
|
|
89
|
+
// token, no matter how loop-like the request sounds'), so a conversational
|
|
90
|
+
// request for a loop pattern-matches straight onto it — and the model
|
|
91
|
+
// answers by telling the user to type /loop, which is precisely the dead end
|
|
92
|
+
// planning exists to remove. Observed live in a canary session.
|
|
93
|
+
"loop_propose is not loop_start. It starts nothing, so the inline-token rule does not apply to it: while planning is open, a conversational request for a loop is exactly when to call loop_propose. Do not refuse and tell the user to type /loop instead — drafting a proposal for them is the whole point of this mode.",
|
|
94
|
+
"The user has already opened planning, so their intent to consider a loop is established. What still requires their explicit approval is starting one, and the card is where they give it.",
|
|
95
|
+
"Never restate the objective as a tidier version of what they meant. If they decline to name checks, say plainly what the gate will and will not catch, and let them decide.",
|
|
96
|
+
"If the work is a bad fit for a loop at all — a recurring cadence, open-ended investigation with no end state, or something that finishes this turn — say so in one line and offer the alternative instead of drafting one anyway.",
|
|
97
|
+
"</system-reminder>",
|
|
98
|
+
].join("\n");
|