@hank-warren/pi-loop 0.5.0 → 0.7.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/src/decide.ts CHANGED
@@ -9,17 +9,13 @@
9
9
  * handler for a lost continuation or an external wait, not the pacemaker.
10
10
  *
11
11
  * Both share one precedence prefix: loop liveness → expiry → plan mode →
12
- * compaction → busy → mode-specific stop criteria caps → act.
12
+ * compaction → busy → wait the turn cap → act.
13
13
  *
14
- * The mode-specific step is the whole difference between the two kinds of
15
- * loop. A goal-bound loop delegates "is the work done" to pi-goal and reads
16
- * its `goal-state`, so a missing goal pauses it and a safety state holds it;
17
- * pi-goal also owns its settle continuations, so `decideContinuation` never
18
- * acts for one. A standalone loop owns its own objective, reads no goal state
19
- * at all, and ends only through `loop_complete`, a cap, or the user.
14
+ * A loop owns its own objective and reads no other extension's state: it ends
15
+ * only through `loop_complete`, a cap, an expiry, or the user.
20
16
  */
21
17
 
22
- import { GOAL_SAFETY_STATUSES, type GoalSnapshot, isStandaloneLoop, type LoopState } from "./state.js";
18
+ import type { LoopState } from "./state.js";
23
19
 
24
20
  export interface TickEnvironment {
25
21
  now: number;
@@ -28,7 +24,6 @@ export interface TickEnvironment {
28
24
  /** A loop-owned proactive compaction is in flight; hold pokes. */
29
25
  compacting: boolean;
30
26
  planModeEnabled: boolean;
31
- goal: GoalSnapshot | undefined;
32
27
  }
33
28
 
34
29
  /** A `loop_wait` whose deadline has passed is due, not waiting. */
@@ -39,8 +34,8 @@ function isWaiting(loop: LoopState, now: number): boolean {
39
34
  }
40
35
 
41
36
  /**
42
- * An expiring standalone loop gets one last turn to write its state into the
43
- * ledger before it stops; every other expiry stops immediately.
37
+ * An expiring loop gets one last turn to write its state into the ledger
38
+ * before it stops; a loop already spending that turn stops immediately.
44
39
  */
45
40
  export type ExpiryReason = "loop-expired" | "expiry-final-wake";
46
41
 
@@ -54,36 +49,26 @@ export type TickDecision =
54
49
  | { action: "none"; reason: "loop-not-active" }
55
50
  | { action: "expire"; reason: ExpiryReason }
56
51
  | { action: "skip"; reason: SkipReason }
57
- | { action: "stop"; reason: "goal-complete" | "max-iterations" | "max-automatic-turns" }
58
- | { action: "pause"; reason: "goal-safety"; cause: string }
59
- | { action: "pause"; reason: "goal-missing" }
60
- | {
61
- action: "poke";
62
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed";
63
- };
52
+ | { action: "stop"; reason: "max-turns" }
53
+ | { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
64
54
 
65
55
  export type ContinuationDecision =
66
- | { action: "none"; reason: "loop-not-active" | "goal-bound" }
56
+ | { action: "none"; reason: "loop-not-active" }
67
57
  | { action: "expire"; reason: ExpiryReason }
68
58
  | { action: "skip"; reason: SkipReason }
69
- | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
59
+ | { action: "stop"; reason: "max-turns" }
70
60
  | { action: "continue"; reason: "settled-idle" };
71
61
 
72
- /** Shared prefix: everything that holds or ends a loop before mode matters. */
62
+ /** Shared prefix: everything that holds or ends a loop before caps matter. */
73
63
  function decideCommonPrefix(
74
64
  loop: LoopState,
75
65
  env: TickEnvironment,
76
66
  ): Extract<TickDecision, { action: "none" | "expire" | "skip" }> | undefined {
77
67
  if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
78
68
  if (env.now >= loop.expiresAt) {
79
- // The final wake is a standalone loop's own summarise-and-stop turn; a
80
- // goal-bound loop has no ledger of its own to write, and pi-goal owns
81
- // that conversation.
82
- return {
83
- action: "expire",
84
- reason:
85
- isStandaloneLoop(loop) && !loop.expiring ? "expiry-final-wake" : "loop-expired",
86
- };
69
+ // The final wake is the loop's own summarise-and-stop turn; a loop already
70
+ // spending it has nothing left to buy.
71
+ return { action: "expire", reason: loop.expiring ? "loop-expired" : "expiry-final-wake" };
87
72
  }
88
73
  if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
89
74
  if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
@@ -95,33 +80,27 @@ function decideCommonPrefix(
95
80
  }
96
81
 
97
82
  /**
98
- * Caps are checked in a fixed order so a loop that trips both reports the
99
- * wake cap first it is the one the user configured with `--max`.
83
+ * The one cap, counting every turn the loop caused: continuations and pokes
84
+ * alike. A delivered-wake cap sat next to it until it was collapsed into
85
+ * this one — in a settle-paced loop the wake counter can stay at zero for the
86
+ * loop's whole life, so it was never the ceiling that held.
100
87
  */
101
- function decideCaps(
102
- loop: LoopState,
103
- ): { action: "stop"; reason: "max-iterations" | "max-automatic-turns" } | undefined {
104
- if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
105
- return { action: "stop", reason: "max-iterations" };
106
- }
107
- if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
108
- return { action: "stop", reason: "max-automatic-turns" };
88
+ function decideCap(loop: LoopState): { action: "stop"; reason: "max-turns" } | undefined {
89
+ if (loop.maxTurns !== null && loop.automaticTurns >= loop.maxTurns) {
90
+ return { action: "stop", reason: "max-turns" };
109
91
  }
110
92
  return undefined;
111
93
  }
112
94
 
113
95
  /**
114
- * The settled-idle boundary of a standalone loop: the session finished a turn
115
- * with the objective unfinished, so the loop continues immediately instead of
116
- * waiting out an interval of idle wall time.
96
+ * The settled-idle boundary: the session finished a turn with the objective
97
+ * unfinished, so the loop continues immediately instead of waiting out an
98
+ * interval of idle wall time.
117
99
  */
118
100
  export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
119
101
  const prefix = decideCommonPrefix(loop, env);
120
102
  if (prefix) return prefix;
121
- // pi-goal drives its own settle continuations for a goal-bound loop; two
122
- // extensions continuing the same session would double every turn.
123
- if (!isStandaloneLoop(loop)) return { action: "none", reason: "goal-bound" };
124
- const capped = decideCaps(loop);
103
+ const capped = decideCap(loop);
125
104
  if (capped) return capped;
126
105
  return { action: "continue", reason: "settled-idle" };
127
106
  }
@@ -129,40 +108,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
129
108
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
130
109
  const prefix = decideCommonPrefix(loop, env);
131
110
  if (prefix) return prefix;
132
-
133
- // A standalone loop carries its own objective, so it never consults
134
- // pi-goal: it runs until loop_complete stops it, a cap is reached, or the
135
- // user intervenes.
136
- if (isStandaloneLoop(loop)) {
137
- const capped = decideCaps(loop);
138
- if (capped) return capped;
139
- // The prefix already let a still-waiting loop skip, so a wait surviving
140
- // to here is one whose deadline has come due: this wake is the wake it
141
- // asked for, and it counts against the wake cap like any other.
142
- return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
143
- }
144
-
145
- const goal = env.goal;
146
- // Goal-bound loops require a goal to operate: the goal evaluator owns the
147
- // stop criterion, so such a loop with no goal has nothing to decide when it
148
- // is done.
149
- if (!goal) return { action: "pause", reason: "goal-missing" };
150
- if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
151
- if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
152
- return { action: "pause", reason: "goal-safety", cause: goal.status };
153
- }
154
- if (goal.status !== "active") {
155
- // Unknown status from a newer pi-goal: treat like a safety state
156
- // rather than poking past a guard we do not understand.
157
- return { action: "pause", reason: "goal-safety", cause: goal.status };
158
- }
159
-
160
- const capped = decideCaps(loop);
111
+ const capped = decideCap(loop);
161
112
  if (capped) return capped;
162
-
163
- // An idle session with an active goal is exactly the stall/wait case:
164
- // pi-goal continues on its own at every idle boundary, so idleness at
165
- // tick time means its continuation was lost, or the goal is waiting on
166
- // an external event — which this wakeup is.
167
- return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
113
+ // The prefix already let a still-waiting loop skip, so a wait surviving to
114
+ // here is one whose deadline has come due: this wake is the wake it asked
115
+ // for, and the turn it starts counts against the cap like any other.
116
+ return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
168
117
  }
package/src/index.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
- * pi-loop: Claude-Code-/loop-inspired pacemaker for Pi. A loop wakes the
3
- * session on an interval to keep an active pi-goal goal moving (stall rescue
4
- * and goal_wait wakes) with loop-aware compaction. Loops require an active
5
- * goal: the loop owns *when* the session wakes; @narumitw/pi-goal (or the
6
- * @hank-warren/pi-goal fork) owns *whether the work is done*, read through
7
- * its `goal-state` session entries only.
2
+ * pi-loop: Claude-Code-/loop-inspired long-running work for Pi. A loop
3
+ * carries its own objective and completion criteria, is paced by the session
4
+ * settling, keeps a durable ledger, compacts itself, and ends through
5
+ * `loop_complete`, a cap, its expiry, or the user. It depends on no other
6
+ * extension; the only sibling state it reads is pi-plan-mode's, fail-open, so
7
+ * a loop never injects into a planning conversation.
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
+ import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
14
+ import { registerLoopStartTool } from "./start-tool.js";
13
15
  import { registerLoopWaitTool } from "./wait-tool.js";
14
16
  import { LoopController, type LoopControllerOptions } from "./loop.js";
15
17
  import { showLoopManager, showLoopSettings } from "./manager.js";
@@ -28,12 +30,19 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
28
30
  });
29
31
  // Registered unconditionally and never toggled with loop state: tools are
30
32
  // part of the cached request prefix, so mutating the tool set mid-session
31
- // would invalidate the whole conversation cache. It refuses when no
32
- // standalone loop is active.
33
+ // would invalidate the whole conversation cache. It refuses when no loop is
34
+ // active.
33
35
  registerLoopCompleteTool(pi, controller);
34
36
  // Registered on the same terms and for the same reason: the tool set is
35
37
  // part of the cached prefix, so it never changes with loop state.
36
38
  registerLoopWaitTool(pi, controller);
39
+ // Inline invocation: an `input` handler arms a one-turn system-prompt hint
40
+ // for a mid-prompt `/loop` token, `before_agent_start` appends it, and
41
+ // loop_start is the model-invoked start it points at — refused on any turn
42
+ // the hint did not arm. The user's message is never transformed.
43
+ const invocation = new InlineInvocationState();
44
+ registerInlineInvocation(pi, controller, invocation);
45
+ registerLoopStartTool(pi, controller, invocation);
37
46
  // Collapse loop pokes into one-line transcript chips (display-only; the
38
47
  // stored message and model context are untouched).
39
48
  registerLoopMessageRendering(pi);
@@ -84,7 +93,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
84
93
  return;
85
94
  }
86
95
  }
87
- controller.startLoop(ctx, command);
96
+ const result = controller.startLoop(ctx, command);
97
+ if (!result.ok) ctx.ui.notify(result.message, "error");
88
98
  return;
89
99
  }
90
100
  }
@@ -169,8 +179,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
169
179
  controller.onSessionShutdown();
170
180
  scheduler.onSessionShutdown();
171
181
  });
172
- // The pacemaker of a standalone loop: agent_end records the intent to
173
- // continue, agent_settled delivers it once Pi will accept a message.
182
+ // The pacemaker: agent_end records the intent to continue, agent_settled
183
+ // delivers it once Pi will accept a message.
174
184
  pi.on("agent_start", async (_event, ctx) => {
175
185
  controller.onAgentStart(ctx);
176
186
  });
@@ -181,10 +191,9 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
181
191
  controller.onAgentSettled(ctx);
182
192
  scheduler.onAgentSettled(ctx);
183
193
  });
184
- // A standalone loop carries its own objective, so it injects it the way
185
- // pi-goal does for a goal-bound one: a byte-stable system append, which is
186
- // what lets the poke messages stay pointer-sized. A goal-bound loop adds
187
- // nothing here — pi-goal already owns that turn's append.
194
+ // A loop carries its own objective and injects it as a byte-stable system
195
+ // append, which is what lets the poke and continuation messages stay
196
+ // pointer-sized.
188
197
  pi.on("before_agent_start", (event) => {
189
198
  const loop = controller.state;
190
199
  if (!loop || loop.status !== "active") return;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Inline slash-command detection.
3
+ *
4
+ * Pi dispatches extension commands only when a message *starts* with the
5
+ * command token; a command mentioned mid-prompt arrives as plain text. This
6
+ * helper finds inline occurrences of a `/cmd` token (and `cmd:` prefix lines)
7
+ * so the caller can react — here, by appending a reminder that tells the model
8
+ * to invoke the corresponding tool. It never rewrites or re-sends the user's
9
+ * message.
10
+ *
11
+ * Ported from @hank-warren/pi-goal, which proved the mechanism before pi-loop
12
+ * absorbed it; the helper was always generic over the command name.
13
+ */
14
+
15
+ export interface InlineCommandSplit {
16
+ /** Text before the command token, trailing whitespace removed. */
17
+ prose: string;
18
+ /** Everything after the command token to end of message, trimmed. */
19
+ commandArgs: string;
20
+ }
21
+
22
+ /**
23
+ * Find the first inline `/<commandName>` occurrence in `text`.
24
+ *
25
+ * Detection rules:
26
+ * - The token must be preceded by start-of-line or whitespace and followed by
27
+ * whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
28
+ * path-like `foo/cmd` never matches.
29
+ * - A token at position 0 is ignored: that is Pi's native dispatch position.
30
+ * - Occurrences inside backtick code (inline spans and fenced blocks) or
31
+ * inside a single/double-quoted span are ignored. Code regions are
32
+ * approximated by the CommonMark backtick-run rule: a run of N backticks
33
+ * opens a region closed by the next run of exactly N backticks.
34
+ *
35
+ * Returns undefined when no qualifying occurrence exists.
36
+ */
37
+ export function extractInlineCommand(
38
+ text: string,
39
+ commandName: string,
40
+ ): InlineCommandSplit | undefined {
41
+ const token = `/${commandName}`;
42
+ const ignored = ignoredRegions(text);
43
+ let searchFrom = 0;
44
+ while (searchFrom < text.length) {
45
+ const index = text.indexOf(token, searchFrom);
46
+ if (index === -1) return undefined;
47
+ searchFrom = index + 1;
48
+ if (index === 0) continue;
49
+ const before = text[index - 1];
50
+ if (before !== undefined && !/\s/.test(before)) continue;
51
+ const afterToken = text[index + token.length];
52
+ if (afterToken === undefined || !/\s/.test(afterToken)) continue;
53
+ if (insideRegion(ignored, index)) continue;
54
+ const commandArgs = text.slice(index + token.length).trim();
55
+ if (!commandArgs) continue;
56
+ return {
57
+ prose: text.slice(0, index).trimEnd(),
58
+ commandArgs,
59
+ };
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /**
65
+ * True when the message inline-invokes the command: a mid-message `/cmd`
66
+ * token with a remainder, or a `cmd:` prefix at the start of any line — both
67
+ * outside backtick code and quoted spans.
68
+ */
69
+ export function detectsInlineInvocation(text: string, commandName: string): boolean {
70
+ if (extractInlineCommand(text, commandName) !== undefined) return true;
71
+ const ignored = ignoredRegions(text);
72
+ const prefixPattern = new RegExp(`^[ \\t]*${escapeRegExpText(commandName)}:[ \\t]+\\S`, "gim");
73
+ for (
74
+ let match = prefixPattern.exec(text);
75
+ match;
76
+ match = prefixPattern.exec(text)
77
+ ) {
78
+ if (!insideRegion(ignored, match.index)) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ type Region = readonly [start: number, end: number];
84
+
85
+ /**
86
+ * Backtick code regions plus single/double-quoted spans. Quoting a command is
87
+ * how people discuss one (`use "/loop 10m ship it" to start`), so a quoted
88
+ * token is a mention, not an invocation.
89
+ */
90
+ function ignoredRegions(text: string): Region[] {
91
+ return [...codeRegions(text), ...quoteRegions(text)];
92
+ }
93
+
94
+ function codeRegions(text: string): Region[] {
95
+ const regions: Region[] = [];
96
+ const runs: Array<{ index: number; length: number }> = [];
97
+ const runPattern = /`+/g;
98
+ for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
99
+ runs.push({ index: match.index, length: match[0].length });
100
+ }
101
+ for (let open = 0; open < runs.length; open += 1) {
102
+ const opener = runs[open];
103
+ if (opener === undefined) continue;
104
+ for (let close = open + 1; close < runs.length; close += 1) {
105
+ const closer = runs[close];
106
+ if (closer === undefined || closer.length !== opener.length) continue;
107
+ regions.push([opener.index, closer.index + closer.length]);
108
+ open = close;
109
+ break;
110
+ }
111
+ }
112
+ return regions;
113
+ }
114
+
115
+ /**
116
+ * Single- and double-quoted spans, matched conservatively so an apostrophe
117
+ * inside a word (`don't`) never opens one: an opening quote follows nothing,
118
+ * whitespace, or an opening bracket and precedes a non-space; its closing
119
+ * quote is on the same line, follows a non-space, and precedes end-of-line,
120
+ * whitespace, or closing punctuation.
121
+ */
122
+ function quoteRegions(text: string): Region[] {
123
+ const regions: Region[] = [];
124
+ for (let index = 0; index < text.length; index += 1) {
125
+ const quote = text[index];
126
+ if (quote !== '"' && quote !== "'") continue;
127
+ const before = text[index - 1];
128
+ const after = text[index + 1];
129
+ if (before !== undefined && !/[\s([{<]/.test(before)) continue;
130
+ if (after === undefined || /\s/.test(after)) continue;
131
+ const close = closingQuoteIndex(text, index, quote);
132
+ if (close === undefined) continue;
133
+ regions.push([index, close + 1]);
134
+ index = close;
135
+ }
136
+ return regions;
137
+ }
138
+
139
+ function closingQuoteIndex(text: string, openIndex: number, quote: string): number | undefined {
140
+ for (let index = openIndex + 1; index < text.length; index += 1) {
141
+ const char = text[index];
142
+ if (char === "\n") return undefined;
143
+ if (char !== quote) continue;
144
+ const before = text[index - 1];
145
+ const after = text[index + 1];
146
+ if (before === undefined || /\s/.test(before)) continue;
147
+ if (after !== undefined && !/[\s.,;:!?)\]}>]/.test(after)) continue;
148
+ return index;
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ function insideRegion(regions: readonly Region[], index: number): boolean {
154
+ return regions.some(([start, end]) => index >= start && index < end);
155
+ }
156
+
157
+ function escapeRegExpText(value: string) {
158
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Inline `/loop` invocation, tool-mediated.
3
+ *
4
+ * Pi only dispatches `/loop` when it starts the message. When the user writes
5
+ * `quick check /loop 10m get CI green` or a `loop:` prefixed line mid-prompt,
6
+ * this module appends a one-turn reminder to the system prompt so the model
7
+ * reliably calls the `loop_start` tool with the objective. The user's message
8
+ * itself is never touched — no cutting, splitting, re-sending, or visible
9
+ * annotation — so there are no delivery races, no message loss, and no
10
+ * transcript noise: guidance is injected at agent-start time, not by
11
+ * rewriting input.
12
+ *
13
+ * Two hooks cooperate because neither alone is safe:
14
+ * - `input` carries a `source` and so can tell user-typed text from
15
+ * extension-sent prompts, but any transform it returns rewrites the stored,
16
+ * visible user message. It only records the armed text here.
17
+ * - `before_agent_start` can extend the system prompt, but fires for
18
+ * extension-sent prompts too, and pi-loop's own kickoff and continuation
19
+ * prompts contain phrases like "the active /loop objective" that the
20
+ * detector would match. It injects only when the starting prompt *is* the
21
+ * armed user message, and disarms on every start, matched or not.
22
+ *
23
+ * Two deliberate limits keep the armed window one turn wide:
24
+ * - Streaming-typed input (`streamingBehavior` set, i.e. steered or queued)
25
+ * never arms. Pi returns from `prompt()` before `before_agent_start` for
26
+ * those, so an armed flag would survive to a later, unrelated turn — the
27
+ * window through which pi-loop's own continuation prompts could be matched.
28
+ * - The hint is a per-turn `systemPrompt` append, not a stored message, so
29
+ * "call loop_start now" cannot linger in the conversation and fire on a
30
+ * later turn.
31
+ *
32
+ * The armed flag is also the `loop_start` tool's gate: unlike pi-goal, which
33
+ * relied on prompt guidelines alone, a loop is self-continuing, so this
34
+ * extension *enforces* that the tool only runs on a turn the user explicitly
35
+ * invoked. See `InlineInvocationState.invokedThisTurn`.
36
+ */
37
+
38
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
+ import { detectsInlineInvocation } from "./inline-command.js";
40
+ import type { LoopController } from "./loop.js";
41
+
42
+ export const INLINE_LOOP_COMMAND = "loop";
43
+
44
+ export const INLINE_LOOP_HINT =
45
+ "<system-reminder>The user's message this turn inline-invoked a loop (/loop or loop:). Call the loop_start tool now with the objective text that follows the token in that message, then begin working toward it. Do not answer the objective as prose without starting the loop. If the message is discussing, quoting, or documenting the /loop command rather than invoking it, do not call loop_start. This reminder applies only to the user's message this turn.</system-reminder>";
46
+
47
+ /**
48
+ * The armed state, shared between the hooks and the `loop_start` tool.
49
+ *
50
+ * `invokedThisTurn` is the hard gate: set when `before_agent_start` matched
51
+ * the armed user message, cleared at `agent_end` and at every session
52
+ * boundary. `loop_start` refuses whenever it is false, so no amount of prompt
53
+ * drift, transcript replay, or model initiative can start a self-continuing
54
+ * loop the user did not ask for.
55
+ */
56
+ export class InlineInvocationState {
57
+ invokedThisTurn = false;
58
+ }
59
+
60
+ export function registerInlineInvocation(
61
+ pi: ExtensionAPI,
62
+ controller: LoopController,
63
+ state: InlineInvocationState,
64
+ ) {
65
+ let armedText: string | undefined;
66
+
67
+ pi.on("input", (event) => {
68
+ // Extension-sourced messages (loop kickoffs, continuations, pokes, other
69
+ // extensions' injections) never arm the hint.
70
+ if (event.source === "extension") return;
71
+ // Steered or queued input returns from prompt() without ever reaching
72
+ // before_agent_start, so arming it would leave stale text armed.
73
+ if (event.streamingBehavior !== undefined) return;
74
+ if (!controller.settings.inlineInvocation) return;
75
+ if (!detectsInlineInvocation(event.text, INLINE_LOOP_COMMAND)) return;
76
+ armedText = event.text;
77
+ });
78
+
79
+ pi.on("before_agent_start", (event) => {
80
+ const armed = armedText;
81
+ armedText = undefined;
82
+ // Every start closes the previous turn's window, so a turn that ends
83
+ // without an agent_end still cannot leave the tool unlocked.
84
+ state.invokedThisTurn = false;
85
+ if (armed === undefined) return;
86
+ if (!controller.settings.inlineInvocation) return;
87
+ if (!promptCarriesArmedMessage(event.prompt, armed)) return;
88
+ state.invokedThisTurn = true;
89
+ return { systemPrompt: `${event.systemPrompt}\n\n${INLINE_LOOP_HINT}` };
90
+ });
91
+
92
+ const disarm = () => {
93
+ armedText = undefined;
94
+ state.invokedThisTurn = false;
95
+ };
96
+ pi.on("agent_end", disarm);
97
+ pi.on("session_start", disarm);
98
+ pi.on("session_shutdown", disarm);
99
+ }
100
+
101
+ /**
102
+ * True when the starting prompt is the armed user message. Pi may wrap the
103
+ * text with expanded prefixes or suffixes, so the armed text has to be the
104
+ * whole prompt or one of its ends — mirroring how upstream recognises its own
105
+ * owned prompts at a terminal boundary.
106
+ */
107
+ function promptCarriesArmedMessage(prompt: string, armed: string) {
108
+ return prompt === armed || prompt.startsWith(armed) || prompt.endsWith(armed);
109
+ }
package/src/ledger.ts CHANGED
@@ -30,9 +30,9 @@ export const LEDGER_DIR_NAME = "loop";
30
30
  export const CRITERIA_FILE = "criteria.json";
31
31
  export const PROGRESS_FILE = "PROGRESS.md";
32
32
 
33
- /** Cap on derived criteria: an objective is a paragraph, not a backlog. */
34
- const MAX_CRITERIA = 12;
35
- const MAX_DESCRIPTION_LENGTH = 500;
33
+ /** Cap on a loop's criteria: an objective is a paragraph, not a backlog. */
34
+ export const MAX_CRITERIA = 12;
35
+ export const MAX_DESCRIPTION_LENGTH = 500;
36
36
 
37
37
  export interface LoopCriterion {
38
38
  id: string;
@@ -69,7 +69,20 @@ export function deriveCriteria(objective: string): LoopCriterion[] {
69
69
  .filter(Boolean);
70
70
  const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
71
71
  if (parts.length < 2) return [implicitCriterion(trimmed)];
72
- return parts.slice(0, MAX_CRITERIA).map((description, index) => ({
72
+ return criteriaFromDescriptions(parts);
73
+ }
74
+
75
+ /**
76
+ * Number a list of descriptions into criteria.
77
+ *
78
+ * Shared by the deterministic split and by the criteria a model may propose
79
+ * at `loop_start`, so nothing downstream — the echo at start, the evidence
80
+ * gate, the immutability rule — can tell the two apart. The extension still
81
+ * writes every field but the description: ids are positional, `check` is
82
+ * empty (audit against authoritative state), and a criterion starts unmet.
83
+ */
84
+ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopCriterion[] {
85
+ return descriptions.slice(0, MAX_CRITERIA).map((description, index) => ({
73
86
  id: `c${index + 1}`,
74
87
  description: truncate(description),
75
88
  check: "",