@hank-warren/pi-loop 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hank Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # pi-loop — interval wakeups for the Pi coding agent
2
+
3
+ Inspired by Claude Code's `/loop`, adapted to Pi: re-run a prompt on an interval, rescue a stalled session that has an unfinished [pi-goal](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal) goal, and keep long loops coherent across context compaction.
4
+
5
+ pi-loop is a **pacemaker, not an evaluator**: it owns *when* the session wakes; pi-goal owns *whether the work is done*. It reads pi-goal's `goal-state` and pi-plan-mode's `plan-mode-state` session entries read-only and fail-open — with neither installed it still runs plain recurring-prompt loops.
6
+
7
+ ## Usage
8
+
9
+ ```
10
+ /loop 5m check my PR, address review comments, fix CI # recurring prompt every 5 minutes
11
+ /loop 30m # pi-goal goal active: poke it every 30 minutes
12
+ /loop # manager TUI (status, pause/resume, edit, settings, stop)
13
+ /loop status | pause | resume | stop | settings
14
+ /loop --max 20 --compact-at 60% 10m <prompt> # per-loop overrides
15
+ ```
16
+
17
+ - **Intervals** are `<number><unit>` with unit `s`/`m`/`h`/`d`, parsed by the extension (never the model), minimum 1 minute (smaller values clamp, and the effective value is echoed).
18
+ - `/loop` also works **mid-prompt** (`get CI green — /loop 10m recheck the pipeline`): the command dispatches first and the surrounding prose follows. Backtick code is ignored; messages with images pass through. Toggle with the `inlineInvocation` setting.
19
+
20
+ ## What a wakeup does
21
+
22
+ Each tick evaluates, in order:
23
+
24
+ 1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
25
+ 2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
26
+ 3. **Agent busy?** Never interrupt: coalesce into a single pending wake delivered at the next fully-settled idle boundary. N missed ticks collapse into one poke.
27
+ 4. **Goal state** (when pi-goal has a goal): completion **stops** the loop; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`) **pauses** the loop — pi-loop never pokes past pi-goal's circuit breakers; an `active` or `goal_wait`-waiting goal in an idle session is exactly the stall this extension exists for, so it pokes toward the goal (a tick is the external wake `goal_wait` arranges).
28
+ 5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
29
+ 6. **Poke**: the stored prompt (with a short scheduled-iteration preamble) or a goal wake message. Every loop-injected message carries a provenance marker (`<!-- pi-loop-poke:<id>:<n> -->`) so wakeups are distinguishable from user prompts and stale wakes are dropped.
30
+
31
+ The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows the full card including the last tick's decision and reason.
32
+
33
+ ## Loop-aware compaction
34
+
35
+ Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
36
+
37
+ - **Proactive compact at a threshold** (default 70% of the context window, `--compact-at` / settings): at an idle boundary, pi-loop triggers `/compact` itself with loop-specific instructions — preserve the objective and acceptance criteria verbatim, decisions and dead-ends, files modified, commands and unresolved errors, the next 1-3 actions, and carry prior summaries forward cumulatively. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
38
+ - **Post-compaction continuation**: after every compaction (whoever triggered it), a follow-up message restates the loop prompt, iteration, and — when a goal exists — the goal text plus pi-goal's accounting (iteration, automatic turns, token budget) so work resumes coherently instead of drifting. It never contains a dispatchable command.
39
+ - Loop state itself lives in custom session entries, which compaction never touches, and survives session restarts (the timer re-arms on resume; expired loops are dropped with a notice).
40
+
41
+ ## Settings
42
+
43
+ `~/.pi/agent/pi-loop.json` (absent file = defaults, never created implicitly; saves are atomic and preserve unknown fields), or `/loop settings`:
44
+
45
+ ```json
46
+ {
47
+ "maxIterations": 25,
48
+ "maxLoopDuration": "7d",
49
+ "compaction": {
50
+ "enabled": true,
51
+ "threshold": 0.7,
52
+ "postCompactContinuation": true,
53
+ "instructions": null
54
+ },
55
+ "pokePreamble": null,
56
+ "inlineInvocation": true
57
+ }
58
+ ```
59
+
60
+ `maxIterations: null` means unlimited. `compaction.instructions` and `pokePreamble` override the built-in templates.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pi install npm:@hank-warren/pi-loop
66
+ ```
67
+
68
+ Works standalone; pairs best with `npm:@hank-warren/pi-goal` (or upstream `@narumitw/pi-goal`) for goal-evaluated stop criteria.
69
+
70
+ ## License
71
+
72
+ MIT
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.js";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@hank-warren/pi-loop",
3
+ "version": "0.1.0",
4
+ "description": "Interval wakeups for Pi: recurring prompt re-runs, stall rescue toward an active pi-goal goal, and loop-aware compaction that survives long sessions.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "loop",
11
+ "scheduler",
12
+ "automation"
13
+ ],
14
+ "author": "Hank Warren",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/hank-warren/pi-extensions.git",
19
+ "directory": "packages/pi-loop"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/hank-warren/pi-extensions/issues"
23
+ },
24
+ "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-loop#readme",
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE",
38
+ "CHANGELOG.md"
39
+ ],
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*"
42
+ }
43
+ }
package/src/command.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Deterministic /loop argument parsing. Grammar:
3
+ *
4
+ * /loop -> show (manager TUI / status)
5
+ * /loop status|pause|resume|stop|settings -> subcommand
6
+ * /loop [--max N] [--compact-at X] <interval> [prompt...]
7
+ *
8
+ * Flags precede the interval; the interval is the first non-flag token;
9
+ * everything after it (raw, newlines preserved) is the prompt.
10
+ */
11
+
12
+ import { parseInterval } from "./interval.js";
13
+
14
+ export const LOOP_SUBCOMMANDS = ["status", "pause", "resume", "stop", "settings"] as const;
15
+ export type LoopSubcommand = (typeof LOOP_SUBCOMMANDS)[number];
16
+
17
+ export interface LoopStartArguments {
18
+ kind: "start";
19
+ requestedMs: number;
20
+ intervalMs: number;
21
+ clamped: boolean;
22
+ /** undefined = use settings default; null = unlimited. */
23
+ maxIterations?: number | null;
24
+ /** undefined = use settings default; null = disabled for this loop. */
25
+ compactAt?: number | null;
26
+ prompt?: string;
27
+ }
28
+
29
+ export type LoopCommand =
30
+ | { kind: "show" }
31
+ | { kind: LoopSubcommand }
32
+ | LoopStartArguments
33
+ | { kind: "error"; message: string };
34
+
35
+ export function parseLoopCommand(args: string): LoopCommand {
36
+ const trimmed = args.trim();
37
+ if (!trimmed) return { kind: "show" };
38
+ if ((LOOP_SUBCOMMANDS as readonly string[]).includes(trimmed)) {
39
+ return { kind: trimmed as LoopSubcommand };
40
+ }
41
+
42
+ const tokens = [...args.matchAll(/\S+/g)].map((match) => ({
43
+ text: match[0],
44
+ index: match.index,
45
+ }));
46
+ let maxIterations: number | null | undefined;
47
+ let compactAt: number | null | undefined;
48
+ let position = 0;
49
+ while (position < tokens.length) {
50
+ const token = tokens[position];
51
+ if (token === undefined || !token.text.startsWith("--")) break;
52
+ const [flag, inlineValue] = splitFlag(token.text);
53
+ const next = tokens[position + 1];
54
+ const value = inlineValue ?? next?.text;
55
+ const consumed = inlineValue !== undefined ? 1 : 2;
56
+ if (flag === "--max") {
57
+ if (value === undefined) return { kind: "error", message: "--max needs a value (a positive number, or unlimited)." };
58
+ maxIterations = parseMax(value);
59
+ if (maxIterations === undefined) {
60
+ return { kind: "error", message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.` };
61
+ }
62
+ } else if (flag === "--compact-at") {
63
+ if (value === undefined) return { kind: "error", message: "--compact-at needs a value (e.g. 60% or off)." };
64
+ compactAt = parseCompactAt(value);
65
+ if (compactAt === undefined) {
66
+ return { kind: "error", message: `Invalid --compact-at value: ${value}. Use a percentage between 1% and 99% (e.g. 60%), a fraction (0.6), or off.` };
67
+ }
68
+ } else {
69
+ return { kind: "error", message: `Unknown flag: ${flag}. Known flags: --max, --compact-at.` };
70
+ }
71
+ position += consumed;
72
+ }
73
+
74
+ const intervalToken = tokens[position];
75
+ if (intervalToken === undefined) {
76
+ return { kind: "error", message: "An interval is required to start a loop, e.g. /loop 5m <prompt>." };
77
+ }
78
+ const interval = parseInterval(intervalToken.text);
79
+ if (!interval) {
80
+ return {
81
+ kind: "error",
82
+ message: `Invalid interval: ${intervalToken.text}. Use <number><unit> with unit s, m, h, or d, e.g. 5m.`,
83
+ };
84
+ }
85
+ const promptToken = tokens[position + 1];
86
+ const prompt = promptToken === undefined ? undefined : args.slice(promptToken.index).trim();
87
+ return {
88
+ kind: "start",
89
+ requestedMs: interval.requestedMs,
90
+ intervalMs: interval.effectiveMs,
91
+ clamped: interval.clamped,
92
+ ...(maxIterations === undefined ? {} : { maxIterations }),
93
+ ...(compactAt === undefined ? {} : { compactAt }),
94
+ ...(prompt ? { prompt } : {}),
95
+ };
96
+ }
97
+
98
+ function splitFlag(token: string): [string, string | undefined] {
99
+ const equals = token.indexOf("=");
100
+ if (equals === -1) return [token, undefined];
101
+ return [token.slice(0, equals), token.slice(equals + 1)];
102
+ }
103
+
104
+ function parseMax(value: string): number | null | undefined {
105
+ if (value === "unlimited" || value === "null") return null;
106
+ const parsed = Number(value);
107
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
108
+ }
109
+
110
+ function parseCompactAt(value: string): number | null | undefined {
111
+ if (value === "off" || value === "none") return null;
112
+ let fraction: number;
113
+ if (value.endsWith("%")) {
114
+ fraction = Number(value.slice(0, -1)) / 100;
115
+ } else {
116
+ fraction = Number(value);
117
+ }
118
+ if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) return undefined;
119
+ return fraction;
120
+ }
121
+
122
+ export interface LoopArgumentCompletion {
123
+ value: string;
124
+ label: string;
125
+ description?: string;
126
+ }
127
+
128
+ const LOOP_ARGUMENT_COMPLETIONS: readonly LoopArgumentCompletion[] = [
129
+ { value: "status", label: "status", description: "Show the current loop" },
130
+ { value: "pause", label: "pause", description: "Pause the loop" },
131
+ { value: "resume", label: "resume", description: "Resume a paused loop" },
132
+ { value: "stop", label: "stop", description: "Stop the loop" },
133
+ { value: "settings", label: "settings", description: "Open pi-loop settings" },
134
+ ];
135
+
136
+ export function completeLoopArguments(prefix: string): LoopArgumentCompletion[] | null {
137
+ const trimmed = prefix.trimStart();
138
+ const matches = LOOP_ARGUMENT_COMPLETIONS.filter((candidate) =>
139
+ candidate.value.startsWith(trimmed),
140
+ );
141
+ return matches.length > 0 ? matches : null;
142
+ }
package/src/decide.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure tick decision. The engine gathers the environment and this function
3
+ * decides what a wakeup does, so the full decision matrix is unit-testable
4
+ * without timers or a Pi runtime.
5
+ *
6
+ * Precedence (approved plan): loop liveness → expiry → plan mode → busy →
7
+ * goal state → iteration cap → poke.
8
+ */
9
+
10
+ import { GOAL_SAFETY_STATUSES, type GoalSnapshot, type LoopState } from "./state.js";
11
+
12
+ export interface TickEnvironment {
13
+ now: number;
14
+ /** Agent is running, retrying, compacting, or has queued messages. */
15
+ busy: boolean;
16
+ /** A loop-owned proactive compaction is in flight; hold pokes. */
17
+ compacting: boolean;
18
+ planModeEnabled: boolean;
19
+ goal: GoalSnapshot | undefined;
20
+ }
21
+
22
+ export type TickDecision =
23
+ | { action: "none"; reason: "loop-not-active" }
24
+ | { action: "expire"; reason: "loop-expired" }
25
+ | { action: "skip"; reason: "plan-mode-active" | "agent-busy" | "compaction-in-flight" }
26
+ | { action: "stop"; reason: "goal-complete" | "max-iterations" }
27
+ | { action: "pause"; reason: "goal-safety"; cause: string }
28
+ | { action: "poke"; reason: "recurring-prompt" | "goal-stalled" | "goal-waiting" };
29
+
30
+ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
31
+ if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
32
+ if (env.now >= loop.expiresAt) return { action: "expire", reason: "loop-expired" };
33
+ if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
34
+ if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
35
+ if (env.busy) return { action: "skip", reason: "agent-busy" };
36
+
37
+ const goal = env.goal;
38
+ if (goal) {
39
+ if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
40
+ if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
41
+ return { action: "pause", reason: "goal-safety", cause: goal.status };
42
+ }
43
+ if (goal.status !== "active") {
44
+ // Unknown status from a newer pi-goal: treat like a safety state
45
+ // rather than poking past a guard we do not understand.
46
+ return { action: "pause", reason: "goal-safety", cause: goal.status };
47
+ }
48
+ }
49
+
50
+ if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
51
+ return { action: "stop", reason: "max-iterations" };
52
+ }
53
+
54
+ if (goal) {
55
+ // An idle session with an active goal is exactly the stall/wait case:
56
+ // pi-goal continues on its own at every idle boundary, so idleness at
57
+ // tick time means its continuation was lost, or the goal is waiting on
58
+ // an external event — which this wakeup is.
59
+ return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
60
+ }
61
+ return { action: "poke", reason: "recurring-prompt" };
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * pi-loop: Claude-Code-/loop-inspired pacemaker for Pi with hybrid semantics
3
+ * (recurring prompt re-runs plus stall rescue) and loop-aware compaction.
4
+ * The loop owns *when* the session wakes; @narumitw/pi-goal (or the
5
+ * @hank-warren/pi-goal fork) owns *whether the work is done*, read through
6
+ * its `goal-state` session entries only.
7
+ */
8
+
9
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+ import { completeLoopArguments, parseLoopCommand } from "./command.js";
11
+ import { registerInlineInvocation } from "./inline-invocation.js";
12
+ import { LoopController, type LoopControllerOptions } from "./loop.js";
13
+ import { showLoopManager, showLoopSettings } from "./manager.js";
14
+
15
+ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
16
+ const controller = new LoopController(pi, options);
17
+
18
+ pi.registerCommand("loop", {
19
+ description:
20
+ "Wake the session on an interval: /loop [--max N] [--compact-at 60%] <interval> [prompt]",
21
+ getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
22
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
23
+ const command = parseLoopCommand(args);
24
+ switch (command.kind) {
25
+ case "show":
26
+ await showLoopManager(controller, ctx);
27
+ return;
28
+ case "status":
29
+ ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
30
+ return;
31
+ case "pause":
32
+ controller.pauseLoop(ctx);
33
+ return;
34
+ case "resume":
35
+ controller.resumeLoop(ctx);
36
+ return;
37
+ case "stop":
38
+ controller.stopLoop(ctx);
39
+ return;
40
+ case "settings":
41
+ await showLoopSettings(controller, ctx);
42
+ return;
43
+ case "error":
44
+ ctx.ui.notify(command.message, "error");
45
+ return;
46
+ case "start": {
47
+ const existing = controller.state;
48
+ if (existing && existing.status !== "stopped") {
49
+ const replace =
50
+ ctx.mode === "tui"
51
+ ? await ctx.ui.confirm(
52
+ "Replace loop?",
53
+ "A loop already exists in this session. Replace it?",
54
+ )
55
+ : false;
56
+ if (!replace) {
57
+ ctx.ui.notify(
58
+ "A loop already exists; /loop stop it first or confirm replacement in the TUI.",
59
+ "warning",
60
+ );
61
+ return;
62
+ }
63
+ }
64
+ controller.startLoop(ctx, command);
65
+ return;
66
+ }
67
+ }
68
+ },
69
+ });
70
+
71
+ pi.on("session_start", async (_event, ctx) => {
72
+ controller.onSessionStart(ctx);
73
+ });
74
+ pi.on("session_shutdown", async () => {
75
+ controller.onSessionShutdown();
76
+ });
77
+ pi.on("agent_settled", async (_event, ctx) => {
78
+ controller.onAgentSettled(ctx);
79
+ });
80
+ pi.on("session_compact", async (_event, ctx) => {
81
+ controller.onSessionCompact(ctx);
82
+ });
83
+ registerInlineInvocation(pi, controller);
84
+ }
@@ -0,0 +1,89 @@
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 the first inline occurrence of a command token so the caller
7
+ * can dispatch it and deliver the surrounding prose separately.
8
+ *
9
+ * This module is deliberately package-agnostic: every package-specific detail
10
+ * is a parameter so the file can be duplicated byte-for-byte into sibling
11
+ * packages (see DUPLICATED_SOURCES in scripts/validate.py once a second copy
12
+ * exists). Edit one copy, then copy it over the other.
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) are
31
+ * ignored. Code regions are approximated by the CommonMark backtick-run
32
+ * rule: a run of N backticks opens a region closed by the next run of
33
+ * 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 code = codeRegions(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(code, 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
+ type Region = readonly [start: number, end: number];
65
+
66
+ function codeRegions(text: string): Region[] {
67
+ const regions: Region[] = [];
68
+ const runs: Array<{ index: number; length: number }> = [];
69
+ const runPattern = /`+/g;
70
+ for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
71
+ runs.push({ index: match.index, length: match[0].length });
72
+ }
73
+ for (let open = 0; open < runs.length; open += 1) {
74
+ const opener = runs[open];
75
+ if (opener === undefined) continue;
76
+ for (let close = open + 1; close < runs.length; close += 1) {
77
+ const closer = runs[close];
78
+ if (closer === undefined || closer.length !== opener.length) continue;
79
+ regions.push([opener.index, closer.index + closer.length]);
80
+ open = close;
81
+ break;
82
+ }
83
+ }
84
+ return regions;
85
+ }
86
+
87
+ function insideRegion(regions: readonly Region[], index: number): boolean {
88
+ return regions.some(([start, end]) => index >= start && index < end);
89
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Inline /loop invocation: recognize `/loop <args>` mid-prompt through Pi's
3
+ * input pipeline, dispatch it command-first, and deliver surrounding prose as
4
+ * a follow-up. Same design as the pi-goal fork's inline /goal; the detection
5
+ * helper (inline-command.ts) is duplicated byte-for-byte from
6
+ * packages/pi-goal and registered in DUPLICATED_SOURCES.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { extractInlineCommand } from "./inline-command.js";
11
+ import type { LoopController } from "./loop.js";
12
+
13
+ export const INLINE_LOOP_COMMAND = "loop";
14
+
15
+ export function registerInlineInvocation(pi: ExtensionAPI, controller: LoopController) {
16
+ pi.on("input", (event) => {
17
+ // Messages sent by extensions (including our own re-dispatch below) are
18
+ // never rewritten; leading "/loop" is consumed by Pi's command dispatch
19
+ // before this event fires, so recursion is impossible either way.
20
+ if (event.source === "extension") return;
21
+ if (!controller.settings.inlineInvocation) return;
22
+ // A message with attached images is passed through untouched: splitting
23
+ // it would detach the images from the text they belong to.
24
+ if (event.images && event.images.length > 0) return;
25
+ const split = extractInlineCommand(event.text, INLINE_LOOP_COMMAND);
26
+ if (!split) return;
27
+ // While the agent streams, Pi requires an explicit delivery mode; reuse
28
+ // the mode the original message would have used.
29
+ const deliverAs = event.streamingBehavior;
30
+ pi.sendUserMessage(
31
+ `/${INLINE_LOOP_COMMAND} ${split.commandArgs}`,
32
+ deliverAs ? { deliverAs } : undefined,
33
+ );
34
+ if (split.prose) {
35
+ pi.sendUserMessage(split.prose, { deliverAs: "followUp" });
36
+ }
37
+ return { action: "handled" as const };
38
+ });
39
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Deterministic duration parsing. The extension owns the grammar (research:
3
+ * model-parsed intervals make timing undebuggable): a positive integer plus
4
+ * one unit, `30s` / `5m` / `2h` / `1d`.
5
+ */
6
+
7
+ export const MIN_INTERVAL_MS = 60_000;
8
+ export const MAX_INTERVAL_MS = 2_147_483_647; // setTimeout's cap.
9
+
10
+ const DURATION_PATTERN = /^(\d{1,9})(s|m|h|d)$/;
11
+
12
+ const UNIT_MS: Record<string, number> = {
13
+ s: 1_000,
14
+ m: 60_000,
15
+ h: 3_600_000,
16
+ d: 86_400_000,
17
+ };
18
+
19
+ /** Parse a duration token to milliseconds, or undefined when malformed. */
20
+ export function parseDuration(token: string): number | undefined {
21
+ const match = DURATION_PATTERN.exec(token.trim());
22
+ if (!match) return undefined;
23
+ const amount = Number(match[1]);
24
+ const unit = match[2] === undefined ? undefined : UNIT_MS[match[2]];
25
+ if (!Number.isSafeInteger(amount) || amount <= 0 || unit === undefined) return undefined;
26
+ const ms = amount * unit;
27
+ return ms > MAX_INTERVAL_MS ? undefined : ms;
28
+ }
29
+
30
+ export interface ParsedInterval {
31
+ requestedMs: number;
32
+ /** Clamped to MIN_INTERVAL_MS; the caller must echo the effective value. */
33
+ effectiveMs: number;
34
+ clamped: boolean;
35
+ }
36
+
37
+ /** Parse a loop interval token, clamping below the minimum. */
38
+ export function parseInterval(token: string): ParsedInterval | undefined {
39
+ const requestedMs = parseDuration(token);
40
+ if (requestedMs === undefined) return undefined;
41
+ const effectiveMs = Math.max(MIN_INTERVAL_MS, requestedMs);
42
+ return { requestedMs, effectiveMs, clamped: effectiveMs !== requestedMs };
43
+ }
44
+
45
+ /** Render a millisecond duration back to the most compact token. */
46
+ export function formatDuration(ms: number): string {
47
+ for (const [unit, size] of [
48
+ ["d", UNIT_MS.d],
49
+ ["h", UNIT_MS.h],
50
+ ["m", UNIT_MS.m],
51
+ ] as const) {
52
+ if (size !== undefined && ms >= size && ms % size === 0) return `${ms / size}${unit}`;
53
+ }
54
+ return `${Math.round(ms / 1_000)}s`;
55
+ }
56
+
57
+ /** Render a wall-clock time as HH:MM for the status widget. */
58
+ export function formatClock(timestamp: number): string {
59
+ const date = new Date(timestamp);
60
+ const hours = `${date.getHours()}`.padStart(2, "0");
61
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
62
+ return `${hours}:${minutes}`;
63
+ }