Update pi

This commit is contained in:
Martin Pander
2026-08-24 08:59:22 +02:00
parent 40223d29dc
commit 331b759b7d
10 changed files with 486 additions and 807 deletions

View File

@@ -3,6 +3,9 @@
let
cfg = config.dot.tmux;
# tmuxp from nixpkgs-unstable (provided by modules/overlays/unstable.nix)
tmuxp = pkgs.unstable.tmuxp;
projectSelector = pkgs.writeScriptBin "projectSelector" ''
#!${pkgs.zsh}/bin/zsh
# Configuration
@@ -48,7 +51,7 @@ let
# Background load all but the last selection
if [[ $i -lt $total ]]; then
if [[ -f "$target/.tmuxp.yaml" || -f "$target/.tmuxp.yml" ]]; then
${pkgs.tmuxp}/bin/tmuxp load --yes -d "$target"
${tmuxp}/bin/tmuxp load --yes -d "$target"
else
if ! ${pkgs.tmux}/bin/tmux has-session -t "$s_name" 2>/dev/null; then
${pkgs.tmux}/bin/tmux new-session -d -s "$s_name" -c "$target"
@@ -57,7 +60,7 @@ let
else
# Last item: Foreground switch/attach
if [[ -f "$target/.tmuxp.yaml" || -f "$target/.tmuxp.yml" ]]; then
${pkgs.tmuxp}/bin/tmuxp load --yes "$target"
${tmuxp}/bin/tmuxp load --yes "$target"
else
if ! ${pkgs.tmux}/bin/tmux has-session -t "$s_name" 2>/dev/null; then
${pkgs.tmux}/bin/tmux new-session -d -s "$s_name" -c "$target"
@@ -116,8 +119,10 @@ in
mouse = true;
keyMode = "vi";
escapeTime = 10;
terminal = "screen-256color";
tmuxp.enable = true;
terminal = "xterm-256color";
# tmuxp is installed manually below to pin it to unstable
# (programs.tmux.tmuxp has no package option)
tmuxp.enable = false;
extraConfig = ''
set -g display-time 1500
set -s set-clipboard on
@@ -281,6 +286,7 @@ in
home.packages = [
tmuxKiller
tmuxp
] ++ lib.optional cfg.workMode projectSelector;
home.shellAliases = {

View File

@@ -39,6 +39,7 @@
environment.systemPackages = with pkgs; [
git
git-lfs
wget
curl
vim

View File

@@ -1,66 +1,42 @@
# Plan Mode Extension
# Plan Mode
Read-only exploration mode for safe code analysis.
Minimal read-only mode. Built iteratively — v1 does one thing only.
## Features
## v1: no writing
- **Built-in write tools disabled**: Disables edit/write while preserving other active tools
- **Bash allowlist**: Only read-only bash commands are allowed
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
- **Progress tracking**: Widget shows completion status during execution
- **[DONE:n] markers**: Explicit step completion tracking
- **Session persistence**: State survives session resume
| Trigger | Effect |
| --- | --- |
| `/plan` | Toggle plan mode |
| `Ctrl+Alt+P` | Toggle plan mode |
| `pi --plan` | Start in plan mode |
## Commands
While active:
- `/plan` - Toggle plan mode
- `/todos` - Show current plan progress
- `Ctrl+Alt+P` - Toggle plan mode (shortcut)
- Built-in `write` and `edit` tools are removed from the active tool set
- A `plan` marker appears in the footer status
- The system prompt tells the model it cannot modify files
- State is persisted, so `/resume` keeps plan mode on
## Usage
Toggling off restores the exact tool set captured when plan mode was enabled.
1. Enable plan mode with `/plan` or `--plan` flag
2. Ask the agent to analyze code and create a plan
3. The agent should output a numbered plan under a `Plan:` header:
## v2: `/handover`
```
Plan:
1. First step description
2. Second step description
3. Third step description
```
`/handover [focus]` distils the session into a standalone plan document.
4. Choose "Execute the plan" when prompted
5. During execution, the agent marks steps complete with `[DONE:n]` tags
6. Progress widget shows completion status
- Runs a **side LLM call** over the current branch — no extra turn is added to the
conversation, and the transcript is not polluted
- Compaction-aware: uses the summary plus surviving entries if the branch was compacted
- Opens the result in the editor for review; saving writes the file, an empty buffer cancels
- Written to `.pi/plans/<YYYYMMDD-HHMM>-<slug>.md`, slug derived from the `#` heading
- Optional `focus` argument steers what the document centres on
## How It Works
Document structure: `Goal`, `Findings`, `Plan` (numbered, file-scoped steps),
`Risks & open questions`, `Files`.
### Plan Mode (Read-Only)
- Built-in edit/write tools disabled
- Other active tools remain available
- Bash commands filtered through allowlist
- Agent creates a plan without making changes
Works whether or not plan mode is active.
### Execution Mode
- Full tool access restored
- Agent executes steps in order
- `[DONE:n]` markers track completion
- Widget shows progress
## Not implemented (yet)
### Command Allowlist
Safe commands (allowed):
- File inspection: `cat`, `head`, `tail`, `less`, `more`
- Search: `grep`, `find`, `rg`, `fd`
- Directory: `ls`, `pwd`, `tree`
- Git read: `git status`, `git log`, `git diff`, `git branch`
- Package info: `npm list`, `npm outdated`, `yarn info`
- System info: `uname`, `whoami`, `date`, `uptime`
Blocked commands:
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
- Git write: `git add`, `git commit`, `git push`
- Package install: `npm install`, `yarn add`, `pip install`
- System: `sudo`, `kill`, `reboot`
- Editors: `vim`, `nano`, `code`
- Guarding `bash` against writes — `bash` is still fully available
- Reading a handover back in / resuming from one
- Approval and step-by-step execution flow

View File

@@ -0,0 +1,196 @@
/**
* Handover document generation for plan mode.
*
* Runs a side LLM call over the current branch (does NOT add a turn to the
* conversation), lets the user edit the result, then writes it to
* `.pi/plans/<slug>-<timestamp>.md`.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { type Message, uuidv7 } from "@earendil-works/pi-ai";
import {
BorderedLoader,
CONFIG_DIR_NAME,
convertToLlm,
type ExtensionCommandContext,
type SessionEntry,
serializeConversation,
} from "@earendil-works/pi-coding-agent";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
export const PLANS_DIR = "plans";
const SYSTEM_PROMPT = `You write handover documents. Another engineer (or agent) will pick up
this work with NO access to the conversation you are summarizing. The document must stand alone.
Output GitHub-flavoured markdown with exactly these sections:
# <short imperative title, max 8 words>
## Goal
One paragraph: what we are trying to achieve and why.
## Findings
What was learned while investigating. Cite concrete file paths and, where useful, line numbers
or symbol names. Include things that were ruled out and why.
## Plan
A numbered list of concrete, actionable steps in execution order. Each step names the files it
touches. No vague steps like "improve error handling".
## Risks & open questions
Bullets. Anything unverified, any decision the user still needs to make. Omit the section if
genuinely empty.
## Files
Bullet list of \`path\` — one-line reason it matters.
Rules:
- No preamble, no "Here is the handover". Start with the \`#\` heading.
- Be specific over complete. Facts from the conversation only; never invent file paths.
- Do not describe the conversation itself ("the user asked..."). Describe the work.`;
function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
if (entry.type === "message") return entry.message;
if (entry.type === "compaction") {
return {
role: "compactionSummary",
summary: entry.summary,
tokensBefore: entry.tokensBefore,
timestamp: new Date(entry.timestamp).getTime(),
};
}
return undefined;
}
/** Branch messages, respecting the most recent compaction boundary. */
function collectMessages(branch: SessionEntry[]): AgentMessage[] {
let compactionIndex = -1;
for (let i = branch.length - 1; i >= 0; i--) {
if (branch[i]?.type === "compaction") {
compactionIndex = i;
break;
}
}
if (compactionIndex < 0) {
return branch.map(entryToMessage).filter((m): m is AgentMessage => m !== undefined);
}
const compaction = branch[compactionIndex];
const firstKept =
compaction?.type === "compaction" ? branch.findIndex((e) => e.id === compaction.firstKeptEntryId) : -1;
return [
compaction,
...(firstKept >= 0 ? branch.slice(firstKept, compactionIndex) : []),
...branch.slice(compactionIndex + 1),
]
.map((e) => (e ? entryToMessage(e) : undefined))
.filter((m): m is AgentMessage => m !== undefined);
}
function slugify(text: string): string {
return (
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
.replace(/-+$/g, "") || "handover"
);
}
function timestamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
}
/** Derive a filename slug from the document's first `# heading`. */
function slugFromDocument(markdown: string): string {
const heading = markdown.match(/^#\s+(.+)$/m);
return slugify(heading?.[1] ?? "handover");
}
/**
* Generate a handover document and write it to `.pi/plans/`.
* Returns the written path, or undefined if cancelled or unavailable.
*/
export async function writeHandover(ctx: ExtensionCommandContext, focus: string): Promise<string | undefined> {
if (ctx.mode !== "tui") {
ctx.ui.notify("/handover requires interactive mode", "error");
return;
}
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
const messages = collectMessages(ctx.sessionManager.getBranch());
if (messages.length === 0) {
ctx.ui.notify("Nothing to hand over yet", "warning");
return;
}
const conversation = serializeConversation(convertToLlm(messages));
const model = ctx.model;
const generated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, "Writing handover document...");
loader.onAbort = () => done(null);
void (async () => {
const prompt = focus
? `## Conversation\n\n${conversation}\n\n## Focus\n\nThe handover should centre on: ${focus}`
: `## Conversation\n\n${conversation}`;
const userMessage: Message = {
role: "user",
content: [{ type: "text", text: prompt }],
timestamp: Date.now(),
};
const response = await ctx.modelRegistry.complete(
model,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ signal: loader.signal, cacheRetention: "none", sessionId: uuidv7() },
);
if (response.stopReason === "aborted") return null;
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n")
.trim();
})()
.then(done)
.catch(() => done(null));
return loader;
});
if (!generated) {
ctx.ui.notify("Handover cancelled", "info");
return;
}
const edited = await ctx.ui.editor("Review handover (save to write, empty to cancel)", generated);
if (edited === undefined || !edited.trim()) {
ctx.ui.notify("Handover cancelled", "info");
return;
}
const dir = join(ctx.cwd, CONFIG_DIR_NAME, PLANS_DIR);
const path = join(dir, `${timestamp()}-${slugFromDocument(edited)}.md`);
try {
await mkdir(dir, { recursive: true });
await writeFile(path, `${edited.trimEnd()}\n`, "utf8");
} catch (err) {
ctx.ui.notify(`Failed to write handover: ${err instanceof Error ? err.message : String(err)}`, "error");
return;
}
ctx.ui.notify(`Handover written to ${path}`, "info");
return path;
}

View File

@@ -1,579 +1,130 @@
/**
* Plan Mode Extension
* Plan Mode — v1
*
* Read-only exploration + structured planning + gated step-by-step execution.
* Scope (deliberately minimal, grown iteratively):
* - Toggle a read-only "plan mode" via `/plan`, `Ctrl+Alt+P`, or `--plan`
* - While active: built-in write tools (`write`, `edit`) are disabled
* - Footer status shows when plan mode is active
* - The model is told it cannot make changes
* - State survives session resume
* - `/handover` distils the session into a plan document under `.pi/plans/`
*
* Features:
* - /plan command or Ctrl+Alt+P to toggle read-only plan mode
* - Bash restricted to allowlisted read-only commands, edit/write disabled
* - `submit_plan` tool: model returns a STRUCTURED plan (title + steps + risks + files)
* - Plans are saved to `.pi/plans/<slug>-<timestamp>.md` (recognizable name + timestamp)
* - Per-step execution gating: you approve/skip/stop each step before it runs
* - Progress tracking widget + footer status, persisted across resume
* NOT handled yet (future iterations): bash write guarding, approval flow,
* execution tracking.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { TextContent } from "@earendil-works/pi-ai";
import {
CONFIG_DIR_NAME,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Key } from "@earendil-works/pi-tui";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Type, type Static } from "typebox";
import { isSafeCommand } from "./utils.ts";
import { writeHandover } from "./handover.ts";
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire", "submit_plan"];
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
/** Built-in tools that can mutate the filesystem. */
const WRITE_TOOLS = new Set(["write", "edit"]);
type Phase = "planning" | "awaiting-approval" | "executing";
interface PlanStep {
text: string;
completed?: boolean;
skipped?: boolean;
}
interface StoredPlan {
title: string;
createdAt: string;
steps: PlanStep[];
risks?: string[];
files?: string[];
}
const STATE_ENTRY = "plan-mode";
interface PlanModeState {
enabled: boolean;
phase?: Phase;
plan?: StoredPlan;
currentStepIndex?: number;
planFilePath?: string;
toolsBeforePlanMode?: string[];
/** Active tool list captured before plan mode narrowed it. */
savedTools?: string[];
}
// ---- submit_plan tool schema ----
const submitPlanSchema = Type.Object({
title: Type.String({
description: "A short, recognizable name for this plan (e.g. 'Add OAuth login').",
}),
steps: Type.Array(
Type.Object({
description: Type.String({ description: "A single, concrete, actionable step." }),
}),
{ description: "Ordered list of steps to implement the plan." },
),
risks: Type.Optional(Type.Array(Type.String(), { description: "Potential risks, caveats, or open questions." })),
files: Type.Optional(Type.Array(Type.String(), { description: "Files likely to be created or modified." })),
});
export type SubmitPlanInput = Static<typeof submitPlanSchema>;
const PLAN_MODE_PROMPT = `[PLAN MODE ACTIVE]
// ---- file persistence helpers ----
function slugify(title: string): string {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
.replace(/-+$/g, "");
return slug || "plan";
}
You are in plan mode. The built-in \`write\` and \`edit\` tools are disabled for
this turn, so you cannot modify files.
function fileTimestamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
function renderPlanMarkdown(plan: StoredPlan): string {
const lines: string[] = [];
lines.push(`# ${plan.title}`);
lines.push("");
lines.push(`_Created: ${plan.createdAt}_`);
lines.push("");
lines.push("## Steps");
lines.push("");
plan.steps.forEach((s, i) => {
const box = s.completed ? "x" : " ";
const suffix = s.skipped ? " _(skipped)_" : "";
lines.push(`${i + 1}. [${box}] ${s.text}${suffix}`);
});
if (plan.risks?.length) {
lines.push("");
lines.push("## Risks");
lines.push("");
for (const r of plan.risks) lines.push(`- ${r}`);
}
if (plan.files?.length) {
lines.push("");
lines.push("## Files");
lines.push("");
for (const f of plan.files) lines.push(`- ${f}`);
}
lines.push("");
return lines.join("\n");
}
Investigate, read code, and answer. When the user asks for changes, describe
what you would do instead of attempting it. Do not try to route around the
restriction (for example by writing files through \`bash\`).`;
export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let phase: Phase | undefined;
let plan: StoredPlan | undefined;
let currentStepIndex = 0;
let awaitingStep = false;
let planFilePath: string | undefined;
let toolsBeforePlanMode: string[] | undefined;
let enabled = false;
let savedTools: string[] | undefined;
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
description: "Start in plan mode (read-only)",
type: "boolean",
default: false,
});
// ---- tool management ----
function uniqueToolNames(toolNames: string[]): string[] {
return [...new Set(toolNames)];
function persist(): void {
pi.appendEntry(STATE_ENTRY, { enabled, savedTools } satisfies PlanModeState);
}
function getPlanModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)),
...PLAN_MODE_TOOLS,
]);
}
function getNormalModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...NORMAL_MODE_TOOLS,
...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
]);
}
function enablePlanModeTools(): void {
if (toolsBeforePlanMode === undefined) {
toolsBeforePlanMode = pi.getActiveTools();
function applyToolRestrictions(): void {
if (savedTools === undefined) {
savedTools = pi.getActiveTools();
}
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
}
function restoreNormalModeTools(): void {
pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
toolsBeforePlanMode = undefined;
pi.setActiveTools(savedTools.filter((name) => !WRITE_TOOLS.has(name)));
}
// ---- state ----
function persistState(): void {
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
phase,
plan,
currentStepIndex,
planFilePath,
toolsBeforePlanMode,
} satisfies PlanModeState);
}
async function persistPlanFile(): Promise<void> {
if (!planFilePath || !plan) return;
try {
await writeFile(planFilePath, renderPlanMarkdown(plan), "utf8");
} catch {
// ignore write failures
function restoreTools(): void {
if (savedTools !== undefined) {
pi.setActiveTools(savedTools);
savedTools = undefined;
return;
}
// No snapshot (e.g. resumed session): re-enable write tools additively.
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...WRITE_TOOLS])]);
}
async function savePlanFile(ctx: ExtensionContext, p: StoredPlan): Promise<string> {
const dir = join(ctx.cwd, CONFIG_DIR_NAME, "plans");
await mkdir(dir, { recursive: true });
const path = join(dir, `${slugify(p.title)}-${fileTimestamp()}.md`);
await writeFile(path, renderPlanMarkdown(p), "utf8");
return path;
}
// ---- UI ----
function updateStatus(ctx: ExtensionContext): void {
if (phase === "executing" && plan) {
const done = plan.steps.filter((s) => s.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${done}/${plan.steps.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
if ((phase === "executing" || phase === "awaiting-approval") && plan) {
const lines = [ctx.ui.theme.fg("accent", plan.title)];
plan.steps.forEach((s, i) => {
let mark: string;
let text = s.text;
if (s.completed) {
mark = ctx.ui.theme.fg("success", "☑ ");
text = ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(text));
} else if (s.skipped) {
mark = ctx.ui.theme.fg("muted", "⊘ ");
text = ctx.ui.theme.fg("muted", text);
} else if (phase === "executing" && i === currentStepIndex) {
mark = ctx.ui.theme.fg("accent", "▶ ");
} else {
mark = ctx.ui.theme.fg("muted", "☐ ");
}
lines.push(`${mark}${text}`);
});
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
ctx.ui.setStatus("plan-mode", enabled ? ctx.ui.theme.fg("warning", "plan") : undefined);
}
// ---- mode toggle ----
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
phase = planModeEnabled ? "planning" : undefined;
plan = undefined;
planFilePath = undefined;
currentStepIndex = 0;
awaitingStep = false;
function setEnabled(next: boolean, ctx: ExtensionContext): void {
if (next === enabled) return;
enabled = next;
if (planModeEnabled) {
enablePlanModeTools();
ctx.ui.notify("Plan mode enabled. Built-in write tools disabled. Ask for a plan, then submit_plan.");
if (enabled) {
applyToolRestrictions();
ctx.ui.notify("Plan mode on — write and edit disabled.", "info");
} else {
restoreNormalModeTools();
ctx.ui.notify("Plan mode disabled. Full access restored.");
restoreTools();
ctx.ui.notify("Plan mode off — write access restored.", "info");
}
updateStatus(ctx);
persistState();
persist();
}
// ---- execution gating ----
function finishExecution(ctx: ExtensionContext): void {
if (plan) {
const summary = plan.steps
.map((s) => (s.completed ? `~~${s.text}~~` : s.skipped ? `${s.text} (skipped)` : s.text))
.join("\n");
pi.sendMessage(
{ customType: "plan-complete", content: `**Plan "${plan.title}" complete!** ✓\n\n${summary}`, display: true },
{ triggerTurn: false },
);
}
phase = undefined;
awaitingStep = false;
updateStatus(ctx);
persistState();
void persistPlanFile();
}
async function gateNext(ctx: ExtensionContext): Promise<void> {
if (!plan) {
phase = undefined;
return;
}
if (currentStepIndex >= plan.steps.length) {
finishExecution(ctx);
return;
}
const step = plan.steps[currentStepIndex];
if (!step) {
finishExecution(ctx);
return;
}
const choice = await ctx.ui.select(
`Step ${currentStepIndex + 1}/${plan.steps.length}: ${step.text}`,
["Execute this step", "Skip this step", "Stop execution"],
);
if (choice?.startsWith("Execute")) {
awaitingStep = true;
updateStatus(ctx);
const stepMsg = `Execute ONLY step ${currentStepIndex + 1} of ${plan.steps.length} from plan "${plan.title}":
${step.text}
Do just this one step. Do NOT start any other step. Stop when this step is done.`;
pi.sendMessage(
{ customType: "plan-step", content: stepMsg, display: true },
{ triggerTurn: true, deliverAs: "followUp" },
);
} else if (choice?.startsWith("Skip")) {
step.skipped = true;
currentStepIndex++;
updateStatus(ctx);
persistState();
await persistPlanFile();
await gateNext(ctx);
} else {
ctx.ui.notify("Plan execution stopped.", "info");
phase = undefined;
awaitingStep = false;
updateStatus(ctx);
persistState();
}
}
async function handleApproval(ctx: ExtensionContext): Promise<void> {
if (!plan) {
phase = undefined;
return;
}
const choice = await ctx.ui.select(
`Plan "${plan.title}" — ${plan.steps.length} steps.\nWhat next?`,
["Execute step-by-step", "Stay in plan mode", "Refine the plan", "Save plan to file"],
);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
phase = "executing";
currentStepIndex = 0;
awaitingStep = false;
restoreNormalModeTools();
updateStatus(ctx);
persistState();
await gateNext(ctx);
} else if (choice === "Refine the plan") {
phase = "planning";
const refinement = await ctx.ui.editor("Refine the plan:", "");
updateStatus(ctx);
persistState();
if (refinement?.trim()) {
pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
}
} else if (choice === "Save plan to file") {
try {
planFilePath = await savePlanFile(ctx, plan);
ctx.ui.notify(`Plan saved to ${planFilePath}`, "info");
persistState();
} catch {
ctx.ui.notify("Failed to save plan.", "error");
}
} else {
phase = "planning";
updateStatus(ctx);
persistState();
}
}
// ---- commands & shortcuts ----
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration + structured planning)",
handler: async (_args, ctx) => togglePlanMode(ctx),
description: "Toggle plan mode (read-only)",
handler: async (_args, ctx) => setEnabled(!enabled, ctx),
});
pi.registerCommand("plan-status", {
description: "Show the current plan and progress",
handler: async (_args, ctx) => {
if (!plan) {
ctx.ui.notify("No plan yet. Enable /plan and ask the agent to submit_plan.", "info");
return;
}
const list = plan.steps
.map((s, i) => `${i + 1}. ${s.completed ? "✓" : s.skipped ? "⊘" : "○"} ${s.text}`)
.join("\n");
const loc = planFilePath ? `\n\nFile: ${planFilePath}` : "";
ctx.ui.notify(`Plan "${plan.title}" (${phase ?? "idle"}):\n${list}${loc}`, "info");
},
});
pi.registerCommand("plan-continue", {
description: "Resume gated step-by-step execution of the current plan",
handler: async (_args, ctx) => {
if (phase !== "executing" || !plan) {
ctx.ui.notify("No plan execution in progress.", "info");
return;
}
if (awaitingStep) {
ctx.ui.notify("A step is already running.", "info");
return;
}
await gateNext(ctx);
},
});
pi.registerCommand("plan-save", {
description: "Save the current plan to a file in .pi/plans/",
handler: async (_args, ctx) => {
if (!plan) {
ctx.ui.notify("No plan to save. Submit a plan first.", "info");
return;
}
try {
planFilePath = await savePlanFile(ctx, plan);
ctx.ui.notify(`Plan saved to ${planFilePath}`, "info");
persistState();
} catch {
ctx.ui.notify("Failed to save plan.", "error");
}
pi.registerCommand("handover", {
description: "Write a handover document for this session to .pi/plans/",
handler: async (args, ctx) => {
await writeHandover(ctx, args.trim());
},
});
pi.registerShortcut(Key.ctrlAlt("p"), {
description: "Toggle plan mode",
handler: async (ctx) => togglePlanMode(ctx),
handler: async (ctx) => setEnabled(!enabled, ctx),
});
// ---- submit_plan tool ----
pi.registerTool({
name: "submit_plan",
label: "Submit Plan",
description:
"Submit a structured implementation plan for user approval. ONLY available in plan mode. " +
"Call this once analysis is finished and you are ready to propose the work. After calling it, stop and wait for the user.",
promptSnippet: "Submit a structured plan (title + numbered steps) for approval while in plan mode",
promptGuidelines: [
"Use submit_plan to deliver your plan when in plan mode instead of writing a free-form 'Plan:' section.",
"After calling submit_plan, do not take further action; wait for the user to approve, refine, or execute.",
],
parameters: submitPlanSchema,
async execute(_toolCallId, params: SubmitPlanInput, _signal, _onUpdate, ctx) {
if (!planModeEnabled) {
return {
content: [
{ type: "text", text: "submit_plan is only available in plan mode. Enable it with /plan first." },
],
isError: true,
details: {},
};
}
plan = {
title: params.title.trim() || "Untitled plan",
createdAt: new Date().toISOString(),
steps: params.steps
.map((s) => ({ text: s.description.trim(), completed: false }))
.filter((s) => s.text.length > 0),
risks: params.risks?.map((r) => r.trim()).filter(Boolean),
files: params.files?.map((f) => f.trim()).filter(Boolean),
};
currentStepIndex = 0;
awaitingStep = false;
phase = "awaiting-approval";
persistState();
updateStatus(ctx);
return {
content: [
{
type: "text",
text: `Plan "${plan.title}" with ${plan.steps.length} step(s) submitted. Stop here and wait for the user to approve, refine, or execute. Use /plan-save to save the plan to a file.`,
},
],
details: { title: plan.title, steps: plan.steps.length },
terminate: true,
};
},
pi.on("before_agent_start", async (event) => {
if (!enabled) return;
return { systemPrompt: `${event.systemPrompt}\n\n${PLAN_MODE_PROMPT}` };
});
// ---- block destructive bash in plan mode ----
pi.on("tool_call", async (event) => {
if (!planModeEnabled || event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// ---- strip stale plan-mode context when not in plan mode ----
pi.on("context", async (event) => {
if (planModeEnabled) return;
return {
messages: event.messages.filter((m) => {
const msg = m as AgentMessage & { customType?: string };
if (msg.customType === "plan-mode-context") return false;
if (msg.role !== "user") return true;
const content = msg.content;
if (typeof content === "string") return !content.includes("[PLAN MODE ACTIVE]");
if (Array.isArray(content)) {
return !content.some(
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
);
}
return true;
}),
};
});
// ---- inject plan-mode instructions ----
pi.on("before_agent_start", async () => {
if (!planModeEnabled) return;
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- Built-in edit and write tools are disabled
- Bash is restricted to an allowlist of read-only commands
- Do NOT attempt to make changes - only investigate and plan
Explore the code, ask clarifying questions if needed, then call the "submit_plan" tool with:
- title: a short, recognizable name for the plan
- steps: an ordered list of concrete, actionable steps
- risks: optional caveats or open questions
- files: optional files you expect to create or modify
Do NOT write a free-form "Plan:" section - use the submit_plan tool instead.
After submitting, stop and wait for the user.`,
display: false,
},
};
});
// ---- drive approval + gated execution when the agent settles ----
pi.on("agent_settled", async (_event, ctx) => {
if (!ctx.hasUI) return;
if (phase === "awaiting-approval") {
await handleApproval(ctx);
return;
}
if (phase === "executing" && awaitingStep) {
awaitingStep = false;
const step = plan?.steps[currentStepIndex];
if (step) step.completed = true;
currentStepIndex++;
updateStatus(ctx);
persistState();
await persistPlanFile();
await gateNext(ctx);
}
});
// ---- restore state on session start / resume ----
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
phase = "planning";
}
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
const last = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === STATE_ENTRY)
.pop() as { data?: PlanModeState } | undefined;
if (planModeEntry?.data) {
const d = planModeEntry.data;
planModeEnabled = d.enabled ?? planModeEnabled;
phase = d.phase ?? phase;
plan = d.plan ?? plan;
currentStepIndex = d.currentStepIndex ?? currentStepIndex;
planFilePath = d.planFilePath ?? planFilePath;
toolsBeforePlanMode = d.toolsBeforePlanMode ?? toolsBeforePlanMode;
awaitingStep = false; // never resume mid-step automatically
if (last?.data) {
enabled = last.data.enabled;
savedTools = last.data.savedTools;
}
if (pi.getFlag("plan") === true) {
enabled = true;
}
if (planModeEnabled) {
enablePlanModeTools();
}
if (enabled) applyToolRestrictions();
updateStatus(ctx);
if (phase === "executing" && plan && ctx.hasUI) {
ctx.ui.notify(`Plan "${plan.title}" execution paused. Use /plan-continue to resume.`, "info");
}
});
}

View File

@@ -1,168 +0,0 @@
/**
* Pure utility functions for plan mode.
* Extracted for testability.
*/
// Destructive commands blocked in plan mode
const DESTRUCTIVE_PATTERNS = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/(^|[^<])>(?!>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish)/i,
/\bpip\s+(install|uninstall)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
// Safe read-only commands allowed in plan mode
const SAFE_PATTERNS = [
/^\s*cat\b/,
/^\s*head\b/,
/^\s*tail\b/,
/^\s*less\b/,
/^\s*more\b/,
/^\s*grep\b/,
/^\s*find\b/,
/^\s*ls\b/,
/^\s*pwd\b/,
/^\s*echo\b/,
/^\s*printf\b/,
/^\s*wc\b/,
/^\s*sort\b/,
/^\s*uniq\b/,
/^\s*diff\b/,
/^\s*file\b/,
/^\s*stat\b/,
/^\s*du\b/,
/^\s*df\b/,
/^\s*tree\b/,
/^\s*which\b/,
/^\s*whereis\b/,
/^\s*type\b/,
/^\s*env\b/,
/^\s*printenv\b/,
/^\s*uname\b/,
/^\s*whoami\b/,
/^\s*id\b/,
/^\s*date\b/,
/^\s*cal\b/,
/^\s*uptime\b/,
/^\s*ps\b/,
/^\s*top\b/,
/^\s*htop\b/,
/^\s*free\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
/^\s*git\s+ls-/i,
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
/^\s*yarn\s+(list|info|why|audit)/i,
/^\s*node\s+--version/i,
/^\s*python\s+--version/i,
/^\s*curl\s/i,
/^\s*wget\s+-O\s*-/i,
/^\s*jq\b/,
/^\s*sed\s+-n/i,
/^\s*awk\b/,
/^\s*rg\b/,
/^\s*fd\b/,
/^\s*bat\b/,
/^\s*eza\b/,
];
export function isSafeCommand(command: string): boolean {
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
return !isDestructive && isSafe;
}
export interface TodoItem {
step: number;
text: string;
completed: boolean;
}
export function cleanStepText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
.replace(/`([^`]+)`/g, "$1") // Remove code
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
.replace(/\s+/g, " ")
.trim();
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
return cleaned;
}
export function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
if (!headerMatch) return items;
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
for (const match of planSection.matchAll(numberedPattern)) {
const text = match[2]
.trim()
.replace(/\*{1,2}$/, "")
.trim();
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
return items;
}
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
const step = Number(match[1]);
if (Number.isFinite(step)) steps.push(step);
}
return steps;
}
export function markCompletedSteps(text: string, items: TodoItem[]): number {
const doneSteps = extractDoneSteps(text);
for (const step of doneSteps) {
const item = items.find((t) => t.step === step);
if (item) item.completed = true;
}
return doneSteps.length;
}

View File

@@ -40,56 +40,141 @@ export default function (pi: ExtensionAPI) {
],
});
// ── OpenAI via Langdock ─────────────────────────────────────────────────
// ── OpenAI via Langdock (Responses API) ─────────────────────────────────
// Chat Completions is unusable for agent work: Langdock rejects the combo pi
// needs with "400 Function tools with reasoning_effort are not supported for
// <model> in /v1/chat/completions. To use function tools, use /v1/responses
// or set reasoning_effort to 'none'."
// Docs: https://docs.langdock.com/en/developer/completion-api/openai-responses
//
// Verified against api.langdock.com/openai/eu/v1/responses:
// ✓ SSE streaming, strict function tools, parallel calls, tool-result replay
// ✓ image input, developer role, store:false (pi always sends it)
// ✗ reasoning effort "minimal" (upstream rejects it for these models)
// ✗ Lark/regex grammar tools → only `type: "function"` is allowed
// ✗ max_output_tokens above the per-model cap (silently clamped)
// • no encrypted reasoning is returned, but replaying reasoning items in
// `input` on follow-up turns is accepted
const openaiCompat = {
// strict JSON-schema function tools are accepted
supportsStrictMode: true,
// Langdock: 'Unsupported tool type "custom". Only function tools are allowed.'
supportsOpenAIGrammarTools: false,
// skip the underscore-containing `session_id` header (proxies drop it),
// keep x-client-request-id for session affinity
sessionAffinityFormat: "openai-nosession",
} as const;
// NOTE: provider-level `compat` is ignored for extension-registered models,
// so it has to be repeated on every model entry below.
// Langdock caps OpenAI models at 60k tokens/minute (Claude gets 200k), and a
// request larger than the budget is rejected outright — so the usable window
// is far below the model's real context size. 60k here makes pi auto-compact
// at ~44k (contextWindow - reserveTokens) and stay under the gate.
// Raise this if your workspace admin lifts the per-model TPM limit.
const openaiContextWindow = 60000;
pi.registerProvider("openai", {
baseUrl: "https://api.langdock.com/openai/eu/v1",
apiKey: "$LANGDOCK_API_KEY",
api: "openai-completions",
api: "openai-responses",
compat: openaiCompat,
models: [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
reasoning: true,
input: ["text", "image"],
contextWindow: 272000,
maxTokens: 128000,
contextWindow: openaiContextWindow,
maxTokens: 16000,
thinkingLevelMap: {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compat: openaiCompat,
},
{
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
reasoning: true,
input: ["text", "image"],
contextWindow: 272000,
maxTokens: 128000,
contextWindow: openaiContextWindow,
maxTokens: 16000,
thinkingLevelMap: {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compat: openaiCompat,
},
{
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
reasoning: true,
input: ["text", "image"],
contextWindow: 272000,
maxTokens: 128000,
contextWindow: openaiContextWindow,
maxTokens: 16000,
thinkingLevelMap: {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compat: openaiCompat,
},
{
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
input: ["text", "image"],
contextWindow: 272000,
maxTokens: 128000,
contextWindow: openaiContextWindow,
maxTokens: 16000,
// "max" is rejected for this model
thinkingLevelMap: {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: null,
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compat: openaiCompat,
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
reasoning: false,
input: ["text"],
contextWindow: 272000,
maxTokens: 16384,
reasoning: true,
input: ["text", "image"],
contextWindow: openaiContextWindow,
maxTokens: 32000,
thinkingLevelMap: {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: null,
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compat: openaiCompat,
},
],
});

View File

@@ -10,12 +10,35 @@
},
"models": [
{
"id": "Qwen/Qwen3-30B-A3B",
"name": "Qwen3 30B A3B (local vLLM)",
"id": "Qwen/Qwen3.8-27B",
"name": "Qwen3.8 27B (local vLLM)",
"reasoning": true,
"input": ["text"],
"contextWindow": 16384,
"maxTokens": 8192,
"maxTokens": 16384,
"samplingParams": {
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20
},
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"compat": {
"thinkingFormat": "qwen-chat-template",
"maxTokensField": "max_tokens"
}
},
{
"id": "Qwen/Qwen3.8-27B-FP8",
"name": "Qwen3.8 27B FP8 (local vLLM)",
"reasoning": true,
"input": ["text"],
"contextWindow": 65536,
"maxTokens": 65536,
"samplingParams": {
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20
},
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"compat": {
"thinkingFormat": "qwen-chat-template",
@@ -40,6 +63,11 @@
"input": ["text"],
"contextWindow": 262144,
"maxTokens": 8192,
"samplingParams": {
"temperature": 0.6,
"top_p": 0.95,
"top_k": 20
},
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"compat": {
"maxTokensField": "max_tokens"

View File

@@ -1,8 +1,8 @@
{
"lastChangelogVersion": "0.81.1",
"lastChangelogVersion": "0.84.2",
"theme": "light",
"defaultProvider": "sparkr",
"defaultModel": "Qwen/Qwen3-Coder-Next-FP8",
"defaultProvider": "sparkl",
"defaultModel": "Qwen/Qwen3.8-27B-FP8",
"defaultThinkingLevel": "high",
"hideThinkingBlock": false,
"retry": {