@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 +69 -0
- package/README.md +18 -37
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +54 -2
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop-action-menus.ts +129 -0
- package/src/loop-env.ts +50 -0
- package/src/loop.ts +299 -47
- package/src/manager.ts +126 -0
- package/src/objective.ts +25 -2
- package/src/planning.ts +108 -0
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +130 -0
- package/src/state.ts +15 -0
- package/src/widget.ts +102 -12
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/planning.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop planning: authoring an objective with the user before any loop exists.
|
|
3
|
+
*
|
|
4
|
+
* The criteria are frozen the moment a loop starts, and until now the first
|
|
5
|
+
* time anyone saw them was after that point. That is the wrong order. The
|
|
6
|
+
* objective's wording is the single leverage point on a loop's whole life —
|
|
7
|
+
* it becomes the acceptance gate — and the moment it is decided is a
|
|
8
|
+
* conversation, not a typed command.
|
|
9
|
+
*
|
|
10
|
+
* So `/loop` with nothing running opens a drafting conversation instead of an
|
|
11
|
+
* error about a missing interval, and the loop starts from an approval card
|
|
12
|
+
* that shows the exact criteria the split will produce. The card is the design
|
|
13
|
+
* language: because the cadence and the caps are on it and editable there, the
|
|
14
|
+
* command grammar does not have to be natural, and none of the
|
|
15
|
+
* optional-interval, `every`-prefix, adverb or dry-run machinery needs to
|
|
16
|
+
* exist. A concept removed rather than a knob added.
|
|
17
|
+
*
|
|
18
|
+
* The typed form (`/loop 30m <objective>`) is untouched, as is inline `loop:`
|
|
19
|
+
* invocation. Planning is the front door, not the only door.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { deriveCriteria, type LoopCriterion } from "./ledger.js";
|
|
23
|
+
import { formatDuration } from "./interval.js";
|
|
24
|
+
|
|
25
|
+
/** A drafted loop, put up for approval and not yet started. */
|
|
26
|
+
export interface LoopProposal {
|
|
27
|
+
objective: string;
|
|
28
|
+
/** Exactly what `deriveCriteria` will produce, computed here so the card cannot lie. */
|
|
29
|
+
criteria: LoopCriterion[];
|
|
30
|
+
intervalMs: number;
|
|
31
|
+
maxTurns: number | null;
|
|
32
|
+
expiresInMs: number;
|
|
33
|
+
proposedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoopPlanningState {
|
|
37
|
+
/** The user opened planning and no loop has started yet. */
|
|
38
|
+
active: boolean;
|
|
39
|
+
/** The current draft awaiting approval, when one has been proposed. */
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildProposal(
|
|
54
|
+
objective: string,
|
|
55
|
+
defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
|
|
56
|
+
now: number,
|
|
57
|
+
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
58
|
+
): LoopProposal {
|
|
59
|
+
return {
|
|
60
|
+
objective: objective.trim(),
|
|
61
|
+
// Derived, never authored: the card has to show the criteria the engine
|
|
62
|
+
// will actually freeze, or approving it means approving something else.
|
|
63
|
+
criteria: deriveCriteria(objective),
|
|
64
|
+
intervalMs: overrides.intervalMs ?? defaults.intervalMs,
|
|
65
|
+
maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
|
|
66
|
+
expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
|
|
67
|
+
proposedAt: now,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The approval card, as transcript lines. */
|
|
72
|
+
export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
73
|
+
return [
|
|
74
|
+
"**◆ Loop ready to start**",
|
|
75
|
+
"",
|
|
76
|
+
"**Objective**",
|
|
77
|
+
...proposal.objective.split("\n").map((line) => `> ${line}`),
|
|
78
|
+
"",
|
|
79
|
+
`**Criteria the gate will hold you to** (${proposal.criteria.length})`,
|
|
80
|
+
...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
|
|
81
|
+
"",
|
|
82
|
+
`**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
|
|
83
|
+
`**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
|
|
84
|
+
"",
|
|
85
|
+
"Run `/loop` for the actions: start here, start in a fresh session, change the cadence, keep editing, or cancel.",
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const LOOP_PLANNING_HINT = [
|
|
90
|
+
"<system-reminder>",
|
|
91
|
+
"The user opened loop planning. You are drafting a loop objective with them; no loop is running and none starts until they approve one.",
|
|
92
|
+
"A loop's objective becomes its acceptance gate, so draft it as an acceptance test, not as a prompt:",
|
|
93
|
+
"- One requirement per line, as a bullet. A conjunction inside a sentence does not split, so 'fix the flaky test and update the docs' becomes one criterion whose evidence must cover both halves.",
|
|
94
|
+
"- Name the check in the requirement itself ('…, verified by npm test passing'), so completion is a lookup instead of an argument.",
|
|
95
|
+
"- The two questions that fix most objectives: how will we know it is done, and what command proves it?",
|
|
96
|
+
"When the draft is ready, call loop_propose with it. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
|
|
97
|
+
// Without this the model reaches for the loop_start prohibition instead. It
|
|
98
|
+
// is stated emphatically and repeatedly ('never start a loop without that
|
|
99
|
+
// token, no matter how loop-like the request sounds'), so a conversational
|
|
100
|
+
// request for a loop pattern-matches straight onto it — and the model
|
|
101
|
+
// answers by telling the user to type /loop, which is precisely the dead end
|
|
102
|
+
// planning exists to remove. Observed live in a canary session.
|
|
103
|
+
"loop_propose is not loop_start. It starts nothing, so the inline-token rule does not apply to it: while planning is open, a conversational request for a loop is exactly when to call loop_propose. Do not refuse and tell the user to type /loop instead — drafting a proposal for them is the whole point of this mode.",
|
|
104
|
+
"The user has already opened planning, so their intent to consider a loop is established. What still requires their explicit approval is starting one, and the card is where they give it.",
|
|
105
|
+
"Never restate the objective as a tidier version of what they meant. If they decline to name checks, say plainly what the gate will and will not catch, and let them decide.",
|
|
106
|
+
"If the work is a bad fit for a loop at all — a recurring cadence, open-ended investigation with no end state, or something that finishes this turn — say so in one line and offer the alternative instead of drafting one anyway.",
|
|
107
|
+
"</system-reminder>",
|
|
108
|
+
].join("\n");
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_progress`: the only supported write path into the loop ledger.
|
|
3
|
+
*
|
|
4
|
+
* The ledger is the one thing that survives compaction, and until this tool
|
|
5
|
+
* existed the model was told to maintain it with no way to do so — so it
|
|
6
|
+
* reached for `write` or a shell heredoc, and a single `cat > PROGRESS.md`
|
|
7
|
+
* replaced the objective line, the other three sections, and days of
|
|
8
|
+
* failed-approach notes. `createLedger` opens that file with `flag: "wx"`
|
|
9
|
+
* precisely so the *engine* can never do that; leaving the *agent* a path that
|
|
10
|
+
* can made the protection decorative.
|
|
11
|
+
*
|
|
12
|
+
* Two operations, deliberately in one tool: record a note in a named section,
|
|
13
|
+
* and flip a criterion with the citation that justified it. They travel
|
|
14
|
+
* together — "here is what I did, and here is the criterion it proves" is one
|
|
15
|
+
* thought, and one tool call per turn keeps the ledger current without a
|
|
16
|
+
* second round trip.
|
|
17
|
+
*
|
|
18
|
+
* Registered unconditionally like `loop_complete` and `loop_wait`: tools are
|
|
19
|
+
* part of the cached request prefix, so adding one mid-session would
|
|
20
|
+
* invalidate the whole conversation cache. It refuses when no loop is active.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
25
|
+
import { Type } from "typebox";
|
|
26
|
+
import {
|
|
27
|
+
MAX_EVIDENCE_LENGTH,
|
|
28
|
+
MAX_PROGRESS_TEXT_LENGTH,
|
|
29
|
+
markCriterion,
|
|
30
|
+
PROGRESS_SECTIONS,
|
|
31
|
+
type ProgressSection,
|
|
32
|
+
writeProgressSection,
|
|
33
|
+
} from "./ledger.js";
|
|
34
|
+
import type { LoopController } from "./loop.js";
|
|
35
|
+
|
|
36
|
+
export const LOOP_PROGRESS_TOOL = "loop_progress";
|
|
37
|
+
|
|
38
|
+
export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopController) {
|
|
39
|
+
pi.registerTool(
|
|
40
|
+
defineTool({
|
|
41
|
+
name: LOOP_PROGRESS_TOOL,
|
|
42
|
+
label: "Loop Progress",
|
|
43
|
+
description:
|
|
44
|
+
"Record progress in the active /loop's durable ledger: append a note to one PROGRESS.md section, and/or mark a completion criterion met with the evidence that proves it. The only supported way to write to the ledger — never edit PROGRESS.md or criteria.json with file or shell tools.",
|
|
45
|
+
promptSnippet: "Record loop progress and mark criteria met with evidence",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"Use loop_progress to update the loop ledger. Never write PROGRESS.md or criteria.json with the file or shell tools: a whole-file write destroys the objective line and the other sections, and hand-editing criteria.json bypasses the rule that only `passes` may change.",
|
|
48
|
+
"Record a note the same turn you learn something, not at the end. The failed-approaches section carries the most value, because it is the only thing that stops the next continuation from re-running an experiment that already failed.",
|
|
49
|
+
"'current status' replaces what is there (it is one current value); the other three sections append.",
|
|
50
|
+
"Mark a criterion met only with authoritative evidence: the command and what it printed, the file and what it now contains, the URL and its state. The citation is stored next to the criterion and is what loop_complete answers for later.",
|
|
51
|
+
],
|
|
52
|
+
parameters: Type.Object({
|
|
53
|
+
section: Type.Optional(
|
|
54
|
+
StringEnum([...PROGRESS_SECTIONS], {
|
|
55
|
+
description:
|
|
56
|
+
"Which PROGRESS.md section to write. 'current status' replaces its contents; the others append.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
note: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
maxLength: MAX_PROGRESS_TEXT_LENGTH,
|
|
62
|
+
description:
|
|
63
|
+
"Markdown to record in that section. Write list sections as '- ' bullets to match the file.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
criterion: Type.Optional(
|
|
67
|
+
Type.String({
|
|
68
|
+
maxLength: 40,
|
|
69
|
+
description: "Criterion id to mark, e.g. 'c2'. Ids come from the loop's criteria.json.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
evidence: Type.Optional(
|
|
73
|
+
Type.String({
|
|
74
|
+
maxLength: MAX_EVIDENCE_LENGTH,
|
|
75
|
+
description:
|
|
76
|
+
"The citation proving that criterion: the command and its output, the file and its contents, the URL and its state. Required when marking one met.",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
met: Type.Optional(
|
|
80
|
+
Type.Boolean({
|
|
81
|
+
description:
|
|
82
|
+
"Whether the criterion is met. Defaults to true; pass false to retract a criterion marked met in error.",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
}),
|
|
86
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
87
|
+
const loop = controller.state;
|
|
88
|
+
if (!loop || loop.objective === undefined) {
|
|
89
|
+
return failure(
|
|
90
|
+
"No /loop with an objective is active, so there is no ledger to write. Start one with /loop <interval> <objective>.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const paths = controller.ledger;
|
|
94
|
+
if (!paths) {
|
|
95
|
+
return failure(
|
|
96
|
+
"This loop has no ledger (it could not be created), so progress cannot be recorded. Keep the state in your reply instead.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const section = params.section as ProgressSection | undefined;
|
|
101
|
+
const note = params.note?.trim();
|
|
102
|
+
const criterion = params.criterion?.trim();
|
|
103
|
+
// A tool call that writes nothing is a mistake worth naming: the
|
|
104
|
+
// model believed it recorded something and it did not.
|
|
105
|
+
if (!note && !criterion) {
|
|
106
|
+
return failure(
|
|
107
|
+
"Nothing to record. Pass section + note to write a ledger entry, criterion + evidence to mark a criterion, or both.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (note && !section) return failure("A note needs a section to write it to.");
|
|
111
|
+
if (section && !note) return failure("A section needs a note to write into it.");
|
|
112
|
+
|
|
113
|
+
const done: string[] = [];
|
|
114
|
+
if (section && note) {
|
|
115
|
+
const failed = writeProgressSection(paths, section, note);
|
|
116
|
+
if (failed) return failure(`Could not write the ledger: ${failed}`);
|
|
117
|
+
done.push(`Recorded under "${section}".`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let remaining: string | undefined;
|
|
121
|
+
if (criterion) {
|
|
122
|
+
const met = params.met ?? true;
|
|
123
|
+
const result = markCriterion(paths, criterion, params.evidence ?? "", met, Date.now());
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
// A half-applied call still reports the half that landed, so the
|
|
126
|
+
// model does not record the note twice on the retry.
|
|
127
|
+
return failure(
|
|
128
|
+
[...done, `Could not mark ${criterion}: ${result.message}`].join(" "),
|
|
129
|
+
done.length > 0,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
done.push(result.message);
|
|
133
|
+
const unmet = (result.criteria ?? []).filter((entry) => !entry.passes);
|
|
134
|
+
remaining =
|
|
135
|
+
unmet.length > 0
|
|
136
|
+
? `Still unmet: ${unmet.map((entry) => entry.id).join(", ")}.`
|
|
137
|
+
: "Every criterion is now marked met; audit them against authoritative current state before calling loop_complete.";
|
|
138
|
+
controller.updateWidget();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{ type: "text" as const, text: [...done, remaining].filter(Boolean).join(" ") },
|
|
144
|
+
],
|
|
145
|
+
details: {
|
|
146
|
+
loopId: loop.id,
|
|
147
|
+
...(section && note ? { section } : {}),
|
|
148
|
+
...(criterion ? { criterion, met: params.met ?? true } : {}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function failure(text: string, partial = false) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text" as const, text }],
|
|
159
|
+
details: { partial },
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_propose`: put a drafted loop up for the user's approval.
|
|
3
|
+
*
|
|
4
|
+
* It starts nothing. That separation is the point — the model drafts, the user
|
|
5
|
+
* approves, and the approval is what arms the start. `loop_start`'s gate
|
|
6
|
+
* exists because a loop is self-continuing and must never begin on model
|
|
7
|
+
* initiative; an explicit approval on a card showing the objective, the
|
|
8
|
+
* criteria, the cadence and the caps is stronger evidence of intent than a
|
|
9
|
+
* typed token, not weaker, so it arms the same gate rather than bypassing it.
|
|
10
|
+
*
|
|
11
|
+
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
12
|
+
* of the cached request prefix, so it never changes with loop state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
18
|
+
import type { LoopController } from "./loop.js";
|
|
19
|
+
import { showLoopProposalCard } from "./presentation.js";
|
|
20
|
+
|
|
21
|
+
export const LOOP_PROPOSE_TOOL = "loop_propose";
|
|
22
|
+
|
|
23
|
+
export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
|
|
24
|
+
pi.registerTool(
|
|
25
|
+
defineTool({
|
|
26
|
+
name: LOOP_PROPOSE_TOOL,
|
|
27
|
+
label: "Loop Propose",
|
|
28
|
+
description:
|
|
29
|
+
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
|
|
30
|
+
promptSnippet: "Propose a drafted loop objective for approval",
|
|
31
|
+
promptGuidelines: [
|
|
32
|
+
"Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
|
|
33
|
+
"Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
|
|
34
|
+
"Do not call loop_start for a planned loop; approving the card is what starts it.",
|
|
35
|
+
"loop_propose starts nothing, so the rule that a loop needs an inline /loop token in the user's message does not apply to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to type /loop instead.",
|
|
36
|
+
],
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
objective: Type.String({
|
|
39
|
+
minLength: 1,
|
|
40
|
+
maxLength: 100_000,
|
|
41
|
+
description:
|
|
42
|
+
"The drafted objective, one requirement per bullet, each naming how it is verified.",
|
|
43
|
+
}),
|
|
44
|
+
interval: Type.Optional(
|
|
45
|
+
Type.String({
|
|
46
|
+
description:
|
|
47
|
+
"Fallback heartbeat as <number><unit> (s, m, h, d), e.g. 30m. Omit to use the configured default.",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
max_turns: Type.Optional(
|
|
51
|
+
Type.Number({
|
|
52
|
+
description:
|
|
53
|
+
"Cap on loop-caused turns. Omit for the configured default; the user can change it on the card.",
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
expires: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
63
|
+
if (!controller.planning.active) {
|
|
64
|
+
return failure(
|
|
65
|
+
"Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (controller.state && controller.state.status !== "stopped") {
|
|
69
|
+
return failure(
|
|
70
|
+
"A loop is already running in this session. Stop it with /loop stop before planning another.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const objective = params.objective.trim();
|
|
74
|
+
if (!objective) return failure("A proposal needs an objective.");
|
|
75
|
+
|
|
76
|
+
const overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
|
|
77
|
+
{};
|
|
78
|
+
if (params.interval !== undefined) {
|
|
79
|
+
const parsed = parseDuration(params.interval);
|
|
80
|
+
if (parsed === undefined) {
|
|
81
|
+
return failure(
|
|
82
|
+
`Invalid interval: ${params.interval}. Use <number><unit> with unit s, m, h, or d, e.g. 30m.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
overrides.intervalMs = Math.min(parsed, MAX_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
if (params.expires !== undefined) {
|
|
88
|
+
const parsed = parseDuration(params.expires);
|
|
89
|
+
if (parsed === undefined) {
|
|
90
|
+
return failure(
|
|
91
|
+
`Invalid expires: ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
overrides.expiresInMs = parsed;
|
|
95
|
+
}
|
|
96
|
+
if (params.max_turns !== undefined) {
|
|
97
|
+
if (!Number.isSafeInteger(params.max_turns) || params.max_turns <= 0) {
|
|
98
|
+
return failure("max_turns must be a positive whole number.");
|
|
99
|
+
}
|
|
100
|
+
overrides.maxTurns = params.max_turns;
|
|
101
|
+
}
|
|
102
|
+
|
|
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);
|
|
110
|
+
return {
|
|
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
|
+
],
|
|
117
|
+
details: {
|
|
118
|
+
criteria: proposal.criteria.length,
|
|
119
|
+
intervalMs: proposal.intervalMs,
|
|
120
|
+
maxTurns: proposal.maxTurns,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function failure(text: string) {
|
|
129
|
+
return { content: [{ type: "text" as const, text }], details: {}, isError: true };
|
|
130
|
+
}
|
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
|
|
package/src/widget.ts
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The loop widget:
|
|
3
|
-
* footer status (interval · loop turns/cap · next wake), with the loop focus
|
|
2
|
+
* The loop widget: one themed line above the editor, with the loop's focus
|
|
4
3
|
* dimmed below it when set.
|
|
5
4
|
*
|
|
5
|
+
* Two rules shape what goes on that line.
|
|
6
|
+
*
|
|
7
|
+
* **Show progress, not consumption.** The line used to lead with the interval
|
|
8
|
+
* and then report turns against the turn cap. The interval is a fallback
|
|
9
|
+
* heartbeat — a settle-paced loop can run its whole life without delivering
|
|
10
|
+
* one — and turns-against-cap is budget burn, which says nothing about how
|
|
11
|
+
* much of the objective is done. Criteria met over criteria total is the
|
|
12
|
+
* progress number, and it leads.
|
|
13
|
+
*
|
|
14
|
+
* **One surface, one story.** The widget and the footer status render the same
|
|
15
|
+
* state, so they render it from the same function. They disagreed before:
|
|
16
|
+
* `setStatus` handled `loop.waiting` and the widget did not, so a loop blocked
|
|
17
|
+
* on CI showed an ordinary "next 17:53" above the editor while the footer said
|
|
18
|
+
* it was waiting.
|
|
19
|
+
*
|
|
20
|
+
* States are ordered by how much they want a human, because the top of that
|
|
21
|
+
* order is the whole reason to glance at the line: paused and blocked and
|
|
22
|
+
* expiring come before the ordinary running line.
|
|
23
|
+
*
|
|
6
24
|
* Presentation only: every entry point tolerates a host without setWidget
|
|
7
25
|
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
8
26
|
* widget must never interrupt loop state transitions.
|
|
9
27
|
*/
|
|
10
28
|
|
|
11
29
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
-
import { formatClock,
|
|
30
|
+
import { formatClock, formatElapsed } from "./interval.js";
|
|
13
31
|
import type { LoopState } from "./state.js";
|
|
14
32
|
|
|
15
33
|
export const LOOP_WIDGET_KEY = "loop";
|
|
@@ -21,27 +39,61 @@ interface WidgetTheme {
|
|
|
21
39
|
|
|
22
40
|
type WidgetHost = { setWidget?: unknown };
|
|
23
41
|
|
|
24
|
-
|
|
42
|
+
/** Criteria progress, absent when the loop has no readable ledger. */
|
|
43
|
+
export interface CriteriaProgress {
|
|
44
|
+
met: number;
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The loop is being drafted with the user and has not started. */
|
|
49
|
+
export interface LoopPlanningView {
|
|
50
|
+
kind: "planning";
|
|
51
|
+
/** Criteria in the proposed draft, once one has been put up for approval. */
|
|
52
|
+
proposedCriteria?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LoopRunningView {
|
|
56
|
+
kind: "loop";
|
|
25
57
|
loop: LoopState;
|
|
26
58
|
/** A wake is held for the next idle boundary. */
|
|
27
59
|
wakePending: boolean;
|
|
28
60
|
/** Epoch ms of the next scheduled tick, when armed. */
|
|
29
61
|
nextWakeAt: number | undefined;
|
|
62
|
+
criteria?: CriteriaProgress;
|
|
63
|
+
/**
|
|
64
|
+
* How long the session has been busy with no completed turn. Set only past
|
|
65
|
+
* the stall threshold, where a blocking prompt is the likely explanation.
|
|
66
|
+
*/
|
|
67
|
+
blockedForMs?: number;
|
|
68
|
+
/** Injected so the elapsed span is deterministic in tests. */
|
|
69
|
+
now?: number;
|
|
30
70
|
}
|
|
31
71
|
|
|
72
|
+
export type LoopWidgetView = LoopPlanningView | LoopRunningView;
|
|
73
|
+
|
|
74
|
+
/** How urgently a line wants a human; picks the colour. */
|
|
75
|
+
type Tone = "normal" | "attention" | "planning";
|
|
76
|
+
|
|
32
77
|
export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
|
|
33
78
|
const setWidget = resolveSetWidget(ui);
|
|
34
79
|
if (!setWidget) return;
|
|
35
80
|
try {
|
|
36
|
-
if (!view || view.loop.status === "stopped") {
|
|
81
|
+
if (!view || (view.kind === "loop" && view.loop.status === "stopped")) {
|
|
37
82
|
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
38
83
|
return;
|
|
39
84
|
}
|
|
40
85
|
setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
|
|
41
86
|
const bold = theme.bold ?? identity;
|
|
87
|
+
const paint = (tone: Tone, text: string) => {
|
|
88
|
+
if (tone === "normal") return text;
|
|
89
|
+
return theme.fg?.(tone === "attention" ? "warning" : "accent", text) ?? text;
|
|
90
|
+
};
|
|
42
91
|
const dim = (text: string) => theme.fg?.("dim", text) ?? text;
|
|
43
|
-
const focus =
|
|
44
|
-
|
|
92
|
+
const focus =
|
|
93
|
+
view.kind === "loop" && view.loop.prompt
|
|
94
|
+
? `\n${dim(` focus: ${view.loop.prompt}`)}`
|
|
95
|
+
: "";
|
|
96
|
+
return new Text(`${paint(widgetTone(view), bold(loopWidgetLine(view)))}${focus}`);
|
|
45
97
|
});
|
|
46
98
|
} catch {
|
|
47
99
|
// Presentation only; a widget failure must never break a loop transition.
|
|
@@ -58,18 +110,56 @@ export function clearLoopWidget(ui: WidgetHost) {
|
|
|
58
110
|
}
|
|
59
111
|
}
|
|
60
112
|
|
|
61
|
-
|
|
113
|
+
/** Exported for tests: the tone the line renders in. */
|
|
114
|
+
export function widgetTone(view: LoopWidgetView): Tone {
|
|
115
|
+
if (view.kind === "planning") return "planning";
|
|
62
116
|
const loop = view.loop;
|
|
63
|
-
if (loop.status === "paused"
|
|
117
|
+
if (loop.status === "paused" || loop.expiring || view.blockedForMs !== undefined) {
|
|
118
|
+
return "attention";
|
|
119
|
+
}
|
|
120
|
+
return "normal";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loopWidgetLine(view: LoopWidgetView): string {
|
|
124
|
+
if (view.kind === "planning") {
|
|
125
|
+
return view.proposedCriteria === undefined
|
|
126
|
+
? "◆ loop planning · drafting an objective"
|
|
127
|
+
: `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
|
|
128
|
+
}
|
|
129
|
+
const loop = view.loop;
|
|
130
|
+
|
|
131
|
+
// Ordered by how much the state wants a human. A paused or blocked loop is
|
|
132
|
+
// not making progress, so reporting progress numbers first would bury the
|
|
133
|
+
// only fact that matters.
|
|
134
|
+
if (loop.status === "paused") {
|
|
135
|
+
return `⏸ loop paused${loop.pauseCause ? ` · ${loop.pauseCause}` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
if (loop.expiring) return "⚠ loop expiring · write your state into the ledger";
|
|
138
|
+
if (view.blockedForMs !== undefined) {
|
|
139
|
+
// The engine cannot see the prompt itself: it only knows the session has
|
|
140
|
+
// been busy without completing a turn, which a blocking prompt explains
|
|
141
|
+
// and ordinary long work also explains. Say which one is being reported.
|
|
142
|
+
return `⚠ loop blocked · no turn for ${formatElapsed(view.blockedForMs)} · a prompt may be waiting`;
|
|
143
|
+
}
|
|
144
|
+
if (loop.waiting) {
|
|
145
|
+
const until =
|
|
146
|
+
loop.waiting.resumeAt === undefined
|
|
147
|
+
? "no deadline"
|
|
148
|
+
: `until ${formatClock(loop.waiting.resumeAt)}`;
|
|
149
|
+
return `⏳ loop waiting · ${loop.waiting.reason} · ${until}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
64
152
|
const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
|
|
65
153
|
const next = view.wakePending
|
|
66
154
|
? "next on idle"
|
|
67
155
|
: view.nextWakeAt !== undefined
|
|
68
156
|
? `next ${formatClock(view.nextWakeAt)}`
|
|
69
157
|
: "next unscheduled";
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
158
|
+
const elapsed = formatElapsed((view.now ?? Date.now()) - loop.startedAt);
|
|
159
|
+
// Progress leads when there is progress to report. Turns are still shown,
|
|
160
|
+
// but as the budget they are, not as the headline.
|
|
161
|
+
const progress = view.criteria ? `${view.criteria.met}/${view.criteria.total} done · ` : "";
|
|
162
|
+
return `⟳ loop ${progress}turn ${loop.automaticTurns}/${cap} · ${elapsed} · ${next}`;
|
|
73
163
|
}
|
|
74
164
|
|
|
75
165
|
function identity(text: string) {
|