@hank-warren/pi-loop 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c079c51: Stage the loop tools behind the workflow that needs them, and stop a loop that outlives its deadline.
8
+
9
+ `loop_propose` activates when planning opens and the runtime trio (`loop_complete`, `loop_progress`, `loop_wait`) when a valid proposal exists, so a session that never touches `/loop` no longer carries four Loop-only schemas in its cached prompt prefix. Every tool stays registered, so a historical transcript still resolves it. An active loop re-asserts its runtime tools on every turn, which is what lets a paused loop restored in a later session be resumed and still finish itself.
10
+
11
+ An expiry watchdog armed on the exact `expiresAt` now ends a loop that has gone quiet past its deadline, independently of the fallback interval, and a bounded grace period stops a loop whose final expiry turn never starts — `sendUserMessage` is fire-and-forget, so an accepted wake is not a delivered one. Terminal states persist a `terminalReason`, and callbacks are loop-identity guarded.
12
+
13
+ Plan mode now owns the prompt outright: while it is enabled the loop injects neither its planning hint nor its objective, and resumes on the first turn after `/plan exit` without rewriting persisted state.
14
+
15
+ The approval card is a display-only session entry rendered through `registerEntryRenderer` rather than a message, so it stays visible and restorable while never entering model context or compaction. The internal `LOOP_PROPOSAL_MESSAGE_TYPE` constant is replaced by `LOOP_PROPOSAL_ENTRY_TYPE`; it was never exported from the package entry point.
16
+
3
17
  ## 1.0.0
4
18
 
5
19
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Long-running work for Pi: settle-paced loops with a durable ledger, adaptive waits, no-progress breakers, evidence-gated completion, and a task scheduler.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
13
  import { registerLoopProgressTool } from "./progress-tool.js";
14
14
  import { registerLoopProposeTool } from "./propose-tool.js";
15
+ import { registerLoopProposalRenderer } from "./presentation.js";
15
16
  import { LOOP_PLANNING_HINT } from "./planning.js";
16
17
  import { registerLoopWaitTool } from "./wait-tool.js";
17
18
  import { LoopController, type LoopControllerOptions } from "./loop.js";
@@ -23,6 +24,7 @@ import {
23
24
  } from "./manager.js";
24
25
  import { buildLoopObjectivePrompt } from "./objective.js";
25
26
  import { registerLoopMessageRendering } from "./render.js";
27
+ import { readPlanModeEnabled } from "./state.js";
26
28
 
27
29
  /** What the planning menu's "Request proposal now" asks for. */
28
30
  export const REQUEST_PROPOSAL_MESSAGE =
@@ -30,16 +32,36 @@ export const REQUEST_PROPOSAL_MESSAGE =
30
32
 
31
33
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
32
34
  const controller = new LoopController(pi, options);
33
- // Registered unconditionally and never toggled with loop state: tools are
34
- // part of the cached request prefix, so mutating the tool set mid-session
35
- // would invalidate the whole conversation cache. It refuses when no loop is
36
- // active.
35
+ const proposeTools = ["loop_propose"];
36
+ const runtimeTools = ["loop_complete", "loop_progress", "loop_wait"];
37
+ let proposeActivated = false;
38
+ let runtimeActivated = false;
39
+ const reconcileTools = () => {
40
+ const active = pi.getActiveTools();
41
+ const wanted = new Set(active);
42
+ for (const name of proposeTools) proposeActivated ? wanted.add(name) : wanted.delete(name);
43
+ for (const name of runtimeTools) runtimeActivated ? wanted.add(name) : wanted.delete(name);
44
+ const next = [...wanted];
45
+ if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
46
+ pi.setActiveTools(next);
47
+ }
48
+ };
49
+ const activatePropose = () => {
50
+ proposeActivated = true;
51
+ reconcileTools();
52
+ };
53
+ const activateRuntime = () => {
54
+ runtimeActivated = true;
55
+ reconcileTools();
56
+ };
57
+
37
58
  registerLoopCompleteTool(pi, controller);
38
- // Registered on the same terms and for the same reason: the tool set is
39
- // part of the cached prefix, so it never changes with loop state.
40
59
  registerLoopWaitTool(pi, controller);
41
60
  registerLoopProgressTool(pi, controller);
42
- registerLoopProposeTool(pi, controller);
61
+ registerLoopProposeTool(pi, controller, activateRuntime);
62
+ registerLoopProposalRenderer(pi);
63
+ // Narrowing happens at session_start, never here: Pi refuses action methods
64
+ // (getActiveTools/setActiveTools among them) during extension loading.
43
65
  // Collapse loop pokes into one-line transcript chips (display-only; the
44
66
  // stored message and model context are untouched).
45
67
  registerLoopMessageRendering(pi);
@@ -61,6 +83,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
61
83
 
62
84
  const beginPlanning = (ctx: ExtensionCommandContext): void => {
63
85
  if (controller.planning.active) return;
86
+ activatePropose();
64
87
  controller.beginPlanning();
65
88
  ctx.ui.notify(
66
89
  "Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
@@ -110,7 +133,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
110
133
  });
111
134
 
112
135
  pi.on("session_start", async (_event, ctx) => {
136
+ proposeActivated = false;
137
+ runtimeActivated = false;
113
138
  controller.onSessionStart(ctx);
139
+ if (controller.state?.status === "active") activateRuntime();
140
+ else reconcileTools();
114
141
  });
115
142
  pi.on("session_shutdown", async () => {
116
143
  controller.onSessionShutdown();
@@ -129,7 +156,21 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
129
156
  // A loop carries its own objective and injects it as a byte-stable system
130
157
  // append, which is what lets the poke and continuation messages stay
131
158
  // pointer-sized.
132
- pi.on("before_agent_start", (event) => {
159
+ pi.on("before_agent_start", (event, ctx) => {
160
+ // Self-heal the runtime tool set every turn an active loop takes, not just
161
+ // at session_start. `resumeLoop` flips a restored *paused* loop to active
162
+ // and dispatches a continuation from the /loop menu, which has no way to
163
+ // reach activateRuntime — so without this the resumed loop would run with
164
+ // loop_complete stripped, be told by its own objective append to call it,
165
+ // and then be re-paused by enforceToolAvailability blaming --tools for
166
+ // something this extension did to itself. Activation is monotonic, so this
167
+ // covers resumeAfterEdit and the fresh-session handoff too, and costs a
168
+ // no-op set comparison on every other turn.
169
+ if (controller.state?.status === "active") activateRuntime();
170
+ // Plan mode owns the prompt while active. Loop scheduling is already held
171
+ // by the same persisted state; suppressing the append removes the remaining
172
+ // mixed-workflow instruction surface.
173
+ if (readPlanModeEnabled(ctx.sessionManager.getBranch())) return;
133
174
  // Planning precedes any loop, so its guidance is injected on the same hook
134
175
  // and is mutually exclusive with the objective append below.
135
176
  if (controller.planning.active) {
package/src/loop.ts CHANGED
@@ -121,6 +121,15 @@ export const MAX_FALLBACK_BACKOFF = 4;
121
121
  * would otherwise be the first sign anything was wrong.
122
122
  */
123
123
  export const STALL_ATTENTION_MS = 900_000;
124
+ /**
125
+ * How long the expiry wake has to become a turn before the loop stops anyway.
126
+ *
127
+ * `sendUserMessage` is fire-and-forget: Pi swallows an asynchronous delivery
128
+ * failure (an expired credential, a torn-down runner), so a successful return
129
+ * is not proof a turn will start. The ordinary dead-delivery counter cannot
130
+ * catch this one, because after expiry there is no next delivery to count.
131
+ */
132
+ export const EXPIRY_TURN_GRACE_MS = 60_000;
124
133
 
125
134
  /** Consecutive loop deliveries that produce no run before the loop pauses. */
126
135
  export const MAX_DEAD_DELIVERIES = 3;
@@ -165,6 +174,8 @@ export interface LoopControllerOptions {
165
174
  now?: () => number;
166
175
  /** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
167
176
  agentDir?: string;
177
+ /** How long the final expiry turn has to start before the loop gives up. */
178
+ expiryTurnGraceMs?: number;
168
179
  }
169
180
 
170
181
  export class LoopController {
@@ -177,6 +188,7 @@ export class LoopController {
177
188
  private readonly now: () => number;
178
189
  readonly settingsPath: string;
179
190
  private timer: NodeJS.Timeout | undefined;
191
+ private expiryTimer: NodeJS.Timeout | undefined;
180
192
  private nextWakeAt: number | undefined;
181
193
  private wakePending = false;
182
194
  private sessionCtx: ExtensionContext | undefined;
@@ -186,6 +198,7 @@ export class LoopController {
186
198
  ledger: LedgerPaths | undefined;
187
199
  private ledgerWarned = false;
188
200
  private readonly agentDir: string | undefined;
201
+ private readonly expiryTurnGraceMs: number;
189
202
  /** Consecutive fallback wakes that produced a no-op turn. */
190
203
  noOpStreak = 0;
191
204
  lastContinuation: (ContinuationDecision & { at: number }) | undefined;
@@ -229,12 +242,14 @@ export class LoopController {
229
242
  this.now = options.now ?? Date.now;
230
243
  this.settingsPath = options.settingsPath ?? loopSettingsPath();
231
244
  this.agentDir = options.agentDir;
245
+ this.expiryTurnGraceMs = options.expiryTurnGraceMs ?? EXPIRY_TURN_GRACE_MS;
232
246
  }
233
247
 
234
248
  // --- lifecycle ---
235
249
 
236
250
  onSessionStart(ctx: ExtensionContext): void {
237
251
  this.clearTimer();
252
+ this.clearExpiryWatchdog();
238
253
  this.wakePending = false;
239
254
  this.compacting = false;
240
255
  this.lastDecision = undefined;
@@ -274,6 +289,7 @@ export class LoopController {
274
289
  // A wait whose deadline passed while the session was away is due now.
275
290
  this.restoreWaitTimer();
276
291
  this.armFallback();
292
+ this.armExpiryWatchdog();
277
293
  // A loop handed over from another session has never had its first turn.
278
294
  if (this.state.handoff) this.consumeHandoff(ctx);
279
295
  }
@@ -306,6 +322,7 @@ export class LoopController {
306
322
  // Withdraw the signal: the process may outlive this session.
307
323
  publishLoopEnv(undefined);
308
324
  this.clearTimer();
325
+ this.clearExpiryWatchdog();
309
326
  this.waitTimer.clear();
310
327
  this.wakePending = false;
311
328
  this.continuationIntent = undefined;
@@ -835,6 +852,63 @@ export class LoopController {
835
852
  this.nextWakeAt = undefined;
836
853
  }
837
854
 
855
+ private armExpiryWatchdog(): void {
856
+ this.clearExpiryWatchdog();
857
+ const loop = this.state;
858
+ if (!loop || loop.status !== "active") return;
859
+ const loopId = loop.id;
860
+ const delay = Math.min(MAX_INTERVAL_MS, Math.max(0, loop.expiresAt - this.now()));
861
+ this.expiryTimer = setTimeout(() => {
862
+ this.expiryTimer = undefined;
863
+ const current = this.state;
864
+ if (!current || current.id !== loopId || current.status !== "active") return;
865
+ if (this.now() < current.expiresAt) {
866
+ this.armExpiryWatchdog();
867
+ return;
868
+ }
869
+ const ctx = this.sessionCtx;
870
+ if (!ctx) {
871
+ this.transition("stopped", "loop expired without an active session context");
872
+ return;
873
+ }
874
+ const planActive = readPlanModeEnabled(ctx.sessionManager.getBranch());
875
+ const busy = !ctx.isIdle() || ctx.hasPendingMessages();
876
+ if (planActive || busy) {
877
+ this.transition(
878
+ "stopped",
879
+ planActive
880
+ ? "loop expired while Plan mode was active"
881
+ : "loop expired while the agent was busy",
882
+ );
883
+ return;
884
+ }
885
+ this.runTick(ctx);
886
+ }, delay);
887
+ this.expiryTimer.unref?.();
888
+ }
889
+
890
+ private clearExpiryWatchdog(): void {
891
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
892
+ this.expiryTimer = undefined;
893
+ }
894
+
895
+ /**
896
+ * The other half of expiry: the wake was handed to Pi, and this stops the
897
+ * loop if it never becomes a run. A run that *did* start leaves
898
+ * `awaitingRun` false, and the settle after it stops the loop normally.
899
+ */
900
+ private armExpiryTurnGuard(loopId: string): void {
901
+ this.clearExpiryWatchdog();
902
+ this.expiryTimer = setTimeout(() => {
903
+ this.expiryTimer = undefined;
904
+ const current = this.state;
905
+ if (!current || current.id !== loopId || current.status !== "active") return;
906
+ if (!this.awaitingRun) return;
907
+ this.transition("stopped", "the final expiry turn never started");
908
+ }, this.expiryTurnGraceMs);
909
+ this.expiryTimer.unref?.();
910
+ }
911
+
838
912
  private gatherEnvironment(ctx: ExtensionContext): TickEnvironment {
839
913
  const branch = ctx.sessionManager.getBranch();
840
914
  return {
@@ -922,6 +996,10 @@ export class LoopController {
922
996
  }
923
997
  this.runOrigin = "fallback";
924
998
  this.continuationIntent = undefined;
999
+ // Marked directly rather than through noteDelivery(): the dead-delivery
1000
+ // counter pauses a loop that should keep trying, and this one is already
1001
+ // ending. The guard below is what acts on it.
1002
+ this.awaitingRun = true;
925
1003
  this.state = {
926
1004
  ...this.consumeWait(loop),
927
1005
  iteration: loop.iteration + 1,
@@ -930,6 +1008,10 @@ export class LoopController {
930
1008
  expiring: true,
931
1009
  };
932
1010
  this.clearTimer();
1011
+ // The wake was accepted, not delivered. Hold one bounded guard so a final
1012
+ // turn that never starts still ends the loop instead of leaving it active
1013
+ // past its deadline with every timer cleared.
1014
+ this.armExpiryTurnGuard(loop.id);
933
1015
  this.persist();
934
1016
  this.updateWidget();
935
1017
  this.sessionCtx?.ui.notify(
@@ -1048,9 +1130,20 @@ export class LoopController {
1048
1130
 
1049
1131
  private transition(status: "paused" | "stopped", why: string, cause?: string): void {
1050
1132
  if (!this.state) return;
1051
- const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
1052
- this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
1133
+ const {
1134
+ waiting: _waiting,
1135
+ pauseCause: _pauseCause,
1136
+ terminalReason: _terminalReason,
1137
+ ...rest
1138
+ } = this.state;
1139
+ this.state = {
1140
+ ...rest,
1141
+ status,
1142
+ ...(cause ? { pauseCause: cause } : {}),
1143
+ ...(status === "stopped" ? { terminalReason: why } : {}),
1144
+ };
1053
1145
  this.clearTimer();
1146
+ this.clearExpiryWatchdog();
1054
1147
  this.waitTimer.clear();
1055
1148
  this.wakePending = false;
1056
1149
  this.continuationIntent = undefined;
@@ -1131,7 +1224,7 @@ export class LoopController {
1131
1224
  const loop = this.state;
1132
1225
  if (!loop) return ["No loop in this session. Run /loop to plan one."];
1133
1226
  const lines = [
1134
- `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
1227
+ `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : loop.terminalReason ? ` (${loop.terminalReason})` : ""}`,
1135
1228
  ...(loop.waiting
1136
1229
  ? [
1137
1230
  `Waiting: ${loop.waiting.reason}${
@@ -1351,6 +1444,7 @@ export class LoopController {
1351
1444
  this.openLedger(started, built.criteria);
1352
1445
  this.persist();
1353
1446
  this.scheduleTick(started.intervalMs);
1447
+ this.armExpiryWatchdog();
1354
1448
  this.updateWidget();
1355
1449
  const clampNote = built.clamped
1356
1450
  ? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
@@ -1439,6 +1533,7 @@ export class LoopController {
1439
1533
  this.noOpStreak = 0;
1440
1534
  this.persist();
1441
1535
  this.scheduleTick(loop.intervalMs);
1536
+ this.armExpiryWatchdog();
1442
1537
  this.updateWidget();
1443
1538
  ctx.ui.notify(
1444
1539
  `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The approval card, as a framed transcript block.
2
+ * The approval card, as a display-only session entry.
3
3
  *
4
4
  * It used to go out twice and neither copy was a card: `loop_propose`
5
5
  * returned it as tool-result text, and `/loop` re-printed it through
@@ -8,18 +8,59 @@
8
8
  * artifact the whole planning flow exists to produce was the least legible
9
9
  * thing on the screen, and duplicated.
10
10
  *
11
- * A custom-type message with `display: true` is what Pi frames, and
12
- * `triggerTurn: false` is what keeps it an artifact rather than a prompt: the
13
- * card appears, the model is not asked to respond to it, and the user's
14
- * approval remains the only thing that starts a loop. This is exactly how
15
- * pi-plan-mode renders a proposed plan (`packages/pi-plan-mode/src/
16
- * presentation.ts`), for the same reason.
11
+ * It is now a custom *entry* with a registered renderer, not a message. That
12
+ * is what buys the property a message could not: Pi maps a `custom` entry to
13
+ * no context messages at all and skips it during compaction, so the card stays
14
+ * visible and restorable in the transcript while never entering model context
15
+ * and never costing a compaction budget. The model is told a proposal exists
16
+ * by the tool result; it never re-reads the rendered card.
17
+ *
18
+ * pi-plan-mode's completed-plan card is the same mechanism for the same
19
+ * reason (`packages/pi-plan-mode/src/presentation.ts`), and `plan_mode_complete`
20
+ * likewise returns a one-line `Plan saved to <path>.` pointer instead of the
21
+ * plan body.
17
22
  */
18
23
 
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ getMarkdownTheme,
26
+ type ExtensionAPI,
27
+ type ExtensionContext,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { Markdown, Text } from "@earendil-works/pi-tui";
20
30
  import { type LoopProposal, renderProposalCard } from "./planning.js";
21
31
 
22
- export const LOOP_PROPOSAL_MESSAGE_TYPE = "loop-proposal";
32
+ /**
33
+ * The card is a custom *entry*, not a custom message, and it carries a new
34
+ * type name to say so. The old `LOOP_PROPOSAL_MESSAGE_TYPE` export is gone
35
+ * rather than aliased: an alias would keep consumers compiling while silently
36
+ * pointing them at a channel proposals no longer travel on, which is worse
37
+ * than the compile error that tells them to look.
38
+ */
39
+ export const LOOP_PROPOSAL_ENTRY_TYPE = "loop-proposal-card";
40
+
41
+ type LoopProposalCardData = { markdown: string; criteria: number; proposedAt: number };
42
+
43
+ /**
44
+ * Persisted entry data is input, not a guarantee.
45
+ *
46
+ * The renderer runs against whatever is on disk, which may predate a field, be
47
+ * truncated by a partial write, or have been hand-edited. Pi contains a
48
+ * renderer throw as an inline `[loop-proposal-card] renderer failed: …` box —
49
+ * survivable, but a needlessly ugly way to say "this card is old".
50
+ */
51
+ function loopProposalCardData(value: unknown): LoopProposalCardData | undefined {
52
+ if (typeof value !== "object" || value === null) return undefined;
53
+ const { markdown } = value as { markdown?: unknown };
54
+ return typeof markdown === "string" ? (value as LoopProposalCardData) : undefined;
55
+ }
56
+
57
+ export function registerLoopProposalRenderer(pi: ExtensionAPI): void {
58
+ pi.registerEntryRenderer(LOOP_PROPOSAL_ENTRY_TYPE, (entry) => {
59
+ const data = loopProposalCardData(entry.data);
60
+ if (!data) return new Text("Loop proposal card unavailable.", 0, 0);
61
+ return new Markdown(data.markdown, 0, 0, getMarkdownTheme());
62
+ });
63
+ }
23
64
 
24
65
  /**
25
66
  * Emit the card. Returns false when Pi refused it, in which case the caller
@@ -32,15 +73,11 @@ export function showLoopProposalCard(
32
73
  proposal: LoopProposal,
33
74
  ): boolean {
34
75
  try {
35
- pi.sendMessage(
36
- {
37
- customType: LOOP_PROPOSAL_MESSAGE_TYPE,
38
- content: renderProposalCard(proposal).join("\n"),
39
- display: true,
40
- details: { criteria: proposal.criteria.length, proposedAt: proposal.proposedAt },
41
- },
42
- { triggerTurn: false },
43
- );
76
+ pi.appendEntry<LoopProposalCardData>(LOOP_PROPOSAL_ENTRY_TYPE, {
77
+ markdown: renderProposalCard(proposal).join("\n"),
78
+ criteria: proposal.criteria.length,
79
+ proposedAt: proposal.proposedAt,
80
+ });
44
81
  return true;
45
82
  } catch (error) {
46
83
  const detail = error instanceof Error ? error.message : String(error);
@@ -20,7 +20,11 @@ import { MAX_GROUND_RULE_LENGTH, MAX_GROUND_RULES } from "./planning.js";
20
20
 
21
21
  export const LOOP_PROPOSE_TOOL = "loop_propose";
22
22
 
23
- export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
23
+ export function registerLoopProposeTool(
24
+ pi: ExtensionAPI,
25
+ controller: LoopController,
26
+ onProposed?: () => void,
27
+ ) {
24
28
  pi.registerTool(
25
29
  defineTool({
26
30
  name: LOOP_PROPOSE_TOOL,
@@ -116,6 +120,7 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
116
120
  }
117
121
 
118
122
  const proposal = controller.propose(objective, overrides);
123
+ onProposed?.();
119
124
  // The card goes to the transcript as a framed block, not back through
120
125
  // this tool result. Returning it here too would render the same
121
126
  // artifact twice, once framed and once as a wall of markdown, and
package/src/state.ts CHANGED
@@ -63,6 +63,8 @@ export interface LoopState {
63
63
  lastFingerprint?: string;
64
64
  /** Why a paused loop paused, for the widget and status after a restore. */
65
65
  pauseCause?: string;
66
+ /** Durable reason recorded when the loop enters its terminal stopped state. */
67
+ terminalReason?: string;
66
68
  /**
67
69
  * Set once the expiry's final wake has been delivered. The loop is still
68
70
  * active for exactly that one turn, so the objective append is present
@@ -144,6 +146,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
144
146
  if (cancelledWaitReason === false) return undefined;
145
147
  const pauseCause = optionalText(record.pauseCause);
146
148
  if (pauseCause === false) return undefined;
149
+ const terminalReason = optionalText(record.terminalReason);
150
+ if (terminalReason === false) return undefined;
147
151
  const toolFreeRepeatCount = record.toolFreeRepeatCount;
148
152
  if (
149
153
  toolFreeRepeatCount !== undefined &&
@@ -176,6 +180,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
176
180
  ...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
177
181
  ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
178
182
  ...(pauseCause === undefined ? {} : { pauseCause }),
183
+ ...(terminalReason === undefined ? {} : { terminalReason }),
179
184
  ...(record.expiring === true ? { expiring: true as const } : {}),
180
185
  ...(record.handoff === true ? { handoff: true as const } : {}),
181
186
  };