@hank-warren/pi-loop 0.8.0 → 1.0.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
@@ -71,6 +71,8 @@ import {
71
71
  readPlanModeEnabled,
72
72
  restoreLoopState,
73
73
  } from "./state.js";
74
+ import { publishLoopEnv } from "./loop-env.js";
75
+ import { showLoopProposalCard } from "./presentation.js";
74
76
  import { isLoopOkAck } from "./ack.js";
75
77
  import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
76
78
  import { classifyInterruption } from "./errors.js";
@@ -87,6 +89,8 @@ import {
87
89
  buildProposal,
88
90
  type LoopPlanningState,
89
91
  type LoopProposal,
92
+ type LoopProposalOverrides,
93
+ normalizeGroundRules,
90
94
  } from "./planning.js";
91
95
  import {
92
96
  clearLoopWidget,
@@ -128,13 +132,27 @@ type RunOrigin = "continuation" | "fallback";
128
132
  * The outcome of a start attempt.
129
133
  *
130
134
  * `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
131
- * which tied the only start path to a UI. Two callers now share that path —
132
- * the `/loop` command and the `loop_start` tool and the tool has to turn
133
- * the same refusal into tool content rather than a toast, so the decision is
134
- * returned and each caller renders it.
135
+ * which tied the only start path to a UI. The approval card's two start
136
+ * actions share that path one installs the loop here, the other hands it to
137
+ * a fresh session so the decision is returned and each caller renders it.
135
138
  */
136
139
  export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
137
140
 
141
+ /**
142
+ * A loop that exists but is not running anywhere: everything `installLoop`
143
+ * needs, and nothing that presumes which session will install it.
144
+ */
145
+ export interface BuiltLoop {
146
+ loop: LoopState;
147
+ /** The criteria to write at install: proposed, or the deterministic split. */
148
+ criteria: LoopCriterion[];
149
+ expiryMs: number;
150
+ clamped: boolean;
151
+ requestedMs: number;
152
+ }
153
+
154
+ export type LoopBuildResult = { ok: true; built: BuiltLoop } | { ok: false; message: string };
155
+
138
156
  interface ContinuationIntent {
139
157
  loopId: string;
140
158
  kind: ContinuationKind;
@@ -240,6 +258,10 @@ export class LoopController {
240
258
  this.ledger = undefined;
241
259
  this.ledgerWarned = false;
242
260
  this.state = restoreLoopState(ctx.sessionManager.getBranch());
261
+ // A restored loop is running from this moment, so the signal other
262
+ // extensions read has to be true again before the first tool call of the
263
+ // session, not only after the first state change.
264
+ publishLoopEnv(this.state);
243
265
  if (this.state && this.state.status === "active") {
244
266
  if (this.now() >= this.state.expiresAt) {
245
267
  this.transition("stopped", "loop expired while the session was away");
@@ -252,11 +274,37 @@ export class LoopController {
252
274
  // A wait whose deadline passed while the session was away is due now.
253
275
  this.restoreWaitTimer();
254
276
  this.armFallback();
277
+ // A loop handed over from another session has never had its first turn.
278
+ if (this.state.handoff) this.consumeHandoff(ctx);
255
279
  }
256
280
  this.updateWidget();
257
281
  }
258
282
 
283
+ /**
284
+ * Take delivery of a loop handed to this session, and start working it.
285
+ *
286
+ * The flag is cleared first and persisted immediately: a handoff is
287
+ * consumed exactly once, and a session that crashed between restoring and
288
+ * kicking off must not re-anchor the objective on the next start.
289
+ */
290
+ private consumeHandoff(ctx: ExtensionContext): void {
291
+ const loop = this.state;
292
+ if (!loop) return;
293
+ const { handoff: _handoff, ...rest } = loop;
294
+ this.state = rest;
295
+ this.persist();
296
+ ctx.ui.notify(
297
+ "Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
298
+ "info",
299
+ );
300
+ this.sendKickoffAnchor(ctx);
301
+ this.requestContinuation(rest, "kickoff");
302
+ this.dispatchContinuationIfSettled(ctx);
303
+ }
304
+
259
305
  onSessionShutdown(): void {
306
+ // Withdraw the signal: the process may outlive this session.
307
+ publishLoopEnv(undefined);
260
308
  this.clearTimer();
261
309
  this.waitTimer.clear();
262
310
  this.wakePending = false;
@@ -324,14 +372,14 @@ export class LoopController {
324
372
  case "usage-limited":
325
373
  this.transition(
326
374
  "paused",
327
- "the provider reports the usage limit is reached; resume with /loop resume once it resets",
375
+ "the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
328
376
  "usage limit reached",
329
377
  );
330
378
  return true;
331
379
  case "fatal":
332
380
  this.transition(
333
381
  "paused",
334
- "the turn failed with an error a retry cannot fix; resolve it, then /loop resume",
382
+ "the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
335
383
  "unrecoverable provider error",
336
384
  );
337
385
  return true;
@@ -339,7 +387,7 @@ export class LoopController {
339
387
  // Esc, or another extension stopping the turn. A loop-caused run
340
388
  // that the user interrupted must not be immediately re-sent.
341
389
  if (origin === undefined) return false;
342
- this.transition("paused", "the turn was interrupted; resume with /loop resume", "interrupted");
390
+ this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
343
391
  return true;
344
392
  case "context-overflow":
345
393
  // The request no longer fits: compact first, then continue. The
@@ -361,7 +409,7 @@ export class LoopController {
361
409
  * The no-progress breaker: consecutive tool-free loop turns with identical
362
410
  * visible output pause the loop instead of waking it again forever. It
363
411
  * pauses rather than stops, so the loop stays configured and one
364
- * `/loop resume` (or the next user prompt) puts it back to work.
412
+ * Resuming from the /loop menu (or the next user prompt) puts it back to work.
365
413
  */
366
414
  private enforceNoProgress(
367
415
  ctx: ExtensionContext,
@@ -404,7 +452,7 @@ export class LoopController {
404
452
  void ctx;
405
453
  this.transition(
406
454
  "paused",
407
- `${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop resume (or your next message) continues it`,
455
+ `${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
408
456
  "no progress",
409
457
  );
410
458
  return true;
@@ -487,7 +535,7 @@ export class LoopController {
487
535
  if (!objective) {
488
536
  this.transition(
489
537
  "paused",
490
- "it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
538
+ "it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
491
539
  "loop with no objective",
492
540
  );
493
541
  return true;
@@ -517,7 +565,7 @@ export class LoopController {
517
565
  if (this.completeToolAvailable()) return false;
518
566
  this.transition(
519
567
  "paused",
520
- `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
568
+ `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
521
569
  "loop_complete unavailable",
522
570
  );
523
571
  void ctx;
@@ -551,7 +599,7 @@ export class LoopController {
551
599
  if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
552
600
  this.transition(
553
601
  "paused",
554
- `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
602
+ `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
555
603
  "deliveries produce no turns",
556
604
  );
557
605
  return true;
@@ -632,7 +680,7 @@ export class LoopController {
632
680
  * no writable ledger still runs, it just loses the durable record, so the
633
681
  * failure is warned once and never repeated.
634
682
  *
635
- * `criteria` is passed at start: the criteria proposed at `loop_start`, or
683
+ * `criteria` is passed at start: the criteria approved with the draft, or
636
684
  * the deterministic split of the objective. On restore it is omitted, and
637
685
  * the criteria already on disk are authoritative — they are the ones the
638
686
  * user saw echoed, and re-deriving them would both discard a proposed set
@@ -660,6 +708,20 @@ export class LoopController {
660
708
  this.ledger = paths;
661
709
  }
662
710
 
711
+ /**
712
+ * Write a built loop's ledger without installing the loop.
713
+ *
714
+ * The fresh-session launch needs the approved criteria on disk *before* the
715
+ * new session restores the state, because the restore path treats an
716
+ * existing `criteria.json` as authoritative and would otherwise re-derive
717
+ * its own. Returns a failure detail, or undefined on success.
718
+ */
719
+ prepareLedgerFor(built: BuiltLoop): string | undefined {
720
+ const objective = built.loop.objective;
721
+ if (objective === undefined) return "the loop has no objective";
722
+ return createLedger(ledgerPaths(built.loop.id, this.agentDir), objective, built.criteria);
723
+ }
724
+
663
725
  /** The loop's criteria as last written to disk, fail-open. */
664
726
  criteria() {
665
727
  return this.ledger ? readCriteria(this.ledger) : undefined;
@@ -756,7 +818,7 @@ export class LoopController {
756
818
  // Nothing is scheduled once the timer has fired. `runTick` re-arms it
757
819
  // through `scheduleTick` when it pokes, but a busy or compacting
758
820
  // session coalesces into `wakePending` instead — and leaving the old
759
- // deadline here made `/loop status` report a clock time that had
821
+ // deadline here made the /loop status screen report a clock time that had
760
822
  // already passed.
761
823
  this.nextWakeAt = undefined;
762
824
  const ctx = this.sessionCtx;
@@ -1000,6 +1062,9 @@ export class LoopController {
1000
1062
 
1001
1063
  persist(): void {
1002
1064
  if (!this.state) return;
1065
+ // Every state change funnels through here, which makes it the one place
1066
+ // the loop-active signal can be published without a caller remembering to.
1067
+ publishLoopEnv(this.state);
1003
1068
  this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
1004
1069
  }
1005
1070
 
@@ -1064,7 +1129,7 @@ export class LoopController {
1064
1129
 
1065
1130
  statusLines(ctx: ExtensionContext): string[] {
1066
1131
  const loop = this.state;
1067
- if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
1132
+ if (!loop) return ["No loop in this session. Run /loop to plan one."];
1068
1133
  const lines = [
1069
1134
  `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
1070
1135
  ...(loop.waiting
@@ -1144,10 +1209,7 @@ export class LoopController {
1144
1209
  }
1145
1210
 
1146
1211
  /** 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 {
1212
+ propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
1151
1213
  const proposal = buildProposal(
1152
1214
  objective,
1153
1215
  {
@@ -1158,11 +1220,29 @@ export class LoopController {
1158
1220
  this.now(),
1159
1221
  overrides,
1160
1222
  );
1223
+ // A new draft supersedes the last one, so the card that was shown for the
1224
+ // old draft no longer describes what would start.
1161
1225
  this.planning = { active: true, proposal };
1162
1226
  this.updateWidget();
1163
1227
  return proposal;
1164
1228
  }
1165
1229
 
1230
+ /**
1231
+ * Render the current draft's approval card, at most once per draft.
1232
+ *
1233
+ * Called by `loop_propose` when the draft is created and by `/loop` when the
1234
+ * user reopens the actions, so the card is present whichever way they got
1235
+ * here without a second copy appearing when they got here both ways.
1236
+ */
1237
+ showProposalCard(ctx: ExtensionContext): boolean {
1238
+ const proposal = this.planning.proposal;
1239
+ if (!proposal) return false;
1240
+ if (this.planning.cardShownAt === proposal.proposedAt) return false;
1241
+ if (!showLoopProposalCard(this.pi, ctx, proposal)) return false;
1242
+ this.planning = { ...this.planning, cardShownAt: proposal.proposedAt };
1243
+ return true;
1244
+ }
1245
+
1166
1246
  endPlanning(): void {
1167
1247
  this.planning = { active: false };
1168
1248
  this.updateWidget();
@@ -1174,16 +1254,38 @@ export class LoopController {
1174
1254
  * Start a loop on its own objective, the only mode there is: the trailing
1175
1255
  * text *is* what the loop works on and what `loop_complete` answers for.
1176
1256
  * With no text there is nothing to work on, and the caller is told so.
1257
+ *
1258
+ * Build and install are separate below, and this is the two of them in the
1259
+ * order they have always run. The split exists because "construct a loop"
1260
+ * and "make this session the one running it" were one indivisible pass, and
1261
+ * a fresh-session launch needs the first without the second: the state has
1262
+ * to exist before `ctx.newSession` so its `setup` can append it to the new
1263
+ * session, and it must not be installed here or the launching session would
1264
+ * start working the objective it is handing away.
1177
1265
  */
1178
1266
  startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
1179
1267
  this.sessionCtx = ctx;
1268
+ const built = this.buildLoop(start);
1269
+ if (!built.ok) return built;
1270
+ return this.installLoop(ctx, built.built);
1271
+ }
1272
+
1273
+ /**
1274
+ * Construct a loop's state and criteria without installing anything.
1275
+ *
1276
+ * Pure with respect to the session: no `this.state`, no ledger on disk, no
1277
+ * timer, no widget, no message. Everything it reads (settings, the clock,
1278
+ * the tool set) is read-only, so a caller may build a loop it intends to
1279
+ * install somewhere else — or discard.
1280
+ */
1281
+ buildLoop(start: LoopStartArguments): LoopBuildResult {
1180
1282
  const now = this.now();
1181
1283
  const objective = start.prompt?.trim();
1182
1284
  if (!objective) {
1183
1285
  return {
1184
1286
  ok: false,
1185
1287
  message:
1186
- "A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
1288
+ "A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
1187
1289
  };
1188
1290
  }
1189
1291
  // A loop with no way to call loop_complete would work, finish, and then be
@@ -1203,10 +1305,12 @@ export class LoopController {
1203
1305
  : this.settings.compaction.enabled
1204
1306
  ? this.settings.compaction.threshold
1205
1307
  : null;
1206
- const started: LoopState = {
1308
+ const groundRules = normalizeGroundRules(start.groundRules);
1309
+ const loop: LoopState = {
1207
1310
  id: randomUUID().slice(0, 8),
1208
1311
  status: "active",
1209
1312
  objective,
1313
+ ...(groundRules ? { groundRules } : {}),
1210
1314
  intervalMs: start.intervalMs,
1211
1315
  maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1212
1316
  compactAt,
@@ -1215,23 +1319,44 @@ export class LoopController {
1215
1319
  startedAt: now,
1216
1320
  expiresAt: now + expiryMs,
1217
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;
1218
1346
  this.state = started;
1219
1347
  this.wakePending = false;
1220
1348
  this.continuationIntent = undefined;
1221
1349
  this.noOpStreak = 0;
1222
1350
  this.ledgerWarned = false;
1223
- this.openLedger(
1224
- this.state,
1225
- start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
1226
- );
1351
+ this.openLedger(started, built.criteria);
1227
1352
  this.persist();
1228
- this.scheduleTick(start.intervalMs);
1353
+ this.scheduleTick(started.intervalMs);
1229
1354
  this.updateWidget();
1230
- const clampNote = start.clamped
1231
- ? ` (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)`
1232
1357
  : "";
1233
1358
  ctx.ui.notify(
1234
- `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 stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
1235
1360
  "info",
1236
1361
  );
1237
1362
  if (this.ledger) {
@@ -1256,6 +1381,8 @@ export class LoopController {
1256
1381
  return { ok: true, loop: started };
1257
1382
  }
1258
1383
 
1384
+
1385
+
1259
1386
  /**
1260
1387
  * Store the objective as an ordinary message so it outlives the loop.
1261
1388
  *