@hank-warren/pi-loop 0.9.0 → 1.1.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.
@@ -2,11 +2,11 @@
2
2
  * `loop_propose`: put a drafted loop up for the user's approval.
3
3
  *
4
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.
5
+ * approves, and the approval is what starts the loop. A loop is
6
+ * self-continuing and must never begin on model initiative, so this is the
7
+ * only way a model can put one in front of a user: a card showing the
8
+ * objective, the criteria, the ground rules, the cadence and the caps, with
9
+ * the start reserved to the human reading it.
10
10
  *
11
11
  * Registered unconditionally, like the other loop tools: the tool set is part
12
12
  * of the cached request prefix, so it never changes with loop state.
@@ -16,23 +16,27 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
17
  import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
18
18
  import type { LoopController } from "./loop.js";
19
- import { showLoopProposalCard } from "./presentation.js";
19
+ import { MAX_GROUND_RULE_LENGTH, MAX_GROUND_RULES } from "./planning.js";
20
20
 
21
21
  export const LOOP_PROPOSE_TOOL = "loop_propose";
22
22
 
23
- export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
23
+ export function registerLoopProposeTool(
24
+ pi: ExtensionAPI,
25
+ controller: LoopController,
26
+ onProposed?: () => void,
27
+ ) {
24
28
  pi.registerTool(
25
29
  defineTool({
26
30
  name: LOOP_PROPOSE_TOOL,
27
31
  label: "Loop Propose",
28
32
  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.",
33
+ "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, plus any ground rules. Starts nothing: the user approves, edits, or cancels.",
30
34
  promptSnippet: "Propose a drafted loop objective for approval",
31
35
  promptGuidelines: [
32
36
  "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
37
  "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.",
38
+ "Pass ground_rules for the hard constraints the loop must never violate while unattended. They bound how the work may be done and never gate completion, so they belong there rather than in the objective.",
39
+ "loop_propose starts nothing, so no rule against starting loops on your own applies 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 run a command instead.",
36
40
  ],
37
41
  parameters: Type.Object({
38
42
  objective: Type.String({
@@ -58,6 +62,13 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
58
62
  description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
59
63
  }),
60
64
  ),
65
+ ground_rules: Type.Optional(
66
+ Type.Array(Type.String({ minLength: 1, maxLength: MAX_GROUND_RULE_LENGTH }), {
67
+ maxItems: MAX_GROUND_RULES,
68
+ description:
69
+ "Hard constraints the loop must never violate, one per entry (e.g. 'never touch production', 'never force-push', 'never edit a test to make it pass'). Constraints, not criteria: they never gate completion.",
70
+ }),
71
+ ),
61
72
  }),
62
73
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
74
  if (!controller.planning.active) {
@@ -67,14 +78,22 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
67
78
  }
68
79
  if (controller.state && controller.state.status !== "stopped") {
69
80
  return failure(
70
- "A loop is already running in this session. Stop it with /loop stop before planning another.",
81
+ "A loop is already running in this session. Stop it from the /loop menu before planning another.",
71
82
  );
72
83
  }
73
84
  const objective = params.objective.trim();
74
85
  if (!objective) return failure("A proposal needs an objective.");
75
86
 
76
- const overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
77
- {};
87
+ const overrides: {
88
+ intervalMs?: number;
89
+ maxTurns?: number | null;
90
+ expiresInMs?: number;
91
+ groundRules?: string[];
92
+ } = {};
93
+ // Bounded and trimmed in buildProposal, so an over-long or empty entry
94
+ // is normalized rather than refused: a rejected proposal costs the whole
95
+ // draft, and the card is where the user reviews them anyway.
96
+ if (params.ground_rules !== undefined) overrides.groundRules = params.ground_rules;
78
97
  if (params.interval !== undefined) {
79
98
  const parsed = parseDuration(params.interval);
80
99
  if (parsed === undefined) {
@@ -101,6 +120,7 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
101
120
  }
102
121
 
103
122
  const proposal = controller.propose(objective, overrides);
123
+ onProposed?.();
104
124
  // The card goes to the transcript as a framed block, not back through
105
125
  // this tool result. Returning it here too would render the same
106
126
  // artifact twice, once framed and once as a wall of markdown, and
@@ -111,13 +131,14 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
111
131
  content: [
112
132
  {
113
133
  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.`,
134
+ text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}${proposal.groundRules ? `, ${proposal.groundRules.length} ground rule${proposal.groundRules.length === 1 ? "" : "s"}` : ""}, 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
135
  },
116
136
  ],
117
137
  details: {
118
138
  criteria: proposal.criteria.length,
119
139
  intervalMs: proposal.intervalMs,
120
140
  maxTurns: proposal.maxTurns,
141
+ groundRules: proposal.groundRules?.length ?? 0,
121
142
  },
122
143
  };
123
144
  },
package/src/settings.ts CHANGED
@@ -29,12 +29,28 @@ export interface LoopCompactionSettings {
29
29
  */
30
30
  const LEGACY_CAP_KEYS = ["maxIterations", "automaticTurns"] as const;
31
31
 
32
+ /**
33
+ * Settings that no longer exist. They are tolerated on read (an unknown field
34
+ * is ignored, never a reason to reject the file) and dropped on the next save,
35
+ * so a settings file written by an older version keeps working and quietly
36
+ * stops advertising a switch that controls nothing.
37
+ *
38
+ * `inlineInvocation` toggled mid-prompt `/loop` detection, which was removed
39
+ * along with the `loop_start` tool it pointed at.
40
+ */
41
+ const REMOVED_KEYS = ["inlineInvocation"] as const;
42
+
32
43
  export interface LoopSettings {
33
44
  /**
34
45
  * Cap on the turns the loop itself causes (settle continuations plus
35
- * fallback pokes); null means unlimited (explicit opt-in). The only cap
36
- * there is: one wake can yield many turns, so counting turns is what
37
- * actually bounds a loop.
46
+ * fallback pokes); null means unlimited, and unlimited is the default. The
47
+ * only cap there is: one wake can yield many turns, so counting turns is
48
+ * what actually bounds a loop.
49
+ *
50
+ * A turn budget is a proxy for cost, not for progress, and a loop that hits
51
+ * one stops in the middle of the work with nothing decided. The real bounds
52
+ * are the expiry and the no-progress breaker, which stop a loop for reasons
53
+ * a user can act on. Set a number here to opt back into a budget.
38
54
  */
39
55
  maxTurns: number | null;
40
56
  /**
@@ -45,26 +61,19 @@ export interface LoopSettings {
45
61
  /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
46
62
  maxLoopDuration: string;
47
63
  /**
48
- * Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
49
- * point the model at the `loop_start` tool. Pi only dispatches `/loop` from
50
- * position 0, so without this a mid-prompt invocation is silently prose.
51
- */
52
- inlineInvocation: boolean;
53
- /**
54
- * Fallback heartbeat used by an inline invocation that names no interval.
55
- * In a settle-paced loop the interval is only a fallback — the settle
56
- * boundary is the pacemaker — so this value is far less consequential than
57
- * it looks; it is still clamped to MIN_INTERVAL_MS.
64
+ * Fallback heartbeat used by a proposal that names no interval. In a
65
+ * settle-paced loop the interval is only a fallback the settle boundary is
66
+ * the pacemaker so this value is far less consequential than it looks; it
67
+ * is still clamped to MIN_INTERVAL_MS.
58
68
  */
59
69
  defaultInterval: string;
60
70
  compaction: LoopCompactionSettings;
61
71
  }
62
72
 
63
73
  export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
64
- maxTurns: 25,
74
+ maxTurns: null,
65
75
  noProgressTurns: 3,
66
76
  maxLoopDuration: "7d",
67
- inlineInvocation: true,
68
77
  defaultInterval: "10m",
69
78
  compaction: {
70
79
  enabled: true,
@@ -98,11 +107,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
98
107
  return undefined;
99
108
  }
100
109
 
101
- const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
102
- ? record.inlineInvocation
103
- : DEFAULT_LOOP_SETTINGS.inlineInvocation;
104
- if (typeof inlineInvocation !== "boolean") return undefined;
105
-
106
110
  const defaultInterval = Object.hasOwn(record, "defaultInterval")
107
111
  ? record.defaultInterval
108
112
  : DEFAULT_LOOP_SETTINGS.defaultInterval;
@@ -140,7 +144,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
140
144
  maxTurns,
141
145
  noProgressTurns,
142
146
  maxLoopDuration,
143
- inlineInvocation,
144
147
  defaultInterval,
145
148
  compaction: { enabled, threshold, instructions },
146
149
  };
@@ -253,13 +256,15 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
253
256
  // unknown: leaving them next to a cap that supersedes them would show the
254
257
  // user two numbers where only one applies.
255
258
  for (const key of LEGACY_CAP_KEYS) delete raw[key];
259
+ // Removed settings are dropped rather than preserved: keeping a switch that
260
+ // controls nothing is worse than losing it.
261
+ for (const key of REMOVED_KEYS) delete raw[key];
256
262
  const document = `${JSON.stringify(
257
263
  {
258
264
  ...raw,
259
265
  maxTurns: normalized.maxTurns,
260
266
  noProgressTurns: normalized.noProgressTurns,
261
267
  maxLoopDuration: normalized.maxLoopDuration,
262
- inlineInvocation: normalized.inlineInvocation,
263
268
  defaultInterval: normalized.defaultInterval,
264
269
  compaction: { ...compaction, ...normalized.compaction },
265
270
  },
package/src/state.ts CHANGED
@@ -29,6 +29,12 @@ export interface LoopState {
29
29
  * before 0.6.0 may predate it — every loop started now has one.
30
30
  */
31
31
  objective?: string;
32
+ /**
33
+ * Hard constraints, approved with the objective and injected alongside it on
34
+ * every active turn. Optional: a loop started before ground rules existed,
35
+ * or approved without any, simply has none.
36
+ */
37
+ groundRules?: string[];
32
38
  intervalMs: number;
33
39
  /**
34
40
  * Cap on the turns this loop causes (continuations plus pokes); null means
@@ -57,6 +63,8 @@ export interface LoopState {
57
63
  lastFingerprint?: string;
58
64
  /** Why a paused loop paused, for the widget and status after a restore. */
59
65
  pauseCause?: string;
66
+ /** Durable reason recorded when the loop enters its terminal stopped state. */
67
+ terminalReason?: string;
60
68
  /**
61
69
  * Set once the expiry's final wake has been delivered. The loop is still
62
70
  * active for exactly that one turn, so the objective append is present
@@ -100,6 +108,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
100
108
  objective = record.objective.trim();
101
109
  if (!objective || objective.length > MAX_PROMPT_LENGTH) return undefined;
102
110
  }
111
+ const groundRules = normalizeGroundRuleList(record.groundRules);
112
+ if (groundRules === false) return undefined;
103
113
  const intervalMs = record.intervalMs;
104
114
  if (!isPositiveSafeInteger(intervalMs)) return undefined;
105
115
  const maxTurns = readTurnCap(record);
@@ -136,6 +146,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
136
146
  if (cancelledWaitReason === false) return undefined;
137
147
  const pauseCause = optionalText(record.pauseCause);
138
148
  if (pauseCause === false) return undefined;
149
+ const terminalReason = optionalText(record.terminalReason);
150
+ if (terminalReason === false) return undefined;
139
151
  const toolFreeRepeatCount = record.toolFreeRepeatCount;
140
152
  if (
141
153
  toolFreeRepeatCount !== undefined &&
@@ -154,6 +166,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
154
166
  status: status as LoopStatus,
155
167
  ...(prompt === undefined ? {} : { prompt }),
156
168
  ...(objective === undefined ? {} : { objective }),
169
+ ...(groundRules === undefined ? {} : { groundRules }),
157
170
  intervalMs,
158
171
  maxTurns,
159
172
  compactAt: compactAt as number | null,
@@ -167,6 +180,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
167
180
  ...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
168
181
  ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
169
182
  ...(pauseCause === undefined ? {} : { pauseCause }),
183
+ ...(terminalReason === undefined ? {} : { terminalReason }),
170
184
  ...(record.expiring === true ? { expiring: true as const } : {}),
171
185
  ...(record.handoff === true ? { handoff: true as const } : {}),
172
186
  };
@@ -235,6 +249,25 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
235
249
  : undefined;
236
250
  }
237
251
 
252
+ /**
253
+ * A present-but-optional ground-rule list: the list, undefined when absent,
254
+ * false when invalid. Empty survives as undefined so an approved loop with no
255
+ * rules and a restored one are the same state.
256
+ */
257
+ function normalizeGroundRuleList(value: unknown): string[] | undefined | false {
258
+ if (value === undefined) return undefined;
259
+ if (!Array.isArray(value)) return false;
260
+ const rules: string[] = [];
261
+ for (const entry of value) {
262
+ if (typeof entry !== "string") return false;
263
+ const trimmed = entry.trim();
264
+ if (!trimmed) continue;
265
+ if (trimmed.length > MAX_PROMPT_LENGTH) return false;
266
+ rules.push(trimmed);
267
+ }
268
+ return rules.length > 0 ? rules : undefined;
269
+ }
270
+
238
271
  /** A present-but-optional string: the value, undefined when absent, false when invalid. */
239
272
  function optionalText(value: unknown): string | undefined | false {
240
273
  if (value === undefined) return undefined;
package/src/wait-tool.ts CHANGED
@@ -59,7 +59,7 @@ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopControlle
59
59
  if (!loop || loop.objective === undefined) {
60
60
  return {
61
61
  content: toolContent(
62
- "No /loop with an objective is active, so there is nothing to wait on. Start one with /loop <interval> <objective>.",
62
+ "No /loop with an objective is active, so there is nothing to wait on. Run /loop to plan and approve one.",
63
63
  ),
64
64
  details: {},
65
65
  isError: true,
package/src/widget.ts CHANGED
@@ -21,6 +21,11 @@
21
21
  * order is the whole reason to glance at the line: paused and blocked and
22
22
  * expiring come before the ordinary running line.
23
23
  *
24
+ * The glyph vocabulary is shared with pi-plan-mode by convention, not by
25
+ * import — `◆` planning or ready, `▶` implementing, `⟳` running, `⏸` paused,
26
+ * `⏳` waiting, `⚠` attention. Six characters are not worth a package; a user
27
+ * reading a footer is worth the consistency.
28
+ *
24
29
  * Presentation only: every entry point tolerates a host without setWidget
25
30
  * (test fixtures, print mode) and swallows render-side failures, because a
26
31
  * widget must never interrupt loop state transitions.
@@ -122,9 +127,8 @@ export function widgetTone(view: LoopWidgetView): Tone {
122
127
 
123
128
  export function loopWidgetLine(view: LoopWidgetView): string {
124
129
  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`;
130
+ if (view.proposedCriteria === undefined) return "◆ loop · drafting objective";
131
+ return `◆ loop · ${view.proposedCriteria} ${view.proposedCriteria === 1 ? "criterion" : "criteria"} proposed · approve to start`;
128
132
  }
129
133
  const loop = view.loop;
130
134
 
@@ -1,159 +0,0 @@
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
- }
@@ -1,109 +0,0 @@
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
- }