@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 +20 -0
- package/README.md +17 -0
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +12 -3
- package/src/fresh-launch.ts +128 -0
- package/src/loop-action-menus.ts +129 -0
- package/src/loop-env.ts +50 -0
- package/src/loop.ts +136 -9
- package/src/manager.ts +95 -43
- package/src/objective.ts +13 -1
- package/src/planning.ts +11 -1
- package/src/presentation.ts +50 -0
- package/src/propose-tool.ts +15 -4
- package/src/state.ts +15 -0
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.
|
|
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
|
}
|
package/skills/pi-loop/SKILL.md
CHANGED
|
@@ -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
|
|
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*.
|
|
104
|
-
|
|
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
|
+
}
|
package/src/loop-env.ts
ADDED
|
@@ -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
|
|
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(
|
|
1353
|
+
this.scheduleTick(started.intervalMs);
|
|
1229
1354
|
this.updateWidget();
|
|
1230
|
-
const clampNote =
|
|
1231
|
-
? ` (requested ${formatDuration(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
+
}
|
package/src/propose-tool.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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: [
|
|
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
|
|