summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-20 20:49:39 +0200
committerPaul Buetow <paul@buetow.org>2026-03-20 20:49:39 +0200
commit097afe5a81849ea8a921286c887014e242fa3794 (patch)
tree09b035931cbca6793b370c33a490f094d8315b37
parente66e46fcc27aee1246f40b76fedd87d2138e6d15 (diff)
Add Pi extensions and usage docs
-rw-r--r--pi/agent/extensions/fresh-subagent/README.md99
-rw-r--r--pi/agent/extensions/handoff/README.md45
-rw-r--r--pi/agent/extensions/handoff/index.ts130
-rw-r--r--pi/agent/extensions/inline-bash/README.md44
-rw-r--r--pi/agent/extensions/inline-bash/index.ts72
-rw-r--r--pi/agent/extensions/loop-scheduler/README.md124
-rw-r--r--pi/agent/extensions/loop-scheduler/index.ts380
-rw-r--r--pi/agent/extensions/modal-editor/README.md45
-rw-r--r--pi/agent/extensions/modal-editor/index.ts68
-rw-r--r--pi/agent/extensions/reload-runtime/README.md46
-rw-r--r--pi/agent/extensions/reload-runtime/index.ts26
-rw-r--r--pi/agent/extensions/session-name/README.md41
-rw-r--r--pi/agent/extensions/session-name/index.ts18
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/README.md169
14 files changed, 1233 insertions, 74 deletions
diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md
index c74dd78..8758bc9 100644
--- a/pi/agent/extensions/fresh-subagent/README.md
+++ b/pi/agent/extensions/fresh-subagent/README.md
@@ -1,29 +1,30 @@
# Fresh Subagent
-Minimal fresh-context subagent support for Pi.
+Generic fresh-context delegation for Pi.
-## What it does
+This extension gives Pi a simple subagent primitive:
-- registers a `subagent` tool the main agent can call
-- registers a `/subagent <prompt>` command for direct use
-- runs the delegated work in a new `pi --mode json -p --no-session` process
-- defaults to the current session model when one is active
-- returns only the final answer or review result
+- the main agent can call the `subagent` tool
+- you can call `/subagent <prompt>` directly
+- the delegated work runs in a new `pi --mode json -p --no-session` process
+- the child starts with a fresh context
+- the result comes back as one final answer
This is intentionally small. It does not manage agent catalogs, chains, or
parallel workers. It is meant for one-off delegation with a clean context.
-## What it is for
+## What It Is For
Subagents are generic. The main agent can hand them any focused prompt that
benefits from a clean context, for example:
-- independent code review
-- fresh-context debugging
-- focused codebase research
+- code review
+- debugging
+- focused research
- second-opinion architecture checks
-- summarizing a noisy command output or diff
-- validating whether a completed task is actually done
+- summarizing noisy output
+- validating whether a task is really complete
+- any other self-contained side task
One common use is the `taskwarrior-task-management` review loop:
@@ -33,51 +34,95 @@ One common use is the `taskwarrior-task-management` review loop:
4. The main agent fixes findings
5. Only then does the task move toward completion
-## Direct usage
+## Usage Flows
-Run a manual fresh-context review:
+### Flow 1: Use it directly inside Pi
+
+Run a direct delegation:
```text
-/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings.
+/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches.
```
-Run a focused side investigation:
+Run a focused investigation:
```text
/subagent Find all code paths that write to the SSH known_hosts file and summarize the risk.
```
-Run a generic delegation:
+Run a review:
```text
-/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches.
+/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings.
```
-One-shot CLI usage also works now:
+### Flow 2: Use it from the main agent
+
+Because this is registered as a tool, the main agent can call it itself.
+
+Generic handoff pattern:
+
+```text
+Use the subagent tool for a fresh-context pass on this side task, then return only the useful result.
+```
+
+Review handoff pattern:
+
+```text
+First review your own changes. Afterwards, use the subagent tool to perform an independent fresh-context review and then address any findings.
+```
+
+Research handoff pattern:
+
+```text
+Use the subagent tool to inspect only the WireGuard setup path in a fresh context and summarize the concrete risks.
+```
+
+### Flow 3: Use it in one-shot CLI mode
+
+This works outside the full TUI as well:
```bash
pi --model openai/gpt-4.1 --no-session -p '/subagent Say only SUBAGENT_COMMAND_OK'
```
-## Agent usage
+### Flow 4: Use it in the Taskwarrior review loop
-Because this is registered as a tool, the main agent can call it itself. A good
-generic pattern is:
+The intended task workflow is:
+
+1. main agent implements
+2. main agent self-reviews
+3. main agent calls `subagent` for independent review
+4. main agent fixes findings
+5. only then complete the task
+
+## What To Put In The Prompt
+
+Subagents start fresh, so include enough context in the prompt:
+
+- what to inspect or do
+- the scope or files to focus on
+- the expected output shape
+- any constraints such as “report only concrete findings”
+
+Good:
```text
-Use the subagent tool for a fresh-context pass on this side task, then return only the useful result.
+/subagent Review the recent SSH bootstrap changes in hyperstack.rb. Report only concrete bugs, regressions, or missing tests.
```
-For review-specific flows:
+Weak:
```text
-First review your own changes. Afterwards, use the subagent tool to perform an independent fresh-context review and then address any findings.
+/subagent Review this
```
-## Notes
+## Notes And Limits
- The subagent uses a fresh session via `--no-session`.
- The subprocess still runs in the same working directory unless you override
`cwd`.
- The extension disables itself inside child subagent processes to avoid
accidental recursive registration.
+- This is deliberately minimal. There is no built-in multi-agent orchestration,
+ planner chain, or background pool here.
diff --git a/pi/agent/extensions/handoff/README.md b/pi/agent/extensions/handoff/README.md
new file mode 100644
index 0000000..1f70211
--- /dev/null
+++ b/pi/agent/extensions/handoff/README.md
@@ -0,0 +1,45 @@
+# Handoff
+
+Focused session handoff for Pi.
+
+This is the upstream `handoff.ts` example installed as a local extension in
+your dotfiles-backed Pi tree. It generates a compact, self-contained prompt for
+starting a new session without manually rewriting the whole context.
+
+## What It Does
+
+- adds `/handoff <goal>`
+- reads the current session branch
+- asks the active model to summarize the relevant context for a new thread
+- opens the generated handoff prompt for editing
+- creates a new session and drops the edited prompt into the new editor
+
+## Usage Flows
+
+### Flow 1: Split off the next implementation phase
+
+```text
+/handoff implement the next phase of the WireGuard cleanup work
+```
+
+Pi generates a fresh prompt with the relevant context, opens it for editing,
+creates a new session, and leaves the draft ready to submit.
+
+### Flow 2: Move into a review-only thread
+
+```text
+/handoff independently review the recent hyperstack changes for concrete bugs and missing tests
+```
+
+### Flow 3: Continue with a narrower subproblem
+
+```text
+/handoff investigate only the SSH host verification path and ignore the rest
+```
+
+## Notes And Limits
+
+- This is for interactive Pi sessions with UI support.
+- It uses the currently selected model to generate the handoff prompt.
+- It is a session-to-session context transfer helper, not the same thing as the
+ fresh subagent extension.
diff --git a/pi/agent/extensions/handoff/index.ts b/pi/agent/extensions/handoff/index.ts
new file mode 100644
index 0000000..6e9fab0
--- /dev/null
+++ b/pi/agent/extensions/handoff/index.ts
@@ -0,0 +1,130 @@
+import { complete, type Message } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+
+const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
+
+1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
+2. Lists any relevant files that were discussed or modified
+3. Clearly states the next task based on the user's goal
+4. Is self-contained - the new thread should be able to proceed without the old conversation
+
+Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
+
+Example output format:
+## Context
+We've been working on X. Key decisions:
+- Decision 1
+- Decision 2
+
+Files involved:
+- path/to/file1.ts
+- path/to/file2.ts
+
+## Task
+[Clear description of what to do next based on the user's goal]`;
+
+export default function (pi: ExtensionAPI) {
+ pi.registerCommand("handoff", {
+ description: "Transfer context to a new focused session",
+ handler: async (args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("handoff requires interactive mode", "error");
+ return;
+ }
+
+ if (!ctx.model) {
+ ctx.ui.notify("No model selected", "error");
+ return;
+ }
+
+ const goal = args.trim();
+ if (!goal) {
+ ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
+ return;
+ }
+
+ const branch = ctx.sessionManager.getBranch();
+ const messages = branch
+ .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
+ .map((entry) => entry.message);
+
+ if (messages.length === 0) {
+ ctx.ui.notify("No conversation to hand off", "error");
+ return;
+ }
+
+ const llmMessages = convertToLlm(messages);
+ const conversationText = serializeConversation(llmMessages);
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
+
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
+ loader.onAbort = () => done(null);
+
+ const doGenerate = async () => {
+ const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
+
+ const userMessage: Message = {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
+ },
+ ],
+ timestamp: Date.now(),
+ };
+
+ const response = await complete(
+ ctx.model!,
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
+ { apiKey, signal: loader.signal },
+ );
+
+ if (response.stopReason === "aborted") {
+ return null;
+ }
+
+ return response.content
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
+ .map((content) => content.text)
+ .join("\n");
+ };
+
+ doGenerate()
+ .then(done)
+ .catch((err) => {
+ console.error("Handoff generation failed:", err);
+ done(null);
+ });
+
+ return loader;
+ });
+
+ if (result === null) {
+ ctx.ui.notify("Cancelled", "info");
+ return;
+ }
+
+ const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
+
+ if (editedPrompt === undefined) {
+ ctx.ui.notify("Cancelled", "info");
+ return;
+ }
+
+ const newSessionResult = await ctx.newSession({
+ parentSession: currentSessionFile,
+ });
+
+ if (newSessionResult.cancelled) {
+ ctx.ui.notify("New session cancelled", "info");
+ return;
+ }
+
+ ctx.ui.setEditorText(editedPrompt);
+ ctx.ui.notify("Handoff ready. Submit when ready.", "info");
+ },
+ });
+}
diff --git a/pi/agent/extensions/inline-bash/README.md b/pi/agent/extensions/inline-bash/README.md
new file mode 100644
index 0000000..777f2fa
--- /dev/null
+++ b/pi/agent/extensions/inline-bash/README.md
@@ -0,0 +1,44 @@
+# Inline Bash
+
+Inline shell expansion for Pi prompts.
+
+This is the upstream `inline-bash.ts` example installed as a local extension in
+your dotfiles-backed Pi tree. It expands `!{...}` before the prompt is sent to
+the model.
+
+## What It Does
+
+- `!{command}` runs a shell command locally
+- the command output replaces the inline expression in your prompt
+- regular whole-line `!command` behavior stays unchanged
+
+## Usage Flows
+
+### Flow 1: Inline one value into a prompt
+
+```text
+What files are in !{pwd}?
+```
+
+Pi sends the expanded prompt after `pwd` runs locally.
+
+### Flow 2: Inline git state
+
+```text
+Summarize the current branch !{git branch --show-current} and these changes: !{git status --short}
+```
+
+### Flow 3: Inline system context
+
+```text
+I am on kernel !{uname -r} and hostname !{hostname}. Explain whether that matters for this bug.
+```
+
+## Notes And Limits
+
+- Commands run on your local machine, not on the model provider.
+- Expansion happens before the prompt is sent.
+- Each inline command has a 30 second timeout.
+- If a command fails, the prompt gets an inline error marker.
+- This is convenient, but it is still shell execution. Treat prompt text
+ accordingly.
diff --git a/pi/agent/extensions/inline-bash/index.ts b/pi/agent/extensions/inline-bash/index.ts
new file mode 100644
index 0000000..957c14c
--- /dev/null
+++ b/pi/agent/extensions/inline-bash/index.ts
@@ -0,0 +1,72 @@
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ const PATTERN = /!\{([^}]+)\}/g;
+ const TIMEOUT_MS = 30000;
+
+ pi.on("input", async (event, ctx) => {
+ const text = event.text;
+
+ // Preserve the existing whole-line !command behavior.
+ if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
+ return { action: "continue" };
+ }
+
+ if (!PATTERN.test(text)) {
+ return { action: "continue" };
+ }
+
+ PATTERN.lastIndex = 0;
+
+ let result = text;
+ const expansions: Array<{ command: string; output: string; error?: string }> = [];
+ const matches: Array<{ full: string; command: string }> = [];
+ let match = PATTERN.exec(text);
+
+ while (match) {
+ matches.push({ full: match[0], command: match[1] });
+ match = PATTERN.exec(text);
+ }
+
+ for (const { full, command } of matches) {
+ try {
+ const bashResult = await pi.exec("bash", ["-c", command], {
+ timeout: TIMEOUT_MS,
+ });
+ const output = bashResult.stdout || bashResult.stderr || "";
+ const trimmed = output.trim();
+
+ if (bashResult.code !== 0 && bashResult.stderr) {
+ expansions.push({
+ command,
+ output: trimmed,
+ error: `exit code ${bashResult.code}`,
+ });
+ } else {
+ expansions.push({ command, output: trimmed });
+ }
+
+ result = result.replace(full, trimmed);
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ expansions.push({ command, output: "", error: errorMsg });
+ result = result.replace(full, `[error: ${errorMsg}]`);
+ }
+ }
+
+ if (ctx.hasUI && expansions.length > 0) {
+ const summary = expansions
+ .map((entry) => {
+ const status = entry.error ? ` (${entry.error})` : "";
+ const preview =
+ entry.output.length > 50 ? `${entry.output.slice(0, 50)}...` : entry.output;
+ return `!{${entry.command}}${status} -> "${preview}"`;
+ })
+ .join("\n");
+
+ ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
+ }
+
+ return { action: "transform", text: result, images: event.images };
+ });
+}
diff --git a/pi/agent/extensions/loop-scheduler/README.md b/pi/agent/extensions/loop-scheduler/README.md
new file mode 100644
index 0000000..78a6635
--- /dev/null
+++ b/pi/agent/extensions/loop-scheduler/README.md
@@ -0,0 +1,124 @@
+# Loop Scheduler
+
+Session-scoped recurring prompts for Pi.
+
+This extension adds a Claude-Code-style `/loop` command for interactive Pi
+sessions. It schedules a prompt to be re-sent on an interval while the current
+Pi process stays open.
+
+## Commands
+
+- `/loop 10m <prompt>`
+ Run a prompt every 10 minutes.
+- `/loop <prompt>`
+ Run a prompt every 10 minutes using the default interval.
+- `/loop <prompt> every 2h`
+ Alternative trailing interval form.
+- `/loop list`
+ Show the active loop jobs.
+- `/loop cancel <id>`
+ Cancel one loop job.
+- `/loop cancel all`
+ Cancel all loop jobs.
+
+Supported units:
+
+- `s`
+- `m`
+- `h`
+- `d`
+
+Examples:
+
+- `5s`
+- `10m`
+- `2h`
+- `1d`
+- `every 2 hours`
+- `hourly`
+- `daily`
+
+## Usage Flows
+
+### Flow 1: Poll something on an interval
+
+Start Pi in the repo, then run:
+
+```text
+/loop 10m check whether the deployment finished and summarize what changed
+```
+
+Pi will keep re-injecting that prompt every 10 minutes while the session stays
+open.
+
+### Flow 2: Loop another command
+
+The scheduled prompt can itself be a slash command or workflow:
+
+```text
+/loop 20m /work-on-tasks highest-impact 1
+```
+
+or:
+
+```text
+/loop 30m /subagent Review the current working tree for concrete regressions only
+```
+
+### Flow 3: Check what is scheduled
+
+```text
+/loop list
+```
+
+This prints the current loop IDs, cadence, next due time, and prompt preview.
+
+### Flow 4: Cancel a loop
+
+Cancel one loop:
+
+```text
+/loop cancel ab12cd34
+```
+
+Cancel everything:
+
+```text
+/loop cancel all
+```
+
+## Busy-Agent Behavior
+
+Loop jobs do not spam turns while Pi is busy.
+
+- if a job becomes due while the agent is running, it is marked pending
+- when the current work finishes, the next pending loop fires once
+- missed intervals do not stack into a catch-up storm
+
+## Session Model
+
+This extension is session-scoped, not durable scheduling.
+
+- loop jobs live only in the current Pi process
+- closing Pi ends all loop jobs
+- `/reload` or a restart drops the active schedules
+- this is for active coding sessions, not unattended automation
+
+## Good Uses
+
+- poll build or deployment status
+- re-run a review command every N minutes
+- check Taskwarrior progress during a work session
+- periodically ask for a summary while you are coding
+
+## Bad Uses
+
+- long-term unattended automation
+- guaranteed exact-time scheduling
+- anything that must survive terminal exit or Pi restart
+
+## Notes
+
+- `/loop` is intended for interactive or RPC sessions that remain open.
+- It is not useful in one-shot `pi -p` mode because the process exits before
+ later runs can fire.
diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts
new file mode 100644
index 0000000..837214f
--- /dev/null
+++ b/pi/agent/extensions/loop-scheduler/index.ts
@@ -0,0 +1,380 @@
+import { randomUUID } from "node:crypto";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
+const MAX_JOBS = 50;
+
+interface LoopJob {
+ id: string;
+ prompt: string;
+ intervalMs: number;
+ intervalLabel: string;
+ createdAt: number;
+ nextRunAt: number;
+ pending: boolean;
+ runs: number;
+ lastRunAt?: number;
+}
+
+type TimerHandle = ReturnType<typeof setTimeout>;
+
+function pluralize(value: number, singular: string): string {
+ return `${value}${singular}`;
+}
+
+function formatInterval(ms: number): string {
+ if (ms % (24 * 60 * 60 * 1000) === 0) return pluralize(ms / (24 * 60 * 60 * 1000), "d");
+ if (ms % (60 * 60 * 1000) === 0) return pluralize(ms / (60 * 60 * 1000), "h");
+ if (ms % (60 * 1000) === 0) return pluralize(ms / (60 * 1000), "m");
+ if (ms % 1000 === 0) return pluralize(ms / 1000, "s");
+ return `${ms}ms`;
+}
+
+function formatDelay(ms: number): string {
+ if (ms <= 0) return "due now";
+ if (ms < 60 * 1000) return `in ${Math.ceil(ms / 1000)}s`;
+ if (ms < 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 1000))}m`;
+ if (ms < 24 * 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 60 * 1000))}h`;
+ return `in ${Math.ceil(ms / (24 * 60 * 60 * 1000))}d`;
+}
+
+function shortenPrompt(prompt: string, limit = 72): string {
+ return prompt.length > limit ? `${prompt.slice(0, limit)}...` : prompt;
+}
+
+function parseDurationPhrase(raw: string): { intervalMs: number; label: string } | undefined {
+ const text = raw.trim().toLowerCase();
+ if (!text) return undefined;
+
+ if (text === "hourly" || text === "every hour") return { intervalMs: 60 * 60 * 1000, label: "1h" };
+ if (text === "daily" || text === "every day") return { intervalMs: 24 * 60 * 60 * 1000, label: "1d" };
+ if (text === "minutely" || text === "every minute") return { intervalMs: 60 * 1000, label: "1m" };
+
+ const match = text.match(
+ /^(?:every\s+)?(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i,
+ );
+ if (!match) return undefined;
+
+ const amount = Number(match[1]);
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
+
+ const unit = match[2].toLowerCase();
+ let intervalMs = 0;
+ let label = "";
+
+ if (["s", "sec", "secs", "second", "seconds"].includes(unit)) {
+ intervalMs = amount * 1000;
+ label = `${amount}s`;
+ } else if (["m", "min", "mins", "minute", "minutes"].includes(unit)) {
+ intervalMs = amount * 60 * 1000;
+ label = `${amount}m`;
+ } else if (["h", "hr", "hrs", "hour", "hours"].includes(unit)) {
+ intervalMs = amount * 60 * 60 * 1000;
+ label = `${amount}h`;
+ } else if (["d", "day", "days"].includes(unit)) {
+ intervalMs = amount * 24 * 60 * 60 * 1000;
+ label = `${amount}d`;
+ }
+
+ if (intervalMs <= 0) return undefined;
+ return { intervalMs, label };
+}
+
+function parseLoopRequest(rawArgs: string): { prompt: string; intervalMs: number; intervalLabel: string } | undefined {
+ const text = rawArgs.trim();
+ if (!text) return undefined;
+
+ const trailingEvery = text.match(/^(.*\S)\s+every\s+(.+)$/i);
+ if (trailingEvery) {
+ const prompt = trailingEvery[1].trim();
+ const duration = parseDurationPhrase(trailingEvery[2]);
+ if (prompt && duration) {
+ return { prompt, intervalMs: duration.intervalMs, intervalLabel: duration.label };
+ }
+ }
+
+ const words = text.split(/\s+/);
+ if (words.length > 1) {
+ const firstDuration = parseDurationPhrase(words[0] ?? "");
+ if (firstDuration) {
+ return {
+ prompt: words.slice(1).join(" "),
+ intervalMs: firstDuration.intervalMs,
+ intervalLabel: firstDuration.label,
+ };
+ }
+
+ if ((words[0] ?? "").toLowerCase() === "every") {
+ for (let i = 2; i <= Math.min(words.length - 1, 4); i++) {
+ const candidate = words.slice(0, i).join(" ");
+ const duration = parseDurationPhrase(candidate);
+ if (duration) {
+ return {
+ prompt: words.slice(i).join(" "),
+ intervalMs: duration.intervalMs,
+ intervalLabel: duration.label,
+ };
+ }
+ }
+ }
+ }
+
+ return {
+ prompt: text,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ intervalLabel: formatInterval(DEFAULT_INTERVAL_MS),
+ };
+}
+
+function formatJobLine(job: LoopJob): string {
+ return `${job.id} every ${job.intervalLabel} ${job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now())} ${shortenPrompt(job.prompt)}`;
+}
+
+export default function loopSchedulerExtension(pi: ExtensionAPI): void {
+ const jobs = new Map<string, LoopJob>();
+ const timers = new Map<string, TimerHandle>();
+ let lastCtx: ExtensionContext | undefined;
+ let agentBusy = false;
+
+ function rememberContext(ctx: ExtensionContext): void {
+ lastCtx = ctx;
+ }
+
+ function clearJobTimer(id: string): void {
+ const timer = timers.get(id);
+ if (timer) {
+ clearTimeout(timer);
+ timers.delete(id);
+ }
+ }
+
+ function clearAllTimers(): void {
+ for (const timer of timers.values()) {
+ clearTimeout(timer);
+ }
+ timers.clear();
+ }
+
+ function getOrderedJobs(): LoopJob[] {
+ return [...jobs.values()].sort((a, b) => a.nextRunAt - b.nextRunAt || a.createdAt - b.createdAt);
+ }
+
+ function writeCommandOutput(text: string): void {
+ process.stdout.write(`${text}\n`);
+ }
+
+ function updateUi(ctx: ExtensionContext | undefined = lastCtx): void {
+ if (!ctx?.hasUI) return;
+
+ const ordered = getOrderedJobs();
+ if (ordered.length === 0) {
+ ctx.ui.setStatus("loop-scheduler", undefined);
+ ctx.ui.setWidget("loop-scheduler", undefined);
+ return;
+ }
+
+ ctx.ui.setStatus("loop-scheduler", ctx.ui.theme.fg("accent", `loop:${ordered.length}`));
+ ctx.ui.setWidget(
+ "loop-scheduler",
+ [
+ ctx.ui.theme.fg("accent", "Scheduled loops"),
+ ...ordered.slice(0, 3).map((job) => `${job.pending ? "⏸" : "⟳"} ${formatJobLine(job)}`),
+ ...(ordered.length > 3 ? [ctx.ui.theme.fg("muted", `+${ordered.length - 3} more`)] : []),
+ ],
+ { placement: "belowEditor" },
+ );
+ }
+
+ function notify(message: string, level: "info" | "warning" | "error" | "success" = "info", ctx?: ExtensionContext): void {
+ const target = ctx ?? lastCtx;
+ if (target?.hasUI) {
+ target.ui.notify(message, level);
+ } else {
+ writeCommandOutput(message);
+ }
+ }
+
+ function scheduleJobTimer(job: LoopJob): void {
+ clearJobTimer(job.id);
+ const delayMs = Math.max(100, job.nextRunAt - Date.now());
+ const timer = setTimeout(() => {
+ void handleJobDue(job.id);
+ }, delayMs);
+ timers.set(job.id, timer);
+ }
+
+ function dispatchLoopJob(job: LoopJob, reason: "timer" | "pending-drain"): void {
+ if (agentBusy) {
+ job.pending = true;
+ updateUi();
+ return;
+ }
+
+ agentBusy = true;
+ job.pending = false;
+ job.runs += 1;
+ job.lastRunAt = Date.now();
+ updateUi();
+
+ try {
+ pi.sendUserMessage(job.prompt);
+ notify(`Loop ${job.id} fired (${reason}).`, "info");
+ } catch (error) {
+ agentBusy = false;
+ job.pending = true;
+ updateUi();
+ const message = error instanceof Error ? error.message : String(error);
+ notify(`Loop ${job.id} could not fire yet: ${message}`, "warning");
+ }
+ }
+
+ function drainPendingJobs(): void {
+ if (agentBusy) return;
+ const nextPending = getOrderedJobs().find((job) => job.pending);
+ if (!nextPending) return;
+ dispatchLoopJob(nextPending, "pending-drain");
+ }
+
+ async function handleJobDue(id: string): Promise<void> {
+ const job = jobs.get(id);
+ if (!job) return;
+
+ job.nextRunAt = Date.now() + job.intervalMs;
+ scheduleJobTimer(job);
+
+ if (agentBusy) {
+ job.pending = true;
+ updateUi();
+ return;
+ }
+
+ dispatchLoopJob(job, "timer");
+ }
+
+ function createJob(prompt: string, intervalMs: number, intervalLabel: string): LoopJob {
+ return {
+ id: randomUUID().replace(/-/g, "").slice(0, 8),
+ prompt,
+ intervalMs,
+ intervalLabel,
+ createdAt: Date.now(),
+ nextRunAt: Date.now() + intervalMs,
+ pending: false,
+ runs: 0,
+ };
+ }
+
+ function resolveJob(idOrPrefix: string): LoopJob | undefined {
+ const needle = idOrPrefix.trim().toLowerCase();
+ if (!needle) return undefined;
+
+ const exact = jobs.get(needle);
+ if (exact) return exact;
+
+ const matches = [...jobs.values()].filter((job) => job.id.startsWith(needle));
+ return matches.length === 1 ? matches[0] : undefined;
+ }
+
+ function formatJobList(): string {
+ const ordered = getOrderedJobs();
+ if (ordered.length === 0) return "No active loop jobs.";
+
+ return ordered.map((job) => `- ${formatJobLine(job)}`).join("\n");
+ }
+
+ function cancelJob(job: LoopJob): void {
+ clearJobTimer(job.id);
+ jobs.delete(job.id);
+ updateUi();
+ }
+
+ pi.registerCommand("loop", {
+ description: "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>",
+ handler: async (args, ctx) => {
+ rememberContext(ctx);
+
+ if (!ctx.hasUI) {
+ writeCommandOutput("The /loop command requires an interactive or RPC session that stays open.");
+ return;
+ }
+
+ const trimmed = args.trim();
+ if (!trimmed || trimmed.toLowerCase() === "help") {
+ notify("Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all>", "info", ctx);
+ return;
+ }
+
+ if (/^(list|ls)$/i.test(trimmed)) {
+ notify(formatJobList(), "info", ctx);
+ updateUi(ctx);
+ return;
+ }
+
+ const cancelAll = /^(cancel|clear)\s+all$/i.test(trimmed);
+ if (cancelAll) {
+ const count = jobs.size;
+ clearAllTimers();
+ jobs.clear();
+ updateUi(ctx);
+ notify(count > 0 ? `Canceled ${count} loop job(s).` : "No active loop jobs.", "info", ctx);
+ return;
+ }
+
+ const cancelMatch = trimmed.match(/^(?:cancel|rm|delete)\s+(\S+)$/i);
+ if (cancelMatch) {
+ const job = resolveJob(cancelMatch[1]);
+ if (!job) {
+ notify(`No loop job matched '${cancelMatch[1]}'.`, "warning", ctx);
+ return;
+ }
+ cancelJob(job);
+ notify(`Canceled loop ${job.id}.`, "info", ctx);
+ return;
+ }
+
+ if (jobs.size >= MAX_JOBS) {
+ notify(`Too many active loop jobs (${jobs.size}). Cancel one before adding another.`, "warning", ctx);
+ return;
+ }
+
+ const request = parseLoopRequest(trimmed);
+ if (!request || !request.prompt.trim()) {
+ notify("Could not parse /loop arguments. Example: /loop 10m check the build", "warning", ctx);
+ return;
+ }
+
+ const job = createJob(request.prompt.trim(), request.intervalMs, request.intervalLabel);
+ jobs.set(job.id, job);
+ scheduleJobTimer(job);
+ updateUi(ctx);
+ notify(`Scheduled loop ${job.id} every ${job.intervalLabel}: ${shortenPrompt(job.prompt)}`, "success", ctx);
+ },
+ });
+
+ pi.on("session_start", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = false;
+ updateUi(ctx);
+ });
+
+ pi.on("agent_start", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = true;
+ updateUi(ctx);
+ });
+
+ pi.on("agent_end", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = false;
+ updateUi(ctx);
+ drainPendingJobs();
+ });
+
+ pi.on("session_shutdown", async (_event, ctx) => {
+ rememberContext(ctx);
+ clearAllTimers();
+ jobs.clear();
+ agentBusy = false;
+ updateUi(ctx);
+ });
+}
diff --git a/pi/agent/extensions/modal-editor/README.md b/pi/agent/extensions/modal-editor/README.md
new file mode 100644
index 0000000..074bff1
--- /dev/null
+++ b/pi/agent/extensions/modal-editor/README.md
@@ -0,0 +1,45 @@
+# Modal Editor
+
+Modal prompt editing for the Pi TUI.
+
+This is the upstream `modal-editor.ts` example installed as a local extension in
+your dotfiles-backed Pi tree. It replaces the default prompt editor with a
+small Vim-like modal editor.
+
+## What It Does
+
+- starts in `INSERT` mode
+- `Esc` switches to `NORMAL`
+- `i` returns to `INSERT`
+- `a` appends and returns to `INSERT`
+- `h`, `j`, `k`, `l` move in `NORMAL`
+- `0`, `$`, and `x` work in `NORMAL`
+
+## Usage Flows
+
+### Flow 1: Edit a prompt normally
+
+1. Start Pi in a real terminal session.
+2. Type in `INSERT` mode as usual.
+3. Press `Esc` to switch to `NORMAL`.
+4. Use `h`, `j`, `k`, `l` to move.
+5. Press `i` to return to insert mode.
+
+### Flow 2: Append instead of inserting
+
+1. Press `Esc`.
+2. Press `a`.
+3. The cursor moves right and returns to `INSERT`.
+
+### Flow 3: Abort agent work from normal mode
+
+When Pi is already running an agent action, `Esc` in `NORMAL` passes through to
+the app-level handling, so the usual abort behavior still works.
+
+## Notes And Limits
+
+- This only affects interactive Pi TUI sessions.
+- It does not matter in one-shot `pi -p` mode.
+- This is the stock upstream example, so it is intentionally more Vim-like than
+ Helix-like. If you want the Helix-