From 331b759b7d035169072612908a158560769a9686 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Mon, 24 Aug 2026 08:59:22 +0200 Subject: [PATCH] Update pi --- flake.lock | 90 +-- modules/home/tmux.nix | 14 +- modules/nixos/common.nix | 1 + .../extensions-common/plan-mode/README.md | 82 +-- .../extensions-common/plan-mode/handover.ts | 196 ++++++ .../extensions-common/plan-mode/index.ts | 589 +++--------------- .../extensions-common/plan-mode/utils.ts | 168 ----- modules/pi/agent/extensions-work/langdock.ts | 113 +++- modules/pi/agent/models.work.json | 34 +- modules/pi/agent/settings.work.json | 6 +- 10 files changed, 486 insertions(+), 807 deletions(-) create mode 100644 modules/pi/agent/extensions-common/plan-mode/handover.ts delete mode 100644 modules/pi/agent/extensions-common/plan-mode/utils.ts diff --git a/flake.lock b/flake.lock index b6fb9c4..13b44af 100644 --- a/flake.lock +++ b/flake.lock @@ -8,7 +8,10 @@ "hunk", "nixpkgs" ], - "systems": "systems", + "systems": [ + "hunk", + "systems" + ], "treefmt-nix": "treefmt-nix" }, "locked": { @@ -100,11 +103,11 @@ ] }, "locked": { - "lastModified": 1782949081, - "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", "type": "github" }, "original": { @@ -156,11 +159,11 @@ ] }, "locked": { - "lastModified": 1784350909, - "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=", + "lastModified": 1787377438, + "narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=", "owner": "nix-community", "repo": "home-manager", - "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3", + "rev": "65258d5c65a250189fde2e35f490d15e064c4c62", "type": "github" }, "original": { @@ -177,11 +180,11 @@ ] }, "locked": { - "lastModified": 1784350909, - "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=", + "lastModified": 1787377438, + "narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=", "owner": "nix-community", "repo": "home-manager", - "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3", + "rev": "65258d5c65a250189fde2e35f490d15e064c4c62", "type": "github" }, "original": { @@ -194,14 +197,15 @@ "hunk": { "inputs": { "bun2nix": "bun2nix", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "systems": "systems" }, "locked": { - "lastModified": 1784815837, - "narHash": "sha256-fVBvarngVvsCS1QaKU2LSa/YmZn2l8oOkXI+EYWlURc=", + "lastModified": 1787535089, + "narHash": "sha256-RlhGqY8S0GDiYDep6UypAcGe22ablYZZ8aqmtrlBURw=", "owner": "modem-dev", "repo": "hunk", - "rev": "b358e0c92eabaa35529d3f3d9afaad2944c825e4", + "rev": "1210c084d38f0d8898d009b82a9142638c21ad80", "type": "github" }, "original": { @@ -234,11 +238,11 @@ "treefmt-nix": "treefmt-nix_2" }, "locked": { - "lastModified": 1784790164, - "narHash": "sha256-woSpbXXGmy1zUnetetY6WiVdXz0WFGAsxHgCc+87oqU=", + "lastModified": 1787551962, + "narHash": "sha256-zfumhlJTGDKSILebSBa8nTKG76ByIPlLkGPAKjxaiwE=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "de9fa923ea0ebed9093f758468d7ed7c5da09739", + "rev": "46e6a013fd49779cd2def2c98fa447f197da3fe7", "type": "github" }, "original": { @@ -252,11 +256,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1784500460, - "narHash": "sha256-UvORnAxTRHax7RG74W8Z2t4GvIkX6AjJ5kk0QlwZomo=", + "lastModified": 1786845137, + "narHash": "sha256-oQFip+v0luP8NIxJzmiW4Wu8bILsbFWom5l0zonl8hQ=", "owner": "nix-darwin", "repo": "nix-darwin", - "rev": "57a3171f94705599a2499248ca5758d5eb47c0e0", + "rev": "4cff07de74b50e64bdd68cd4e722ab5b6b35ee48", "type": "github" }, "original": { @@ -367,11 +371,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1784432872, - "narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=", + "lastModified": 1787414105, + "narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870", + "rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a", "type": "github" }, "original": { @@ -383,11 +387,11 @@ }, "nixpkgs-wsl-stable": { "locked": { - "lastModified": 1784432872, - "narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=", + "lastModified": 1787414105, + "narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870", + "rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a", "type": "github" }, "original": { @@ -399,11 +403,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1784555310, - "narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=", + "lastModified": 1787209939, + "narHash": "sha256-WvvHR4kSQLAbtouMC/ruZ5UpLwlUcY3K4FAllMN+yGk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493", + "rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb", "type": "github" }, "original": { @@ -431,11 +435,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "lastModified": 1787360063, + "narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=", "owner": "nixos", "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5", "type": "github" }, "original": { @@ -498,11 +502,11 @@ "nixpkgs": "nixpkgs_5" }, "locked": { - "lastModified": 1783174389, - "narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", + "lastModified": 1786629091, + "narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=", "owner": "Mic92", "repo": "sops-nix", - "rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", + "rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f", "type": "github" }, "original": { @@ -513,16 +517,16 @@ }, "systems": { "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "lastModified": 1776166891, + "narHash": "sha256-bI8yrEGjrohR5hkQox7UrxDH7XqrYMwI8SL/LrJ1+S8=", "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "repo": "triplet", + "rev": "6de7bc09397911ce03636afbcf6118745ab2cda0", "type": "github" }, "original": { "owner": "nix-systems", - "repo": "default", + "repo": "triplet", "type": "github" } }, @@ -591,11 +595,11 @@ ] }, "locked": { - "lastModified": 1784369104, - "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", "type": "github" }, "original": { diff --git a/modules/home/tmux.nix b/modules/home/tmux.nix index 0c6553d..cbb89c2 100644 --- a/modules/home/tmux.nix +++ b/modules/home/tmux.nix @@ -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 = { diff --git a/modules/nixos/common.nix b/modules/nixos/common.nix index 142ffb0..f35a9c0 100644 --- a/modules/nixos/common.nix +++ b/modules/nixos/common.nix @@ -39,6 +39,7 @@ environment.systemPackages = with pkgs; [ git + git-lfs wget curl vim diff --git a/modules/pi/agent/extensions-common/plan-mode/README.md b/modules/pi/agent/extensions-common/plan-mode/README.md index 2568a68..ed1cb81 100644 --- a/modules/pi/agent/extensions-common/plan-mode/README.md +++ b/modules/pi/agent/extensions-common/plan-mode/README.md @@ -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/-.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 diff --git a/modules/pi/agent/extensions-common/plan-mode/handover.ts b/modules/pi/agent/extensions-common/plan-mode/handover.ts new file mode 100644 index 0000000..fbb45b5 --- /dev/null +++ b/modules/pi/agent/extensions-common/plan-mode/handover.ts @@ -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/-.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: + +# + +## 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 { + 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((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; +} diff --git a/modules/pi/agent/extensions-common/plan-mode/index.ts b/modules/pi/agent/extensions-common/plan-mode/index.ts index ca721d7..4540e68 100644 --- a/modules/pi/agent/extensions-common/plan-mode/index.ts +++ b/modules/pi/agent/extensions-common/plan-mode/index.ts @@ -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/-.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(["edit", "write"]); -const PLAN_MANAGED_TOOLS = new Set([...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; +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 { - 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 { - 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 { - 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 { - 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"); - } }); } diff --git a/modules/pi/agent/extensions-common/plan-mode/utils.ts b/modules/pi/agent/extensions-common/plan-mode/utils.ts deleted file mode 100644 index 62123f9..0000000 --- a/modules/pi/agent/extensions-common/plan-mode/utils.ts +++ /dev/null @@ -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; -} diff --git a/modules/pi/agent/extensions-work/langdock.ts b/modules/pi/agent/extensions-work/langdock.ts index f108ab7..3bc3424 100644 --- a/modules/pi/agent/extensions-work/langdock.ts +++ b/modules/pi/agent/extensions-work/langdock.ts @@ -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 + // 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, }, ], }); diff --git a/modules/pi/agent/models.work.json b/modules/pi/agent/models.work.json index b981f9b..d53f78f 100644 --- a/modules/pi/agent/models.work.json +++ b/modules/pi/agent/models.work.json @@ -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" diff --git a/modules/pi/agent/settings.work.json b/modules/pi/agent/settings.work.json index 8ac19a8..247c98d 100644 --- a/modules/pi/agent/settings.work.json +++ b/modules/pi/agent/settings.work.json @@ -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": {