@openlines/opl-pi-sht 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. package/package.json +55 -0
@@ -0,0 +1,132 @@
1
+ # opl-modes
2
+
3
+ Unified mode manager for Pi. It provides normal, read-only chat, read-only planning, plan execution, and configurable custom modes. It also publishes shared mode state consumed by `opl-input` and `opl-footer`.
4
+
5
+ ## Commands, flags, and shortcuts
6
+
7
+ - `/mode` opens the picker; `/mode chat`, `/mode plan`, and `/mode normal` select built-in modes.
8
+ - `/chat` toggles chat mode; `/chat off` exits it.
9
+ - `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
10
+ - `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
11
+ - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
+ - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan.
13
+
14
+ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `plan_complete` is available only in execute mode. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically.
15
+
16
+ ## Extension features
17
+
18
+ ### Mode behavior
19
+
20
+ Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. Destructive patterns are checked even when a command matches a safe pattern. User-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them.
21
+
22
+ Custom modes can add or override modes with prompts, tool lists, Bash patterns, model overrides, visibility, enabled state, `plan_complete` permission, execute-handoff permission, labels, and `appearance`. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color.
23
+
24
+ ### Execute handoff
25
+
26
+ Any mode can start plan execution via the mode picker's `Execute:` entries or `/execute`, unless it sets `allowExecute: false`. When blocked, the picker hides the `Execute:` entries and `/execute` reports that execution is unavailable from the current mode. The plan-mode action menu (`Execute / Refine / Save & Exit / Discard & Exit`) is the designed plan-to-execute pipeline and is always available in plan mode regardless of this flag.
27
+
28
+ `allowPlanComplete: true` on a custom mode appends the `plan_complete` tool to that mode's tool list, letting a custom mode finish and exit through the same completion path as execute mode (the `tool_call` gate enforces the flag).
29
+
30
+ ### Tool inheritance warning
31
+
32
+ A custom mode with no `tools` array inherits **all** tools, including `write` and `edit` — it is write-capable by default. Always specify an explicit read-only tool list for restrictive modes.
33
+
34
+ Mode state is persisted in session entries and restored on session resume or branch changes. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts.
35
+
36
+ ## Configuration
37
+
38
+ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sample`](../../configs/opl-modes.json.sample). All fields are optional. The module reads the file when loaded; restart Pi or run `/reload` after changes.
39
+
40
+ ```json
41
+ {
42
+ "ui": { "hideNotify": false, "hideWidget": true },
43
+ "shortcuts": { "cycleMode": "ctrl+alt+m" },
44
+ "cleanup": { "cleanupOnComplete": true },
45
+ "defaultNotifyTemplate": "✓ {Name} mode ON",
46
+ "bashPatterns": {
47
+ "safePatterns": ["^\\s*cat\\b"],
48
+ "destructivePatterns": ["\\brm\\b"]
49
+ },
50
+ "modes": {
51
+ "review": {
52
+ "enabled": true,
53
+ "allowExecute": false,
54
+ "prompt": "Review diffs and codebases for correctness, security, and architecture. Do not modify files.",
55
+ "tools": ["read", "bash", "grep", "find", "ls", "web_search", "fetch_content", "get_search_content", "artifact", "questionnaire"],
56
+ "safePatterns": ["^git", "^cat", "^grep", "^diff", "^ls"],
57
+ "labels": { "widgetColor": "accent" }
58
+ },
59
+ "research": {
60
+ "enabled": true,
61
+ "allowExecute": false,
62
+ "prompt": "Deep web/local research with subagent fan-out. Create new outputs in any location; never modify existing files.",
63
+ "tools": ["read", "grep", "find", "ls", "web_search", "fetch_content", "get_search_content", "artifact", "questionnaire", "subagent", "subagent_wait", "write"]
64
+ },
65
+ "verify": {
66
+ "enabled": true,
67
+ "allowPlanComplete": true,
68
+ "prompt": "Verify the implementation, then call plan_complete.",
69
+ "tools": ["read", "grep", "bash", "plan_complete"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ | Area | Behavior |
76
+ |---|---|
77
+ | `ui.hideNotify` / `ui.hideWidget` | Suppress mode notifications or widgets. |
78
+ | `shortcuts.cycleMode` | Keybinding for cycling enabled visible modes. |
79
+ | `cleanup.cleanupOnComplete` | Delete the active plan after successful `plan_complete`. |
80
+ | `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
81
+ | `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
82
+ | `chatAllowedTools` / `planAllowedTools` | **Deprecated compatibility aliases** for the built-in tool lists. They are used only when the corresponding `modes.<name>.tools` is omitted; migrate to `modes.chat.tools` or `modes.plan.tools`. |
83
+ | `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Replace the shared Bash policy for built-in chat and plan. Per-mode pattern fields intentionally override this shared policy when those modes need to differ. |
84
+ | `lazyTools` | Tool names withheld from the active set at rest and enabled on demand (see Lazy tool loading below). |
85
+ | `modes.<name>` | Add or override a mode, including `model`, `tools`, patterns, `allowPlanComplete`, `allowExecute`, `visible`, `enabled`, `prompt`, `labels`, and `appearance`. |
86
+
87
+ ### Mode appearance
88
+
89
+ Use `modes.<name>.appearance` to keep any mode's visual identity with its definition. This applies to built-in `off`, `chat`, `plan`, and `execute` modes as well as custom modes:
90
+
91
+ ```json
92
+ {
93
+ "modes": {
94
+ "chat": {
95
+ "appearance": {
96
+ "prefix": "󰭻",
97
+ "prefixColor": "#157cd6",
98
+ "borderColor": "#157cd6",
99
+ "modeColor": "#157cd6"
100
+ }
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ `prefix`, `prefixColor`, and `borderColor` style `opl-input`; omitted fields use its compiled mode defaults. `modeColor` styles the value in `opl-footer`'s `mode_switcher` segment; if omitted, the footer uses hardcoded `muted`. Colors accept Pi theme tokens or six-digit hex strings.
107
+
108
+ Model overrides are resolved through Pi's model registry when entering a mode and the previously active model is restored on exit when applicable. Use Pi theme color tokens for widget label colors.
109
+
110
+ ### Lazy tool loading
111
+
112
+ `lazyTools` withholds heavy tool schemas from the active set until the model actually needs them, keeping the system-prompt prefix (and its prompt-cache write) smaller every session:
113
+
114
+ ```json
115
+ {
116
+ "lazyTools": ["subagent", "subagent_wait", "subagent_supervisor", "browser", "simplebench"]
117
+ }
118
+ ```
119
+
120
+ Behavior:
121
+
122
+ - Listed tools are removed from the active set wherever opl-modes computes it. In practice this only affects modes that would otherwise include them: `off`/`execute` (which inherit all tools) and custom modes that list them. Read-only `chat`/`plan` already exclude these tools, so nothing changes there.
123
+ - When at least one lazy tool is withheld from a mode, opl-modes registers and activates a `load_tools` tool. The model calls `load_tools({ tools: [...] })` (or with no argument to enable all currently-allowed lazy tools) to activate them before use.
124
+ - `load_tools` is bounded by the current mode's tool policy: it can only enable a lazy tool the active mode already permits, so a read-only mode cannot enable a write-capable tool.
125
+ - Core built-ins (`read`, `edit`, `write`, `bash`, `powershell`, `grep`, `find`, `ls`), `plan_complete`, and `load_tools` itself are protected and silently ignored if listed in `lazyTools`.
126
+ - Switching modes re-applies the policy, so a lazy tool enabled earlier returns to inactive on the next mode change and must be re-enabled.
127
+
128
+ Caching note: activating a lazy tool mid-session preserves the cached prefix on models with native deferred tool loading (Anthropic 4.5+, OpenAI gpt-5.4+) and otherwise triggers one prompt-cache rewrite from that point. It is most effective for tools you use occasionally (delegation, browser automation, benchmarking).
129
+
130
+ ## Architecture
131
+
132
+ `config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state and is the single publisher of `globalThis.__agentMode`. `index.ts` wires commands, picker, lifecycle hooks, tool replacement, Bash interception, and `plan_complete`; `utils.ts` handles plan files and shared helpers.
@@ -0,0 +1,473 @@
1
+ /** Config: tool allowlists, bash patterns, prompt templates, plan file constants, user config. */
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ModeSwitcherUserConfig, ModeDefinition } from "./types.js";
7
+
8
+ // ─── Plan File Constants ────────────────────────────────────────────────────
9
+
10
+ /** Directory for plan files, relative to project root. */
11
+ export const PLAN_DIR = ".pi/plans";
12
+
13
+ /** File prefix for plan files. */
14
+ export const PLAN_FILE_PREFIX = "plan-";
15
+
16
+ // ─── Tool Lists ─────────────────────────────────────────────────────────────
17
+
18
+ /** Default tool names available in PLAN mode (read-only). */
19
+ const DEFAULT_PLAN_MODE_TOOLS: string[] = [
20
+ "read",
21
+ "bash",
22
+ "grep",
23
+ "find",
24
+ "ls",
25
+ "web_search",
26
+ "fetch_content",
27
+ "get_search_content",
28
+ "artifact",
29
+ "questionnaire",
30
+ ];
31
+
32
+ /** Default tool names available in CHAT mode (read-only). */
33
+ const DEFAULT_CHAT_MODE_TOOLS: string[] = [
34
+ "read",
35
+ "bash",
36
+ "grep",
37
+ "find",
38
+ "ls",
39
+ "web_search",
40
+ "fetch_content",
41
+ "get_search_content",
42
+ "artifact",
43
+ "questionnaire",
44
+ ];
45
+
46
+ // ─── Bash Safety ─────────────────────────────────────────────────────────────
47
+
48
+ /** Default safe command patterns — only these are allowed in read-only modes (chat and plan). */
49
+ const DEFAULT_SAFE_PATTERNS: RegExp[] = [
50
+ /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/,
51
+ /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*cd\b/,
52
+ /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/,
53
+ /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/,
54
+ /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/,
55
+ /^\s*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/,
56
+ /^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*free\b/,
57
+ /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*jq\b/,
58
+ /^\s*git\s+(status|log|diff|show|branch|remote)/i,
59
+ /^\s*node\s+--version/i, /^\s*python\s+--version/i,
60
+ /^\s*(npx\s+)?tsc\b.*--noEmit/i,
61
+ /^\s*npm\s+(list|ls|view|info|outdated|audit)/i,
62
+ /^\s*yarn\s+(list|info|why|audit)/i,
63
+ ];
64
+
65
+ /** Default destructive command patterns — always blocked in read-only modes, even if matching a safe pattern. */
66
+ const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
67
+ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i,
68
+ /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i,
69
+ /\btee\b/i, /\bdd\b/i, /\bshred\b/i,
70
+ /(^|[^<])>(?!>|&)/, />>/,
71
+ /\bnpm\s+(install|uninstall|update|ci)/i,
72
+ /\byarn\s+(add|remove|install)/i,
73
+ /\bpip\s+(install|uninstall)/i,
74
+ /\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s+-)/i,
75
+ /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i,
76
+ /\b(sh|bash|zsh)\b/i,
77
+ /\b(vim?|nano|emacs|code|subl)\b/i,
78
+ ];
79
+
80
+ // ─── Prompt Templates ────────────────────────────────────────────────────────
81
+
82
+ /** System prompt injected when in CHAT mode. */
83
+ export const CHAT_MODE_PROMPT = `\
84
+ **SUPERSEDES ALL OTHER BEHAVIOR INSTRUCTIONS.** This overrides any role or style directives (e.g. caveman, roleplay, tone modifiers). The constraints below take absolute priority.
85
+
86
+ You are in CHAT MODE. You have read-only access — you may read files, search code, run safe inspection commands, and search the web to answer, discuss, and explore. Converse naturally — answer questions, explain, brainstorm, look things up.
87
+
88
+ You MUST NOT attempt to edit, create, delete, or modify any files, or run any command that changes state. If the user asks for a change, explain what you would do but do not attempt it — they can exit chat mode first.
89
+
90
+ There is no plan format and no plan_complete tool. Just respond helpfully within read-only constraints.`;
91
+
92
+ /** System prompt injected when in PLAN mode. */
93
+ export const PLAN_MODE_PROMPT = `\
94
+ **SUPERSEDES ALL OTHER BEHAVIOR INSTRUCTIONS.** This overrides any role or style directives (e.g. caveman, roleplay, tone modifiers). The constraints below take absolute priority.
95
+
96
+ You are in PLAN MODE. You have read-only access — you may explore and analyze, but you MUST NOT make any changes.
97
+
98
+ Your task: produce an action plan.
99
+
100
+ You MUST begin the plan with exactly this heading on its own line:
101
+
102
+ # Plan:
103
+ 1. [Step title — short verb-object phrase]
104
+ [2-4 sentences of context: which file(s), where, what to change, and why.]
105
+ 2. [Step title]
106
+ [Context...]
107
+ ...
108
+
109
+ Each step MUST be self-contained — write it as if the executor has no memory of this conversation. Include enough context that it can be carried out with only the plan file and the codebase. Assume the executor will read the relevant files fresh — do not rely on findings you discovered during planning.
110
+
111
+ Good: "Add auth middleware to routes/index.ts
112
+ Apply it as \`app.use(authMiddleware)\` before the route definitions (~line 45). Currently routes/index.ts has no middleware."
113
+
114
+ Bad: "Add it to the file we looked at"
115
+
116
+ Bad (over-prescribed): "Insert \`const authMiddleware = require('./middleware/auth');\` at line 3, then add \`app.use(authMiddleware);\` at line 46"
117
+
118
+ Specify what to do and where — not the exact implementation. The executor reads the relevant files and decides how.
119
+
120
+ After listing all steps, stop and wait for the user to choose:
121
+ - "Execute plan" — switches to execute mode where you carry out each step
122
+ - "Refine" — revise the plan based on feedback
123
+ - Continue exploring if you need more information before planning
124
+
125
+ Do NOT attempt to make any file changes, run destructive commands, or modify anything.`;
126
+
127
+ /** System prompt injected when in EXECUTE mode. */
128
+ export function buildExecutePrompt(planContent: string): string {
129
+ return `\
130
+ You are in EXECUTE MODE. Execute the plan below step by step.
131
+
132
+ After completing ALL steps, call plan_complete() to signal that execution is finished. Do NOT call plan_complete before all steps are done.
133
+
134
+ If the \`todo\` tool is available, use it to track progress: add all plan steps at the start of execution, then toggle each one done as you complete it.
135
+
136
+ Plan:
137
+ ${planContent}`;
138
+ }
139
+
140
+ /** System prompt injected when refining a plan in PLAN mode. */
141
+ export function buildRefinePrompt(planContent: string): string {
142
+ return `\
143
+ You are in PLAN MODE (refining). The user wants to revise the current plan based on their feedback.
144
+
145
+ Current plan:
146
+ ${planContent}
147
+
148
+ Each step MUST be self-contained — write it as if the executor has no memory of this conversation. Include enough context that it can be carried out with only the plan file and the codebase. Assume the executor will read the relevant files fresh — do not rely on findings you discovered during planning.
149
+
150
+ Revise the plan and output the full updated plan.
151
+
152
+ You MUST begin the revised plan with exactly this heading on its own line:
153
+
154
+ # Plan:
155
+
156
+ Do NOT make any changes. Only produce a revised plan.`;
157
+ }
158
+
159
+ // ─── Custom Entry Types ──────────────────────────────────────────────────────
160
+
161
+ /** customType value stored in session entries. */
162
+ export const ENTRY_TYPE = "mode-switcher"; // Stable session identifier.
163
+
164
+ // ─── User Config ────────────────────────────────────────────────────────────
165
+
166
+ const DEFAULT_CONFIG = {
167
+ CLEANUP: {
168
+ CLEANUP_ON_COMPLETE: true,
169
+ },
170
+ UI: {
171
+ HIDE_NOTIFY: false,
172
+ HIDE_WIDGET: true,
173
+ },
174
+ SHORTCUTS: {
175
+ CYCLE_MODE: "ctrl+alt+m",
176
+ },
177
+ LABELS: {
178
+ CHAT: {
179
+ NOTIFY: "✓ Chat mode ON",
180
+ NOTIFY_TYPE: "info",
181
+ WIDGET: "✓ Chat mode",
182
+ WIDGET_COLOR: "accent",
183
+ },
184
+ PLAN: {
185
+ NOTIFY: "✓ Plan mode ON",
186
+ NOTIFY_TYPE: "info",
187
+ NOTIFY_WITH_TITLE: "✓ Active plan {title}",
188
+ NOTIFY_LOADED: "✓ Active plan: {title}",
189
+ WIDGET: "✓ Plan mode active",
190
+ WIDGET_WITH_TITLE: "✓ Active plan: {title}",
191
+ WIDGET_COLOR: "accent",
192
+ },
193
+ EXECUTE: {
194
+ NOTIFY: "✓ Executing plan",
195
+ NOTIFY_WITH_TITLE: "✓ Executing plan: {title}",
196
+ NOTIFY_TYPE: "info",
197
+ WIDGET: "✓ Executing plan",
198
+ WIDGET_WITH_TITLE: "✓ Executing plan: {title}",
199
+ WIDGET_COLOR: "muted",
200
+ },
201
+ OFF: {
202
+ NOTIFY: "✓ Normal mode",
203
+ NOTIFY_TYPE: "info",
204
+ },
205
+ },
206
+ };
207
+
208
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-modes.json");
209
+
210
+ export function loadUserConfig(path = CONFIG_PATH): ModeSwitcherUserConfig {
211
+ try {
212
+ const raw = readFileSync(path, "utf8");
213
+ return JSON.parse(raw) as ModeSwitcherUserConfig;
214
+ } catch {
215
+ return {};
216
+ }
217
+ }
218
+
219
+ const userConfig = loadUserConfig();
220
+
221
+ // ─── Bash Pattern Resolution ──────────────────────────────────────────────────
222
+
223
+ /** Compile string patterns to RegExp. Falls back to defaults if list is empty or all fail. */
224
+ function resolvePatterns(strings: string[] | undefined, defaults: RegExp[]): RegExp[] {
225
+ if (!strings || strings.length === 0) return defaults;
226
+ const patterns: RegExp[] = [];
227
+ for (const p of strings) {
228
+ try {
229
+ patterns.push(new RegExp(p, "i"));
230
+ } catch {
231
+ console.warn(`[opl-modes] Invalid pattern: "${p}" — skipping`);
232
+ }
233
+ }
234
+ return patterns.length > 0 ? patterns : defaults;
235
+ }
236
+
237
+ /** Safe command patterns — resolved from user config or defaults. Replace-only: user list replaces all defaults. */
238
+ export const SAFE_COMMAND_PATTERNS: RegExp[] = resolvePatterns(
239
+ userConfig.bashPatterns?.safePatterns,
240
+ DEFAULT_SAFE_PATTERNS,
241
+ );
242
+
243
+ /** Destructive command patterns — resolved from user config or defaults. Replace-only: user list replaces all defaults. */
244
+ export const DESTRUCTIVE_PATTERNS: RegExp[] = resolvePatterns(
245
+ userConfig.bashPatterns?.destructivePatterns,
246
+ DEFAULT_DESTRUCTIVE_PATTERNS,
247
+ );
248
+
249
+ /** Tool names available in PLAN mode. Replace-only: user-provided list replaces defaults. */
250
+ export const PLAN_MODE_TOOLS: string[] =
251
+ userConfig.planAllowedTools && userConfig.planAllowedTools.length > 0
252
+ ? userConfig.planAllowedTools
253
+ : DEFAULT_PLAN_MODE_TOOLS;
254
+
255
+ /** Tool names available in CHAT mode. Replace-only: user-provided list replaces defaults. */
256
+ export const CHAT_MODE_TOOLS: string[] =
257
+ userConfig.chatAllowedTools && userConfig.chatAllowedTools.length > 0
258
+ ? userConfig.chatAllowedTools
259
+ : DEFAULT_CHAT_MODE_TOOLS;
260
+
261
+ // ─── Lazy Tool Loading ────────────────────────────────────────────────────────
262
+
263
+ /** Loader tool name. Registered by opl-modes when any lazy tools are configured. */
264
+ export const LOADER_TOOL_NAME = "load_tools";
265
+
266
+ /** Tools that must never be lazy: core built-ins, the execute-only signal, and the loader itself. */
267
+ export const PROTECTED_TOOLS: Set<string> = new Set([
268
+ "read", "edit", "write", "bash", "powershell", "grep", "find", "ls",
269
+ "plan_complete", LOADER_TOOL_NAME,
270
+ ]);
271
+
272
+ /** Filter a user lazy-tool list: drop protected/core tools and empties, dedupe. */
273
+ export function resolveLazyTools(list: string[] | undefined): string[] {
274
+ return [...new Set((list ?? []).filter((n) => n && !PROTECTED_TOOLS.has(n)))];
275
+ }
276
+
277
+ /** Configured lazy tools, protected-filtered. */
278
+ export const LAZY_TOOLS: Set<string> = new Set(resolveLazyTools(userConfig.lazyTools));
279
+
280
+ /**
281
+ * Apply lazy policy to an active-set candidate: remove lazy tools, and inject the
282
+ * loader ONLY when at least one lazy tool was actually withheld from this set.
283
+ * A set with no lazy tools (e.g. read-only chat/plan lists) is returned unchanged.
284
+ */
285
+ export function applyLazyPolicy(names: string[], lazy: Set<string> = LAZY_TOOLS, loader = LOADER_TOOL_NAME): string[] {
286
+ if (lazy.size === 0) return names;
287
+ const heldBack = names.some((n) => lazy.has(n));
288
+ const filtered = names.filter((n) => !lazy.has(n));
289
+ return heldBack && !filtered.includes(loader) ? [...filtered, loader] : filtered;
290
+ }
291
+
292
+ /**
293
+ * Select which lazy tools to activate: requested (or all lazy when omitted),
294
+ * intersected with the lazy set and the current mode's allowed tools
295
+ * (modeAllowed=null means the mode inherits all tools), minus already-active.
296
+ */
297
+ export function lazyToolsToEnable(
298
+ requested: string[] | undefined,
299
+ current: string[],
300
+ modeAllowed: string[] | null,
301
+ lazy: Set<string> = LAZY_TOOLS,
302
+ ): string[] {
303
+ const allowed = modeAllowed ? new Set(modeAllowed) : null;
304
+ const want = requested && requested.length ? requested : [...lazy];
305
+ const active = new Set(current);
306
+ return want.filter((n) => lazy.has(n) && (!allowed || allowed.has(n)) && !active.has(n));
307
+ }
308
+
309
+ export const USER_CONFIG = {
310
+ cleanup: {
311
+ cleanupOnComplete: userConfig.cleanup?.cleanupOnComplete ?? DEFAULT_CONFIG.CLEANUP.CLEANUP_ON_COMPLETE,
312
+ },
313
+ ui: {
314
+ hideNotify: userConfig.ui?.hideNotify ?? DEFAULT_CONFIG.UI.HIDE_NOTIFY,
315
+ hideWidget: userConfig.ui?.hideWidget ?? DEFAULT_CONFIG.UI.HIDE_WIDGET,
316
+ },
317
+ shortcuts: {
318
+ cycleMode: userConfig.shortcuts?.cycleMode ?? DEFAULT_CONFIG.SHORTCUTS.CYCLE_MODE,
319
+ },
320
+ labels: {
321
+ chat: {
322
+ notify: userConfig.labels?.chat?.notify ?? DEFAULT_CONFIG.LABELS.CHAT.NOTIFY,
323
+ notifyType: userConfig.labels?.chat?.notifyType ?? DEFAULT_CONFIG.LABELS.CHAT.NOTIFY_TYPE,
324
+ widget: userConfig.labels?.chat?.widget ?? DEFAULT_CONFIG.LABELS.CHAT.WIDGET,
325
+ widgetColor: userConfig.labels?.chat?.widgetColor ?? DEFAULT_CONFIG.LABELS.CHAT.WIDGET_COLOR,
326
+ },
327
+ plan: {
328
+ notify: userConfig.labels?.plan?.notify ?? DEFAULT_CONFIG.LABELS.PLAN.NOTIFY,
329
+ notifyType: userConfig.labels?.plan?.notifyType ?? DEFAULT_CONFIG.LABELS.PLAN.NOTIFY_TYPE,
330
+ notifyWithTitle: userConfig.labels?.plan?.notifyWithTitle ?? DEFAULT_CONFIG.LABELS.PLAN.NOTIFY_WITH_TITLE,
331
+ notifyLoaded: userConfig.labels?.plan?.notifyLoaded ?? DEFAULT_CONFIG.LABELS.PLAN.NOTIFY_LOADED,
332
+ widget: userConfig.labels?.plan?.widget ?? DEFAULT_CONFIG.LABELS.PLAN.WIDGET,
333
+ widgetWithTitle: userConfig.labels?.plan?.widgetWithTitle ?? DEFAULT_CONFIG.LABELS.PLAN.WIDGET_WITH_TITLE,
334
+ widgetColor: userConfig.labels?.plan?.widgetColor ?? DEFAULT_CONFIG.LABELS.PLAN.WIDGET_COLOR,
335
+ },
336
+ execute: {
337
+ notify: userConfig.labels?.execute?.notify ?? DEFAULT_CONFIG.LABELS.EXECUTE.NOTIFY,
338
+ notifyWithTitle: userConfig.labels?.execute?.notifyWithTitle ?? DEFAULT_CONFIG.LABELS.EXECUTE.NOTIFY_WITH_TITLE,
339
+ notifyType: userConfig.labels?.execute?.notifyType ?? DEFAULT_CONFIG.LABELS.EXECUTE.NOTIFY_TYPE,
340
+ widget: userConfig.labels?.execute?.widget ?? DEFAULT_CONFIG.LABELS.EXECUTE.WIDGET,
341
+ widgetWithTitle: userConfig.labels?.execute?.widgetWithTitle ?? DEFAULT_CONFIG.LABELS.EXECUTE.WIDGET_WITH_TITLE,
342
+ widgetColor: userConfig.labels?.execute?.widgetColor ?? DEFAULT_CONFIG.LABELS.EXECUTE.WIDGET_COLOR,
343
+ },
344
+ off: {
345
+ notify: userConfig.labels?.off?.notify ?? DEFAULT_CONFIG.LABELS.OFF.NOTIFY,
346
+ notifyType: userConfig.labels?.off?.notifyType ?? DEFAULT_CONFIG.LABELS.OFF.NOTIFY_TYPE,
347
+ },
348
+ },
349
+ };
350
+
351
+ // ─── Mode Registry ─────────────────────────────────────────────────────────
352
+ // Must come AFTER all the consts above (CHAT_MODE_TOOLS, PLAN_MODE_TOOLS,
353
+ // SAFE_COMMAND_PATTERNS, DESTRUCTIVE_PATTERNS, userConfig) since defaults
354
+ // reference them.
355
+
356
+ /** Mode Registry — stores all registered mode definitions. */
357
+ export const MODE_REGISTRY = new Map<string, ModeDefinition>();
358
+
359
+ /** Helper to convert string patterns to RegExp. */
360
+ function compilePatterns(patterns: string[] | undefined): RegExp[] | undefined {
361
+ if (!patterns) return undefined;
362
+ return patterns.map((p) => new RegExp(p, "i"));
363
+ }
364
+
365
+ /** Register a mode with the registry. Called by extensions at startup. */
366
+ export function registerMode(name: string, definition: ModeDefinition): void {
367
+ MODE_REGISTRY.set(name, definition);
368
+ }
369
+
370
+ /** Get a mode definition from the registry. */
371
+ export function getModeDefinition(name: string): ModeDefinition | undefined {
372
+ return MODE_REGISTRY.get(name);
373
+ }
374
+
375
+ /** Whether plan execution may be started while `mode` is active. Default: true. */
376
+ export function executeHandoffAllowed(mode: string, registry: Map<string, ModeDefinition> = MODE_REGISTRY): boolean {
377
+ const def = registry.get(mode);
378
+ return def ? def.allowExecute !== false : true;
379
+ }
380
+
381
+ /** Append plan_complete to a tool list when the mode allows it. */
382
+ export function withPlanComplete(mode: string, tools: string[], registry: Map<string, ModeDefinition> = MODE_REGISTRY): string[] {
383
+ const def = registry.get(mode);
384
+ if (def?.allowPlanComplete && !tools.includes("plan_complete")) return [...tools, "plan_complete"];
385
+ return tools;
386
+ }
387
+
388
+ /** Initialize MODE_REGISTRY with built-in defaults, then merge user-defined modes. */
389
+ function initModeRegistry(): void {
390
+ const customNotifyTemplate = userConfig.defaultNotifyTemplate ?? "✓ {Name} mode ON";
391
+
392
+ registerMode("off", {
393
+ prompt: "",
394
+ allowPlanComplete: false,
395
+ allowExecute: true,
396
+ visible: true,
397
+ labels: {
398
+ notify: DEFAULT_CONFIG.LABELS.OFF.NOTIFY,
399
+ notifyType: DEFAULT_CONFIG.LABELS.OFF.NOTIFY_TYPE,
400
+ widget: DEFAULT_CONFIG.LABELS.OFF.NOTIFY,
401
+ widgetColor: "dim",
402
+ },
403
+ });
404
+
405
+ registerMode("chat", {
406
+ prompt: CHAT_MODE_PROMPT,
407
+ tools: CHAT_MODE_TOOLS,
408
+ safePatterns: SAFE_COMMAND_PATTERNS,
409
+ destructivePatterns: DESTRUCTIVE_PATTERNS,
410
+ allowPlanComplete: false,
411
+ allowExecute: true,
412
+ visible: true,
413
+ labels: USER_CONFIG.labels.chat,
414
+ });
415
+
416
+ registerMode("plan", {
417
+ prompt: PLAN_MODE_PROMPT,
418
+ tools: PLAN_MODE_TOOLS,
419
+ safePatterns: SAFE_COMMAND_PATTERNS,
420
+ destructivePatterns: DESTRUCTIVE_PATTERNS,
421
+ allowPlanComplete: false,
422
+ allowExecute: true,
423
+ visible: true,
424
+ labels: USER_CONFIG.labels.plan,
425
+ });
426
+
427
+ registerMode("execute", {
428
+ prompt: "", // Built dynamically with buildExecutePrompt from the active plan file
429
+ allowPlanComplete: true,
430
+ allowExecute: true,
431
+ visible: false, // entered only via "execute:<plan-file>" picker items, never as a bare menu choice
432
+ labels: USER_CONFIG.labels.execute,
433
+ });
434
+
435
+ // Merge user-defined modes from config (can override built-ins or add new ones).
436
+ if (userConfig.modes) {
437
+ for (const [name, def] of Object.entries(userConfig.modes)) {
438
+ const existing = MODE_REGISTRY.get(name);
439
+ if (existing) {
440
+ registerMode(name, {
441
+ ...existing,
442
+ ...def,
443
+ tools: def.tools ?? existing.tools,
444
+ safePatterns: def.safePatterns ? compilePatterns(def.safePatterns) : existing.safePatterns,
445
+ destructivePatterns: def.destructivePatterns ? compilePatterns(def.destructivePatterns) : existing.destructivePatterns,
446
+ labels: { ...existing.labels, ...def.labels },
447
+ appearance: def.appearance ?? existing.appearance,
448
+ });
449
+ } else {
450
+ registerMode(name, {
451
+ prompt: def.prompt ?? "",
452
+ tools: def.tools,
453
+ safePatterns: compilePatterns(def.safePatterns),
454
+ destructivePatterns: compilePatterns(def.destructivePatterns),
455
+ allowPlanComplete: def.allowPlanComplete ?? false,
456
+ allowExecute: def.allowExecute ?? true,
457
+ visible: def.visible ?? true,
458
+ enabled: def.enabled ?? true,
459
+ model: def.model,
460
+ labels: {
461
+ notify: def.labels?.notify ?? customNotifyTemplate.replace("{Name}", name.charAt(0).toUpperCase() + name.slice(1)),
462
+ notifyType: def.labels?.notifyType ?? "info",
463
+ widget: def.labels?.widget ?? `${name} mode active`,
464
+ widgetColor: def.labels?.widgetColor ?? "accent",
465
+ },
466
+ appearance: def.appearance,
467
+ });
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ initModeRegistry();