diff options
Diffstat (limited to 'pi/agent/extensions')
| -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-shaped editor you described earlier, this + should be treated as the baseline install, not the final customization. diff --git a/pi/agent/extensions/modal-editor/index.ts b/pi/agent/extensions/modal-editor/index.ts new file mode 100644 index 0000000..e99b69a --- /dev/null +++ b/pi/agent/extensions/modal-editor/index.ts @@ -0,0 +1,68 @@ +import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +const NORMAL_KEYS: Record<string, string | null> = { + h: "\x1b[D", + j: "\x1b[B", + k: "\x1b[A", + l: "\x1b[C", + "0": "\x01", + $: "\x05", + x: "\x1b[3~", + i: null, + a: null, +}; + +class ModalEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + if (matchesKey(data, "escape")) { + if (this.mode === "insert") { + this.mode = "normal"; + } else { + super.handleInput(data); + } + return; + } + + if (this.mode === "insert") { + super.handleInput(data); + return; + } + + if (data in NORMAL_KEYS) { + const seq = NORMAL_KEYS[data]; + if (data === "i") { + this.mode = "insert"; + } else if (data === "a") { + this.mode = "insert"; + super.handleInput("\x1b[C"); + } else if (seq) { + super.handleInput(seq); + } + return; + } + + if (data.length === 1 && data.charCodeAt(0) >= 32) return; + super.handleInput(data); + } + + render(width: number): string[] { + const lines = super.render(width); + if (lines.length === 0) return lines; + + const label = this.mode === "normal" ? " NORMAL " : " INSERT "; + const last = lines.length - 1; + if (visibleWidth(lines[last]!) >= label.length) { + lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label; + } + return lines; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb)); + }); +} diff --git a/pi/agent/extensions/reload-runtime/README.md b/pi/agent/extensions/reload-runtime/README.md new file mode 100644 index 0000000..1c209d5 --- /dev/null +++ b/pi/agent/extensions/reload-runtime/README.md @@ -0,0 +1,46 @@ +# Reload Runtime + +Runtime reload support for Pi. + +This is the upstream `reload-runtime.ts` example installed as a local extension +in your dotfiles-backed Pi tree. Pi does not ship a `reload-runtime.sh` +extension in the bundled examples; the actual upstream example is TypeScript and +registers both a slash command and a tool. + +## What It Does + +- adds `/reload-runtime` +- adds the `reload_runtime` tool +- reloads extensions, skills, prompts, and themes in the current Pi session + +## Usage Flows + +### Flow 1: Reload after editing dotfiles + +1. Edit an extension, skill, prompt, or theme on disk. +2. In the same Pi session, run: + +```text +/reload-runtime +``` + +3. Pi reloads the runtime without restarting the process. + +### Flow 2: Let the agent request a reload + +Because this also exposes a tool, the agent can ask to reload the runtime when +that makes sense: + +```text +Use the reload_runtime tool after updating the extension files so the new command set is active. +``` + +The tool queues `/reload-runtime` as a follow-up command. + +## Notes And Limits + +- Reload affects the current Pi session only. +- It is most useful in an interactive session that stays open while you are + editing your Pi configuration. +- In one-shot `pi -p` usage, reload is usually not very interesting because the + process exits immediately after handling the prompt. diff --git a/pi/agent/extensions/reload-runtime/index.ts b/pi/agent/extensions/reload-runtime/index.ts new file mode 100644 index 0000000..ebe6b7f --- /dev/null +++ b/pi/agent/extensions/reload-runtime/index.ts @@ -0,0 +1,26 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; + }, + }); + + pi.registerTool({ + name: "reload_runtime", + label: "Reload Runtime", + description: "Reload extensions, skills, prompts, and themes", + parameters: Type.Object({}), + async execute() { + pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); + return { + content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }], + details: {}, + }; + }, + }); +} diff --git a/pi/agent/extensions/session-name/README.md b/pi/agent/extensions/session-name/README.md new file mode 100644 index 0000000..df2dc75 --- /dev/null +++ b/pi/agent/extensions/session-name/README.md @@ -0,0 +1,41 @@ +# Session Name + +Friendly session naming for Pi. + +This is the upstream `session-name.ts` example installed as a local extension in +your dotfiles-backed Pi tree. It lets you label a session with something more +useful than the first prompt line. + +## What It Does + +- adds `/session-name [name]` +- shows the current session name when called without arguments +- sets a custom session name when called with text + +## Usage Flows + +### Flow 1: Name the current session + +```text +/session-name hyperstack vm bootstrap review +``` + +### Flow 2: Check the current name + +```text +/session-name +``` + +### Flow 3: Keep many active sessions readable + +Use this when you are juggling separate Pi sessions for: + +- implementation +- review +- planning +- VM1 versus VM2 work + +## Notes + +- This is most useful in interactive sessions where you use the session picker. +- It changes the visible session label, not the underlying worktree or model. diff --git a/pi/agent/extensions/session-name/index.ts b/pi/agent/extensions/session-name/index.ts new file mode 100644 index 0000000..330a1ee --- /dev/null +++ b/pi/agent/extensions/session-name/index.ts @@ -0,0 +1,18 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("session-name", { + description: "Set or show session name (usage: /session-name [new name])", + handler: async (args, ctx) => { + const name = args.trim(); + + if (name) { + pi.setSessionName(name); + ctx.ui.notify(`Session named: ${name}`, "info"); + } else { + const current = pi.getSessionName(); + ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info"); + } + }, + }); +} diff --git a/pi/agent/extensions/taskwarrior-plan-mode/README.md b/pi/agent/extensions/taskwarrior-plan-mode/README.md index 2613b12..92de7e6 100644 --- a/pi/agent/extensions/taskwarrior-plan-mode/README.md +++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md @@ -1,82 +1,157 @@ # Taskwarrior Plan Mode -Custom Pi plan mode built on the official `plan-mode` example, but using -Taskwarrior as the actual task source of truth through the -`taskwarrior-task-management` workflow. - -## What it changes - -- `/plan` enters read-only planning mode -- `/plan-exit` leaves planning mode and restores normal tools -- blocks raw `task` and requires `ask ...` -- injects current project Taskwarrior context into planning turns -- extracts `Plan:` sections into actionable steps -- `/plan-create-tasks [sequential|independent]` turns the last extracted plan - into real Taskwarrior tasks -- `/task-sync [sequential|independent]` remains as a legacy alias -- `/task-update <selector> :: <new description>` replaces a task description -- `/task-modify <selector> :: <mods>` runs raw `ask ... modify ...` arguments -- `/task-next [run]` focuses the started task, or starts the next `+READY` task -- `/tasks` shows the current started and READY tasks for the repo -- `/work-on-tasks [strategy] [max]` kicks off the project task loop using the - Taskwarrior skill semantics - -## Task semantics - -This extension is aligned to the `taskwarrior-task-management` skill: - -- `ask ...` only, never raw `task` -- project-scoped by current git repo -- continue started task first +Taskwarrior-backed planning for Pi. + +This extension keeps planning and execution separate: + +- use `/plan` to enter read-only planning mode +- ask Pi to produce a numbered `Plan:` +- convert the extracted plan into Taskwarrior tasks explicitly +- leave planning mode and continue execution against real tasks + +Taskwarrior remains the source of truth. This extension does not keep a private +todo list. + +## Commands + +- `/plan` + Enter read-only planning mode. The active tool set is reduced to safe + exploration tools. +- `/plan-exit` + Leave planning mode and restore the previous tool set. +- `/plan-create-tasks [sequential|independent]` + Create Taskwarrior tasks from the last extracted `Plan:`. +- `/task-sync [sequential|independent]` + Legacy alias for `/plan-create-tasks`. +- `/task-update <selector> :: <new description>` + Replace a task description. +- `/task-modify <selector> :: <mods>` + Apply raw `ask ... modify ...` arguments to a task. +- `/tasks` + Show started and `+READY` tasks for the current repo. +- `/task-next [run]` + Focus the started task, or start the next `+READY` task. +- `/work-on-tasks [strategy] [max]` + Kick off the Taskwarrior execution loop aligned to the + `taskwarrior-task-management` workflow. + +## Rules + +- all Taskwarrior operations go through `ask`, never raw `task` +- tasks are scoped to the current git repo through your `ask` wrapper - use UUIDs for stable references -- do not mark a task done until implementation, tests, and commit are complete -- self-review first, then run an independent fresh-context subagent review if - the `subagent` tool is available +- planning mode is read-only by design +- the extracted plan is session-local, so `/plan`, the planning prompt, + `/plan-create-tasks`, and `/plan-exit` should happen in the same interactive + or continued Pi session -## Core workflow +## Usage Flows -1. Run `/plan` -2. Ask Pi to analyze the repo and produce a numbered `Plan:` -3. After the plan is extracted, run `/plan-create-tasks sequential` -4. If needed, adjust tasks with `/task-update` or `/task-modify` -5. Run `/plan-exit` +### Flow 1: Turn a plan into Taskwarrior tasks -Planning mode is intentionally read-only. The extension no longer auto-prompts -you to create tasks after planning; task creation is explicit. +1. Start Pi in the project. +2. Run: -The extracted plan is session-local. Use `/plan`, your planning prompt, -`/plan-create-tasks`, and `/plan-exit` within the same interactive or continued -Pi session. +```text +/plan +``` + +3. Ask for analysis and a numbered `Plan:`. Example: -## Examples +```text +Analyze the current repo and propose a concise Plan: for fixing the SSH bootstrap trust model. +``` -Create tasks from the last plan: +4. After Pi replies with a `Plan:`, create tasks: ```text /plan-create-tasks sequential ``` +5. Leave planning mode: + +```text +/plan-exit +``` + +Use `sequential` when each step should depend on the previous one. Use +`independent` when the planned tasks can be worked separately. + +### Flow 2: Adjust a task after planning + Rewrite a task description: ```text /task-update uuid:12345678-1234-1234-1234-123456789abc :: Restore SSH host verification during bootstrap ``` -Apply raw Taskwarrior modify arguments: +Apply standard modify arguments: ```text /task-modify uuid:12345678-1234-1234-1234-123456789abc :: priority:H +security ``` -In-place description replacement with Taskwarrior syntax: +Use Taskwarrior replacement syntax: ```text /task-modify uuid:12345678-1234-1234-1234-123456789abc :: /bootstrap/provisioning/ ``` -## Notes +### Flow 3: Start executing the real tasks + +See what is active: + +```text +/tasks +``` + +Focus the current task: + +```text +/task-next +``` + +Focus and immediately start execution: + +```text +/task-next run +``` + +Run the full repo task loop: + +```text +/work-on-tasks highest-impact +``` + +### Flow 4: Planning session pattern + +This is the cleanest end-to-end interactive pattern: + +```text +/plan +``` + +```text +Analyze the repo and give me a Plan: for the next implementation slice. +``` + +```text +/plan-create-tasks sequential +``` + +```text +/plan-exit +``` + +```text +/work-on-tasks +``` + +## Notes And Limits - Planning mode is read-only by design. - All Taskwarrior operations still go through `ask`, never raw `task`. - Execution mode injects the current Taskwarrior task back into the agent prompt so the model works against the real task rather than an in-memory checklist. +- Full `/plan` state is not meant to be passed across unrelated one-shot `pi -p` + invocations. Use a real interactive or continued session for planning. |
