@hank-warren/pi-loop 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
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
+
23
+ ## 0.8.0
24
+
25
+ ### Minor Changes
26
+
27
+ - 2a04a46: Plan mode, a ledger write tool, and a widget that shows progress.
28
+
29
+ **Breaking: `/schedule` is removed.** It lived here because the machinery was the same
30
+ machinery — an idle-gated delivery path, coalescing, caps, an expiry. True, and still the
31
+ wrong home: a loop pursues one objective until it is done and then stops, while a schedule
32
+ fires forever on a clock. Persisted tasks in `~/.pi/agent/loop/schedules.json` are no longer
33
+ read and the `schedules.lease` lockfile is no longer taken.
34
+
35
+ **`/loop` now opens planning.** With nothing running it starts a drafting conversation
36
+ instead of printing "No loop in this session". The model drafts the objective as an
37
+ acceptance test, then calls the new `loop_propose` tool, which renders an approval card
38
+ showing the exact criteria the split will produce, the cadence and the caps — and starts
39
+ nothing. You start it, change the cadence, keep editing, or cancel. The criteria used to be
40
+ frozen before anyone had ever seen them; the card is the moment they can still be fixed.
41
+ `/loop 30m <objective>` and inline `loop:` are unchanged.
42
+
43
+ **Fixed: a wrapped bullet silently truncated its criterion.** `deriveCriteria` kept lines
44
+ carrying a bullet marker and discarded the rest, so a bullet longer than a terminal width
45
+ lost everything after its first line — while the criterion still looked well-formed. The
46
+ acceptance gate was quietly weakened to whatever survived, with no signal.
47
+
48
+ **New `loop_progress` tool, the only supported ledger write path.** The ledger is the one
49
+ thing that survives compaction, and the model was told to maintain it with no tool to do so,
50
+ so it reached for a shell heredoc — and one `cat > PROGRESS.md` replaced the objective line,
51
+ the other sections and every failed-approach note. `loop_progress` edits one section and
52
+ leaves every other byte alone, and marks a criterion met with the citation that justified
53
+ it, stored alongside it. Only `passes` ever changes, now by construction rather than by
54
+ prose in a skill file.
55
+
56
+ **A blocking prompt no longer deadlocks the loop invisibly.** A session waiting on a modal
57
+ is `busy`, which makes every continuation and every fallback tick skip; no turn completes,
58
+ so the cap never trips and the no-progress breaker never fires. Expiry was the only thing
59
+ left — up to seven days — while the widget showed a next-wake time throughout. Loops now
60
+ carry an autonomy posture (decide and record rather than ask, `loop_wait` as the only
61
+ non-deadlocking way to ask, never reshape a command to get past a permission prompt, prefer
62
+ the undoable), and a run left open past fifteen minutes flips the widget to an attention
63
+ state. The loop never answers the prompt.
64
+
65
+ **The widget was redesigned around progress.** It led with the interval — a fallback
66
+ heartbeat a settle-paced loop may never deliver — and reported turns against the cap, which
67
+ is budget burn. Criteria met over total now leads, with the turn budget, the loop's age and
68
+ the next wake after it. The widget and the footer render the same function: they had drifted,
69
+ so a loop waiting on CI showed an ordinary next-wake time above the editor while the footer
70
+ said it was waiting, and a paused loop dropped the cause that `/loop status` printed.
71
+
3
72
  ## 0.7.0
4
73
 
5
74
  ### 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.
@@ -156,42 +173,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
156
173
  - **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle — re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation. The loop no longer goes quiet until the next wake. A re-anchor supersedes an ordinary continuation already queued: after a compaction, "re-read the ledger" is strictly the better instruction.
157
174
  - Loop state itself lives in custom session entries, which compaction never touches, and survives session restarts (the timer re-arms on resume; expired loops are dropped with a notice).
158
175
 
159
- ## `/schedule`: recurring prompts and headless runs
160
-
161
- The same extension also schedules work, because the machinery is the same machinery: an idle-gated delivery path, coalescing, caps, and an expiry.
162
-
163
- ```
164
- /schedule manager TUI
165
- /schedule list
166
- /schedule every 30m check the release queue in-session prompt, every 30 minutes
167
- /schedule at +2h remind me to cut the RC once, two hours from now
168
- /schedule at 2026-01-31T09:00 monthly report once, at an ISO timestamp
169
- /schedule cron "0 9 * * 1" weekly triage Monday mornings
170
- /schedule every 6h --run --cwd /srv/app sync headless `pi -p` run
171
- /schedule pause|resume|run|status|delete <id>
172
- ```
173
-
174
- Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlimited>`, `--wake always|failure|success|never`, `--name <text>`.
175
-
176
- **Two task kinds, deliberately different lifetimes:**
177
-
178
- - **`prompt`** injects a prompt into the owning session, delivered exactly like a loop wake — only at a settled idle boundary, queued while the agent is busy. It is **session-scoped**: it lives in memory and dies with the session, because a prompt with no session to arrive in is not a task, it is a leak.
179
- - **`run`** spawns a headless `pi -p "<prompt>"` in a working directory, tees stdout and stderr to `~/.pi/agent/loop/runs/<task-id>/<timestamp>.log`, and records the exit code. It never touches the conversation unless `wakeOn` says to report back (default: only failures). These are the only tasks persisted, in `~/.pi/agent/loop/schedules.json`.
180
-
181
- A headless run is a **fresh `pi` invocation**, so it uses your *default* model and settings, not the model the scheduling session happens to be using. If a run needs a specific model, say so in the prompt's environment — or check the run log, which records the command, cwd, prompt, and exit code precisely so a surprise like this is one `cat` away.
182
-
183
- **One fire per occurrence, not one per open session.** Headless firing is arbitrated by a lockfile lease (`schedules.lease`) holding a pid and a heartbeat: without it, a task scheduled for 09:00 fires once in every Pi session that happens to be open. A holder that dies stops renewing and the next session takes over after 90 seconds. It is not a distributed lock — the failure it must prevent is duplicate work, and the worst it can produce is one skipped tick.
184
-
185
- **Missed occurrences coalesce into a single fire.** A laptop asleep for a weekend wakes to one catch-up, never one turn per missed interval.
186
-
187
- **Every task is bounded twice**: `maxRuns` (default 25, `--max unlimited` is an explicit opt-in) and a hard 90-day expiry.
188
-
189
- **Cron** is five numeric fields at minute granularity (`minute hour day-of-month month day-of-week`), supporting `*`, `n`, `a-b`, `a,b`, and `/step`. No names, no `@daily`, no seconds, no timezones beyond the host's local clock — each of those is a place where two implementations disagree, and a scheduler with debatable semantics is worse than one that refuses the expression. When both day fields are restricted, a day matching *either* fires, as in every crontab in the world.
190
-
191
- `/schedule` is **user-typed only**, exactly like `/loop`: the model gets no scheduling tools. A model that can schedule its own future turns can schedule its way around every limit the loop imposes.
192
-
193
- > **Never co-install [`@jl1990/pi-scheduler`](https://www.npmjs.com/package/@jl1990/pi-scheduler).** Both register `/schedule`; the commands and the concepts collide.
194
-
195
176
  ## Settings
196
177
 
197
178
  `~/.pi/agent/pi-loop.json` (absent file = defaults, never created implicitly; saves are atomic and preserve unknown fields), or `/loop settings`:
@@ -223,7 +204,7 @@ These were considered and cut, and the reasoning is recorded so they are not sil
223
204
 
224
205
  ## The companion skill
225
206
 
226
- The package is a hybrid: it ships the extension **and** a `pi-loop` skill (`skills/pi-loop/SKILL.md`), which carries the judgment the engine cannot encode — how an objective becomes falsifiable criteria, what the evidence gate accepts as a citation, when to declare a `loop_wait` instead of polling, what `PROGRESS.md` is worth, and when the work belongs in `/schedule` or in no loop at all.
207
+ The package is a hybrid: it ships the extension **and** a `pi-loop` skill (`skills/pi-loop/SKILL.md`), which carries the judgment the engine cannot encode — how an objective becomes falsifiable criteria, what the evidence gate accepts as a citation, when to declare a `loop_wait` instead of polling, what `PROGRESS.md` is worth, and when the work belongs in no loop at all.
227
208
 
228
209
  It is **loaded on demand**: one line in each of `loop_start` and `loop_complete`'s prompt guidelines points at it by name, exactly as `pi-processes` does, and the model reads the body when it judges it needs it. Nothing about the guidance enters a stored loop message or the system append — those bytes are the cache prefix, and `test/bytes.test.ts` fails if any of them so much as mentions a skill.
229
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.7.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
  }
@@ -70,6 +70,56 @@ c2: "gh pr checks 171 → all 3 checks passed (ci/gate, ci/typecheck, ci/scan-se
70
70
 
71
71
  Audit requirement by requirement before calling it, and match the verification scope to the requirement scope: a criterion about the whole suite is not proven by one test file. **Effort exhaustion is not completion** — running long, running out of ideas, or nearing a cap is never a reason to call the tool. If the criteria are not met, keep working; another continuation is coming.
72
72
 
73
+ ## A loop runs unattended, so a question is a deadlock
74
+
75
+ Outside a loop, asking the user costs a few seconds of their attention. Inside one they are not
76
+ there by construction, and the cost is not a slow answer — it is the loop's death.
77
+
78
+ A session blocked on a modal prompt is **busy**, and `busy` is the third test in the decision
79
+ prefix both drivers share:
80
+
81
+ ```
82
+ loop liveness -> expiry -> plan mode -> compaction -> busy -> wait -> the turn cap -> act
83
+ ```
84
+
85
+ So `agent_settled` never fires and no continuation is queued; every fallback tick returns
86
+ `skip: agent-busy`; `automaticTurns` never increments, so the turn cap never trips; and the
87
+ no-progress breaker needs tool-free *turns*, of which there are none. **Expiry is the only thing
88
+ left, up to seven days later** — and the widget shows the next wake time the whole time.
89
+
90
+ This is why the guidance is a posture rather than a list of forbidden tools. Your other
91
+ extensions are unknowable and a blacklist goes stale the moment one of them ships a new prompt.
92
+ The rule follows from the mechanics instead:
93
+
94
+ 1. **Decide, do not ask.** Take the reversible option, write the decision *and the reasoning*
95
+ into `PROGRESS.md`, and keep going. The user reads it when they return and the loop never
96
+ stopped. A decision recorded beats a question unanswered.
97
+ 2. **`loop_wait` is the loop-safe form of asking.** It is the one way to say "I need a human"
98
+ that does not deadlock: continuations stop, the reason shows in the widget and `/loop status`,
99
+ and any wake resumes it. Use it exactly where you would otherwise open a modal, and put the
100
+ options in the ledger first so the answer can be one word.
101
+ 3. **Never reshape a command to get *around* a permission gate.** This is the dangerous one. An
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*. 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.
114
+ 4. **Prefer the undoable.** Nobody is watching to catch a bad call, so when two paths are close,
115
+ take the one that is cheap to reverse: a worktree over the clone, additive over destructive, a
116
+ draft pull request over a merge.
117
+ 5. **Ask only when proceeding is irreversible *and* the choice is load-bearing.** Then use
118
+ `loop_wait`, not a prompt.
119
+
120
+ Autonomy is not permission to be reckless. It is the opposite: the absence of a human in the loop
121
+ is exactly why the reversible path is the right default.
122
+
73
123
  ## Waiting on the world
74
124
 
75
125
  When progress depends on something outside the session — a CI run, a deploy, a human reply — call `loop_wait` with a one-sentence reason. Do not spend continuations re-checking, and never sleep in a shell to pass time.
@@ -87,7 +137,9 @@ The ledger lives at `~/.pi/agent/loop/<loop-id>/`, and `PROGRESS.md` has four fi
87
137
 
88
138
  The failed-approaches section carries the most value, because it is the only thing that survives compaction and the only thing that stops the next continuation — or the next engineer — from re-running an experiment that already failed. "Tried X, it failed because Y" is the whole point; "tried several things" is worth nothing.
89
139
 
90
- `criteria.json` sits next to it. You may flip an entry's `passes` field when authoritative evidence proves it, citing that evidence in `PROGRESS.md`. You may **never** add, remove, reword, or re-id an entry: a model that can rewrite its acceptance criteria eventually rewrites them into something it has already achieved.
140
+ Write both files with the **`loop_progress`** tool, never with the file or shell tools. `loop_progress` edits one section and leaves every other byte alone; a whole-file write takes out the objective line, the other three sections, and however many days of failed-approach notes were in them. That is not a hypothetical it is what a `cat > PROGRESS.md <<EOF` does on the first ledger update, and `createLedger` opens the file with `flag: "wx"` precisely so the engine can never do it.
141
+
142
+ `criteria.json` sits next to it. Mark an entry met with `loop_progress`, which flips `passes` and stores the citation that justified it alongside the criterion, where `loop_complete` can be held to it later. Only `passes` ever changes: you may **never** add, remove, reword, or re-id an entry, and never hand-edit the file. A model that can rewrite its acceptance criteria eventually rewrites them into something it has already achieved.
91
143
 
92
144
  Both files are best-effort. If the ledger could not be created the loop still runs; it just has no memory outside the conversation.
93
145
 
@@ -103,7 +155,7 @@ The interval is a fallback heartbeat, not the pacemaker — the loop advances wh
103
155
 
104
156
  An inline invocation *permits* a loop; it does not oblige you to start one. When the work is a bad fit, say so in one line and offer the alternative instead:
105
157
 
106
- - **Recurring cadence** ("check the release queue every morning") → `/schedule`, which owns cron, one-shots, and headless runs. A loop pursues one objective until it is done; it is not a timer.
158
+ - **Recurring cadence** ("check the release queue every morning") → not a loop. A loop pursues one objective until it is done and then stops; it is not a timer, and an objective that is never "done" only produces turns until a cap. Use whatever scheduling your setup provides.
107
159
  - **Genuine open exploration** ("figure out why memory grows") → do the investigation in the conversation. A loop's value is the gate at the end; an investigation has no end state to gate on, so the loop only supplies unwanted turns. Once the investigation names a fix, *that* is a loop objective.
108
160
  - **Work that finishes this turn** → just do it, and say why no loop was needed.
109
161
 
@@ -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
+ }
package/src/index.ts CHANGED
@@ -11,23 +11,18 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
13
  import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
14
+ import { registerLoopProgressTool } from "./progress-tool.js";
15
+ import { registerLoopProposeTool } from "./propose-tool.js";
16
+ import { LOOP_PLANNING_HINT } from "./planning.js";
14
17
  import { registerLoopStartTool } from "./start-tool.js";
15
18
  import { registerLoopWaitTool } from "./wait-tool.js";
16
19
  import { LoopController, type LoopControllerOptions } from "./loop.js";
17
- import { showLoopManager, showLoopSettings } from "./manager.js";
20
+ import { showLoopApproval, showLoopManager, showLoopSettings } from "./manager.js";
18
21
  import { buildLoopObjectivePrompt } from "./objective.js";
19
22
  import { registerLoopMessageRendering } from "./render.js";
20
- import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
21
- import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
22
- import { describeSchedule } from "./schedule/model.js";
23
- import { Scheduler } from "./schedule/runner.js";
24
23
 
25
24
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
26
25
  const controller = new LoopController(pi, options);
27
- const scheduler = new Scheduler(pi, {
28
- ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
29
- ...(options.now === undefined ? {} : { now: options.now }),
30
- });
31
26
  // Registered unconditionally and never toggled with loop state: tools are
32
27
  // part of the cached request prefix, so mutating the tool set mid-session
33
28
  // would invalidate the whole conversation cache. It refuses when no loop is
@@ -36,6 +31,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
36
31
  // Registered on the same terms and for the same reason: the tool set is
37
32
  // part of the cached prefix, so it never changes with loop state.
38
33
  registerLoopWaitTool(pi, controller);
34
+ registerLoopProgressTool(pi, controller);
35
+ registerLoopProposeTool(pi, controller);
39
36
  // Inline invocation: an `input` handler arms a one-turn system-prompt hint
40
37
  // for a mid-prompt `/loop` token, `before_agent_start` appends it, and
41
38
  // loop_start is the model-invoked start it points at — refused on any turn
@@ -54,9 +51,34 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
54
51
  handler: async (args: string, ctx: ExtensionCommandContext) => {
55
52
  const command = parseLoopCommand(args);
56
53
  switch (command.kind) {
57
- case "show":
58
- await showLoopManager(controller, ctx);
54
+ case "show": {
55
+ // Bare /loop is the front door. With a loop running it is the
56
+ // manager, as before. With a draft awaiting approval it is the
57
+ // card. With neither it opens planning, which is what used to be
58
+ // a one-line "no loop in this session" dead end.
59
+ const running = controller.state && controller.state.status !== "stopped";
60
+ if (running) {
61
+ await showLoopManager(controller, ctx);
62
+ return;
63
+ }
64
+ if (controller.planning.proposal) {
65
+ await showLoopApproval(controller, ctx);
66
+ return;
67
+ }
68
+ if (controller.planning.active) {
69
+ ctx.ui.notify(
70
+ "Still planning: describe the objective, and the agent will put a loop up for approval.",
71
+ "info",
72
+ );
73
+ return;
74
+ }
75
+ controller.beginPlanning();
76
+ ctx.ui.notify(
77
+ "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.",
78
+ "info",
79
+ );
59
80
  return;
81
+ }
60
82
  case "status":
61
83
  ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
62
84
  return;
@@ -101,83 +123,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
101
123
  },
102
124
  });
103
125
 
104
- // The scheduler is user-typed only, exactly like /loop: the model gets no
105
- // scheduling tools, because a model that can schedule its own future turns
106
- // can schedule its way around every limit the loop imposes.
107
- pi.registerCommand("schedule", {
108
- description:
109
- 'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
110
- getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
111
- handler: async (args: string, ctx: ExtensionCommandContext) => {
112
- const command = parseScheduleCommand(args, { cwd: ctx.cwd });
113
- switch (command.kind) {
114
- case "show":
115
- await showScheduleManager(scheduler, ctx);
116
- return;
117
- case "list":
118
- ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
119
- return;
120
- case "error":
121
- ctx.ui.notify(command.message, "error");
122
- return;
123
- case "create": {
124
- const { task, warning } = scheduler.create(command);
125
- if (warning) {
126
- ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
127
- }
128
- if (command.clampedFrom !== undefined) {
129
- ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
130
- }
131
- ctx.ui.notify(
132
- [
133
- `Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
134
- task.task.kind === "run"
135
- ? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
136
- : "Injects a prompt into this session at an idle boundary; it dies with the session.",
137
- `Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
138
- ].join("\n"),
139
- "info",
140
- );
141
- return;
142
- }
143
- default: {
144
- const task = scheduler.find(command.id);
145
- if (!task) {
146
- ctx.ui.notify(
147
- `No scheduled task matches ${command.id}. Run /schedule list to see them.`,
148
- "error",
149
- );
150
- return;
151
- }
152
- if (command.kind === "status") {
153
- ctx.ui.notify(describeTask(task).join("\n"), "info");
154
- return;
155
- }
156
- if (command.kind === "pause" || command.kind === "resume") {
157
- const status = command.kind === "pause" ? "paused" : "active";
158
- scheduler.update({ ...task, status });
159
- ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
160
- return;
161
- }
162
- if (command.kind === "run") {
163
- scheduler.fireNow(task);
164
- ctx.ui.notify(`Running "${task.name}" now.`, "info");
165
- return;
166
- }
167
- scheduler.remove(task.id);
168
- ctx.ui.notify(`Deleted "${task.name}".`, "info");
169
- }
170
- }
171
- },
172
- });
173
-
174
126
  pi.on("session_start", async (_event, ctx) => {
175
127
  controller.onSessionStart(ctx);
176
- scheduler.onSessionStart(ctx);
177
128
  });
178
129
  pi.on("session_shutdown", async () => {
179
130
  controller.onSessionShutdown();
180
- scheduler.onSessionShutdown();
181
131
  });
182
132
  // The pacemaker: agent_end records the intent to continue, agent_settled
183
133
  // delivers it once Pi will accept a message.
@@ -189,12 +139,16 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
189
139
  });
190
140
  pi.on("agent_settled", async (_event, ctx) => {
191
141
  controller.onAgentSettled(ctx);
192
- scheduler.onAgentSettled(ctx);
193
142
  });
194
143
  // A loop carries its own objective and injects it as a byte-stable system
195
144
  // append, which is what lets the poke and continuation messages stay
196
145
  // pointer-sized.
197
146
  pi.on("before_agent_start", (event) => {
147
+ // Planning precedes any loop, so its guidance is injected on the same hook
148
+ // and is mutually exclusive with the objective append below.
149
+ if (controller.planning.active) {
150
+ return { systemPrompt: `${event.systemPrompt}\n\n${LOOP_PLANNING_HINT}` };
151
+ }
198
152
  const loop = controller.state;
199
153
  if (!loop || loop.status !== "active") return;
200
154
  const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
package/src/interval.ts CHANGED
@@ -54,6 +54,31 @@ export function formatDuration(ms: number): string {
54
54
  return `${Math.round(ms / 1_000)}s`;
55
55
  }
56
56
 
57
+ /**
58
+ * Render an elapsed span approximately, for display only.
59
+ *
60
+ * `formatDuration` renders the *canonical token* for a configured interval and
61
+ * only ever emits one unit on an exact multiple, so an arbitrary elapsed span
62
+ * falls through it to seconds — 2h12m comes back as "7920s". An age needs the
63
+ * opposite trade: two units at most, truncated, never exact.
64
+ */
65
+ export function formatElapsed(ms: number): string {
66
+ const clamped = Math.max(0, ms);
67
+ if (clamped < UNIT_MS.m) return `${Math.floor(clamped / 1_000)}s`;
68
+ for (const [big, small] of [
69
+ ["d", "h"],
70
+ ["h", "m"],
71
+ ] as const) {
72
+ const bigMs = UNIT_MS[big];
73
+ const smallMs = UNIT_MS[small];
74
+ if (bigMs === undefined || smallMs === undefined || clamped < bigMs) continue;
75
+ const whole = Math.floor(clamped / bigMs);
76
+ const rest = Math.floor((clamped % bigMs) / smallMs);
77
+ return rest > 0 ? `${whole}${big}${rest}${small}` : `${whole}${big}`;
78
+ }
79
+ return `${Math.floor(clamped / UNIT_MS.m)}m`;
80
+ }
81
+
57
82
  /** Render a wall-clock time as HH:MM for the status widget. */
58
83
  export function formatClock(timestamp: number): string {
59
84
  const date = new Date(timestamp);