summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-20 22:52:50 +0200
committerPaul Buetow <paul@buetow.org>2026-03-20 22:52:50 +0200
commit129dcd81dd1a929b03ba88ad8bc2b852fefb39eb (patch)
tree59ca9a0ac0bcf940900ea8dfd6e9aa0c1c6ac1bc
parent097afe5a81849ea8a921286c887014e242fa3794 (diff)
update
-rw-r--r--fish/conf.d/ai.fish3
-rw-r--r--pi/agent/extensions/fresh-subagent/README.md166
-rw-r--r--pi/agent/extensions/fresh-subagent/index.ts1005
-rw-r--r--pi/agent/extensions/modal-editor/README.md91
-rw-r--r--pi/agent/extensions/modal-editor/index.ts486
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/README.md6
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/index.ts97
-rw-r--r--pi/agent/settings.json2
-rw-r--r--prompts/skills/taskwarrior-task-management/SKILL.md17
-rw-r--r--prompts/skills/taskwarrior-task-management/references/00-context.md1
-rw-r--r--prompts/skills/taskwarrior-task-management/references/1-create-task.md8
11 files changed, 1720 insertions, 162 deletions
diff --git a/fish/conf.d/ai.fish b/fish/conf.d/ai.fish
index 49b6211..51ca295 100644
--- a/fish/conf.d/ai.fish
+++ b/fish/conf.d/ai.fish
@@ -6,3 +6,6 @@ end
# Claude Code via vLLM + LiteLLM proxy on Hyperstack VM (requires wg1 tunnel active)
abbr -a hyperstack-claude 'ANTHROPIC_BASE_URL=http://hyperstack.wg1:4000 ANTHROPIC_API_KEY=sk-litellm-master claude --model claude-opus-4-6-20260604 --dangerously-skip-permissions'
+
+abbr pi-hyperstack-nemotron pi --model hyperstack1/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit
+abbr pi-hyperstack-coder pi --model hyperstack2/bullpoint/Qwen3-Coder-Next-AWQ-4bit
diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md
index 8758bc9..701fdda 100644
--- a/pi/agent/extensions/fresh-subagent/README.md
+++ b/pi/agent/extensions/fresh-subagent/README.md
@@ -1,17 +1,20 @@
# Fresh Subagent
-Generic fresh-context delegation for Pi.
+Generic fresh-context delegation for Pi with live status, per-run log files, and
+history browsing.
This extension gives Pi a simple subagent primitive:
- 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
+- 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
+- each run gets its own log file plus JSON sidecar metadata
+- you can list past runs and open any run's full transcript in `$VISUAL` or `$EDITOR`
-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.
+This is still intentionally small. It does not manage agent pools, agent
+catalogs, or planner chains. It is meant for focused delegation with a clean
+context and auditable output.
## What It Is For
@@ -34,31 +37,126 @@ 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
-## Usage Flows
+## Usage Flow
-### Flow 1: Use it directly inside Pi
+### Step 1: Run a subagent
-Run a direct delegation:
+Direct delegation:
```text
/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches.
```
-Run a focused investigation:
+Focused investigation:
```text
/subagent Find all code paths that write to the SSH known_hosts file and summarize the risk.
```
-Run a review:
+Independent review:
```text
/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings.
```
-### Flow 2: Use it from the main agent
+The watched slash command is the normal interactive path. It updates status in
+the footer, keeps a widget with recent activity, and writes the full run to a
+durable log file.
-Because this is registered as a tool, the main agent can call it itself.
+### Step 2: Inspect history
+
+List recent runs:
+
+```text
+/subagent-history
+```
+
+List more:
+
+```text
+/subagent-history 20
+```
+
+Each entry includes:
+
+- run ID
+- status
+- started timestamp
+- prompt summary
+- log path
+- output preview when available
+
+You can select later runs either by:
+
+- `latest`
+- numeric index from `/subagent-history`
+- run ID prefix
+
+### Step 3: Inspect a specific run
+
+Show the paths and metadata for the latest run:
+
+```text
+/subagent-log
+```
+
+Show the paths and metadata for a specific run:
+
+```text
+/subagent-log 3
+/subagent-log 20260320T194522-review-ssh
+```
+
+This prints:
+
+- run ID
+- status
+- prompt
+- log path
+- metadata path
+- `tail -f` command
+
+### Step 4: Open the full transcript in Helix or another editor
+
+Open the latest run in `$VISUAL` or `$EDITOR`:
+
+```text
+/subagent-open
+```
+
+Open a specific run:
+
+```text
+/subagent-open 2
+/subagent-open 20260320T194522-review-ssh
+```
+
+In TUI mode the extension temporarily releases the terminal, launches your
+configured editor, then restores Pi when you exit the editor.
+
+In one-shot or print mode it runs the editor command directly.
+
+## Other Commands
+
+Alias with the same watched behavior:
+
+```text
+/subagent-watch <prompt>
+```
+
+Launch a visible fresh Pi session instead of a headless child:
+
+```text
+/subagent-session <prompt>
+```
+
+This is useful when you want to watch the subagent itself, not just the logged
+transcript.
+
+## Tool Usage From The Main Agent
+
+Because this extension registers a `subagent` tool, the main agent can call it
+itself.
Generic handoff pattern:
@@ -78,23 +176,21 @@ Research handoff pattern:
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
+## 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'
+pi --no-session -p '/subagent-history'
+pi --no-session -p '/subagent-log latest'
```
-### Flow 4: Use it in the Taskwarrior review loop
-
-The intended task workflow is:
+If you want to open a run from a shell:
-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
+```bash
+pi --no-session -p '/subagent-open latest'
+```
## What To Put In The Prompt
@@ -117,12 +213,34 @@ Weak:
/subagent Review this
```
+## Log Storage
+
+Fresh-subagent history lives under:
+
+```text
+${XDG_STATE_HOME:-~/.local/state}/pi/subagents
+```
+
+Each run creates:
+
+- one `*.log` transcript file
+- one `*.json` metadata file
+- a rolling `latest.log` symlink pointing at the newest run
+
+That means you can also inspect logs outside Pi with tools like:
+
+```bash
+tail -f ~/.local/state/pi/subagents/latest.log
+ls ~/.local/state/pi/subagents
+```
+
## Notes And Limits
-- The subagent uses a fresh session via `--no-session`.
+- The headless 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.
+- `subagent-session` is visible because it uses a real Pi session instead of a
+ headless child. Its transcript is the session itself, not one of the
+ `fresh-subagent` log files.
diff --git a/pi/agent/extensions/fresh-subagent/index.ts b/pi/agent/extensions/fresh-subagent/index.ts
index 52fed1b..366f94a 100644
--- a/pi/agent/extensions/fresh-subagent/index.ts
+++ b/pi/agent/extensions/fresh-subagent/index.ts
@@ -1,11 +1,25 @@
-import { spawn } from "node:child_process";
+import { spawn, spawnSync } from "node:child_process";
+import { createWriteStream } from "node:fs";
+import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import path from "node:path";
import type { AgentToolResult, AgentToolResultContent } from "@mariozechner/pi-agent-core";
import type { Message, TextContent } from "@mariozechner/pi-ai";
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
const CHILD_ENV_FLAG = "PI_FRESH_SUBAGENT_CHILD";
+const LOG_BASENAME = "latest.log";
+const HISTORY_SUFFIX = ".json";
+const DEFAULT_HISTORY_LIMIT = 10;
+const MAX_HISTORY_LIMIT = 50;
+const MAX_RECENT_ACTIVITY = 12;
+const MAX_WIDGET_LINES = 10;
+const MAX_RENDER_PREVIEW_LINES = 8;
+const MAX_ACTIVITY_LINE_LENGTH = 160;
+const MAX_UPDATE_INTERVAL_MS = 150;
+const HISTORY_PERSIST_INTERVAL_MS = 1000;
interface UsageStats {
input: number;
@@ -17,6 +31,7 @@ interface UsageStats {
}
interface FreshSubagentResult {
+ runId: string;
prompt: string;
model?: string;
cwd: string;
@@ -26,8 +41,56 @@ interface FreshSubagentResult {
stderr: string;
output: string;
usage: UsageStats;
+ logPath: string;
+ metadataPath: string;
+ latestLogPath: string;
+ eventCount: number;
+ lastStatus: string;
+ currentTool?: string;
+ recentActivity: string[];
}
+interface SubagentHistoryEntry {
+ runId: string;
+ prompt: string;
+ promptSummary: string;
+ model?: string;
+ cwd: string;
+ startedAt: string;
+ finishedAt?: string;
+ active: boolean;
+ exitCode?: number;
+ stopReason?: string;
+ errorMessage?: string;
+ logPath: string;
+ metadataPath: string;
+ eventCount: number;
+ lastStatus: string;
+ currentTool?: string;
+ outputPreview?: string;
+}
+
+interface SubagentLog {
+ runId: string;
+ logPath: string;
+ metadataPath: string;
+ latestLogPath: string;
+ write(line: string): void;
+ close(): Promise<void>;
+}
+
+interface RunFreshSubagentOptions {
+ cwd: string;
+ model?: string;
+ tools?: string[];
+ signal?: AbortSignal;
+ onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void;
+ onState?: (details: FreshSubagentResult) => void;
+}
+
+let latestLogPathHint: string | undefined;
+let activeLogPathHint: string | undefined;
+
function getProviderScopedModel(ctx: ExtensionContext): string | undefined {
if (!ctx.model) return undefined;
return `${ctx.model.provider}/${ctx.model.id}`;
@@ -47,28 +110,425 @@ function getLastAssistantText(messages: Message[]): string {
return "";
}
-async function runFreshSubagent(
- prompt: string,
- options: {
- cwd: string;
- model?: string;
- tools?: string[];
- signal?: AbortSignal;
- onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void;
- },
-): Promise<FreshSubagentResult> {
+function getSubagentLogDir(): string {
+ const stateHome = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
+ return path.join(stateHome, "pi", "subagents");
+}
+
+function sanitizePromptForFile(prompt: string): string {
+ const slug = prompt
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 40);
+ return slug || "subagent";
+}
+
+function makeRunId(prompt: string): string {
+ const suffix = Math.random().toString(36).slice(2, 8);
+ return `${timestampForFile()}-${sanitizePromptForFile(prompt)}-${suffix}`;
+}
+
+function timestampForFile(date = new Date()): string {
+ const pad = (value: number) => String(value).padStart(2, "0");
+ return [
+ date.getFullYear(),
+ pad(date.getMonth() + 1),
+ pad(date.getDate()),
+ "T",
+ pad(date.getHours()),
+ pad(date.getMinutes()),
+ pad(date.getSeconds()),
+ ].join("");
+}
+
+async function writeHistoryEntry(entry: SubagentHistoryEntry): Promise<void> {
+ await writeFile(entry.metadataPath, `${JSON.stringify(entry, null, 2)}\n`, "utf8");
+}
+
+async function readHistoryEntries(): Promise<SubagentHistoryEntry[]> {
+ const dir = getSubagentLogDir();
+ await mkdir(dir, { recursive: true });
+
+ const files = await readdir(dir, { withFileTypes: true });
+ const entries: SubagentHistoryEntry[] = [];
+
+ for (const file of files) {
+ if (!file.isFile() || !file.name.endsWith(HISTORY_SUFFIX)) continue;
+
+ const metadataPath = path.join(dir, file.name);
+ try {
+ const raw = await readFile(metadataPath, "utf8");
+ const parsed = JSON.parse(raw) as Partial<SubagentHistoryEntry>;
+ if (!parsed.runId || !parsed.prompt || !parsed.logPath || !parsed.startedAt) continue;
+
+ entries.push({
+ runId: parsed.runId,
+ prompt: parsed.prompt,
+ promptSummary: parsed.promptSummary || summarizePrompt(parsed.prompt, 120),
+ model: parsed.model,
+ cwd: parsed.cwd || "",
+ startedAt: parsed.startedAt,
+ finishedAt: parsed.finishedAt,
+ active: Boolean(parsed.active),
+ exitCode: parsed.exitCode,
+ stopReason: parsed.stopReason,
+ errorMessage: parsed.errorMessage,
+ logPath: parsed.logPath,
+ metadataPath,
+ eventCount: parsed.eventCount || 0,
+ lastStatus: parsed.lastStatus || "unknown",
+ currentTool: parsed.currentTool,
+ outputPreview: parsed.outputPreview,
+ });
+ } catch {
+ // Ignore malformed files so one bad history entry does not break browsing.
+ }
+ }
+
+ return entries.sort((a, b) => {
+ const aTime = Date.parse(a.startedAt) || 0;
+ const bTime = Date.parse(b.startedAt) || 0;
+ return bTime - aTime;
+ });
+}
+
+function getHistoryStatus(entry: SubagentHistoryEntry): string {
+ if (entry.active) {
+ return entry.currentTool ? `active:${entry.currentTool}` : `active:${entry.lastStatus}`;
+ }
+
+ if (entry.stopReason === "aborted") return "aborted";
+ if (entry.exitCode === 0 && entry.stopReason !== "error") return "done";
+ return "error";
+}
+
+function normalizeHistorySelector(selector: string): string {
+ return selector.trim();
+}
+
+async function resolveHistoryEntry(selector: string): Promise<{
+ entry?: SubagentHistoryEntry;
+ error?: string;
+}> {
+ const entries = await readHistoryEntries();
+ if (entries.length === 0) {
+ return { error: "No subagent history is available yet." };
+ }
+
+ const normalized = normalizeHistorySelector(selector || "latest");
+ if (!normalized || normalized === "latest") {
+ return { entry: entries[0] };
+ }
+
+ if (/^\d+$/.test(normalized)) {
+ const index = Number(normalized);
+ if (index >= 1 && index <= entries.length) return { entry: entries[index - 1] };
+ return { error: `History index ${normalized} is out of range.` };
+ }
+
+ const exact = entries.find((entry) => entry.runId === normalized);
+ if (exact) return { entry: exact };
+
+ const matches = entries.filter((entry) => entry.runId.startsWith(normalized));
+ if (matches.length === 1) return { entry: matches[0] };
+ if (matches.length > 1) {
+ return {
+ error: `Selector '${normalized}' is ambiguous:\n${matches
+ .slice(0, 8)
+ .map((entry) => `- ${entry.runId}`)
+ .join("\n")}`,
+ };
+ }
+
+ return { error: `No subagent history entry matched '${normalized}'.` };
+}
+
+async function createSubagentLog(prompt: string): Promise<SubagentLog> {
+ const dir = getSubagentLogDir();
+ await mkdir(dir, { recursive: true });
+
+ const runId = makeRunId(prompt);
+ const logPath = path.join(dir, `${runId}.log`);
+ const metadataPath = path.join(dir, `${runId}${HISTORY_SUFFIX}`);
+ const latestLogPath = path.join(dir, LOG_BASENAME);
+ const stream = createWriteStream(logPath, { flags: "a" });
+
+ try {
+ await rm(latestLogPath, { force: true });
+ await symlink(path.basename(logPath), latestLogPath);
+ } catch {
+ // Best-effort only. The per-run log path still works even if the symlink fails.
+ }
+
+ const write = (line: string) => {
+ const timestamp = new Date().toISOString();
+ stream.write(`${timestamp} ${line}\n`);
+ };
+
+ return {
+ runId,
+ logPath,
+ metadataPath,
+ latestLogPath,
+ write,
+ close: () =>
+ new Promise<void>((resolve) => {
+ stream.end(resolve);
+ }),
+ };
+}
+
+function truncate(text: string, max: number): string {
+ if (text.length <= max) return text;
+ return `${text.slice(0, Math.max(0, max - 3))}...`;
+}
+
+function summarizePrompt(prompt: string, max = 80): string {
+ return truncate(prompt.replace(/\s+/g, " ").trim(), max);
+}
+
+function summarizeValue(value: unknown, max = 120): string {
+ if (value === undefined) return "";
+ if (typeof value === "string") return truncate(value.replace(/\s+/g, " ").trim(), max);
+
+ try {
+ const json = JSON.stringify(value);
+ return truncate(json, max);
+ } catch {
+ return truncate(String(value), max);
+ }
+}
+
+function splitActivityLines(text: string): string[] {
+ const collapsed = text.replace(/\r/g, "");
+ const rawLines = collapsed.split("\n");
+ const output: string[] = [];
+
+ for (const raw of rawLines) {
+ const line = raw.trimEnd();
+ if (!line) continue;
+ if (line.length <= MAX_ACTIVITY_LINE_LENGTH) {
+ output.push(line);
+ continue;
+ }
+
+ let remaining = line;
+ while (remaining.length > MAX_ACTIVITY_LINE_LENGTH) {
+ output.push(`${remaining.slice(0, MAX_ACTIVITY_LINE_LENGTH - 1)}…`);
+ remaining = remaining.slice(MAX_ACTIVITY_LINE_LENGTH - 1);
+ }
+ if (remaining) output.push(remaining);
+ }
+
+ return output;
+}
+
+function extractContentText(content: unknown): string {
+ if (!Array.isArray(content)) return "";
+
+ return content
+ .map((item) => {
+ if (!item || typeof item !== "object") return "";
+ const typedItem = item as { type?: string; text?: string };
+ return typedItem.type === "text" && typeof typedItem.text === "string" ? typedItem.text : "";
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+function cloneResult(result: FreshSubagentResult): FreshSubagentResult {
+ return {
+ ...result,
+ recentActivity: [...result.recentActivity],
+ usage: { ...result.usage },
+ };
+}
+
+function renderRunningSummary(details: FreshSubagentResult): string {
+ const lines = [
+ `subagent: ${details.lastStatus || "running"}`,
+ `run: ${details.runId}`,
+ `log: ${details.logPath}`,
+ ];
+
+ if (details.currentTool) lines.push(`tool: ${details.currentTool}`);
+
+ if (details.recentActivity.length > 0) {
+ lines.push("", ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES));
+ }
+
+ return lines.join("\n");
+}
+
+function buildWidgetLines(details: FreshSubagentResult): string[] {
+ const lines = [
+ `subagent: ${details.lastStatus || "running"}`,
+ `run: ${details.runId}`,
+ `log: ${details.latestLogPath}`,
+ ];
+
+ if (details.currentTool) lines.push(`tool: ${details.currentTool}`);
+
+ if (details.recentActivity.length > 0) {
+ lines.push(...details.recentActivity.slice(-MAX_WIDGET_LINES));
+ }
+
+ return lines;
+}
+
+function renderSubagentSummary(details: FreshSubagentResult, expanded: boolean, theme: any): string {
+ const status =
+ details.exitCode === 0 && details.stopReason !== "error" && details.stopReason !== "aborted"
+ ? theme.fg("success", "✓")
+ : theme.fg("error", "✗");
+ const header = `${status} ${theme.fg("toolTitle", theme.bold("subagent"))}${
+ details.model ? theme.fg("muted", ` ${details.model}`) : ""
+ }`;
+
+ const lines = [
+ header,
+ theme.fg("muted", `run: ${details.runId}`),
+ theme.fg("muted", `cwd: ${details.cwd}`),
+ theme.fg("muted", `log: ${details.logPath}`),
+ theme.fg("muted", `meta: ${details.metadataPath}`),
+ theme.fg("muted", `latest: ${details.latestLogPath}`),
+ theme.fg("muted", `events: ${details.eventCount}`),
+ ];
+
+ if (details.currentTool) lines.push(theme.fg("muted", `current tool: ${details.currentTool}`));
+
+ if (expanded) {
+ lines.push("", theme.fg("muted", "Prompt:"), details.prompt);
+ lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)"));
+ if (details.recentActivity.length > 0) {
+ lines.push("", theme.fg("muted", "Recent Activity:"), ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES));
+ }
+ } else {
+ const preview = details.output ? details.output.split("\n").slice(0, 5).join("\n") : "(no output)";
+ lines.push("", preview);
+ }
+
+ if (details.errorMessage) lines.push("", theme.fg("error", `Error: ${details.errorMessage}`));
+ if (details.stderr.trim()) lines.push("", theme.fg("dim", details.stderr.trim()));
+ return lines.join("\n");
+}
+
+function formatHistoryEntries(entries: SubagentHistoryEntry[], limit: number): string {
+ if (entries.length === 0) return "No subagent history is available yet.";
+
+ return entries
+ .slice(0, limit)
+ .map((entry, index) => {
+ const lines = [
+ `${index + 1}. ${entry.runId} [${getHistoryStatus(entry)}]${entry.model ? ` ${entry.model}` : ""}`,
+ ` started: ${entry.startedAt}`,
+ ` prompt: ${entry.promptSummary}`,
+ ` log: ${entry.logPath}`,
+ ];
+ if (entry.outputPreview) lines.push(` output: ${entry.outputPreview}`);
+ return lines.join("\n");
+ })
+ .join("\n\n");
+}
+
+function formatHistoryDetails(entry: SubagentHistoryEntry): string {
+ const lines = [
+ `run: ${entry.runId}`,
+ `status: ${getHistoryStatus(entry)}`,
+ `started: ${entry.startedAt}`,
+ `finished: ${entry.finishedAt || "(still running)"}`,
+ `model: ${entry.model || "(session default)"}`,
+ `cwd: ${entry.cwd}`,
+ `prompt: ${entry.prompt}`,
+ `log: ${entry.logPath}`,
+ `metadata: ${entry.metadataPath}`,
+ `tail: tail -f ${entry.logPath}`,
+ ];
+
+ if (entry.outputPreview) lines.push(`output preview: ${entry.outputPreview}`);
+ if (entry.errorMessage) lines.push(`error: ${entry.errorMessage}`);
+ return lines.join("\n");
+}
+
+function quoteForShell(value: string): string {
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
+}
+
+function getEditorCommand(): string | undefined {
+ return process.env.VISUAL || process.env.EDITOR;
+}
+
+async function openInExternalEditor(filePath: string, ctx: ExtensionCommandContext): Promise<{
+ ok: boolean;
+ message: string;
+}> {
+ const editorCmd = getEditorCommand();
+ if (!editorCmd) {
+ return { ok: false, message: "No editor configured. Set $VISUAL or $EDITOR." };
+ }
+
+ const command = `exec ${editorCmd} ${quoteForShell(filePath)}`;
+
+ if (!ctx.hasUI) {
+ const result = spawnSync("bash", ["-lc", command], {
+ stdio: "inherit",
+ env: process.env,
+ });
+ return result.status === 0
+ ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` }
+ : { ok: false, message: `Editor exited with code ${result.status ?? 1}` };
+ }
+
+ await ctx.waitForIdle();
+ const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => {
+ tui.stop();
+ process.stdout.write("\x1b[2J\x1b[H");
+
+ const result = spawnSync("bash", ["-lc", command], {
+ stdio: "inherit",
+ env: process.env,
+ });
+
+ tui.start();
+ tui.requestRender(true);
+ done(result.status);
+
+ return {
+ render: () => [],
+ invalidate: () => {},
+ };
+ });
+
+ return exitCode === 0
+ ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` }
+ : { ok: false, message: `Editor exited with code ${exitCode ?? 1}` };
+}
+
+async function runFreshSubagent(prompt: string, options: RunFreshSubagentOptions): Promise<FreshSubagentResult> {
+ const log = await createSubagentLog(prompt);
+ latestLogPathHint = log.latestLogPath;
+ activeLogPathHint = log.logPath;
+
const args = ["--mode", "json", "-p", "--no-session"];
if (options.model) args.push("--model", options.model);
if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
args.push(prompt);
const result: FreshSubagentResult = {
+ runId: log.runId,
prompt,
model: options.model,
cwd: options.cwd,
exitCode: 0,
stderr: "",
output: "",
+ logPath: log.logPath,
+ metadataPath: log.metadataPath,
+ latestLogPath: log.latestLogPath,
+ eventCount: 0,
+ lastStatus: "starting",
+ recentActivity: [],
usage: {
input: 0,
output: 0,
@@ -79,17 +539,113 @@ async function runFreshSubagent(
},
};
+ const startedAt = new Date().toISOString();
+ let finishedAt: string | undefined;
+ let isFinished = false;
+ let lastHistoryPersistAt = 0;
+ let historyWriteChain: Promise<void> = Promise.resolve();
+
+ const buildHistoryEntry = (): SubagentHistoryEntry => ({
+ runId: result.runId,
+ prompt: result.prompt,
+ promptSummary: summarizePrompt(result.prompt, 120),
+ model: result.model,
+ cwd: result.cwd,
+ startedAt,
+ finishedAt,
+ active: !isFinished,
+ exitCode: result.exitCode,
+ stopReason: result.stopReason,
+ errorMessage: result.errorMessage,
+ logPath: result.logPath,
+ metadataPath: result.metadataPath,
+ eventCount: result.eventCount,
+ lastStatus: result.lastStatus,
+ currentTool: result.currentTool,
+ outputPreview: result.output || result.errorMessage ? summarizePrompt(result.output || result.errorMessage || "", 140) : undefined,
+ });
+
+ const persistHistory = async (force = false) => {
+ const now = Date.now();
+ if (!force && now - lastHistoryPersistAt < HISTORY_PERSIST_INTERVAL_MS) return;
+ lastHistoryPersistAt = now;
+ const entry = buildHistoryEntry();
+ historyWriteChain = historyWriteChain
+ .then(() => writeHistoryEntry(entry))
+ .catch(() => {
+ // Best-effort. Logging should not fail because the history sidecar write failed.
+ });
+ await historyWriteChain;
+ };
+
const messages: Message[] = [];
+ const toolOutputById = new Map<string, string>();
+ let assistantBuffer = "";
+ let lastEmitAt = 0;
+ let wasAborted = false;
+
+ log.write(`[start] run=${result.runId}`);
+ log.write(`[start] prompt=${summarizePrompt(prompt, 200)}`);
+ log.write(`[start] cwd=${options.cwd}`);
+ if (options.model) log.write(`[start] model=${options.model}`);
+ await persistHistory(true);
+
+ const pushActivity = (line: string) => {
+ const lines = splitActivityLines(line);
+ for (const entry of lines) {
+ result.recentActivity.push(entry);
+ if (result.recentActivity.length > MAX_RECENT_ACTIVITY) result.recentActivity.shift();
+ log.write(entry);
+ }
+ };
+
+ const flushAssistantBuffer = (force: boolean) => {
+ let emitted = false;
+
+ while (true) {
+ const newlineIndex = assistantBuffer.indexOf("\n");
+ if (newlineIndex >= 0) {
+ const line = assistantBuffer.slice(0, newlineIndex);
+ assistantBuffer = assistantBuffer.slice(newlineIndex + 1);
+ if (line.trim()) pushActivity(`assistant> ${line}`);
+ emitted = true;
+ continue;
+ }
+
+ if (force && assistantBuffer.trim()) {
+ pushActivity(`assistant> ${assistantBuffer}`);
+ assistantBuffer = "";
+ emitted = true;
+ continue;
+ }
+
+ if (!force && assistantBuffer.length > 240) {
+ pushActivity(`assistant> ${assistantBuffer.slice(0, 239)}…`);
+ assistantBuffer = assistantBuffer.slice(239);
+ emitted = true;
+ continue;
+ }
+
+ break;
+ }
+
+ return emitted;
+ };
- const emitUpdate = () => {
+ const emitUpdate = (force = false) => {
+ const now = Date.now();
+ if (!force && now - lastEmitAt < MAX_UPDATE_INTERVAL_MS) return;
+ lastEmitAt = now;
+ void persistHistory(force);
+
+ const snapshot = cloneResult(result);
options.onUpdate?.({
- content: [{ type: "text", text: result.output || "(running...)" }],
- details: { ...result },
+ content: [{ type: "text", text: renderRunningSummary(snapshot) }],
+ details: snapshot,
});
+ options.onState?.(snapshot);
};
- let wasAborted = false;
-
result.exitCode = await new Promise<number>((resolve) => {
const proc = spawn("pi", args, {
cwd: options.cwd,
@@ -110,31 +666,164 @@ async function runFreshSubagent(
try {
event = JSON.parse(line);
} catch {
+ log.write(`[raw] ${line}`);
return;
}
- if (event.type !== "message_end" || !event.message) return;
-
- const message = event.message as Message;
- messages.push(message);
- result.output = getLastAssistantText(messages);
-
- if (message.role === "assistant") {
- result.usage.turns++;
- const usage = message.usage;
- if (usage) {
- result.usage.input += usage.input || 0;
- result.usage.output += usage.output || 0;
- result.usage.cacheRead += usage.cacheRead || 0;
- result.usage.cacheWrite += usage.cacheWrite || 0;
- result.usage.cost += usage.cost?.total || 0;
+ result.eventCount++;
+
+ switch (event.type) {
+ case "agent_start":
+ result.lastStatus = "agent started";
+ pushActivity("[agent] started");
+ emitUpdate(true);
+ return;
+ case "agent_end":
+ flushAssistantBuffer(true);
+ result.lastStatus = "agent finished";
+ pushActivity("[agent] finished");
+ emitUpdate(true);
+ return;
+ case "turn_start":
+ result.lastStatus = "turn started";
+ emitUpdate();
+ return;
+ case "turn_end":
+ result.lastStatus = "turn finished";
+ emitUpdate();
+ return;
+ case "message_update": {
+ if (event.message?.role !== "assistant" || !event.assistantMessageEvent) return;
+ const assistantEvent = event.assistantMessageEvent;
+
+ switch (assistantEvent.type) {
+ case "text_delta":
+ if (typeof assistantEvent.delta === "string") {
+ result.lastStatus = "assistant streaming";
+ assistantBuffer += assistantEvent.delta;
+ flushAssistantBuffer(false);
+ emitUpdate();
+ }
+ return;
+ case "text_end":
+ result.lastStatus = "assistant text complete";
+ if (flushAssistantBuffer(true)) emitUpdate(true);
+ return;
+ case "thinking_start":
+ result.lastStatus = "assistant thinking";
+ emitUpdate();
+ return;
+ case "toolcall_start":
+ result.lastStatus = "assistant preparing tool call";
+ emitUpdate();
+ return;
+ case "toolcall_end": {
+ const toolCall = assistantEvent.toolCall || {};
+ const toolName = toolCall.toolName || toolCall.name || "tool";
+ const argsPreview = summarizeValue(toolCall.args || toolCall.input);
+ result.lastStatus = `assistant requested ${toolName}`;
+ pushActivity(argsPreview ? `[plan] ${toolName} ${argsPreview}` : `[plan] ${toolName}`);
+ emitUpdate(true);
+ return;
+ }
+ case "done":
+ result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant done";
+ emitUpdate(true);
+ return;
+ case "error":
+ result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant error";
+ emitUpdate(true);
+ return;
+ default:
+ return;
+ }
}
- if (!result.model && message.model) result.model = message.model;
- if (message.stopReason) result.stopReason = message.stopReason;
- if (message.errorMessage) result.errorMessage = message.errorMessage;
- }
+ case "message_end": {
+ const message = event.message as Message | undefined;
+ if (!message) return;
+
+ messages.push(message);
+ result.output = getLastAssistantText(messages);
+
+ if (message.role === "assistant") {
+ flushAssistantBuffer(true);
+ result.usage.turns++;
+ const usage = message.usage;
+ if (usage) {
+ result.usage.input += usage.input || 0;
+ result.usage.output += usage.output || 0;
+ result.usage.cacheRead += usage.cacheRead || 0;
+ result.usage.cacheWrite += usage.cacheWrite || 0;
+ result.usage.cost += usage.cost?.total || 0;
+ }
+ if (!result.model && message.model) result.model = message.model;
+ if (message.stopReason) result.stopReason = message.stopReason;
+ if (message.errorMessage) result.errorMessage = message.errorMessage;
+ }
+
+ emitUpdate(true);
+ return;
+ }
+ case "tool_execution_start": {
+ flushAssistantBuffer(true);
+ result.currentTool = event.toolName;
+ result.lastStatus = `tool ${event.toolName} running`;
+ const argsPreview = summarizeValue(event.args);
+ pushActivity(argsPreview ? `[tool:start] ${event.toolName} ${argsPreview}` : `[tool:start] ${event.toolName}`);
+ emitUpdate(true);
+ return;
+ }
+ case "tool_execution_update": {
+ flushAssistantBuffer(true);
+ result.currentTool = event.toolName;
+ result.lastStatus = `tool ${event.toolName} running`;
+
+ const partialText = extractContentText(event.partialResult?.content);
+ if (partialText) {
+ const previous = toolOutputById.get(event.toolCallId) || "";
+ const delta = partialText.startsWith(previous) ? partialText.slice(previous.length) : partialText;
+ toolOutputById.set(event.toolCallId, partialText);
- emitUpdate();
+ if (delta.trim()) {
+ for (const line of splitActivityLines(delta)) {
+ pushActivity(`[tool:${event.toolName}] ${line}`);
+ }
+ }
+ }
+
+ emitUpdate();
+ return;
+ }
+ case "tool_execution_end": {
+ flushAssistantBuffer(true);
+ const toolName = event.toolName || result.currentTool || "tool";
+ const finalText = extractContentText(event.result?.content);
+ const previous = toolOutputById.get(event.toolCallId) || "";
+ const delta = finalText.startsWith(previous) ? finalText.slice(previous.length) : finalText;
+ if (delta.trim()) {
+ for (const line of splitActivityLines(delta)) {
+ pushActivity(`[tool:${toolName}] ${line}`);
+ }
+ }
+
+ result.lastStatus = event.isError ? `tool ${toolName} failed` : `tool ${toolName} done`;
+ pushActivity(event.isError ? `[tool:end] ${toolName} error` : `[tool:end] ${toolName} done`);
+ result.currentTool = undefined;
+ emitUpdate(true);
+ return;
+ }
+ case "auto_retry_start":
+ result.lastStatus = `retry ${event.attempt}/${event.maxAttempts}`;
+ pushActivity(`[retry] ${event.attempt}/${event.maxAttempts} ${event.errorMessage || ""}`.trim());
+ emitUpdate(true);
+ return;
+ case "auto_retry_end":
+ result.lastStatus = event.success ? "retry recovered" : "retry failed";
+ emitUpdate(true);
+ return;
+ default:
+ return;
+ }
};
proc.stdout.on("data", (data) => {
@@ -145,7 +834,11 @@ async function runFreshSubagent(
});
proc.stderr.on("data", (data) => {
- result.stderr += data.toString();
+ const chunk = data.toString();
+ result.stderr += chunk;
+ for (const line of splitActivityLines(chunk)) {
+ log.write(`[stderr] ${line}`);
+ }
});
proc.on("close", (code) => {
@@ -153,7 +846,8 @@ async function runFreshSubagent(
resolve(code ?? 0);
});
- proc.on("error", () => {
+ proc.on("error", (err) => {
+ result.errorMessage = err.message;
resolve(1);
});
@@ -174,35 +868,89 @@ async function runFreshSubagent(
if (wasAborted) {
result.stopReason = "aborted";
result.errorMessage = "Fresh subagent was aborted";
+ result.lastStatus = "aborted";
+ pushActivity("[agent] aborted");
}
result.output ||= getLastAssistantText(messages);
+ log.write(`[finish] exit=${result.exitCode} stop=${result.stopReason || "unknown"}`);
+ if (result.errorMessage) log.write(`[finish] error=${result.errorMessage}`);
+
+ finishedAt = new Date().toISOString();
+ isFinished = true;
+ await persistHistory(true);
+ await log.close();
+ activeLogPathHint = undefined;
+ emitUpdate(true);
+ await historyWriteChain;
return result;
}
-function renderSubagentSummary(details: FreshSubagentResult, expanded: boolean, theme: any): string {
- const status =
- details.exitCode === 0 && details.stopReason !== "error" && details.stopReason !== "aborted"
- ? theme.fg("success", "✓")
- : theme.fg("error", "✗");
- const header = `${status} ${theme.fg("toolTitle", theme.bold("subagent"))}${
- details.model ? theme.fg("muted", ` ${details.model}`) : ""
- }`;
+function getLogInfoText(entry?: SubagentHistoryEntry): string {
+ if (entry) return formatHistoryDetails(entry);
- const lines = [header, theme.fg("muted", `cwd: ${details.cwd}`)];
- if (expanded) {
- lines.push("", theme.fg("muted", "Prompt:"), details.prompt);
- lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)"));
- } else {
- const preview = details.output ? details.output.split("\n").slice(0, 5).join("\n") : "(no output)";
- lines.push("", preview);
- }
-
- if (details.errorMessage) lines.push("", theme.fg("error", `Error: ${details.errorMessage}`));
- if (details.stderr.trim()) lines.push("", theme.fg("dim", details.stderr.trim()));
+ const latestPath = latestLogPathHint || path.join(getSubagentLogDir(), LOG_BASENAME);
+ const lines = [`latest log: ${latestPath}`, `tail -f ${latestPath}`];
+ if (activeLogPathHint) lines.push(`active run: ${activeLogPathHint}`);
return lines.join("\n");
}
+function createSlashCommandHandler(watch: boolean) {
+ return async (args: string, ctx: ExtensionContext, pi: ExtensionAPI) => {
+ const prompt = args.trim();
+ if (!prompt) {
+ ctx.ui.notify("Usage: /subagent <prompt>", "warning");
+ return;
+ }
+
+ const statusId = "fresh-subagent";
+ const widgetId = "fresh-subagent-watch";
+ const applyUiState = (details: FreshSubagentResult) => {
+ if (!ctx.hasUI || !watch) return;
+ const statusText = details.currentTool
+ ? `subagent: ${details.currentTool}`
+ : `subagent: ${details.lastStatus}`;
+ ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", statusText));
+ ctx.ui.setWidget(widgetId, buildWidgetLines(details), { placement: "belowEditor" });
+ };
+
+ if (ctx.hasUI) {
+ ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", "subagent: starting"));
+ if (watch) ctx.ui.setWidget(widgetId, ["subagent: starting"], { placement: "belowEditor" });
+ }
+
+ try {
+ const details = await runFreshSubagent(prompt, {
+ cwd: ctx.cwd,
+ model: getProviderScopedModel(ctx),
+ onState: applyUiState,
+ });
+
+ if (!ctx.hasUI) {
+ const text = details.output || details.errorMessage || details.stderr || "(no output)";
+ if (text) process.stdout.write(`${text}\n`);
+ process.stdout.write(`${getLogInfoText()}\n`);
+ return;
+ }
+
+ pi.sendMessage(
+ {
+ customType: "fresh-subagent-result",
+ content: details.output || "(no output)",
+ display: true,
+ details,
+ },
+ { triggerTurn: false },
+ );
+ } finally {
+ if (ctx.hasUI) {
+ ctx.ui.setStatus(statusId, undefined);
+ ctx.ui.setWidget(widgetId, undefined);
+ }
+ }
+ };
+}
+
export default function freshSubagentExtension(pi: ExtensionAPI): void {
if (process.env[CHILD_ENV_FLAG] === "1") return;
@@ -216,11 +964,12 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void {
pi.registerTool({
name: "subagent",
label: "Subagent",
- description: "Spawn a fresh-context subagent with a prompt and return its final answer.",
+ description: "Spawn a fresh-context subagent with a prompt and return its final answer. Each run is logged and added to subagent history.",
promptSnippet: "Delegate a self-contained task to a fresh-context subagent and get its result back",
promptGuidelines: [
"Use this tool for any self-contained side task that benefits from a clean context, such as review, research, summarization, or focused implementation checks.",
"Pass a complete prompt with enough context for the subagent to succeed independently, because it starts with a fresh session.",
+ "Each subagent run is logged to its own file. Tell the user about /subagent-history or /subagent-open if they want the full transcript later.",
],
parameters: params,
@@ -249,7 +998,7 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void {
},
renderCall(args, theme) {
- const preview = args.prompt.length > 80 ? `${args.prompt.slice(0, 80)}...` : args.prompt;
+ const preview = summarizePrompt(args.prompt);
return new Text(
`${theme.fg("toolTitle", theme.bold("subagent"))}\n ${theme.fg("dim", preview)}`,
0,
@@ -267,47 +1016,129 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void {
},
});
+ const watchedSubagentHandler = createSlashCommandHandler(true);
+
pi.registerCommand("subagent", {
- description: "Run a fresh-context subagent with a prompt",
+ description: "Run a fresh-context subagent with live status, widget updates, and durable run history",
+ handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi),
+ });
+
+ pi.registerCommand("subagent-watch", {
+ description: "Alias for /subagent with live watched output",
+ handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi),
+ });
+
+ pi.registerCommand("subagent-session", {
+ description: "Launch a visible fresh-context Pi session for a subagent task",
handler: async (args, ctx) => {
const prompt = args.trim();
if (!prompt) {
- ctx.ui.notify("Usage: /subagent <prompt>", "warning");
+ ctx.ui.notify("Usage: /subagent-session <prompt>", "warning");
return;
}
- ctx.ui.setStatus("fresh-subagent", ctx.ui.theme.fg("warning", "subagent: running"));
+ if (!ctx.hasUI) {
+ process.stdout.write("/subagent-session requires interactive mode.\n");
+ return;
+ }
- try {
- const details = await runFreshSubagent(prompt, {
- cwd: ctx.cwd,
- model: getProviderScopedModel(ctx),
- });
-
- if (!ctx.hasUI) {
- const text = details.output || details.errorMessage || details.stderr || "(no output)";
- if (text) {
- process.stdout.write(`${text}\n`);
- }
- return;
- }
+ await ctx.waitForIdle();
+ const currentSession = ctx.sessionManager.getSessionFile();
+ const result = await ctx.newSession({
+ parentSession: currentSession,
+ });
+
+ if (result.cancelled) {
+ ctx.ui.notify("Subagent session launch cancelled", "info");
+ return;
+ }
+
+ pi.setSessionName(`subagent: ${summarizePrompt(prompt, 40)}`);
+ pi.sendUserMessage(prompt);
+ },
+ });
+
+ pi.registerCommand("subagent-log", {
+ description: "Show the log path and metadata for the latest or selected subagent run",
+ handler: async (args, ctx) => {
+ const selector = args.trim();
+ const resolved = selector ? await resolveHistoryEntry(selector) : {};
+ const text = resolved.entry ? getLogInfoText(resolved.entry) : resolved.error || getLogInfoText();
- pi.sendMessage(
- {
- customType: "fresh-subagent-result",
- content: details.output || "(no output)",
- display: true,
- details,
- },
- { triggerTurn: false },
- );
- } finally {
- ctx.ui.setStatus("fresh-subagent", undefined);
+ if (!ctx.hasUI) {
+ process.stdout.write(`${text}\n`);
+ return;
}
+
+ pi.sendMessage(
+ {
+ customType: "fresh-subagent-log-info",
+ content: text,
+ display: true,
+ details: { text },
+ },
+ { triggerTurn: false },
+ );
+ },
+ });
+
+ pi.registerCommand("subagent-history", {
+ description: "List recent fresh-subagent runs so you can browse their full logs later",
+ handler: async (args, ctx) => {
+ const requested = Number(args.trim() || DEFAULT_HISTORY_LIMIT);
+ const limit = Number.isFinite(requested)
+ ? Math.max(1, Math.min(MAX_HISTORY_LIMIT, requested))
+ : DEFAULT_HISTORY_LIMIT;
+ const text = formatHistoryEntries(await readHistoryEntries(), limit);
+
+ if (!ctx.hasUI) {
+ process.stdout.write(`${text}\n`);
+ return;
+ }
+
+ pi.sendMessage(
+ {
+ customType: "fresh-subagent-history",
+ content: text,
+ display: true,
+ details: { text },
+ },
+ { triggerTurn: false },
+ );
+ },
+ });
+
+ pi.registerCommand("subagent-open", {
+ description: "Open a subagent log in $VISUAL/$EDITOR. Usage: /subagent-open [latest|index|run-id-prefix]",
+ handler: async (args, ctx) => {
+ const selector = args.trim() || "latest";
+ const resolved = await resolveHistoryEntry(selector);
+ if (!resolved.entry) {
+ const text = resolved.error || `No subagent history entry matched '${selector}'.`;
+ if (!ctx.hasUI) process.stdout.write(`${text}\n`);
+ else ctx.ui.notify(text, "warning");
+ return;
+ }
+
+ const opened = await openInExternalEditor(resolved.entry.logPath, ctx);
+ if (!ctx.hasUI) {
+ process.stdout.write(`${opened.message}\n`);
+ return;
+ }
+
+ ctx.ui.notify(opened.message, opened.ok ? "info" : "error");
},
});
pi.registerMessageRenderer("fresh-subagent-result", (message, { expanded }, theme) => {
return new Text(renderSubagentSummary(message.details as FreshSubagentResult, expanded, theme), 0, 0);
});
+
+ pi.registerMessageRenderer("fresh-subagent-log-info", (message) => {
+ return new Text(String(message.content || message.details?.text || getLogInfoText()), 0, 0);
+ });
+
+ pi.registerMessageRenderer("fresh-subagent-history", (message) => {
+ return new Text(String(message.content || message.details?.text || "No subagent history is available yet."), 0, 0);
+ });
}
diff --git a/pi/agent/extensions/modal-editor/README.md b/pi/agent/extensions/modal-editor/README.md
index 074bff1..bc5a86b 100644
--- a/pi/agent/extensions/modal-editor/README.md
+++ b/pi/agent/extensions/modal-editor/README.md
@@ -2,36 +2,89 @@
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.
+This is now a custom Helix-leaning modal editor for your dotfiles-backed Pi
+tree. It replaces the earlier upstream toy example with a more capable normal
+mode and a few prompt-editing operations that are actually useful in daily use.
## What It Does
-- starts in `INSERT` mode
-- `Esc` switches to `NORMAL`
-- `i` returns to `INSERT`
-- `a` appends and returns to `INSERT`
+- starts in `NORMAL` mode
+- `Esc` leaves `INSERT` mode and returns to `NORMAL`
- `h`, `j`, `k`, `l` move in `NORMAL`
-- `0`, `$`, and `x` work in `NORMAL`
+- `b`, `w`, `e` handle word motions in `NORMAL`
+- `gh` goes to line start and `gl` goes to line end
+- `gg` goes to the start of the prompt and `ge` goes to the end
+- `i`, `a`, `I`, `A`, `o`, `O` enter insert mode in useful places
+- `x` deletes the current character
+- `D` deletes from the cursor to line end
+- `dd`, `dw`, `de`, `db`, `d0`, and `d$` handle common deletes
+- `u` undoes the last change
## 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.
+2. You begin in `NORMAL`.
+3. Move with `h`, `j`, `k`, `l`.
+4. Jump by word with `b`, `w`, `e`.
+5. Press `i` to start inserting text.
### Flow 2: Append instead of inserting
-1. Press `Esc`.
-2. Press `a`.
-3. The cursor moves right and returns to `INSERT`.
+1. Press `a` to append after the cursor.
+2. Press `A` to append at line end.
+3. Press `I` to insert at line start.
-### Flow 3: Abort agent work from normal mode
+### Flow 3: Use Helix-style line motions
+
+Move to line start:
+
+```text
+gh
+```
+
+Move to line end:
+
+```text
+gl
+```
+
+You still also have `0` and `$` if you want the Vim-style single-key versions.
+
+### Flow 4: Delete text without dropping into insert mode
+
+Delete a character:
+
+```text
+x
+```
+
+Delete the current line:
+
+```text
+dd
+```
+
+Delete to the next word boundary:
+
+```text
+dw
+```
+
+Delete to word end:
+
+```text
+de
+```
+
+Delete from the cursor to the end of the current line:
+
+```text
+D
+```
+
+### Flow 5: 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.
@@ -40,6 +93,6 @@ the app-level handling, so the usual abort behavior still works.
- 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.
+- This is still constrained by Pi's underlying editor model. It is a
+ Helix-leaning prompt editor, not a full Helix clone with multiple selections,
+ text objects, or the entire command surface.
diff --git a/pi/agent/extensions/modal-editor/index.ts b/pi/agent/extensions/modal-editor/index.ts
index e99b69a..abb660a 100644
--- a/pi/agent/extensions/modal-editor/index.ts
+++ b/pi/agent/extensions/modal-editor/index.ts
@@ -1,26 +1,480 @@
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
-const NORMAL_KEYS: Record<string, string | null> = {
+type Mode = "normal" | "insert";
+type PendingAction = "g" | "d" | null;
+type CharKind = "whitespace" | "word" | "punct";
+
+type EditorStateAccess = {
+ state: {
+ lines: string[];
+ cursorLine: number;
+ cursorCol: number;
+ };
+ historyIndex: number;
+ lastAction: string | null;
+ preferredVisualCol: number | null;
+ setCursorCol(col: number): void;
+ pushUndoSnapshot(): void;
+ undo(): void;
+};
+
+type Position = {
+ line: number;
+ col: number;
+};
+
+const NORMAL_KEYS: Record<string, string> = {
h: "\x1b[D",
j: "\x1b[B",
k: "\x1b[A",
l: "\x1b[C",
- "0": "\x01",
- $: "\x05",
- x: "\x1b[3~",
- i: null,
- a: null,
};
+function isWordChar(char: string): boolean {
+ return /[0-9A-Za-z_]/.test(char);
+}
+
+function charKind(char: string | null): CharKind {
+ if (char === null || char === "\n" || /\s/.test(char)) return "whitespace";
+ return isWordChar(char) ? "word" : "punct";
+}
+
class ModalEditor extends CustomEditor {
- private mode: "normal" | "insert" = "insert";
+ private mode: Mode = "normal";
+ private pending: PendingAction = null;
+
+ private internals(): EditorStateAccess {
+ return this as unknown as EditorStateAccess;
+ }
+
+ private resetTransientState(): void {
+ const editor = this.internals();
+ editor.historyIndex = -1;
+ editor.lastAction = null;
+ editor.preferredVisualCol = null;
+ }
+
+ private setMode(mode: Mode): void {
+ this.mode = mode;
+ this.pending = null;
+ this.tui.requestRender();
+ }
+
+ private lines(): string[] {
+ return this.getLines();
+ }
+
+ private currentPosition(): Position {
+ const cursor = this.getCursor();
+ return { line: cursor.line, col: cursor.col };
+ }
+
+ private setPosition(position: Position): void {
+ const editor = this.internals();
+ editor.state.cursorLine = position.line;
+ editor.setCursorCol(position.col);
+ editor.preferredVisualCol = null;
+ this.tui.requestRender();
+ }
+
+ private withUndo(change: () => void): void {
+ const editor = this.internals();
+ editor.pushUndoSnapshot();
+ this.resetTransientState();
+ change();
+ this.onChange?.(this.getText());
+ this.tui.requestRender();
+ }
+
+ private moveLeft(position: Position): Position {
+ const lines = this.lines();
+ if (position.col > 0) return { line: position.line, col: position.col - 1 };
+ if (position.line > 0) {
+ return {
+ line: position.line - 1,
+ col: (lines[position.line - 1] || "").length,
+ };
+ }
+ return position;
+ }
+
+ private moveRight(position: Position): Position {
+ const lines = this.lines();
+ const line = lines[position.line] || "";
+ if (position.col < line.length) return { line: position.line, col: position.col + 1 };
+ if (position.line < lines.length - 1) return { line: position.line + 1, col: 0 };
+ return position;
+ }
+
+ private charAt(position: Position): string | null {
+ const lines = this.lines();
+ const line = lines[position.line] || "";
+ if (position.col < line.length) return line[position.col] || null;
+ if (position.line < lines.length - 1) return "\n";
+ return null;
+ }
+
+ private charBefore(position: Position): string | null {
+ const lines = this.lines();
+ if (position.col > 0) {
+ const line = lines[position.line] || "";
+ return line[position.col - 1] || null;
+ }
+ if (position.line > 0) return "\n";
+ return null;
+ }
+
+ private moveLineStart(): void {
+ const { line } = this.currentPosition();
+ this.setPosition({ line, col: 0 });
+ }
+
+ private moveLineEnd(): void {
+ const { line } = this.currentPosition();
+ this.setPosition({ line, col: (this.lines()[line] || "").length });
+ }
+
+ private moveFileStart(): void {
+ this.setPosition({ line: 0, col: 0 });
+ }
+
+ private moveFileEnd(): void {
+ const lines = this.lines();
+ const lastLine = Math.max(0, lines.length - 1);
+ this.setPosition({ line: lastLine, col: (lines[lastLine] || "").length });
+ }
+
+ private moveWordBackwardPosition(from: Position): Position {
+ let position = from;
+
+ while (true) {
+ const previous = this.charBefore(position);
+ if (previous === null || charKind(previous) !== "whitespace") break;
+ const next = this.moveLeft(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ }
+
+ while (true) {
+ const previous = this.charBefore(position);
+ if (previous === null) break;
+ const kind = charKind(previous);
+ if (kind === "whitespace") break;
+ const next = this.moveLeft(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ const beforeNext = this.charBefore(position);
+ if (beforeNext === null || charKind(beforeNext) !== kind) break;
+ }
+
+ return position;
+ }
+
+ private moveWordForwardPosition(from: Position): Position {
+ let position = from;
+ let current = this.charAt(position);
+
+ if (current !== null && charKind(current) !== "whitespace") {
+ const kind = charKind(current);
+ while (current !== null && charKind(current) === kind) {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+ }
+
+ current = this.charAt(position);
+ while (current !== null && charKind(current) === "whitespace") {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ return position;
+ }
+
+ private moveWordEndPosition(from: Position): Position {
+ let position = from;
+ let current = this.charAt(position);
+
+ while (current !== null && charKind(current) === "whitespace") {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ if (current === null) return from;
+
+ const kind = charKind(current);
+ let last = position;
+
+ while (current !== null && charKind(current) === kind) {
+ last = position;
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ return last;
+ }
+
+ private moveWordBackward(): void {
+ this.setPosition(this.moveWordBackwardPosition(this.currentPosition()));
+ }
+
+ private moveWordForward(): void {
+ this.setPosition(this.moveWordForwardPosition(this.currentPosition()));
+ }
+
+ private moveWordEnd(): void {
+ this.setPosition(this.moveWordEndPosition(this.currentPosition()));
+ }
+
+ private comparePositions(a: Position, b: Position): number {
+ if (a.line !== b.line) return a.line - b.line;
+ return a.col - b.col;
+ }
+
+ private nextPosition(position: Position): Position {
+ return this.moveRight(position);
+ }
+
+ private deleteRange(start: Position, end: Position): void {
+ if (this.comparePositions(start, end) >= 0) return;
+
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+
+ if (start.line === end.line) {
+ const line = lines[start.line] || "";
+ lines[start.line] = line.slice(0, start.col) + line.slice(end.col);
+ } else {
+ const first = (lines[start.line] || "").slice(0, start.col);
+ const last = (lines[end.line] || "").slice(end.col);
+ lines.splice(start.line, end.line - start.line + 1, first + last);
+ }
+
+ editor.state.lines = lines.length > 0 ? lines : [""];
+ editor.state.cursorLine = start.line;
+ editor.setCursorCol(start.col);
+ });
+ }
+
+ private deleteCurrentChar(): void {
+ const start = this.currentPosition();
+ const end = this.nextPosition(start);
+ if (start.line === end.line && start.col === end.col) return;
+ this.deleteRange(start, end);
+ }
+
+ private deleteToLineStart(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange({ line: cursor.line, col: 0 }, cursor);
+ }
+
+ private deleteToLineEnd(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(cursor, { line: cursor.line, col: (this.lines()[cursor.line] || "").length });
+ }
+
+ private deleteWordBackward(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(this.moveWordBackwardPosition(cursor), cursor);
+ }
+
+ private deleteWordForward(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(cursor, this.moveWordForwardPosition(cursor));
+ }
+
+ private deleteToWordEnd(): void {
+ const cursor = this.currentPosition();
+ const end = this.nextPosition(this.moveWordEndPosition(cursor));
+ this.deleteRange(cursor, end);
+ }
+
+ private deleteCurrentLine(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+
+ if (lines.length === 1) {
+ lines[0] = "";
+ editor.state.lines = lines;
+ editor.state.cursorLine = 0;
+ editor.setCursorCol(0);
+ return;
+ }
+
+ lines.splice(line, 1);
+ const newLine = Math.min(line, lines.length - 1);
+ editor.state.lines = lines;
+ editor.state.cursorLine = newLine;
+ editor.setCursorCol(Math.min(editor.state.cursorCol, (lines[newLine] || "").length));
+ });
+ }
+
+ private openLineBelow(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+ lines.splice(line + 1, 0, "");
+ editor.state.lines = lines;
+ editor.state.cursorLine = line + 1;
+ editor.setCursorCol(0);
+ });
+ this.setMode("insert");
+ }
+
+ private openLineAbove(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+ lines.splice(line, 0, "");
+ editor.state.lines = lines;
+ editor.state.cursorLine = line;
+ editor.setCursorCol(0);
+ });
+ this.setMode("insert");
+ }
+
+ private handlePending(data: string): boolean {
+ if (this.pending === "g") {
+ this.pending = null;
+ switch (data) {
+ case "h":
+ case "0":
+ this.moveLineStart();
+ return true;
+ case "l":
+ case "$":
+ this.moveLineEnd();
+ return true;
+ case "g":
+ this.moveFileStart();
+ return true;
+ case "e":
+ this.moveFileEnd();
+ return true;
+ default:
+ this.tui.requestRender();
+ return data.length === 1;
+ }
+ }
+
+ if (this.pending === "d") {
+ this.pending = null;
+ switch (data) {
+ case "d":
+ this.deleteCurrentLine();
+ return true;
+ case "w":
+ this.deleteWordForward();
+ return true;
+ case "e":
+ this.deleteToWordEnd();
+ return true;
+ case "b":
+ this.deleteWordBackward();
+ return true;
+ case "0":
+ this.deleteToLineStart();
+ return true;
+ case "$":
+ this.deleteToLineEnd();
+ return true;
+ case "x":
+ this.deleteCurrentChar();
+ return true;
+ default:
+ this.tui.requestRender();
+ return data.length === 1;
+ }
+ }
+
+ return false;
+ }
+
+ private handleNormalMode(data: string): boolean {
+ if (this.handlePending(data)) return true;
+
+ if (data in NORMAL_KEYS) {
+ super.handleInput(NORMAL_KEYS[data]!);
+ return true;
+ }
+
+ switch (data) {
+ case "i":
+ this.setMode("insert");
+ return true;
+ case "a":
+ super.handleInput("\x1b[C");
+ this.setMode("insert");
+ return true;
+ case "I":
+ this.moveLineStart();
+ this.setMode("insert");
+ return true;
+ case "A":
+ this.moveLineEnd();
+ this.setMode("insert");
+ return true;
+ case "o":
+ this.openLineBelow();
+ return true;
+ case "O":
+ this.openLineAbove();
+ return true;
+ case "b":
+ this.moveWordBackward();
+ return true;
+ case "w":
+ this.moveWordForward();
+ return true;
+ case "e":
+ this.moveWordEnd();
+ return true;
+ case "0":
+ this.moveLineStart();
+ return true;
+ case "$":
+ this.moveLineEnd();
+ return true;
+ case "D":
+ this.deleteToLineEnd();
+ return true;
+ case "g":
+ case "d":
+ this.pending = data as PendingAction;
+ this.tui.requestRender();
+ return true;
+ case "x":
+ this.deleteCurrentChar();
+ return true;
+ case "u":
+ this.internals().undo();
+ this.tui.requestRender();
+ return true;
+ default:
+ return false;
+ }
+ }
handleInput(data: string): void {
if (matchesKey(data, "escape")) {
if (this.mode === "insert") {
- this.mode = "normal";
+ this.setMode("normal");
} else {
+ this.pending = null;
super.handleInput(data);
}
return;
@@ -31,18 +485,7 @@ class ModalEditor extends CustomEditor {
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 (this.handleNormalMode(data)) return;
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
super.handleInput(data);
@@ -52,7 +495,8 @@ class ModalEditor extends CustomEditor {
const lines = super.render(width);
if (lines.length === 0) return lines;
- const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
+ const pendingLabel = this.pending ? ` ${this.pending}` : "";
+ const label = this.mode === "normal" ? ` NORMAL${pendingLabel} ` : " INSERT ";
const last = lines.length - 1;
if (visibleWidth(lines[last]!) >= label.length) {
lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/README.md b/pi/agent/extensions/taskwarrior-plan-mode/README.md
index 92de7e6..b280bcb 100644
--- a/pi/agent/extensions/taskwarrior-plan-mode/README.md
+++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md
@@ -151,7 +151,13 @@ Analyze the repo and give me a Plan: for the next implementation slice.
- Planning mode is read-only by design.
- All Taskwarrior operations still go through `ask`, never raw `task`.
+- `ask` must use real Taskwarrior CLI syntax. It is not a natural-language
+ task assistant and should never be called like `ask taskwarrior-task-management ...`.
- 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.
+- Execution mode now treats the focused task as the already-selected starting
+ point and blocks repeated identical `ask uuid:<current>` lookups until the
+ agent has moved on to repo inspection, implementation, tests, review, or a
+ different command.
- 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.
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/index.ts b/pi/agent/extensions/taskwarrior-plan-mode/index.ts
index 6fbfac3..59a223e 100644
--- a/pi/agent/extensions/taskwarrior-plan-mode/index.ts
+++ b/pi/agent/extensions/taskwarrior-plan-mode/index.ts
@@ -33,6 +33,42 @@ interface WorkOnTasksArgs {
maxTasks?: number;
}
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function normalizeCommandText(command: string): string {
+ return command.trim().replace(/\s+/g, " ");
+}
+
+function isMutatingAskCommand(command: string): boolean {
+ return /\b(add|annotate|append|delete|denotate|done|log|modify|prepend|start|stop|undo)\b/.test(command);
+}
+
+function repeatedCurrentTaskLookupKey(command: string, currentTaskUuid?: string): string | undefined {
+ if (!currentTaskUuid) return undefined;
+
+ const normalized = normalizeCommandText(command);
+ if (!/^ask(?:\s|$)/.test(normalized)) return undefined;
+ if (isMutatingAskCommand(normalized)) return undefined;
+
+ const uuidPattern = new RegExp(`(?:^|\\s)["']?uuid:${escapeRegExp(currentTaskUuid)}["']?(?:\\s|$)`);
+ if (!uuidPattern.test(normalized)) return undefined;
+
+ return normalized;
+}
+
+function malformedAskReason(command: string): string | undefined {
+ const normalized = normalizeCommandText(command);
+ if (!/^ask(?:\s|$)/.test(normalized)) return undefined;
+
+ if (/\btaskwarrior-task-management\b/.test(normalized)) {
+ return "The 'ask' command is only a Taskwarrior CLI wrapper. Do not pass the skill name or natural-language workflow text to it. Use concrete Taskwarrior syntax such as 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate \"note\"', 'ask uuid:<uuid> modify priority:H', or 'ask uuid:<uuid> done'.";
+ }
+
+ return undefined;
+}
+
function parseSelectorAndPayload(rawArgs: string): { selector: string; payload: string } | undefined {
const separator = rawArgs.indexOf("::");
if (separator === -1) return undefined;
@@ -94,6 +130,8 @@ export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
let planItems: PlanItem[] = [];
let createdTaskUuids: string[] = [];
let normalTools: string[] = [];
+ let executionTaskUuid: string | undefined;
+ let repeatedTaskLookups = new Set<string>();
pi.registerFlag("plan", {
description: "Start in Taskwarrior plan mode (read-only exploration)",
@@ -305,11 +343,13 @@ export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
const currentTask = await getCurrentTask(ctx);
if (!currentTask) {
+ executionTaskUuid = undefined;
ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("muted", "task: none"));
ctx.ui.setWidget("task-plan-mode", undefined);
return;
}
+ executionTaskUuid = currentTask.uuid;
ctx.ui.setStatus(
"task-plan-mode",
ctx.ui.theme.fg("accent", `task ${currentTask.priority ?? "-"} ${currentTask.id ?? "?"}`),
@@ -330,6 +370,8 @@ export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
if (enabled) {
normalTools = pi.getActiveTools();
pi.setActiveTools(PLAN_MODE_TOOLS);
+ executionTaskUuid = undefined;
+ repeatedTaskLookups.clear();
ctx.ui.notify(`Taskwarrior plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(normalTools);
@@ -422,6 +464,8 @@ export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
executionMode = true;
planModeEnabled = false;
pi.setActiveTools(normalTools);
+ executionTaskUuid = task.uuid;
+ repeatedTaskLookups.clear();
persistState();
await updateStatus(ctx);
@@ -514,7 +558,7 @@ export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
const projectName = await getProjectName(ctx);
const maxTasksText = parsed.maxTasks ? String(parsed.maxTasks) : "none";
- pi.sendUserMessage(`Use the taskwarrior-task-management workflow for the current git project.
+ pi.sendUserMessage(`Use the Taskwarrior workflow rules below for the current git project.
Project: ${projectName}
Selection strategy: ${parsed.strategy}
@@ -524,8 +568,8 @@ Current focused task:
${formatTaskDetails(currentTask)}
Workflow:
-1. Load project-scoped tasks using ask only.
-2. Continue already-started tasks first. Only if none are started, use the next READY task.
+1. Treat the current focused task above as the already-selected starting point for this run.
+2. Only use ask to load project-scoped tasks when the current task is missing, blocked, completed, or you are ready to pick the next task.
3. Use priority first, then urgency, as the stable ordering rule. Use the requested selection strategy only as a tie-breaker or framing hint.
4. Start and execute the chosen task.
5. Annotate meaningful implementation progress back to Taskwarrior using UUID selectors.
@@ -538,12 +582,17 @@ Workflow:
Rules:
- Never use raw task; always use ask.
+- 'ask' is a thin Taskwarrior CLI wrapper, not a natural-language interface and not a skill runner.
+- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'.
+- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing.
- Scope all work to project:${projectName} +agent tasks only.
- Use UUIDs for all long-lived references.
+- Do not repeat the same ask lookup for the current task unless task state may have changed or required information is still missing.
+- After one task lookup, move into repo inspection, implementation, testing, review, or annotation before refreshing Taskwarrior again.
- Do not ask the user to choose a task unless there is a real ambiguity or risk.
- Keep working autonomously until the workflow reaches a stop condition.
-Begin with the current focused task unless a higher-priority started task appears when you re-check Taskwarrior.`, {
+Begin with the current focused task now. Do not re-check Taskwarrior immediately just to confirm the same task again.`, {
deliverAs: ctx.isIdle() ? undefined : "steer",
});
},
@@ -555,9 +604,36 @@ Begin with the current focused task unless a higher-priority started task appear
});
pi.on("tool_call", async (event) => {
- if (event.toolName !== "bash") return;
+ if (!executionMode) {
+ if (event.toolName !== "bash") return;
+ } else if (event.toolName !== "bash") {
+ repeatedTaskLookups.clear();
+ return;
+ }
const command = String(event.input.command ?? "");
+ const repeatedLookupKey = executionMode ? repeatedCurrentTaskLookupKey(command, executionTaskUuid) : undefined;
+ if (executionMode && repeatedLookupKey) {
+ if (repeatedTaskLookups.has(repeatedLookupKey)) {
+ return {
+ block: true,
+ reason:
+ "Repeated lookup of the same current Taskwarrior task was blocked. Use the task details already in context and move to code inspection, implementation, tests, review, or an annotation before refreshing the same task again.",
+ };
+ }
+ repeatedTaskLookups.add(repeatedLookupKey);
+ } else if (executionMode) {
+ repeatedTaskLookups.clear();
+ }
+
+ const malformedAsk = malformedAskReason(command);
+ if (malformedAsk) {
+ return {
+ block: true,
+ reason: malformedAsk,
+ };
+ }
+
if (containsRawTaskCommand(command)) {
return {
block: true,
@@ -619,6 +695,7 @@ Plan:
if (executionMode) {
const currentTask = await getCurrentTask(ctx);
if (!currentTask) return;
+ executionTaskUuid = currentTask.uuid;
return {
message: {
@@ -626,10 +703,15 @@ Plan:
content: `[TASKWARRIOR EXECUTION MODE]
Project: ${projectName}
-Use the taskwarrior-task-management skill semantics:
+Use the Taskwarrior workflow rules below:
- Use 'ask ...' for all task operations. Never use raw 'task'.
+- 'ask' is only a Taskwarrior CLI wrapper. It does not understand the skill name or natural-language requests.
+- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'.
+- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing.
- Continue an already-started task before starting a new one.
- Use UUIDs for long-lived references and follow-up commands.
+- The current task below is already the selected task for this turn. Do not immediately query the same UUID again unless required details are missing or task state changed.
+- After one Taskwarrior lookup, move to repo inspection or implementation work before refreshing Taskwarrior again.
- Do not mark a task done until implementation, tests, and commit are complete.
- Annotate meaningful progress back to the task with 'ask uuid:<uuid> annotate ...' when appropriate.
- Self-review first, then if the subagent tool is available use it for an independent fresh-context review before the task is marked done.
@@ -643,12 +725,14 @@ ${formatTaskDetails(currentTask)}`,
});
pi.on("turn_end", async (_event, ctx) => {
+ repeatedTaskLookups.clear();
if (executionMode) {
await updateStatus(ctx);
}
});
pi.on("agent_end", async (event, ctx) => {
+ repeatedTaskLookups.clear();
if (executionMode) {
await updateStatus(ctx);
return;
@@ -698,6 +782,7 @@ ${formatTaskDetails(currentTask)}`,
} else {
normalTools = pi.getActiveTools();
}
+ repeatedTaskLookups.clear();
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
diff --git a/pi/agent/settings.json b/pi/agent/settings.json
index a6f0710..972476b 100644
--- a/pi/agent/settings.json
+++ b/pi/agent/settings.json
@@ -1,5 +1,5 @@
{
- "lastChangelogVersion": "0.61.0",
+ "lastChangelogVersion": "0.61.1",
"defaultProvider": "openai",
"defaultModel": "gpt-4.1"
} \ No newline at end of file
diff --git a/prompts/skills/taskwarrior-task-management/SKILL.md b/prompts/skills/taskwarrior-task-management/SKILL.md
index b706ff8..06f8d06 100644
--- a/prompts/skills/taskwarrior-task-management/SKILL.md
+++ b/prompts/skills/taskwarrior-task-management/SKILL.md
@@ -9,6 +9,23 @@ Taskwarrior tasks are scoped to the current git repository. **Load only the file
Always use `ask ...` for all task operations. `ask` is a tiny wrapper that pre-sets `project:<name> +agent`, scoping every command to the current project's agent-managed tasks while preserving normal Taskwarrior commands, reports, filters, and UUID workflows. `hexai task ...` is a compatibility alias with the same behavior. Never use the raw `task` command directly.
+`ask` is only a Taskwarrior CLI wrapper. It is not a natural-language assistant, and it does not understand skill names. Use normal Taskwarrior syntax only.
+
+Valid examples:
+
+- `ask start.any: export`
+- `ask +READY export`
+- `ask uuid:<uuid> annotate "note"`
+- `ask uuid:<uuid> modify priority:H`
+- `ask uuid:<uuid> done`
+
+Invalid examples:
+
+- `ask taskwarrior-task-management ...`
+- `ask list tasks`
+- `ask show task 298`
+- any other natural-language phrasing passed to `ask`
+
Taskwarrior numeric IDs are **ephemeral working-set indices** and may be renumbered; this skill treats **UUIDs as the stable identifiers** for tasks. Use numeric IDs only within a single “report → immediate command” flow, and use UUIDs for anything that must survive across sessions, agents, or reports.
## Context and compaction
diff --git a/prompts/skills/taskwarrior-task-management/references/00-context.md b/prompts/skills/taskwarrior-task-management/references/00-context.md
index 55422cb..bd7d08b 100644
--- a/prompts/skills/taskwarrior-task-management/references/00-context.md
+++ b/prompts/skills/taskwarrior-task-management/references/00-context.md
@@ -15,6 +15,7 @@ The `ask` command automatically injects this as `project:<name> +agent` into eve
## Rules that apply to all task commands
- **Always use `ask ...` for all task operations.** `ask` is a tiny wrapper that automatically injects `project:<name> +agent`, scoping every command to the current project's agent-managed tasks. `hexai task ...` is a compatibility alias with the same behavior. Never use the raw `task` command directly.
+- **`ask` only accepts normal Taskwarrior CLI syntax.** It is not a natural-language interface and it does not understand skill names. Valid examples: `ask start.any: export`, `ask +READY export`, `ask uuid:<uuid> annotate "note"`, `ask uuid:<uuid> modify priority:H`, `ask uuid:<uuid> done`. Invalid examples: `ask taskwarrior-task-management ...`, `ask list tasks`, `ask show task 298`.
- **Project and tag matching:** The agent only reads, modifies, or creates tasks that have **both** `project:<name>` **and** the `+agent` tag. Do not touch any task that does not have `+agent` set.
- **NEVER modify, delete, complete, start, or annotate tasks from other projects or tasks without `+agent`.** Only act on tasks where `project:<name>` matches the current git repo and the task has the `+agent` tag.
- **One task in progress per project.** Do not start a second task while another is started and not completed, unless the user explicitly asks.
diff --git a/prompts/skills/taskwarrior-task-management/references/1-create-task.md b/prompts/skills/taskwarrior-task-management/references/1-create-task.md
index df6819a..c980e8b 100644
--- a/prompts/skills/taskwarrior-task-management/references/1-create-task.md
+++ b/prompts/skills/taskwarrior-task-management/references/1-create-task.md
@@ -8,7 +8,7 @@ Use with `00-context.md`. Project name and global rules apply. New tasks get `+a
- **Create tasks in smaller chunks that fit into the context window.** Break work into multiple tasks so that each task’s scope, description, and required context (refs, files, docs) can fit in one context window when the agent works on it with a fresh context. Do not create single tasks that would require more context than available.
- **Every task MUST have at least one tag** for sub-project/feature/area (e.g. `+integrationtests`, `+flamegraph`, `+bpf`, `+cli`, `+refactor`, `+bugfix`).
- **When an agent creates a task, always add the tag `+agent`** so agent-created tasks can be identified.
-- **After creating a task, add an annotation** so any agent working on the task is reminded to use this skill and to auto-progress: `ask <id> annotate "Agent: be aware of taskwarrior-task-management skill. When working on this task, also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, automatically progress to the next task in the list."` This ensures agents (including those with fresh context) know to load and follow the taskwarrior-task-management skill, respect code-quality skills (best-practices, SOLID, beyond-SOLID), and continue to the next task after completion.
+- **After creating a task, add an annotation** so any agent working on the task is reminded how to operate and to auto-progress: `ask <id> annotate "Agent workflow: load the taskwarrior-task-management skill as instructions only, not as a shell command. Never run ask taskwarrior-task-management ... or other natural-language ask commands. Use only normal Taskwarrior syntax through ask. Also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, commit and automatically progress to the next ready task."` This keeps fresh-context agents aligned on Taskwarrior usage, code-quality skills, and auto-progression without encouraging malformed `ask` commands.
- **Include references to all context required** to work on the task. So that work can be done with a fresh context, every task must list or link everything needed: relevant files, docs, specs, other tasks, or project guidelines (e.g. paths, doc links, `AGENTS.md`, `README` sections). Put these in the task description or in an initial annotation so that an agent starting with no prior conversation has everything they need in the task itself.
- **Record the task’s UUID for future reference.** After creating a task, resolve its UUID (for example, `ask <id> _uuid`) and include it in an annotation such as `UUID: <uuid>` so the exact task can be recovered even if IDs are renumbered.
- When tasks refer to other tasks in free text (annotations, descriptions, docs, or commit messages), **use the other task’s UUID**, not just its numeric ID.
@@ -21,10 +21,10 @@ Use with `00-context.md`. Project name and global rules apply. New tasks get `+a
ask add +<tag> "Description"
```
-Then add the agent-awareness annotation (use the ID from the add output):
+Then add the workflow annotation (use the ID from the add output):
```bash
-ask <id> annotate "Agent: be aware of taskwarrior-task-management skill. When working on this task, also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, automatically progress to the next task in the list."
+ask <id> annotate "Agent workflow: load the taskwarrior-task-management skill as instructions only, not as a shell command. Never run ask taskwarrior-task-management ... or other natural-language ask commands. Use only normal Taskwarrior syntax through ask. Also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, commit and automatically progress to the next ready task."
```
Also add an annotation that records the task’s UUID, for example:
@@ -41,7 +41,7 @@ ask add +<tag> "Description" depends:<id>
Multiple dependencies: `depends:<id1>,<id2>`.
-After adding (with or without dependency), run the same annotation: `ask <id> annotate "Agent: be aware of taskwarrior-task-management skill. When working on this task, also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, automatically progress to the next task in the list."`
+After adding (with or without dependency), run the same annotation: `ask <id> annotate "Agent workflow: load the taskwarrior-task-management skill as instructions only, not as a shell command. Never run ask taskwarrior-task-management ... or other natural-language ask commands. Use only normal Taskwarrior syntax through ask. Also load and apply: (1) the best-practices skill for the programming language used in the project, (2) solid-principles, and (3) beyond-solid-principles. When all tests and sub-agent reviews pass, commit and automatically progress to the next ready task."`
## Conventions