@hank-warren/pi-loop 0.8.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 897739d: Framed approval card, fresh-session launch, and a loop-safe permission posture.
8
+
9
+ **pi-loop**
10
+
11
+ - The approval card is now a framed `customType` transcript block, emitted once per draft, with its choices in an action menu. It used to go out twice and neither copy was a card: `loop_propose` returned it as tool-result markdown (re-spending the objective's tokens in the model's own context) and `/loop` re-printed it as a toast.
12
+ - `startLoop` is split into `buildLoop` and `installLoop`, and the menu gains **Start loop in a fresh session** — the loop is built in the planning session and installed in a new one, so only the objective crosses and the drafting conversation does not.
13
+ - A loop publishes `PI_LOOP_ACTIVE=1` and `PI_LOOP_ID` on the process while active. This is a contract for other extensions, mirroring `PI_SUBAGENT_CHILD`: no import, no dependency, no RPC.
14
+ - The autonomy posture is reconciled with it. Reshaping a command to get _around_ a permission gate stays forbidden; revising it to satisfy a concern a guardian actually stated is legitimate and bounded. The `⚠ loop blocked` detector is unchanged.
15
+
16
+ **pi-auto-permissions**
17
+
18
+ - While `PI_LOOP_ACTIVE=1`, an `ask_user` verdict returns the guardian's concern to the agent as a non-blocking block instead of opening a modal. A session waiting on a modal is busy, and a busy session starves a loop's continuation path — the prompt would not be answered, it would deadlock the loop until it expired. **The verdict is unchanged; only its delivery is.** The absence of a user is never authorization.
19
+ - Revision is bounded — per concern, per gate, and by consecutive blocked attempts — after which the block names `loop_wait` instead of inviting another try. The bounds survive a compaction and a session restore.
20
+
21
+ Detection is fail-open in both directions: with the environment variables absent, behaviour is exactly as before, and neither package requires the other.
22
+
3
23
  ## 0.8.0
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -128,6 +128,23 @@ The turn a wake delivers for an elapsed wait **counts against `maxTurns`** like
128
128
 
129
129
  There is deliberately **no cancel tool**. The events that legitimately cancel a wait (you typing, an earlier wake arriving) are not the model's to report — so when one of them ends a wait, its reason rides along once on the next loop message as `Previous wait (cancelled): …` and is then dropped.
130
130
 
131
+ ## Working unattended, and the `PI_LOOP_ACTIVE` contract
132
+
133
+ The posture in the system append is stated as mechanics rather than as rules to obey, because the mechanics are the reason: a session waiting on a modal is *busy*, and busy makes every continuation and every fallback tick skip. Nothing ends the loop until it expires. So: decide rather than ask, take the reversible path, record the decision in the ledger, and use `loop_wait` — the one way to ask that does not deadlock the session.
134
+
135
+ The subtle half is what to do with a *blocked* command. Reshaping one to get **around** a permission gate — splitting it up, obfuscating it, routing it through another tool, retrying variations until one is allowed — is forbidden outright, and it is the dangerous failure precisely because it *looks like progress*. But a guardian that blocks with a specific objection is naming something to fix, and fixing exactly that is the response it asked for. Treating every block as terminal would have made the loop stop at objections that named a one-word remedy.
136
+
137
+ What separates the two is a bound, which is why pi-loop publishes its state to the process while a loop is active:
138
+
139
+ | Variable | Meaning |
140
+ | --- | --- |
141
+ | `PI_LOOP_ACTIVE` | `1` while a loop is active; absent otherwise (a paused or stopped loop withdraws it) |
142
+ | `PI_LOOP_ID` | the loop's id, so a reader can tell one loop from the next |
143
+
144
+ This is a contract for other extensions, deliberately the same mechanism `pi-subagents` established with `PI_SUBAGENT_CHILD=1` — an environment variable, not an import, not a dependency, not an RPC. [pi-auto-permissions](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-auto-permissions) is the first consumer: while it is set, a guardian verdict that would have opened a modal instead returns the concern to the agent as a block, carrying a bounded number of revision rounds and then naming `loop_wait`. Nothing is approved that would not have been approved with a user present; only the delivery changes. Neither package needs the other installed, in either direction.
145
+
146
+ The `⚠ loop blocked` widget state remains, unchanged, as the backstop: it catches prompts from extensions pi-loop cannot influence, where there is no contract to read.
147
+
131
148
  ## Breakers
132
149
 
133
150
  - **No progress.** The characteristic failure of an autonomous loop is not crashing, it is *restating*: the same paragraph of "here is what I would do next", turn after turn, calling no tools. pi-loop fingerprints the visible assistant text (SHA-256 over NFKC-normalised, case- and whitespace-folded text) of every tool-free loop-caused turn; `noProgressTurns` consecutive repeats (default 3, settings-tunable, `null` disables) **pause** the loop rather than stopping it — it stays configured, the widget says why, and `/loop resume` or your next message continues it with a fresh safety epoch. A turn that called **any** tool, including `loop_wait`, is progress by definition and resets the counter; counting a declared wait is the false positive that made this class of breaker infamous.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": [
@@ -47,5 +47,8 @@
47
47
  "@earendil-works/pi-coding-agent": "*",
48
48
  "@earendil-works/pi-tui": "*",
49
49
  "typebox": "*"
50
+ },
51
+ "dependencies": {
52
+ "@narumitw/pi-tui-kit": "^0.49.3"
50
53
  }
51
54
  }
@@ -98,10 +98,19 @@ The rule follows from the mechanics instead:
98
98
  that does not deadlock: continuations stop, the reason shows in the widget and `/loop status`,
99
99
  and any wake resumes it. Use it exactly where you would otherwise open a modal, and put the
100
100
  options in the ledger first so the answer can be one word.
101
- 3. **Never reshape a command to get past a permission prompt.** This is the dangerous one. An
101
+ 3. **Never reshape a command to get *around* a permission gate.** This is the dangerous one. An
102
102
  agent under a turn cap has real incentive to rewrite a blocked command into something the
103
- guardian waves through, and that failure *looks like progress*. A blocked command means stop
104
- and ask through `loop_wait` never find another way around it.
103
+ guardian waves through, and that failure *looks like progress*. Splitting the command up,
104
+ obfuscating it, routing it through a different tool, or simply retrying variations until one
105
+ is allowed are all the same move, whatever the loop's state.
106
+
107
+ Addressing a stated concern is not that move. A guardian that blocks with a specific
108
+ objection — pi-auto-permissions does exactly this while `PI_LOOP_ACTIVE=1`, because a modal
109
+ would deadlock the loop — is naming something to fix, and fixing precisely that is the
110
+ response it asked for. The bound is what keeps the two apart: the block tells you how many
111
+ revision rounds remain against that concern, and when they run out it stops offering the
112
+ option and names `loop_wait` instead. A block that states no concern you can address is
113
+ already final; do not spend the rounds on it.
105
114
  4. **Prefer the undoable.** Nobody is watching to catch a bad call, so when two paths are close,
106
115
  take the one that is cheap to reverse: a worktree over the clone, additive over destructive, a
107
116
  draft pull request over a merge.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Starting an approved loop in a fresh session.
3
+ *
4
+ * A loop started interactively owns the session it was planned in, and the
5
+ * planning conversation is the worst possible context for it: every turn of
6
+ * drafting is carried, re-read and re-billed for the whole run, and none of it
7
+ * is the objective. The card is where that gets fixed, because the card is
8
+ * where the user is already deciding how the loop should run.
9
+ *
10
+ * Modelled on `packages/pi-plan-mode/src/fresh-implementation.ts`, which
11
+ * solves the same problem for a plan. The difference is what crosses: a plan
12
+ * hands over a file path, while a loop hands over its state, appended to the
13
+ * new session in `setup` exactly as `persist` would have appended it here.
14
+ * Only the objective and the caps cross; the drafting conversation does not.
15
+ *
16
+ * The ledger is written before the handoff, from this session. It is a
17
+ * filesystem artifact keyed by loop id, not session state, and writing it here
18
+ * is what makes the approved criteria authoritative: the restoring session
19
+ * treats whatever is already on disk as the truth, so criteria written after
20
+ * it restores would arrive too late to be the ones it is held to.
21
+ */
22
+
23
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import type { BuiltLoop } from "./loop.js";
25
+ import { LOOP_STATE_ENTRY_TYPE } from "./state.js";
26
+
27
+ type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
28
+ type SessionManagerLike = Parameters<NonNullable<NewSessionOptions["setup"]>>[0];
29
+
30
+ export type FreshLoopResult =
31
+ | { kind: "started" }
32
+ | { kind: "cancelled" }
33
+ /** The session exists and holds the loop, but it could not be kicked off. */
34
+ | { kind: "partial"; detail: string }
35
+ | { kind: "rejected"; detail: string };
36
+
37
+ export interface FreshLoopRequest {
38
+ built: BuiltLoop;
39
+ /** Write the ledger for `built` before the handoff; returns a failure detail. */
40
+ prepareLedger(): string | undefined;
41
+ }
42
+
43
+ export async function startLoopInFreshSession(
44
+ ctx: ExtensionContext,
45
+ request: FreshLoopRequest,
46
+ ): Promise<FreshLoopResult> {
47
+ if (!isCommandContext(ctx)) {
48
+ return {
49
+ kind: "rejected",
50
+ detail:
51
+ "Starting a loop in a fresh session needs the interactive /loop command. Run /loop again and choose it from the menu.",
52
+ };
53
+ }
54
+ if (ctx.mode === "print" || ctx.mode === "json") {
55
+ return {
56
+ kind: "rejected",
57
+ detail: "A fresh session is unavailable in print/JSON mode. Start the loop in this session.",
58
+ };
59
+ }
60
+
61
+ const ledgerFailure = request.prepareLedger();
62
+ if (ledgerFailure) {
63
+ // Not fatal to the loop — a loop runs without a ledger — but it is fatal
64
+ // to *this* path: the new session would derive its own criteria from the
65
+ // objective and could be held to a different gate than the one approved.
66
+ return {
67
+ kind: "rejected",
68
+ detail: `The loop's ledger could not be written (${ledgerFailure}), so the approved criteria could not be handed to a new session. Start the loop in this session instead.`,
69
+ };
70
+ }
71
+
72
+ await ctx.waitForIdle();
73
+
74
+ const parentSession = ctx.sessionManager.getSessionFile();
75
+ let setupError: string | undefined;
76
+
77
+ let result: Awaited<ReturnType<ExtensionCommandContext["newSession"]>>;
78
+ try {
79
+ result = await ctx.newSession({
80
+ ...(parentSession ? { parentSession } : {}),
81
+ setup: async (sessionManager: SessionManagerLike) => {
82
+ try {
83
+ // The same entry `persist` writes, so the new session's ordinary
84
+ // restore path picks it up with no special case — plus the handoff
85
+ // flag, which is what tells that session it owns the first turn.
86
+ // The kickoff cannot be driven from here: Pi builds a new extension
87
+ // instance for the new session, so this session's controller is not
88
+ // the one that ends up holding the loop.
89
+ sessionManager.appendCustomEntry(LOOP_STATE_ENTRY_TYPE, {
90
+ loop: { ...request.built.loop, handoff: true },
91
+ });
92
+ } catch (error: unknown) {
93
+ setupError = errorDetail(error);
94
+ }
95
+ },
96
+ withSession: async (replacementCtx: ExtensionContext) => {
97
+ if (setupError) {
98
+ replacementCtx.ui.notify(
99
+ `Fresh session created, but the loop could not be handed to it: ${setupError}. Nothing is running; start the loop from /loop in either session.`,
100
+ "error",
101
+ );
102
+ }
103
+ },
104
+ });
105
+ } catch (error: unknown) {
106
+ return {
107
+ kind: "rejected",
108
+ detail: `Unable to start a fresh session: ${errorDetail(error)}. The draft is unchanged; start the loop in this session instead.`,
109
+ };
110
+ }
111
+
112
+ if (result.cancelled) return { kind: "cancelled" };
113
+ return setupError ? { kind: "partial", detail: setupError } : { kind: "started" };
114
+ }
115
+
116
+ function isCommandContext(ctx: ExtensionContext): ctx is ExtensionCommandContext {
117
+ return typeof (ctx as Partial<ExtensionCommandContext>).newSession === "function";
118
+ }
119
+
120
+ function errorDetail(error: unknown) {
121
+ const detail = error instanceof Error ? error.message : String(error);
122
+ const normalized =
123
+ detail
124
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
125
+ .replace(/\s+/gu, " ")
126
+ .trim() || "unknown error";
127
+ return normalized.length > 500 ? `${normalized.slice(0, 499)}…` : normalized;
128
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The approval card's actions, as a menu.
3
+ *
4
+ * Presentation and choices are separate surfaces on purpose, the way
5
+ * pi-plan-mode splits `presentation.ts` from `plan-action-menus.ts`: the card
6
+ * is a durable artifact in the transcript that the user can scroll back to,
7
+ * and the menu is a transient dialog over it. A plain `ui.select` of label
8
+ * strings could not say what "start in a fresh session" means, and that is
9
+ * exactly the entry that needs explaining.
10
+ *
11
+ * The screen is built by a pure function so a test can assert what the menu
12
+ * offers without a terminal.
13
+ */
14
+
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
17
+ import type { ActionsScreen } from "@narumitw/pi-tui-kit";
18
+ import { formatDuration } from "./interval.js";
19
+ import type { LoopProposal } from "./planning.js";
20
+
21
+ export type LoopApprovalAction =
22
+ | "start-here"
23
+ | "start-fresh"
24
+ | "change-cadence"
25
+ | "keep-editing"
26
+ | "cancel";
27
+
28
+ type Screen = "approval";
29
+
30
+ export interface LoopApprovalMenuOptions {
31
+ proposal: LoopProposal;
32
+ signal?: AbortSignal;
33
+ isCurrent?(): boolean;
34
+ startHere(): void | Promise<void>;
35
+ startFresh(signal: AbortSignal): void | Promise<void>;
36
+ changeCadence(): void | Promise<void>;
37
+ keepEditing(): void;
38
+ cancel(): void;
39
+ }
40
+
41
+ /**
42
+ * The approval screen. Pure and exported for tests: the set of actions on
43
+ * offer is the contract, and it is cheaper to pin here than through a TUI.
44
+ */
45
+ export function loopApprovalScreen(
46
+ proposal: LoopProposal,
47
+ ): ActionsScreen<Screen, LoopApprovalAction> {
48
+ const criteria = `${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}`;
49
+ return {
50
+ kind: "actions",
51
+ title: "Start this loop?",
52
+ lines: [
53
+ `${criteria} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
54
+ "The card above shows exactly what loop_complete will be held to.",
55
+ ],
56
+ items: [
57
+ {
58
+ id: "start-here",
59
+ label: "Start loop here",
60
+ description: "Run it in this session, keeping the planning conversation.",
61
+ action: "start-here",
62
+ },
63
+ {
64
+ id: "start-fresh",
65
+ label: "Start loop in a fresh session",
66
+ description:
67
+ "Open a new session that runs the loop with only the objective — no planning history.",
68
+ action: "start-fresh",
69
+ busyLabel: "Starting the loop in a fresh session…",
70
+ },
71
+ {
72
+ id: "change-cadence",
73
+ label: "Change cadence…",
74
+ description: "Edit the fallback heartbeat before starting.",
75
+ action: "change-cadence",
76
+ },
77
+ {
78
+ id: "keep-editing",
79
+ label: "Keep editing",
80
+ description: "Go back to drafting; tell the agent what to change.",
81
+ action: "keep-editing",
82
+ },
83
+ {
84
+ id: "cancel",
85
+ label: "Cancel",
86
+ description: "Discard the draft. Nothing is started.",
87
+ action: "cancel",
88
+ },
89
+ ],
90
+ hint: "close",
91
+ };
92
+ }
93
+
94
+ export async function showLoopApprovalMenu(
95
+ ctx: ExtensionContext,
96
+ options: LoopApprovalMenuOptions,
97
+ ) {
98
+ const menu = defineMenu<undefined, Screen, LoopApprovalAction, ExtensionContext>({
99
+ start: "approval",
100
+ screens: { approval: () => loopApprovalScreen(options.proposal) },
101
+ actions: {
102
+ "start-here": async () => {
103
+ await options.startHere();
104
+ return { kind: "close" };
105
+ },
106
+ "start-fresh": async ({ signal }) => {
107
+ await options.startFresh(signal);
108
+ return { kind: "close" };
109
+ },
110
+ "change-cadence": async () => {
111
+ await options.changeCadence();
112
+ return { kind: "close" };
113
+ },
114
+ "keep-editing": async () => {
115
+ options.keepEditing();
116
+ return { kind: "close" };
117
+ },
118
+ cancel: async () => {
119
+ options.cancel();
120
+ return { kind: "close" };
121
+ },
122
+ },
123
+ });
124
+ return runMenu(ctx, menu, {
125
+ getState: () => undefined,
126
+ ...(options.signal ? { signal: options.signal } : {}),
127
+ ...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
128
+ });
129
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The loop-active environment contract.
3
+ *
4
+ * pi-loop publishes two variables into its own process environment while a
5
+ * loop is active, and removes them the moment it is not:
6
+ *
7
+ * - `PI_LOOP_ACTIVE=1` — an unattended loop is running in this session.
8
+ * - `PI_LOOP_ID=<id>` — the loop's id, so a reader can tell one loop from the
9
+ * next without asking pi-loop anything.
10
+ *
11
+ * It exists for other extensions, and pi-auto-permissions is the first
12
+ * consumer: a modal permission prompt does not pause a loop, it deadlocks it,
13
+ * so a guardian that would have asked a human needs to know there is no human
14
+ * to ask. The mechanism is deliberately the one `pi-subagents` already
15
+ * established with `PI_SUBAGENT_CHILD=1` and `detectSubagentContext` reads —
16
+ * an environment variable, not a package dependency, not an import, not an
17
+ * RPC. Neither extension needs the other installed, in either direction, and
18
+ * a reader that never sees the variable behaves exactly as it does today.
19
+ *
20
+ * Both variables are set on the process, so they are visible to every
21
+ * extension in the session and inherited by anything it spawns. That is the
22
+ * point: a subagent launched by a looping session is running unattended for
23
+ * the same reason its parent is.
24
+ */
25
+
26
+ import type { LoopState } from "./state.js";
27
+
28
+ export const LOOP_ACTIVE_ENV = "PI_LOOP_ACTIVE";
29
+ export const LOOP_ID_ENV = "PI_LOOP_ID";
30
+
31
+ /**
32
+ * Publish (or withdraw) the loop-active signal for `loop`.
33
+ *
34
+ * Only an `active` loop publishes. A paused loop is not working unattended —
35
+ * the user paused it and is, by construction, present — and a stopped loop is
36
+ * not working at all, so both withdraw the signal rather than leaving a stale
37
+ * one behind for the rest of the session.
38
+ */
39
+ export function publishLoopEnv(
40
+ loop: LoopState | undefined,
41
+ env: Record<string, string | undefined> = process.env,
42
+ ): void {
43
+ if (loop?.status === "active") {
44
+ env[LOOP_ACTIVE_ENV] = "1";
45
+ env[LOOP_ID_ENV] = loop.id;
46
+ return;
47
+ }
48
+ delete env[LOOP_ACTIVE_ENV];
49
+ delete env[LOOP_ID_ENV];
50
+ }
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";
@@ -135,6 +137,21 @@ type RunOrigin = "continuation" | "fallback";
135
137
  */
136
138
  export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
137
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
+
138
155
  interface ContinuationIntent {
139
156
  loopId: string;
140
157
  kind: ContinuationKind;
@@ -240,6 +257,10 @@ export class LoopController {
240
257
  this.ledger = undefined;
241
258
  this.ledgerWarned = false;
242
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);
243
264
  if (this.state && this.state.status === "active") {
244
265
  if (this.now() >= this.state.expiresAt) {
245
266
  this.transition("stopped", "loop expired while the session was away");
@@ -252,11 +273,37 @@ export class LoopController {
252
273
  // A wait whose deadline passed while the session was away is due now.
253
274
  this.restoreWaitTimer();
254
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);
255
278
  }
256
279
  this.updateWidget();
257
280
  }
258
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
+
259
304
  onSessionShutdown(): void {
305
+ // Withdraw the signal: the process may outlive this session.
306
+ publishLoopEnv(undefined);
260
307
  this.clearTimer();
261
308
  this.waitTimer.clear();
262
309
  this.wakePending = false;
@@ -660,6 +707,20 @@ export class LoopController {
660
707
  this.ledger = paths;
661
708
  }
662
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
+
663
724
  /** The loop's criteria as last written to disk, fail-open. */
664
725
  criteria() {
665
726
  return this.ledger ? readCriteria(this.ledger) : undefined;
@@ -1000,6 +1061,9 @@ export class LoopController {
1000
1061
 
1001
1062
  persist(): void {
1002
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);
1003
1067
  this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
1004
1068
  }
1005
1069
 
@@ -1158,11 +1222,29 @@ export class LoopController {
1158
1222
  this.now(),
1159
1223
  overrides,
1160
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.
1161
1227
  this.planning = { active: true, proposal };
1162
1228
  this.updateWidget();
1163
1229
  return proposal;
1164
1230
  }
1165
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
+
1166
1248
  endPlanning(): void {
1167
1249
  this.planning = { active: false };
1168
1250
  this.updateWidget();
@@ -1174,9 +1256,31 @@ export class LoopController {
1174
1256
  * Start a loop on its own objective, the only mode there is: the trailing
1175
1257
  * text *is* what the loop works on and what `loop_complete` answers for.
1176
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.
1177
1267
  */
1178
1268
  startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
1179
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 {
1180
1284
  const now = this.now();
1181
1285
  const objective = start.prompt?.trim();
1182
1286
  if (!objective) {
@@ -1203,7 +1307,7 @@ export class LoopController {
1203
1307
  : this.settings.compaction.enabled
1204
1308
  ? this.settings.compaction.threshold
1205
1309
  : null;
1206
- const started: LoopState = {
1310
+ const loop: LoopState = {
1207
1311
  id: randomUUID().slice(0, 8),
1208
1312
  status: "active",
1209
1313
  objective,
@@ -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 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).`,
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
  *
package/src/manager.ts CHANGED
@@ -14,7 +14,10 @@ import {
14
14
  saveLoopSettings,
15
15
  } from "./settings.js";
16
16
  import { parseDuration } from "./interval.js";
17
- import { renderProposalCard } from "./planning.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";
18
21
 
19
22
  export async function showLoopManager(
20
23
  controller: LoopController,
@@ -284,7 +287,10 @@ export async function showLoopApproval(
284
287
  ): Promise<void> {
285
288
  const proposal = controller.planning.proposal;
286
289
  if (!proposal) return;
287
- ctx.ui.notify(renderProposalCard(proposal).join("\n"), "info");
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);
288
294
  if (ctx.mode !== "tui") {
289
295
  ctx.ui.notify(
290
296
  "Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
@@ -292,51 +298,97 @@ export async function showLoopApproval(
292
298
  );
293
299
  return;
294
300
  }
295
- const choice = await ctx.ui.select("Start this loop?", [
296
- "Start loop",
297
- "Change cadence",
298
- "Keep editing",
299
- "Cancel",
300
- ]);
301
- switch (choice) {
302
- case "Start loop": {
303
- const result = controller.startLoop(ctx, {
304
- kind: "start",
305
- requestedMs: proposal.intervalMs,
306
- intervalMs: proposal.intervalMs,
307
- clamped: false,
308
- maxTurns: proposal.maxTurns,
309
- expiresInMs: proposal.expiresInMs,
310
- prompt: proposal.objective,
311
- });
301
+ await showLoopApprovalMenu(ctx, {
302
+ proposal,
303
+ startHere: () => {
304
+ const result = controller.startLoop(ctx, startArgumentsFor(proposal));
312
305
  if (result.ok) controller.endPlanning();
313
306
  else ctx.ui.notify(result.message, "error");
314
- return;
315
- }
316
- case "Change cadence": {
317
- const text = await ctx.ui.input(
318
- "Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
319
- formatDuration(proposal.intervalMs),
320
- );
321
- if (text === undefined) return;
322
- const interval = parseInterval(text.trim());
323
- if (!interval) {
324
- ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
325
- return;
326
- }
327
- controller.propose(proposal.objective, {
328
- intervalMs: interval.effectiveMs,
329
- maxTurns: proposal.maxTurns,
330
- expiresInMs: proposal.expiresInMs,
331
- });
332
- await showLoopApproval(controller, ctx);
333
- return;
334
- }
335
- case "Keep editing":
307
+ },
308
+ startFresh: async () => {
309
+ await startApprovedLoopFresh(controller, ctx, proposal);
310
+ },
311
+ changeCadence: async () => {
312
+ await changeCadence(controller, ctx, proposal);
313
+ },
314
+ keepEditing: () => {
336
315
  ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
337
- return;
338
- default:
316
+ },
317
+ cancel: () => {
339
318
  controller.endPlanning();
340
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");
341
368
  }
342
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
@@ -52,7 +52,19 @@ export function buildLoopObjectivePrompt(
52
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
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
54
  "- When you genuinely need a human, call loop_wait: it is the only way to ask that does not deadlock the session. Put the options in the ledger first, so the answer can be one word.",
55
- "- Never reshape a command to get past a permission prompt. A blocked command means stop and ask through loop_wait, never find another way around it.",
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.",
56
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.",
57
69
  ...(ledger ? ledgerRules(ledger) : []),
58
70
  `${focus}`,
package/src/planning.ts CHANGED
@@ -38,6 +38,16 @@ export interface LoopPlanningState {
38
38
  active: boolean;
39
39
  /** The current draft awaiting approval, when one has been proposed. */
40
40
  proposal?: LoopProposal;
41
+ /**
42
+ * `proposedAt` of the draft whose card has already been rendered.
43
+ *
44
+ * The card is an artifact in the transcript, not a status line, so re-running
45
+ * `/loop` to reopen the menu must not emit a second copy of the same card.
46
+ * A new draft — a reworded objective, a changed cadence — has a new
47
+ * `proposedAt` and does get its own card, because it is a different thing to
48
+ * approve.
49
+ */
50
+ cardShownAt?: number;
41
51
  }
42
52
 
43
53
  export function buildProposal(
@@ -72,7 +82,7 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
72
82
  `**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
73
83
  `**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
74
84
  "",
75
- "Run `/loop` to approve and start, keep editing, or cancel.",
85
+ "Run `/loop` for the actions: start here, start in a fresh session, change the cadence, keep editing, or cancel.",
76
86
  ];
77
87
  }
78
88
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The approval card, as a framed transcript block.
3
+ *
4
+ * It used to go out twice and neither copy was a card: `loop_propose`
5
+ * returned it as tool-result text, and `/loop` re-printed it through
6
+ * `ctx.ui.notify`. Tool text is rendered as a wall of markdown inside a tool
7
+ * result, and a toast is a transient line that scrolls away — so the one
8
+ * artifact the whole planning flow exists to produce was the least legible
9
+ * thing on the screen, and duplicated.
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.
17
+ */
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { type LoopProposal, renderProposalCard } from "./planning.js";
21
+
22
+ export const LOOP_PROPOSAL_MESSAGE_TYPE = "loop-proposal";
23
+
24
+ /**
25
+ * Emit the card. Returns false when Pi refused it, in which case the caller
26
+ * still has a working flow — the menu carries the actions, and the criteria
27
+ * are on disk the moment the loop starts.
28
+ */
29
+ export function showLoopProposalCard(
30
+ pi: ExtensionAPI,
31
+ ctx: ExtensionContext,
32
+ proposal: LoopProposal,
33
+ ): boolean {
34
+ 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
+ );
44
+ return true;
45
+ } catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ ctx.ui.notify(`Unable to show the loop proposal: ${detail}`, "error");
48
+ return false;
49
+ }
50
+ }
@@ -14,9 +14,9 @@
14
14
 
15
15
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
- import { MAX_INTERVAL_MS, parseDuration } from "./interval.js";
17
+ import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
18
18
  import type { LoopController } from "./loop.js";
19
- import { renderProposalCard } from "./planning.js";
19
+ import { showLoopProposalCard } from "./presentation.js";
20
20
 
21
21
  export const LOOP_PROPOSE_TOOL = "loop_propose";
22
22
 
@@ -59,7 +59,7 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
59
59
  }),
60
60
  ),
61
61
  }),
62
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
62
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
63
  if (!controller.planning.active) {
64
64
  return failure(
65
65
  "Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
@@ -101,8 +101,19 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
101
101
  }
102
102
 
103
103
  const proposal = controller.propose(objective, overrides);
104
+ // The card goes to the transcript as a framed block, not back through
105
+ // this tool result. Returning it here too would render the same
106
+ // artifact twice, once framed and once as a wall of markdown, and
107
+ // spend the objective's tokens a second time in the model's own
108
+ // context for no reader that does not already have it.
109
+ controller.showProposalCard(ctx);
104
110
  return {
105
- content: [{ type: "text" as const, text: renderProposalCard(proposal).join("\n") }],
111
+ content: [
112
+ {
113
+ type: "text" as const,
114
+ text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
115
+ },
116
+ ],
106
117
  details: {
107
118
  criteria: proposal.criteria.length,
108
119
  intervalMs: proposal.intervalMs,
package/src/state.ts CHANGED
@@ -63,6 +63,19 @@ export interface LoopState {
63
63
  * while it writes its state down; the next settle stops it.
64
64
  */
65
65
  expiring?: true;
66
+ /**
67
+ * Set on a loop handed to a fresh session and cleared the moment that
68
+ * session restores it.
69
+ *
70
+ * The launching session cannot kick the loop off itself: Pi builds a new
71
+ * extension instance for the new session, so the controller that ran the
72
+ * approval menu is not the controller that ends up holding the loop —
73
+ * observed live, where the loop crossed correctly and then sat idle waiting
74
+ * for its first fallback wake. Carrying the intent in the state instead
75
+ * means whichever instance restores it does the kickoff, which is true for
76
+ * every lifecycle the host might have.
77
+ */
78
+ handoff?: true;
66
79
  }
67
80
 
68
81
  const MAX_PROMPT_LENGTH = 100_000;
@@ -135,6 +148,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
135
148
  const lastFingerprint = optionalText(record.lastFingerprint);
136
149
  if (lastFingerprint === false) return undefined;
137
150
  if (record.expiring !== undefined && record.expiring !== true) return undefined;
151
+ if (record.handoff !== undefined && record.handoff !== true) return undefined;
138
152
  return {
139
153
  id,
140
154
  status: status as LoopStatus,
@@ -154,6 +168,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
154
168
  ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
155
169
  ...(pauseCause === undefined ? {} : { pauseCause }),
156
170
  ...(record.expiring === true ? { expiring: true as const } : {}),
171
+ ...(record.handoff === true ? { handoff: true as const } : {}),
157
172
  };
158
173
  }
159
174