@hank-warren/pi-loop 0.7.0 → 0.9.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 +69 -0
- package/README.md +18 -37
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +54 -2
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop-action-menus.ts +129 -0
- package/src/loop-env.ts +50 -0
- package/src/loop.ts +299 -47
- package/src/manager.ts +126 -0
- package/src/objective.ts +25 -2
- package/src/planning.ts +108 -0
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +130 -0
- package/src/state.ts +15 -0
- package/src/widget.ts +102 -12
- 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,7 +34,13 @@ 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,
|
|
40
46
|
criteriaFromDescriptions,
|
|
@@ -65,6 +71,8 @@ import {
|
|
|
65
71
|
readPlanModeEnabled,
|
|
66
72
|
restoreLoopState,
|
|
67
73
|
} from "./state.js";
|
|
74
|
+
import { publishLoopEnv } from "./loop-env.js";
|
|
75
|
+
import { showLoopProposalCard } from "./presentation.js";
|
|
68
76
|
import { isLoopOkAck } from "./ack.js";
|
|
69
77
|
import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
|
|
70
78
|
import { classifyInterruption } from "./errors.js";
|
|
@@ -77,7 +85,17 @@ import {
|
|
|
77
85
|
type ResolvedWaitDelay,
|
|
78
86
|
} from "./wait.js";
|
|
79
87
|
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
80
|
-
import {
|
|
88
|
+
import {
|
|
89
|
+
buildProposal,
|
|
90
|
+
type LoopPlanningState,
|
|
91
|
+
type LoopProposal,
|
|
92
|
+
} from "./planning.js";
|
|
93
|
+
import {
|
|
94
|
+
clearLoopWidget,
|
|
95
|
+
loopWidgetLine,
|
|
96
|
+
type LoopWidgetView,
|
|
97
|
+
updateLoopWidget,
|
|
98
|
+
} from "./widget.js";
|
|
81
99
|
|
|
82
100
|
export const LOOP_STATUS_KEY = "loop";
|
|
83
101
|
|
|
@@ -91,6 +109,17 @@ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
|
91
109
|
*/
|
|
92
110
|
export const MAX_FALLBACK_BACKOFF = 4;
|
|
93
111
|
|
|
112
|
+
/**
|
|
113
|
+
* How long a single run may stay open before the widget calls it blocked.
|
|
114
|
+
*
|
|
115
|
+
* Deliberately generous: a long build, a big test suite, or a deep subagent
|
|
116
|
+
* fan-out are all legitimately busy for a while, and crying blocked on honest
|
|
117
|
+
* work would train the signal to be ignored. Fifteen minutes with no completed
|
|
118
|
+
* turn is well past ordinary work and well short of the seven-day expiry that
|
|
119
|
+
* would otherwise be the first sign anything was wrong.
|
|
120
|
+
*/
|
|
121
|
+
export const STALL_ATTENTION_MS = 900_000;
|
|
122
|
+
|
|
94
123
|
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
95
124
|
export const MAX_DEAD_DELIVERIES = 3;
|
|
96
125
|
|
|
@@ -108,6 +137,21 @@ type RunOrigin = "continuation" | "fallback";
|
|
|
108
137
|
*/
|
|
109
138
|
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
110
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A loop that exists but is not running anywhere: everything `installLoop`
|
|
142
|
+
* needs, and nothing that presumes which session will install it.
|
|
143
|
+
*/
|
|
144
|
+
export interface BuiltLoop {
|
|
145
|
+
loop: LoopState;
|
|
146
|
+
/** The criteria to write at install: proposed, or the deterministic split. */
|
|
147
|
+
criteria: LoopCriterion[];
|
|
148
|
+
expiryMs: number;
|
|
149
|
+
clamped: boolean;
|
|
150
|
+
requestedMs: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type LoopBuildResult = { ok: true; built: BuiltLoop } | { ok: false; message: string };
|
|
154
|
+
|
|
111
155
|
interface ContinuationIntent {
|
|
112
156
|
loopId: string;
|
|
113
157
|
kind: ContinuationKind;
|
|
@@ -154,6 +198,30 @@ export class LoopController {
|
|
|
154
198
|
private awaitingRun = false;
|
|
155
199
|
/** Set when an interrupted turn needs a compaction before the loop continues. */
|
|
156
200
|
private compactionRequested = false;
|
|
201
|
+
/**
|
|
202
|
+
* When the in-flight run started, or undefined between runs.
|
|
203
|
+
*
|
|
204
|
+
* A session blocked on a modal prompt — a permission approval, a question,
|
|
205
|
+
* anything that waits for a human — is `busy`, and `busy` makes every
|
|
206
|
+
* continuation and every fallback tick skip. No turn completes, so the cap
|
|
207
|
+
* never trips and the no-progress breaker (which counts turns) never fires.
|
|
208
|
+
* Expiry is the only thing left, up to seven days later. The engine cannot
|
|
209
|
+
* see the prompt, but it can see that a run has been open far longer than
|
|
210
|
+
* work usually takes, which is enough to put an attention state on the
|
|
211
|
+
* widget instead of a cheerful next-wake time.
|
|
212
|
+
*/
|
|
213
|
+
private busySince: number | undefined;
|
|
214
|
+
/** How long the current run has been open, once past the stall threshold. */
|
|
215
|
+
private blockedForMs: number | undefined;
|
|
216
|
+
/**
|
|
217
|
+
* Loop planning: the drafting conversation that precedes a loop.
|
|
218
|
+
*
|
|
219
|
+
* In-memory on purpose. A draft is a short interactive flow, and persisting
|
|
220
|
+
* it would mean a half-written objective could outlive the conversation that
|
|
221
|
+
* produced it and be approved later out of context. The loop it becomes is
|
|
222
|
+
* persisted; the draft is not.
|
|
223
|
+
*/
|
|
224
|
+
planning: LoopPlanningState = { active: false };
|
|
157
225
|
|
|
158
226
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
159
227
|
this.pi = pi;
|
|
@@ -175,6 +243,9 @@ export class LoopController {
|
|
|
175
243
|
this.noOpStreak = 0;
|
|
176
244
|
this.deadDeliveries = 0;
|
|
177
245
|
this.awaitingRun = false;
|
|
246
|
+
this.busySince = undefined;
|
|
247
|
+
this.blockedForMs = undefined;
|
|
248
|
+
this.planning = { active: false };
|
|
178
249
|
this.sessionCtx = ctx;
|
|
179
250
|
|
|
180
251
|
const loaded = readLoopSettings(this.settingsPath);
|
|
@@ -186,6 +257,10 @@ export class LoopController {
|
|
|
186
257
|
this.ledger = undefined;
|
|
187
258
|
this.ledgerWarned = false;
|
|
188
259
|
this.state = restoreLoopState(ctx.sessionManager.getBranch());
|
|
260
|
+
// A restored loop is running from this moment, so the signal other
|
|
261
|
+
// extensions read has to be true again before the first tool call of the
|
|
262
|
+
// session, not only after the first state change.
|
|
263
|
+
publishLoopEnv(this.state);
|
|
189
264
|
if (this.state && this.state.status === "active") {
|
|
190
265
|
if (this.now() >= this.state.expiresAt) {
|
|
191
266
|
this.transition("stopped", "loop expired while the session was away");
|
|
@@ -198,11 +273,37 @@ export class LoopController {
|
|
|
198
273
|
// A wait whose deadline passed while the session was away is due now.
|
|
199
274
|
this.restoreWaitTimer();
|
|
200
275
|
this.armFallback();
|
|
276
|
+
// A loop handed over from another session has never had its first turn.
|
|
277
|
+
if (this.state.handoff) this.consumeHandoff(ctx);
|
|
201
278
|
}
|
|
202
279
|
this.updateWidget();
|
|
203
280
|
}
|
|
204
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Take delivery of a loop handed to this session, and start working it.
|
|
284
|
+
*
|
|
285
|
+
* The flag is cleared first and persisted immediately: a handoff is
|
|
286
|
+
* consumed exactly once, and a session that crashed between restoring and
|
|
287
|
+
* kicking off must not re-anchor the objective on the next start.
|
|
288
|
+
*/
|
|
289
|
+
private consumeHandoff(ctx: ExtensionContext): void {
|
|
290
|
+
const loop = this.state;
|
|
291
|
+
if (!loop) return;
|
|
292
|
+
const { handoff: _handoff, ...rest } = loop;
|
|
293
|
+
this.state = rest;
|
|
294
|
+
this.persist();
|
|
295
|
+
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 run /loop stop.",
|
|
297
|
+
"info",
|
|
298
|
+
);
|
|
299
|
+
this.sendKickoffAnchor(ctx);
|
|
300
|
+
this.requestContinuation(rest, "kickoff");
|
|
301
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
302
|
+
}
|
|
303
|
+
|
|
205
304
|
onSessionShutdown(): void {
|
|
305
|
+
// Withdraw the signal: the process may outlive this session.
|
|
306
|
+
publishLoopEnv(undefined);
|
|
206
307
|
this.clearTimer();
|
|
207
308
|
this.waitTimer.clear();
|
|
208
309
|
this.wakePending = false;
|
|
@@ -224,11 +325,16 @@ export class LoopController {
|
|
|
224
325
|
this.sessionCtx = ctx;
|
|
225
326
|
this.awaitingRun = false;
|
|
226
327
|
this.deadDeliveries = 0;
|
|
328
|
+
this.busySince = this.now();
|
|
329
|
+
this.blockedForMs = undefined;
|
|
227
330
|
}
|
|
228
331
|
|
|
229
332
|
onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
|
|
230
333
|
this.sessionCtx = ctx;
|
|
231
334
|
this.awaitingRun = false;
|
|
335
|
+
// A completed turn is proof the session was not blocked on a human.
|
|
336
|
+
this.busySince = undefined;
|
|
337
|
+
this.blockedForMs = undefined;
|
|
232
338
|
const origin = this.runOrigin;
|
|
233
339
|
this.runOrigin = undefined;
|
|
234
340
|
const loop = this.state;
|
|
@@ -601,6 +707,20 @@ export class LoopController {
|
|
|
601
707
|
this.ledger = paths;
|
|
602
708
|
}
|
|
603
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Write a built loop's ledger without installing the loop.
|
|
712
|
+
*
|
|
713
|
+
* The fresh-session launch needs the approved criteria on disk *before* the
|
|
714
|
+
* new session restores the state, because the restore path treats an
|
|
715
|
+
* existing `criteria.json` as authoritative and would otherwise re-derive
|
|
716
|
+
* its own. Returns a failure detail, or undefined on success.
|
|
717
|
+
*/
|
|
718
|
+
prepareLedgerFor(built: BuiltLoop): string | undefined {
|
|
719
|
+
const objective = built.loop.objective;
|
|
720
|
+
if (objective === undefined) return "the loop has no objective";
|
|
721
|
+
return createLedger(ledgerPaths(built.loop.id, this.agentDir), objective, built.criteria);
|
|
722
|
+
}
|
|
723
|
+
|
|
604
724
|
/** The loop's criteria as last written to disk, fail-open. */
|
|
605
725
|
criteria() {
|
|
606
726
|
return this.ledger ? readCriteria(this.ledger) : undefined;
|
|
@@ -744,6 +864,13 @@ export class LoopController {
|
|
|
744
864
|
// Busy or compacting: coalesce into one pending wake that
|
|
745
865
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
746
866
|
this.wakePending = true;
|
|
867
|
+
if (decision.reason === "agent-busy") this.noteBusyTick(env.now);
|
|
868
|
+
// Keep the heartbeat armed while busy. Coalescing is about
|
|
869
|
+
// deliveries, not about the timer: wakePending is a boolean, so a
|
|
870
|
+
// further tick cannot produce a second wake. Without re-arming,
|
|
871
|
+
// the first busy tick would be the last one ever taken, and a
|
|
872
|
+
// session blocked on a prompt would never be noticed at all.
|
|
873
|
+
this.armFallback();
|
|
747
874
|
}
|
|
748
875
|
this.updateWidget();
|
|
749
876
|
return;
|
|
@@ -934,47 +1061,69 @@ export class LoopController {
|
|
|
934
1061
|
|
|
935
1062
|
persist(): void {
|
|
936
1063
|
if (!this.state) return;
|
|
1064
|
+
// Every state change funnels through here, which makes it the one place
|
|
1065
|
+
// the loop-active signal can be published without a caller remembering to.
|
|
1066
|
+
publishLoopEnv(this.state);
|
|
937
1067
|
this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
|
|
938
1068
|
}
|
|
939
1069
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1070
|
+
/**
|
|
1071
|
+
* A run has been open past the stall threshold, so the most likely
|
|
1072
|
+
* explanation is a prompt waiting for a human. Reported, never acted on:
|
|
1073
|
+
* approving on the user's behalf is exactly the boundary pi-auto-permissions
|
|
1074
|
+
* exists to hold.
|
|
1075
|
+
*/
|
|
1076
|
+
private noteBusyTick(now: number): void {
|
|
1077
|
+
const since = this.busySince;
|
|
1078
|
+
if (since === undefined) return;
|
|
1079
|
+
const openFor = now - since;
|
|
1080
|
+
this.blockedForMs = openFor >= STALL_ATTENTION_MS ? openFor : undefined;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The view both surfaces render, or undefined when there is nothing to show.
|
|
1085
|
+
*
|
|
1086
|
+
* One function feeds the widget and the footer because two hand-rolled
|
|
1087
|
+
* formatters drifted: the footer handled `loop.waiting` and the widget did
|
|
1088
|
+
* not, so a loop blocked on CI read as an ordinary scheduled loop above the
|
|
1089
|
+
* editor while the footer said it was waiting.
|
|
1090
|
+
*/
|
|
1091
|
+
widgetView(): LoopWidgetView | undefined {
|
|
943
1092
|
const loop = this.state;
|
|
944
|
-
updateLoopWidget(
|
|
945
|
-
ui,
|
|
946
|
-
loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
|
|
947
|
-
);
|
|
948
1093
|
if (!loop || loop.status === "stopped") {
|
|
949
|
-
|
|
950
|
-
|
|
1094
|
+
if (!this.planning.active) return undefined;
|
|
1095
|
+
const proposed = this.planning.proposal?.criteria.length;
|
|
1096
|
+
return { kind: "planning", ...(proposed === undefined ? {} : { proposedCriteria: proposed }) };
|
|
951
1097
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
);
|
|
1098
|
+
const criteria = this.criteriaProgress();
|
|
1099
|
+
return {
|
|
1100
|
+
kind: "loop",
|
|
1101
|
+
loop,
|
|
1102
|
+
wakePending: this.wakePending,
|
|
1103
|
+
nextWakeAt: this.nextWakeAt,
|
|
1104
|
+
...(criteria ? { criteria } : {}),
|
|
1105
|
+
...(this.blockedForMs === undefined ? {} : { blockedForMs: this.blockedForMs }),
|
|
1106
|
+
now: this.now(),
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Criteria progress for the widget. Re-read rather than cached: this runs on
|
|
1112
|
+
* discrete state transitions, not per frame, and a stale count right after a
|
|
1113
|
+
* criterion is marked would undercut the one number the line exists to show.
|
|
1114
|
+
*/
|
|
1115
|
+
private criteriaProgress(): { met: number; total: number } | undefined {
|
|
1116
|
+
const criteria = this.criteria();
|
|
1117
|
+
if (!criteria || criteria.length === 0) return undefined;
|
|
1118
|
+
return { met: criteria.filter((criterion) => criterion.passes).length, total: criteria.length };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
updateWidget(): void {
|
|
1122
|
+
const ui = this.sessionCtx?.ui;
|
|
1123
|
+
if (!ui) return;
|
|
1124
|
+
const view = this.widgetView();
|
|
1125
|
+
updateLoopWidget(ui, view);
|
|
1126
|
+
ui.setStatus(LOOP_STATUS_KEY, view ? loopWidgetLine(view) : undefined);
|
|
978
1127
|
}
|
|
979
1128
|
|
|
980
1129
|
statusLines(ctx: ExtensionContext): string[] {
|
|
@@ -1023,7 +1172,11 @@ export class LoopController {
|
|
|
1023
1172
|
}
|
|
1024
1173
|
}
|
|
1025
1174
|
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
1026
|
-
|
|
1175
|
+
// A pending wake is delivered at the next settle, whatever the timer says.
|
|
1176
|
+
// The heartbeat stays armed while the agent is busy so a stalled session is
|
|
1177
|
+
// still noticed, which means nextWakeAt can be set here even though no wake
|
|
1178
|
+
// will fire at that time — claiming it would be a stale clock time.
|
|
1179
|
+
if (this.nextWakeAt && !this.wakePending && loop.status === "active") {
|
|
1027
1180
|
lines.push(
|
|
1028
1181
|
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1029
1182
|
this.noOpStreak > 0
|
|
@@ -1043,15 +1196,91 @@ export class LoopController {
|
|
|
1043
1196
|
return lines;
|
|
1044
1197
|
}
|
|
1045
1198
|
|
|
1199
|
+
// --- planning ---
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Open the drafting conversation. Idempotent: running /loop again while
|
|
1203
|
+
* planning shows the current draft rather than restarting the flow.
|
|
1204
|
+
*/
|
|
1205
|
+
beginPlanning(): void {
|
|
1206
|
+
this.planning = { active: true };
|
|
1207
|
+
this.updateWidget();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** 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 {
|
|
1215
|
+
const proposal = buildProposal(
|
|
1216
|
+
objective,
|
|
1217
|
+
{
|
|
1218
|
+
intervalMs: parseDuration(this.settings.defaultInterval) ?? 600_000,
|
|
1219
|
+
maxTurns: this.settings.maxTurns,
|
|
1220
|
+
expiresInMs: parseDuration(this.settings.maxLoopDuration) ?? 604_800_000,
|
|
1221
|
+
},
|
|
1222
|
+
this.now(),
|
|
1223
|
+
overrides,
|
|
1224
|
+
);
|
|
1225
|
+
// A new draft supersedes the last one, so the card that was shown for the
|
|
1226
|
+
// old draft no longer describes what would start.
|
|
1227
|
+
this.planning = { active: true, proposal };
|
|
1228
|
+
this.updateWidget();
|
|
1229
|
+
return proposal;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Render the current draft's approval card, at most once per draft.
|
|
1234
|
+
*
|
|
1235
|
+
* Called by `loop_propose` when the draft is created and by `/loop` when the
|
|
1236
|
+
* user reopens the actions, so the card is present whichever way they got
|
|
1237
|
+
* here without a second copy appearing when they got here both ways.
|
|
1238
|
+
*/
|
|
1239
|
+
showProposalCard(ctx: ExtensionContext): boolean {
|
|
1240
|
+
const proposal = this.planning.proposal;
|
|
1241
|
+
if (!proposal) return false;
|
|
1242
|
+
if (this.planning.cardShownAt === proposal.proposedAt) return false;
|
|
1243
|
+
if (!showLoopProposalCard(this.pi, ctx, proposal)) return false;
|
|
1244
|
+
this.planning = { ...this.planning, cardShownAt: proposal.proposedAt };
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
endPlanning(): void {
|
|
1249
|
+
this.planning = { active: false };
|
|
1250
|
+
this.updateWidget();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1046
1253
|
// --- command actions ---
|
|
1047
1254
|
|
|
1048
1255
|
/**
|
|
1049
1256
|
* Start a loop on its own objective, the only mode there is: the trailing
|
|
1050
1257
|
* text *is* what the loop works on and what `loop_complete` answers for.
|
|
1051
1258
|
* With no text there is nothing to work on, and the caller is told so.
|
|
1259
|
+
*
|
|
1260
|
+
* Build and install are separate below, and this is the two of them in the
|
|
1261
|
+
* order they have always run. The split exists because "construct a loop"
|
|
1262
|
+
* and "make this session the one running it" were one indivisible pass, and
|
|
1263
|
+
* a fresh-session launch needs the first without the second: the state has
|
|
1264
|
+
* to exist before `ctx.newSession` so its `setup` can append it to the new
|
|
1265
|
+
* session, and it must not be installed here or the launching session would
|
|
1266
|
+
* start working the objective it is handing away.
|
|
1052
1267
|
*/
|
|
1053
1268
|
startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
|
|
1054
1269
|
this.sessionCtx = ctx;
|
|
1270
|
+
const built = this.buildLoop(start);
|
|
1271
|
+
if (!built.ok) return built;
|
|
1272
|
+
return this.installLoop(ctx, built.built);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Construct a loop's state and criteria without installing anything.
|
|
1277
|
+
*
|
|
1278
|
+
* Pure with respect to the session: no `this.state`, no ledger on disk, no
|
|
1279
|
+
* timer, no widget, no message. Everything it reads (settings, the clock,
|
|
1280
|
+
* the tool set) is read-only, so a caller may build a loop it intends to
|
|
1281
|
+
* install somewhere else — or discard.
|
|
1282
|
+
*/
|
|
1283
|
+
buildLoop(start: LoopStartArguments): LoopBuildResult {
|
|
1055
1284
|
const now = this.now();
|
|
1056
1285
|
const objective = start.prompt?.trim();
|
|
1057
1286
|
if (!objective) {
|
|
@@ -1078,7 +1307,7 @@ export class LoopController {
|
|
|
1078
1307
|
: this.settings.compaction.enabled
|
|
1079
1308
|
? this.settings.compaction.threshold
|
|
1080
1309
|
: null;
|
|
1081
|
-
const
|
|
1310
|
+
const loop: LoopState = {
|
|
1082
1311
|
id: randomUUID().slice(0, 8),
|
|
1083
1312
|
status: "active",
|
|
1084
1313
|
objective,
|
|
@@ -1090,23 +1319,44 @@ export class LoopController {
|
|
|
1090
1319
|
startedAt: now,
|
|
1091
1320
|
expiresAt: now + expiryMs,
|
|
1092
1321
|
};
|
|
1322
|
+
return {
|
|
1323
|
+
ok: true,
|
|
1324
|
+
built: {
|
|
1325
|
+
loop,
|
|
1326
|
+
criteria: start.criteria
|
|
1327
|
+
? criteriaFromDescriptions(start.criteria)
|
|
1328
|
+
: deriveCriteria(objective),
|
|
1329
|
+
expiryMs,
|
|
1330
|
+
clamped: start.clamped,
|
|
1331
|
+
requestedMs: start.requestedMs,
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Install a built loop into `ctx`'s session: adopt it as the live state,
|
|
1338
|
+
* open its ledger, persist, arm the fallback, anchor the objective and kick
|
|
1339
|
+
* off the first turn. This is the half that makes a session *the* session
|
|
1340
|
+
* running the loop, and it is the half a fresh-session launch runs over
|
|
1341
|
+
* there rather than here.
|
|
1342
|
+
*/
|
|
1343
|
+
installLoop(ctx: ExtensionContext, built: BuiltLoop): LoopStartResult {
|
|
1344
|
+
this.sessionCtx = ctx;
|
|
1345
|
+
const started = built.loop;
|
|
1093
1346
|
this.state = started;
|
|
1094
1347
|
this.wakePending = false;
|
|
1095
1348
|
this.continuationIntent = undefined;
|
|
1096
1349
|
this.noOpStreak = 0;
|
|
1097
1350
|
this.ledgerWarned = false;
|
|
1098
|
-
this.openLedger(
|
|
1099
|
-
this.state,
|
|
1100
|
-
start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
|
|
1101
|
-
);
|
|
1351
|
+
this.openLedger(started, built.criteria);
|
|
1102
1352
|
this.persist();
|
|
1103
|
-
this.scheduleTick(
|
|
1353
|
+
this.scheduleTick(started.intervalMs);
|
|
1104
1354
|
this.updateWidget();
|
|
1105
|
-
const clampNote =
|
|
1106
|
-
? ` (requested ${formatDuration(
|
|
1355
|
+
const clampNote = built.clamped
|
|
1356
|
+
? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
|
|
1107
1357
|
: "";
|
|
1108
1358
|
ctx.ui.notify(
|
|
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(
|
|
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 run /loop stop. 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).`,
|
|
1110
1360
|
"info",
|
|
1111
1361
|
);
|
|
1112
1362
|
if (this.ledger) {
|
|
@@ -1131,6 +1381,8 @@ export class LoopController {
|
|
|
1131
1381
|
return { ok: true, loop: started };
|
|
1132
1382
|
}
|
|
1133
1383
|
|
|
1384
|
+
|
|
1385
|
+
|
|
1134
1386
|
/**
|
|
1135
1387
|
* Store the objective as an ordinary message so it outlives the loop.
|
|
1136
1388
|
*
|
package/src/manager.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
saveLoopSettings,
|
|
15
15
|
} from "./settings.js";
|
|
16
16
|
import { parseDuration } from "./interval.js";
|
|
17
|
+
import type { LoopStartArguments } from "./command.js";
|
|
18
|
+
import { startLoopInFreshSession } from "./fresh-launch.js";
|
|
19
|
+
import { showLoopApprovalMenu } from "./loop-action-menus.js";
|
|
20
|
+
import type { LoopProposal } from "./planning.js";
|
|
17
21
|
|
|
18
22
|
export async function showLoopManager(
|
|
19
23
|
controller: LoopController,
|
|
@@ -266,3 +270,125 @@ function applySettings(
|
|
|
266
270
|
controller.settings = next;
|
|
267
271
|
return true;
|
|
268
272
|
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The approval card and its actions.
|
|
276
|
+
*
|
|
277
|
+
* This is where a planned loop starts, and the approval is what authorises it.
|
|
278
|
+
* `loop_start`'s gate exists because a loop is self-continuing and must never
|
|
279
|
+
* begin on model initiative; an explicit choice here, on a card showing the
|
|
280
|
+
* objective, the derived criteria, the cadence and the caps, is stronger
|
|
281
|
+
* evidence of intent than a typed token, so it starts the loop directly rather
|
|
282
|
+
* than routing back through a tool the model could reach on its own.
|
|
283
|
+
*/
|
|
284
|
+
export async function showLoopApproval(
|
|
285
|
+
controller: LoopController,
|
|
286
|
+
ctx: ExtensionCommandContext,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const proposal = controller.planning.proposal;
|
|
289
|
+
if (!proposal) return;
|
|
290
|
+
// The card is an artifact, emitted once per draft; the menu below is the
|
|
291
|
+
// dialog over it. A draft proposed by loop_propose already has its card, so
|
|
292
|
+
// this only renders one when the user reached the approval some other way.
|
|
293
|
+
controller.showProposalCard(ctx);
|
|
294
|
+
if (ctx.mode !== "tui") {
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
"Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
|
|
297
|
+
"info",
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
await showLoopApprovalMenu(ctx, {
|
|
302
|
+
proposal,
|
|
303
|
+
startHere: () => {
|
|
304
|
+
const result = controller.startLoop(ctx, startArgumentsFor(proposal));
|
|
305
|
+
if (result.ok) controller.endPlanning();
|
|
306
|
+
else ctx.ui.notify(result.message, "error");
|
|
307
|
+
},
|
|
308
|
+
startFresh: async () => {
|
|
309
|
+
await startApprovedLoopFresh(controller, ctx, proposal);
|
|
310
|
+
},
|
|
311
|
+
changeCadence: async () => {
|
|
312
|
+
await changeCadence(controller, ctx, proposal);
|
|
313
|
+
},
|
|
314
|
+
keepEditing: () => {
|
|
315
|
+
ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
|
|
316
|
+
},
|
|
317
|
+
cancel: () => {
|
|
318
|
+
controller.endPlanning();
|
|
319
|
+
ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The approved draft, as the arguments both start paths take. */
|
|
325
|
+
function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
|
|
326
|
+
return {
|
|
327
|
+
kind: "start",
|
|
328
|
+
requestedMs: proposal.intervalMs,
|
|
329
|
+
intervalMs: proposal.intervalMs,
|
|
330
|
+
clamped: false,
|
|
331
|
+
maxTurns: proposal.maxTurns,
|
|
332
|
+
expiresInMs: proposal.expiresInMs,
|
|
333
|
+
prompt: proposal.objective,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Build the loop here, install it over there. The build/install split is what
|
|
339
|
+
* makes this possible at all: the state has to exist before `newSession` so
|
|
340
|
+
* its `setup` can append it, and it must not be installed in this session or
|
|
341
|
+
* the planning session would start working the objective it is handing away.
|
|
342
|
+
*/
|
|
343
|
+
async function startApprovedLoopFresh(
|
|
344
|
+
controller: LoopController,
|
|
345
|
+
ctx: ExtensionCommandContext,
|
|
346
|
+
proposal: LoopProposal,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
const built = controller.buildLoop(startArgumentsFor(proposal));
|
|
349
|
+
if (!built.ok) {
|
|
350
|
+
ctx.ui.notify(built.message, "error");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const result = await startLoopInFreshSession(ctx, {
|
|
354
|
+
built: built.built,
|
|
355
|
+
prepareLedger: () => controller.prepareLedgerFor(built.built),
|
|
356
|
+
});
|
|
357
|
+
switch (result.kind) {
|
|
358
|
+
case "started":
|
|
359
|
+
case "partial":
|
|
360
|
+
// The draft has been handed off either way: the planning session must
|
|
361
|
+
// not keep offering to start it a second time.
|
|
362
|
+
controller.endPlanning();
|
|
363
|
+
return;
|
|
364
|
+
case "cancelled":
|
|
365
|
+
return;
|
|
366
|
+
default:
|
|
367
|
+
ctx.ui.notify(result.detail, "error");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function changeCadence(
|
|
372
|
+
controller: LoopController,
|
|
373
|
+
ctx: ExtensionCommandContext,
|
|
374
|
+
proposal: LoopProposal,
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
const text = await ctx.ui.input(
|
|
377
|
+
"Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
|
|
378
|
+
formatDuration(proposal.intervalMs),
|
|
379
|
+
);
|
|
380
|
+
if (text === undefined) return;
|
|
381
|
+
const interval = parseInterval(text.trim());
|
|
382
|
+
if (!interval) {
|
|
383
|
+
ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// A new draft, so it gets a new card: the cadence on the old one is no
|
|
387
|
+
// longer what would start.
|
|
388
|
+
controller.propose(proposal.objective, {
|
|
389
|
+
intervalMs: interval.effectiveMs,
|
|
390
|
+
maxTurns: proposal.maxTurns,
|
|
391
|
+
expiresInMs: proposal.expiresInMs,
|
|
392
|
+
});
|
|
393
|
+
await showLoopApproval(controller, ctx);
|
|
394
|
+
}
|
package/src/objective.ts
CHANGED
|
@@ -44,6 +44,28 @@ 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
|
+
// The old wording forbade reshaping a blocked command outright, and a
|
|
56
|
+
// permission guardian that blocks with a stated concern depends on exactly
|
|
57
|
+
// that: its block is an instruction to fix the named problem. Both cannot
|
|
58
|
+
// stand, and "never reshape" is the one that was wrong — it made every
|
|
59
|
+
// block terminal, including the ones that named a one-word fix. The line
|
|
60
|
+
// that matters is not whether the command changes but what the change is
|
|
61
|
+
// aimed at: satisfying the concern, or getting around the gate that raised
|
|
62
|
+
// it. So the prohibition is stated against the aim, and the number of
|
|
63
|
+
// attempts is bounded so that "revise to address it" cannot decay into
|
|
64
|
+
// "retry until it passes".
|
|
65
|
+
"- Never reshape a command to get around a permission gate. Splitting it up, obfuscating it, routing it through another tool, or retrying variations until one is allowed are all the same move, and it is forbidden however the loop is going.",
|
|
66
|
+
"- A block that states a concern is different: it names something to fix, and fixing exactly that is legitimate. Revise only to satisfy the stated concern, and only while the block says rounds remain against it. When they run out, the block will say so — stop revising and call loop_wait.",
|
|
67
|
+
"- A block that states no concern, or one you cannot address without widening what the command does, is already final. Do not spend the rounds; call loop_wait.",
|
|
68
|
+
"- 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
69
|
...(ledger ? ledgerRules(ledger) : []),
|
|
48
70
|
`${focus}`,
|
|
49
71
|
]
|
|
@@ -63,8 +85,9 @@ function ledgerRules(ledger: LedgerPaths): string[] {
|
|
|
63
85
|
return [
|
|
64
86
|
"",
|
|
65
87
|
`Loop ledger (durable state for this loop, at ${ledger.dir}):`,
|
|
66
|
-
`- ${PROGRESS_FILE} is yours to maintain
|
|
67
|
-
`-
|
|
88
|
+
`- ${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.`,
|
|
89
|
+
`- 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.`,
|
|
90
|
+
`- ${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
91
|
"- After a compaction, re-read both files before acting. They are the record; a summary is not.",
|
|
69
92
|
];
|
|
70
93
|
}
|