@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/src/loop.ts CHANGED
@@ -34,7 +34,13 @@ import {
34
34
  type TickDecision,
35
35
  type TickEnvironment,
36
36
  } from "./decide.js";
37
- import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
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 { clearLoopWidget, updateLoopWidget } from "./widget.js";
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
- updateWidget(): void {
941
- const ui = this.sessionCtx?.ui;
942
- if (!ui) return;
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
- ui.setStatus(LOOP_STATUS_KEY, undefined);
950
- return;
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
- if (loop.status === "paused") {
953
- ui.setStatus(
954
- LOOP_STATUS_KEY,
955
- loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
956
- );
957
- return;
958
- }
959
- if (loop.waiting) {
960
- ui.setStatus(
961
- LOOP_STATUS_KEY,
962
- `loop waiting · ${loop.waiting.reason}${
963
- loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
964
- }`,
965
- );
966
- return;
967
- }
968
- const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
969
- const next = this.wakePending
970
- ? "next on idle"
971
- : this.nextWakeAt
972
- ? `next ${formatClock(this.nextWakeAt)}`
973
- : "next unscheduled";
974
- ui.setStatus(
975
- LOOP_STATUS_KEY,
976
- `loop ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`,
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
- if (this.nextWakeAt && loop.status === "active") {
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 started: LoopState = {
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(start.intervalMs);
1353
+ this.scheduleTick(started.intervalMs);
1104
1354
  this.updateWidget();
1105
- const clampNote = start.clamped
1106
- ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
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(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
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. Update it as you work, keeping its four sections: current status, completed, failed approaches and why, next actions. Record failures and their reasons — nothing else remembers them once the conversation is compacted.`,
67
- `- ${CRITERIA_FILE} holds this loop's completion criteria. You may change only the \`passes\` field of an entry, and only when authoritative evidence proves that criterion, citing the evidence in ${PROGRESS_FILE}. Never edit an id, description, or check, and never add or remove entries.`,
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
  }