diff --git a/flake.lock b/flake.lock index 257dac6..040f97a 100644 --- a/flake.lock +++ b/flake.lock @@ -45,11 +45,11 @@ ] }, "locked": { - "lastModified": 1782772816, - "narHash": "sha256-s9BuFv0mRuZx9C1MF8qPHRdcAK14ONi0A5m6E2wqOoM=", + "lastModified": 1784665499, + "narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=", "owner": "nix-community", "repo": "bun2nix", - "rev": "5a39d717029e94163ac223aee8d5c9946cafed1c", + "rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729", "type": "github" }, "original": { @@ -117,6 +117,24 @@ "inputs": { "nixpkgs-lib": "nixpkgs-lib_2" }, + "locked": { + "lastModified": 1772408722, + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_4": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_3" + }, "locked": { "lastModified": 1769996383, "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", @@ -131,24 +149,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems_3" - }, - "locked": { - "lastModified": 1681202837, - "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "cfacdce06f30d2b68473a46042957675eebb3401", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "home-manager": { "inputs": { "nixpkgs": [ @@ -156,11 +156,11 @@ ] }, "locked": { - "lastModified": 1783740085, - "narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=", + "lastModified": 1784350909, + "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=", "owner": "nix-community", "repo": "home-manager", - "rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb", + "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3", "type": "github" }, "original": { @@ -177,11 +177,11 @@ ] }, "locked": { - "lastModified": 1783740085, - "narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=", + "lastModified": 1784350909, + "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=", "owner": "nix-community", "repo": "home-manager", - "rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb", + "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3", "type": "github" }, "original": { @@ -197,11 +197,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1783931038, - "narHash": "sha256-gKf9U1sjnmR2Ok7lGAO5qqYsJ7ZWcP41jGqhcMzFQLk=", + "lastModified": 1784601628, + "narHash": "sha256-crcorpWHo3WxScdmuSw68QjhBt2FCSYws/uNmh1Lt1g=", "owner": "modem-dev", "repo": "hunk", - "rev": "4fdf4bc1e26f6fdceeced81bf7bae8f9bcb8588a", + "rev": "b94aab6b127bc5af0faec94e0e231a4c64251acf", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "treefmt-nix": "treefmt-nix_2" }, "locked": { - "lastModified": 1784011337, - "narHash": "sha256-5+VL25vjGT8wxdKz8ebFr7eL3Ot4Bmy+QW/Y1ayFjHQ=", + "lastModified": 1784790164, + "narHash": "sha256-woSpbXXGmy1zUnetetY6WiVdXz0WFGAsxHgCc+87oqU=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "8e68da4b08d1f5d0365dc1dcb066d998f0c69b31", + "rev": "de9fa923ea0ebed9093f758468d7ed7c5da09739", "type": "github" }, "original": { @@ -252,11 +252,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1783395956, - "narHash": "sha256-AAbexQvDoK+6GFJdhY6kqjA+6ECKRkidgWxkn4gMwAA=", + "lastModified": 1784500460, + "narHash": "sha256-UvORnAxTRHax7RG74W8Z2t4GvIkX6AjJ5kk0QlwZomo=", "owner": "LnL7", "repo": "nix-darwin", - "rev": "d5bd9cd77aea4c0a8f49e7fd85545671a208ed15", + "rev": "57a3171f94705599a2499248ca5758d5eb47c0e0", "type": "github" }, "original": { @@ -267,15 +267,14 @@ }, "nixos-vscode-server": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_4" + "flake-parts": "flake-parts_3" }, "locked": { - "lastModified": 1770124655, - "narHash": "sha256-yHmd2B13EtBUPLJ+x0EaBwNkQr9LTne1arLVxT6hSnY=", + "lastModified": 1784312229, + "narHash": "sha256-2uHCSUw341o3my1R0U0YCfbnMEazylxb58evWsjGL50=", "owner": "nix-community", "repo": "nixos-vscode-server", - "rev": "92ce71c3ba5a94f854e02d57b14af4997ab54ef0", + "rev": "2f984dfbe7e5271b5c413d3e734374cc1306c921", "type": "github" }, "original": { @@ -292,11 +291,11 @@ ] }, "locked": { - "lastModified": 1783897462, - "narHash": "sha256-SggqDrhk0+7YtsUgDArP42vnolNITo602XY9mn5amF0=", + "lastModified": 1784058838, + "narHash": "sha256-499ZAnS4nWV2Wtgoi3WdbIVuZbNoN7KptltceQhZc38=", "owner": "nix-community", "repo": "nixos-wsl", - "rev": "ff26608a372676b74799da5a9a4d06ce1a4fca2a", + "rev": "787a4c128292010ad182eed66f879fa688592e4c", "type": "github" }, "original": { @@ -338,6 +337,21 @@ } }, "nixpkgs-lib_2": { + "locked": { + "lastModified": 1772328832, + "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_3": { "locked": { "lastModified": 1769909678, "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", @@ -354,11 +368,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1783856661, - "narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=", + "lastModified": 1784432872, + "narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=", "owner": "nixos", "repo": "nixpkgs", - "rev": "569d578509928497eddc3fdbf94a799027050be4", + "rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870", "type": "github" }, "original": { @@ -370,11 +384,11 @@ }, "nixpkgs-wsl-stable": { "locked": { - "lastModified": 1783856661, - "narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=", + "lastModified": 1784432872, + "narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=", "owner": "nixos", "repo": "nixpkgs", - "rev": "569d578509928497eddc3fdbf94a799027050be4", + "rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870", "type": "github" }, "original": { @@ -386,11 +400,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1783816212, - "narHash": "sha256-2aZisVTVGSFEk3MYcOiWQp3zvwyzbqpktB69Rcfp150=", + "lastModified": 1784555310, + "narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3b32825de172d0bc85664f495edb096b10862524", + "rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493", "type": "github" }, "original": { @@ -418,25 +432,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1682134069, - "narHash": "sha256-TnI/ZXSmRxQDt2sjRYK/8j8iha4B4zP2cnQCZZ3vp7k=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "fd901ef4bf93499374c5af385b2943f5801c0833", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "type": "indirect" - } - }, - "nixpkgs_5": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", "type": "github" }, "original": { @@ -446,7 +446,7 @@ "type": "github" } }, - "nixpkgs_6": { + "nixpkgs_5": { "locked": { "lastModified": 1782175435, "narHash": "sha256-EMzXKmnOtBQ2MnvpiNOm7E+kOMvdPrIKaeg52Tip2Uk=", @@ -462,7 +462,7 @@ "type": "github" } }, - "nixpkgs_7": { + "nixpkgs_6": { "locked": { "lastModified": 1771369470, "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", @@ -487,7 +487,7 @@ "nix-darwin": "nix-darwin", "nixos-vscode-server": "nixos-vscode-server", "nixos-wsl": "nixos-wsl", - "nixpkgs": "nixpkgs_5", + "nixpkgs": "nixpkgs_4", "nixpkgs-stable": "nixpkgs-stable", "nixpkgs-wsl-stable": "nixpkgs-wsl-stable", "sops-nix": "sops-nix", @@ -496,7 +496,7 @@ }, "sops-nix": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1783174389, @@ -542,25 +542,10 @@ "type": "github" } }, - "systems_3": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, "tasksquire": { "inputs": { - "flake-parts": "flake-parts_3", - "nixpkgs": "nixpkgs_7" + "flake-parts": "flake-parts_4", + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1771878653, @@ -607,11 +592,11 @@ ] }, "locked": { - "lastModified": 1780220602, - "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "lastModified": 1784369104, + "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", "type": "github" }, "original": { diff --git a/hosts/work/common.nix b/hosts/work/common.nix index 717e51e..d77240c 100644 --- a/hosts/work/common.nix +++ b/hosts/work/common.nix @@ -21,6 +21,7 @@ opencode.enableConfig = true; opencode.workMode = true; pi.enable = true; + pi.workMode = true; }; dot.tmux.workMode = true; diff --git a/modules/home/common.nix b/modules/home/common.nix index 15db05b..b51297d 100644 --- a/modules/home/common.nix +++ b/modules/home/common.nix @@ -18,6 +18,7 @@ ./nvim.nix ./task.nix ./opencode.nix + ./pi.nix ]; config = { diff --git a/modules/home/dev.nix b/modules/home/dev.nix index c3f7cc6..f2d52c1 100644 --- a/modules/home/dev.nix +++ b/modules/home/dev.nix @@ -26,6 +26,11 @@ in default = false; description = "Enable pi"; }; + pi.workMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Apply work machine (WSL NixOS) specific pi patches"; + }; bubblewrap.enable = lib.mkOption { type = lib.types.bool; default = pkgs.stdenv.isLinux; @@ -64,7 +69,33 @@ in (lib.optional cfg.claude-code.enable pkgs.unstable.claude-code) ++ (lib.optional cfg.opencode.enable pkgs.unstable.opencode) ++ (lib.optional cfg.gemini-cli.enable pkgs.unstable.gemini-cli) ++ - (lib.optional cfg.pi.enable inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi) ++ + (lib.optional cfg.pi.enable ( + let + pi = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi; + in + if cfg.pi.workMode then + # Work machine runs WSL NixOS, which needs the checks disabled and the + # Bun binary's ELF interpreter patched to the correct dynamic linker. + pi.overrideAttrs (oldAttrs: { + doInstallCheck = false; + + # Run patchelf after the package is installed/fixed up by nix + postFixup = (oldAttrs.postFixup or "") + '' + # 1. Target the actual Bun binary inside libexec, not the shell wrapper in bin/ + TARGET_BIN="$out/libexec/pi/pi" + + if [ -f "$TARGET_BIN" ]; then + # 2. Make it writable in the build store so patchelf can edit it + chmod +w "$TARGET_BIN" + + # 3. Running patchelf to set the interpreter forces it to rebuild the ELF headers + patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" "$TARGET_BIN" + fi + ''; + }) + else + pi + )) ++ (lib.optional (cfg.bubblewrap.enable && pkgs.stdenv.isLinux) pkgs.unstable.bubblewrap); }) ]; diff --git a/modules/home/git.nix b/modules/home/git.nix index dbd635a..92b18c7 100644 --- a/modules/home/git.nix +++ b/modules/home/git.nix @@ -56,6 +56,7 @@ ignores = [ ".direnv/" ".envrc" + ".pi/" ]; }; diff --git a/modules/home/nvim.nix b/modules/home/nvim.nix index 689523c..0cdc63d 100644 --- a/modules/home/nvim.nix +++ b/modules/home/nvim.nix @@ -58,6 +58,7 @@ in nvim-lspconfig lspkind-nvim # opencode-nvim + pi-nvim bullets-vim nvim-dap nvim-nio diff --git a/modules/home/pi.nix b/modules/home/pi.nix new file mode 100644 index 0000000..fffae33 --- /dev/null +++ b/modules/home/pi.nix @@ -0,0 +1,25 @@ +{ config, lib, ... }: + +let + cfg = config.dot.pi; + piPath = "${config.dot.dotfilesPath}/modules/pi/agent"; +in +{ + options.dot.pi = { + enable = lib.mkEnableOption "managed pi coding agent configuration" // { + default = true; + }; + }; + + config = lib.mkIf cfg.enable { + # Out-of-store symlinks so the files stay editable without a nix rebuild. + # Only the managed files are linked; pi's runtime files (auth.json, + # sessions/, models-store.json) are left untouched in ~/.pi/agent. + home.file = { + ".pi/agent/settings.json".source = + config.lib.file.mkOutOfStoreSymlink "${piPath}/settings.json"; + ".pi/agent/extensions".source = + config.lib.file.mkOutOfStoreSymlink "${piPath}/extensions"; + }; + }; +} diff --git a/modules/nvim/lua/filetype.lua b/modules/nvim/lua/filetype.lua index 8bab121..b224458 100644 --- a/modules/nvim/lua/filetype.lua +++ b/modules/nvim/lua/filetype.lua @@ -38,7 +38,10 @@ vim.lsp.config('basedpyright', { analysis = { indexing = true, typeCheckingMode = "standard", - autoImportCompletions = true + autoImportCompletions = true, + diagnosticSeverityOverrides = { + reportAttributeAccessIssue = "none", + }, } } } diff --git a/modules/nvim/lua/keymaps.lua b/modules/nvim/lua/keymaps.lua index fc91c04..fdcffc7 100644 --- a/modules/nvim/lua/keymaps.lua +++ b/modules/nvim/lua/keymaps.lua @@ -103,6 +103,11 @@ vim.keymap.set('v', 'cf', end ) +vim.keymap.set("n", "ai", ":PiAsk", { desc = "Ask pi" }) +vim.keymap.set("v", "ai", ":PiAskSelection", { desc = "Ask pi (selection)" }) +vim.keymap.set("n", "ac", ":PiCancel", { desc = "Cancel pi request" }) +vim.keymap.set("n", "al", ":PiLog", { desc = "Show session log" }) + -- Yanky vim.keymap.set({"n","x"}, "p", "(YankyPutAfter)") vim.keymap.set({"n","x"}, "P", "(YankyPutBefore)") @@ -135,10 +140,11 @@ vim.keymap.set('n', "dt", function() require("dap").terminate() end) vim.keymap.set('n', "dw", function() require("dap.ui.widgets").hover() end) vim.keymap.set('n', "dv", function() require("dap-view").toggle() end) -vim.keymap.set('n', "", function() require("dap").continue() end) -vim.keymap.set('n', "", function() require("dap").step_over() end) -vim.keymap.set('n', "", function() require("dap").step_into() end) -vim.keymap.set('n', "", function() require("dap").step_out() end) -vim.keymap.set('n', "", function() require("dap").run_to_cursor() end) - +vim.keymap.set('n', "", function() require("dap").continue() end, { desc = "Debug: Continue/Start" }) +vim.keymap.set('n', "", function() require("dap").terminate() end, { desc = "Debug: Stop" }) +vim.keymap.set('n', "", function() require("dap").pause() end, { desc = "Debug: Pause" }) +vim.keymap.set('n', "", function() require("dap").toggle_breakpoint() end, { desc = "Debug: Toggle Breakpoint" }) +vim.keymap.set('n', "", function() require("dap").step_over() end, { desc = "Debug: Step Over" }) +vim.keymap.set('n', "", function() require("dap").step_into() end, { desc = "Debug: Step Into" }) +vim.keymap.set('n', "", function() require("dap").step_out() end, { desc = "Debug: Step Out" }) diff --git a/modules/pi/agent/extensions/langdock.ts b/modules/pi/agent/extensions/langdock.ts new file mode 100644 index 0000000..ca83594 --- /dev/null +++ b/modules/pi/agent/extensions/langdock.ts @@ -0,0 +1,190 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // ── Anthropic via Langdock ────────────────────────────────────────────── + pi.registerProvider("anthropic", { + baseUrl: "https://api.langdock.com/anthropic/eu", + apiKey: "$LANGDOCK_API_KEY", + api: "anthropic-messages", + models: [ + { + id: "claude-opus-4-8-default", + name: "Opus 4.8", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 32000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { forceAdaptiveThinking: true }, + }, + { + id: "claude-sonnet-5-default", + name: "Sonnet 5", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 16384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { forceAdaptiveThinking: true }, + }, + { + id: "claude-opus-4-6-default", + name: "Opus 4.6", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 32000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { forceAdaptiveThinking: true }, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Haiku 4.5", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 16384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }); + + // ── OpenAI via Langdock ───────────────────────────────────────────────── + pi.registerProvider("openai", { + baseUrl: "https://api.langdock.com/openai/eu/v1", + apiKey: "$LANGDOCK_API_KEY", + api: "openai-completions", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + reasoning: true, + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + reasoning: true, + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + reasoning: true, + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text", "image"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + reasoning: false, + input: ["text"], + contextWindow: 272000, + maxTokens: 16384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }); + + // ── Google via Langdock ───────────────────────────────────────────────── + // pi.registerProvider("google", { + // baseUrl: "https://api.langdock.com/google/eu/v1beta", + // apiKey: "$LANGDOCK_API_KEY", + // api: "google-generative-ai", + // models: [ + // { + // id: "models/gemini-3.5-flash", + // name: "Gemini 3.5 Flash", + // reasoning: true, + // input: ["text", "image"], + // contextWindow: 1048576, + // maxTokens: 8192, + // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + // }, + // { + // id: "models/gemini-2.5-flash", + // name: "Gemini 2.5 Flash", + // reasoning: true, + // input: ["text", "image"], + // contextWindow: 1048576, + // maxTokens: 8192, + // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + // }, + // { + // id: "models/gemini-2.5-pro", + // name: "Gemini 2.5 Pro", + // reasoning: true, + // input: ["text", "image"], + // contextWindow: 2097152, + // maxTokens: 65536, + // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + // }, + // ], + // }); + + // ── Hide unwanted built-in providers ──────────────────────────────────── + const keep = new Set([ + "anthropic", + "openai", + // "openrouter", + ]); + + const hide = [ + "openrouter", + "ant-ling", + "azure-openai", + "azure-openai-responses", + "deepseek", + "nvidia-nim", + "google-vertex", + "amazon-bedrock", + "google", + "mistral", + "groq", + "cerebras", + "cloudflare-ai-gateway", + "cloudflare-workers-ai", + "xai", + "vercel-ai-gateway", + "zai-coding-plan-global", + "zai-coding-plan-china", + "opencode-zen", + "opencode-go", + "huggingface", + "fireworks", + "together-ai", + "kimi-for-coding", + "minimax", + "xiaomi-mimo", + "xiaomi-mimo-china", + "xiaomi-mimo-amsterdam", + "xiaomi-mimo-singapore", + ]; + + for (const provider of hide) { + if (!keep.has(provider)) { + try { + pi.unregisterProvider(provider); + } catch { + // provider may not exist in this build + } + } + } +} diff --git a/modules/pi/agent/extensions/plan-mode/README.md b/modules/pi/agent/extensions/plan-mode/README.md new file mode 100644 index 0000000..2568a68 --- /dev/null +++ b/modules/pi/agent/extensions/plan-mode/README.md @@ -0,0 +1,66 @@ +# Plan Mode Extension + +Read-only exploration mode for safe code analysis. + +## Features + +- **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 + +## Commands + +- `/plan` - Toggle plan mode +- `/todos` - Show current plan progress +- `Ctrl+Alt+P` - Toggle plan mode (shortcut) + +## Usage + +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: + +``` +Plan: +1. First step description +2. Second step description +3. Third step description +``` + +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 + +## How It Works + +### 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 + +### Execution Mode +- Full tool access restored +- Agent executes steps in order +- `[DONE:n]` markers track completion +- Widget shows progress + +### 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` diff --git a/modules/pi/agent/extensions/plan-mode/index.ts b/modules/pi/agent/extensions/plan-mode/index.ts new file mode 100644 index 0000000..e9ee73f --- /dev/null +++ b/modules/pi/agent/extensions/plan-mode/index.ts @@ -0,0 +1,561 @@ +/** + * Plan Mode Extension + * + * Read-only exploration + structured planning + gated step-by-step execution. + * + * 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 + */ + +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 { 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"; + +// 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]); + +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[]; +} + +interface PlanModeState { + enabled: boolean; + phase?: Phase; + plan?: StoredPlan; + currentStepIndex?: number; + planFilePath?: string; + toolsBeforePlanMode?: 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; + +// ---- 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"; +} + +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"); +} + +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; + + pi.registerFlag("plan", { + description: "Start in plan mode (read-only exploration)", + type: "boolean", + default: false, + }); + + // ---- tool management ---- + function uniqueToolNames(toolNames: string[]): string[] { + return [...new Set(toolNames)]; + } + 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(); + } + pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode)); + } + function restoreNormalModeTools(): void { + pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools())); + toolsBeforePlanMode = undefined; + } + + // ---- 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 + } + } + + 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); + } + } + + // ---- mode toggle ---- + function togglePlanMode(ctx: ExtensionContext): void { + planModeEnabled = !planModeEnabled; + phase = planModeEnabled ? "planning" : undefined; + plan = undefined; + planFilePath = undefined; + currentStepIndex = 0; + awaitingStep = false; + + if (planModeEnabled) { + enablePlanModeTools(); + ctx.ui.notify("Plan mode enabled. Built-in write tools disabled. Ask for a plan, then submit_plan."); + } else { + restoreNormalModeTools(); + ctx.ui.notify("Plan mode disabled. Full access restored."); + } + updateStatus(ctx); + persistState(); + } + + // ---- 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 where = planFilePath ? `\nSaved to: ${planFilePath}` : ""; + const choice = await ctx.ui.select( + `Plan "${plan.title}" — ${plan.steps.length} steps.${where}\nWhat next?`, + ["Execute step-by-step", "Stay in plan mode", "Refine the plan"], + ); + + 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 { + 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), + }); + + 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.registerShortcut(Key.ctrlAlt("p"), { + description: "Toggle plan mode", + handler: async (ctx) => togglePlanMode(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"; + + try { + planFilePath = await savePlanFile(ctx, plan); + } catch { + planFilePath = undefined; + } + persistState(); + updateStatus(ctx); + + const loc = planFilePath ? ` and saved to ${planFilePath}` : ""; + return { + content: [ + { + type: "text", + text: `Plan "${plan.title}" with ${plan.steps.length} step(s) submitted${loc}. Stop here and wait for the user to approve, refine, or execute.`, + }, + ], + details: { title: plan.title, steps: plan.steps.length, path: planFilePath ?? null }, + terminate: true, + }; + }, + }); + + // ---- 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") + .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 (planModeEnabled) { + enablePlanModeTools(); + } + 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/plan-mode/utils.ts b/modules/pi/agent/extensions/plan-mode/utils.ts new file mode 100644 index 0000000..62123f9 --- /dev/null +++ b/modules/pi/agent/extensions/plan-mode/utils.ts @@ -0,0 +1,168 @@ +/** + * 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/settings.json b/modules/pi/agent/settings.json new file mode 100644 index 0000000..edd763d --- /dev/null +++ b/modules/pi/agent/settings.json @@ -0,0 +1,16 @@ +{ + "lastChangelogVersion": "0.81.1", + "theme": "light", + "defaultProvider": "anthropic", + "defaultModel": "claude-opus-4-8-default", + "defaultThinkingLevel": "high", + "hideThinkingBlock": false, + "retry": { + "enabled": true, + "maxRetries": 4, + "baseDelayMs": 10000 + }, + "enableInstallTelemetry": false, + "doubleEscapeAction": "tree", + "treeFilterMode": "all" +} \ No newline at end of file diff --git a/things.py b/things.py new file mode 100644 index 0000000..7c44f9d --- /dev/null +++ b/things.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""things.py - description here.""" + +import argparse + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="things.py - description here.") + parser.add_argument( + "input", + nargs="?", + help="input file or value to process", + ) + parser.add_argument( + "-o", + "--output", + help="output file (defaults to stdout)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="enable verbose output", + ) + return parser.parse_args(argv) + + +def main() -> None: + args = parse_args() + if args.verbose: + print(f"input={args.input!r} output={args.output!r}") + + +if __name__ == "__main__": + main()