diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-20 20:49:39 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-20 20:49:39 +0200 |
| commit | 097afe5a81849ea8a921286c887014e242fa3794 (patch) | |
| tree | 09b035931cbca6793b370c33a490f094d8315b37 | |
| parent | e66e46fcc27aee1246f40b76fedd87d2138e6d15 (diff) | |
Add Pi extensions and usage docs
| -rw-r--r-- | pi/agent/extensions/fresh-subagent/README.md | 99 | ||||
| -rw-r--r-- | pi/agent/extensions/handoff/README.md | 45 | ||||
| -rw-r--r-- | pi/agent/extensions/handoff/index.ts | 130 | ||||
| -rw-r--r-- | pi/agent/extensions/inline-bash/README.md | 44 | ||||
| -rw-r--r-- | pi/agent/extensions/inline-bash/index.ts | 72 | ||||
| -rw-r--r-- | pi/agent/extensions/loop-scheduler/README.md | 124 | ||||
| -rw-r--r-- | pi/agent/extensions/loop-scheduler/index.ts | 380 | ||||
| -rw-r--r-- | pi/agent/extensions/modal-editor/README.md | 45 | ||||
| -rw-r--r-- | pi/agent/extensions/modal-editor/index.ts | 68 | ||||
| -rw-r--r-- | pi/agent/extensions/reload-runtime/README.md | 46 | ||||
| -rw-r--r-- | pi/agent/extensions/reload-runtime/index.ts | 26 | ||||
| -rw-r--r-- | pi/agent/extensions/session-name/README.md | 41 | ||||
| -rw-r--r-- | pi/agent/extensions/session-name/index.ts | 18 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/README.md | 169 |
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- |
