summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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-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.