@openlines/opl-pi-sht 0.2.12 → 0.3.0

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 (81) hide show
  1. package/README.md +92 -84
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +31 -6
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +209 -105
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/package.json +19 -12
@@ -3,6 +3,7 @@ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
3
3
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
4
  import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
5
5
  import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
6
+ import { registerInputConfiguratorTab } from "./configure.js";
6
7
  import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
7
8
  import { applyColor, CompanionAnimator, COMPANION_TICK_MS, IDLE_REPAINT_MS, startRenderTimer } from "./utils.js";
8
9
 
@@ -13,6 +14,34 @@ function plainText(line: string): string {
13
14
  return line.replace(ANSI_RE, "");
14
15
  }
15
16
 
17
+ // ─── pi-tui private render format ─────────────────────────────────────────────
18
+ // The box renderers below must tell the editor's horizontal borders from its scroll
19
+ // indicator, and pi-tui exposes neither as an API nor as a stable class: it is plain text.
20
+ // These two markers are the whole contract, so they live here instead of being duplicated in
21
+ // each renderer, and tests/opl-input-pi-tui-markers.test.mjs asserts the installed pi-tui
22
+ // still emits them. Verified byte-identical in pi-tui 0.87.0, 0.99.1 and 1.1.0
23
+ // (dist/components/editor.js: `─── ${direction} ${hiddenLineCount} more `).
24
+ const BORDER_CHAR = "─";
25
+ const SCROLL_INDICATOR_RE = /((?:↑|↓)\s*\d+\s*more)/;
26
+
27
+ /** Solid border: after stripping ANSI every character is the box-drawing horizontal. */
28
+ export function isSolidBorder(line: string): boolean {
29
+ return plainText(line).replaceAll(BORDER_CHAR, "").length === 0;
30
+ }
31
+
32
+ /** The scroll indicator (`─── ↑ 3 more `) is border-like but carries text worth preserving. */
33
+ export function scrollIndicatorText(line: string): string | null {
34
+ const plain = plainText(line);
35
+ if (!plain.startsWith(BORDER_CHAR)) return null;
36
+ const match = plain.match(SCROLL_INDICATOR_RE);
37
+ return match ? match[1]! : null;
38
+ }
39
+
40
+ /** Anything that marks the top or bottom edge of the stock editor output. */
41
+ export function isBorderLike(line: string): boolean {
42
+ return isSolidBorder(line) || scrollIndicatorText(line) !== null;
43
+ }
44
+
16
45
  function activeMode(): { mode: string; appearance?: ModeAppearance } {
17
46
  const state = (globalThis as Record<string, unknown>).__agentMode as
18
47
  | { mode?: string; appearance?: ModeAppearance }
@@ -121,19 +150,6 @@ class ChatInput extends CustomEditor {
121
150
  ): string[] {
122
151
  const innerWidth = width - 2;
123
152
 
124
- // Solid border: after stripping ANSI every char is "─"
125
- const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
126
-
127
- // Scroll indicator: starts with "─" and contains ↑/↓ N more
128
- const getScrollText = (line: string): string | null => {
129
- const plain = plainText(line);
130
- if (!plain.startsWith("─")) return null;
131
- const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
132
- return m ? m[1] : null;
133
- };
134
-
135
- const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
136
-
137
153
  const firstIdx = stock.findIndex(isBorderLike);
138
154
  let lastIdx = -1;
139
155
  for (let i = stock.length - 1; i >= 0; i--) {
@@ -158,8 +174,8 @@ class ChatInput extends CustomEditor {
158
174
  return border("└") + border(mid) + border("─".repeat(remaining)) + border("┘");
159
175
  };
160
176
 
161
- const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
162
- const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
177
+ const topScrollText = firstIdx !== -1 ? scrollIndicatorText(stock[firstIdx]!) : null;
178
+ const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? scrollIndicatorText(stock[lastIdx]!) : null;
163
179
 
164
180
  const top = buildTop(topScrollText);
165
181
  const bottom = buildBottom(bottomScrollText);
@@ -207,19 +223,6 @@ class ChatInput extends CustomEditor {
207
223
  accent: (s: string) => string,
208
224
  prefix: string,
209
225
  ): string[] {
210
- // Solid border: after stripping ANSI every char is "─"
211
- const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
212
-
213
- // Scroll indicator: starts with "─" and contains ↑/↓ N more
214
- const getScrollText = (line: string): string | null => {
215
- const plain = plainText(line);
216
- if (!plain.startsWith("─")) return null;
217
- const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
218
- return m ? m[1] : null;
219
- };
220
-
221
- const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
222
-
223
226
  const firstIdx = stock.findIndex(isBorderLike);
224
227
  let lastIdx = -1;
225
228
  for (let i = stock.length - 1; i >= 0; i--) {
@@ -244,8 +247,8 @@ class ChatInput extends CustomEditor {
244
247
  return border(mid) + border("─".repeat(remaining));
245
248
  };
246
249
 
247
- const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
248
- const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
250
+ const topScrollText = firstIdx !== -1 ? scrollIndicatorText(stock[firstIdx]!) : null;
251
+ const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? scrollIndicatorText(stock[lastIdx]!) : null;
249
252
 
250
253
  const top = buildTop(topScrollText);
251
254
  const bottom = buildBottom(bottomScrollText);
@@ -304,4 +307,9 @@ export default function (pi: ExtensionAPI) {
304
307
  activeEditor?.dispose();
305
308
  activeEditor = null;
306
309
  });
310
+
311
+ // Expose the settings screen to opl-configurator's generic shell; the shell owns the
312
+ // /configurator command and the tab only writes opl-input.json (a /reload is still
313
+ // required before the running editor picks the change up).
314
+ registerInputConfiguratorTab();
307
315
  }
@@ -19,8 +19,12 @@ export interface ResolvedModeStyle {
19
19
  prefix: string;
20
20
  }
21
21
 
22
+ /** The baseline every unknown mode falls back to, kept as a constant so the fallback is a value
23
+ * rather than an index into a record that TypeScript must treat as possibly missing. */
24
+ const OFF_DEFAULT: Required<ModeAppearance> = { prefix: "❯", prefixColor: "accent", borderColor: "border" };
25
+
22
26
  const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
23
- off: { prefix: "❯", prefixColor: "accent", borderColor: "border" },
27
+ off: OFF_DEFAULT,
24
28
  chat: { prefix: "»", prefixColor: "borderAccent", borderColor: "borderAccent" },
25
29
  plan: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
26
30
  execute: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
@@ -28,7 +32,7 @@ const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
28
32
 
29
33
  /** Precedence: bash > active mode appearance > hardcoded mode fallback. */
30
34
  export function resolveModeStyle(state: ModeState): ResolvedModeStyle {
31
- const fallback = MODE_DEFAULTS[state.mode] ?? MODE_DEFAULTS.off;
35
+ const fallback = MODE_DEFAULTS[state.mode] ?? OFF_DEFAULT;
32
36
  if (state.bash) return { borderColor: "bashMode", prefixColor: "bashMode", prefix: fallback.prefix };
33
37
  return {
34
38
  borderColor: state.appearance?.borderColor ?? fallback.borderColor,
@@ -11,7 +11,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
12
  - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `ctrl+alt+m` does not collide with any built-in Pi keybinding; set `shortcuts.cycleMode` to rebind.
13
13
 
14
- Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
14
+ Plans are Markdown files under `<session directory>/.pi/plans/` with the `plan-` filename prefix - the directory comes from `ExtensionContext.cwd`, captured at `session_start`, so a session opened in one project never reads or writes another project's plans. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan, capped at `plan.maxInjectBytes` (default 24 KB), truncating on a line boundary and ending with a marker that names the plan file — the file on disk stays authoritative and is never rewritten. The card stores at most `plan.maxEntryBytes` (default 4 KB) and shows the same marker. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
15
15
 
16
16
  ## Extension features
17
17
 
@@ -35,7 +35,7 @@ A custom mode with no `tools` array inherits **all** tools, including `write` an
35
35
 
36
36
  `off` behaves the same way: it restores the tools that were active before the mode (never a wider set). Set `modes.off.tools` to pin the resting tool set instead — for example to keep `subagent`/`browser` out of normal mode entirely. Pinning replaces the snapshot taken on mode entry, so a later mode that lists no `tools` inherits the pinned set rather than the older, wider one. `load_tools` is offered in `off` because withheld lazy tools are otherwise unreachable there.
37
37
 
38
- Model overrides are resolved through Pi's model registry when entering a mode. A blank or partial `model` (for example `{ "provider": "", "id": "" }`) is treated as **no override**, so the mode keeps the current model instead of warning `Model not found: /`. The previously active model is captured once per mode and restored on exit; the restore point is persisted in the mode session entry, so `/reload` and `/resume` inside a mode no longer lose it. If the restore target is gone from the registry it is dropped immediately; if it exists but has no credentials (before `/login`, say) the restore is retried once and then released, so a stale point cannot block future captures indefinitely. `Pi.setModel` is session-scoped: it never rewrites your configured `defaultProvider`/`defaultModel`, but it does append a model change to the session transcript, which is why the restore point is persisted alongside the mode. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Note that setting `modes.off.model` makes OFF a pinned baseline: it is applied on every mode exit *and* at session start, overriding `--model` and the configured default. Use Pi theme color tokens for widget label colors.
38
+ Model overrides are resolved through Pi's model registry when entering a mode. A blank or partial `model` (for example `{ "provider": "", "id": "" }`) is treated as **no override**, so the mode keeps the current model instead of warning `Model not found: /`. The previously active model is captured once per mode and restored on exit; the restore point is persisted in the mode session entry, so `/reload` and `/resume` inside a mode no longer lose it. If the restore target is gone from the registry it is dropped immediately; if it exists but has no credentials (before `/login`, say) the restore is retried once and then released, so a stale point cannot block future captures indefinitely. `pi.setModel` is session-scoped: it never rewrites your configured `defaultProvider`/`defaultModel`, but it does append a model change to the session transcript, which is why the restore point is persisted alongside the mode. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Note that setting `modes.off.model` makes OFF a pinned baseline: it is applied on every mode exit *and* at session start, overriding `--model` and the configured default. Use Pi theme color tokens for widget label colors.
39
39
 
40
40
  Mode state is persisted in session entries and restored on session resume or branch changes. Pi ignores tool names it does not know, so a `tools` list with a typo would silently lose tools: every mode entry checks the list against the registered tools and warns once per unknown name per session. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts. On resume, an unknown mode name in a session entry is ignored (falling back to normal), and plan filenames containing path separators or `..` are dropped, so a stale or foreign branch cannot leave the session unrestricted or point plan reads outside `.pi/plans/`.
41
41
 
@@ -83,6 +83,8 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
83
83
  | `ui.hideNotify` / `ui.hideWidget` | Suppress mode notifications or widgets. |
84
84
  | `shortcuts.cycleMode` | Keybinding for cycling enabled visible modes. |
85
85
  | `cleanup.cleanupOnComplete` | Delete the active plan after successful `plan_complete`. |
86
+ | `plan.maxInjectBytes` | Bytes of plan text allowed into the system prompt for execute or refine (default `24000`). The UTF-8 cap includes the marker and retains complete lines. If the source path cannot fit, the marker refers to the plan file; very small budgets use a shortened notice. The file itself is never modified. Configured ceilings must be positive integers; invalid values fall back to defaults. |
87
+ | `plan.maxEntryBytes` | Bytes of plan text stored in the `plan-mode` session entry (default `4096`). The cap includes the marker; the card retains the full file path separately. |
86
88
  | `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
87
89
  | `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
88
90
  | `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`. |
@@ -113,8 +115,6 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
113
115
 
114
116
  `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, six-digit hex, or the three-digit `#abc` shorthand (expanded to `#aabbcc`).
115
117
 
116
- Model overrides are resolved through Pi's model registry when entering a mode and the previously active model is restored on exit when applicable. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Use Pi theme color tokens for widget label colors.
117
-
118
118
  ### Lazy tool loading
119
119
 
120
120
  `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:
@@ -136,6 +136,18 @@ Behavior:
136
136
 
137
137
  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).
138
138
 
139
+ ## Cross-extension seams
140
+
141
+ Extensions install one directory at a time and load in any combination, so `globalThis` is
142
+ the only channel between them. `opl-modes` publishes `__agentMode`, `__planMode` and
143
+ `__chatMode`; `opl-footer` publishes `__footerRequestRender` while a footer is mounted.
144
+
145
+ Nothing may assume the other side is present or healthy. The render trigger is checked for
146
+ being a function and called inside a `try`: a footer whose TUI was already torn down must not
147
+ fail a mode transition. `session_start` re-publishes the three mode globals and
148
+ `session_shutdown` deletes them, so a later session in the same process never reads a mode
149
+ that belongs to a dead one.
150
+
139
151
  ## Architecture
140
152
 
141
153
  `config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state (including the persisted model restore point) 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, the per-segment Bash gate, and shared helpers.
@@ -1,8 +1,8 @@
1
1
  /** Config: tool allowlists, bash patterns, prompt templates, plan file constants, user config. */
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
4
  import { join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition, ModeModelConfig } from "./types.js";
7
7
 
8
8
  // ─── Plan File Constants ────────────────────────────────────────────────────
@@ -150,7 +150,8 @@ After listing all steps, stop and wait for the user to choose:
150
150
  Do NOT attempt to make any file changes, run destructive commands, or modify anything.`;
151
151
 
152
152
  /** System prompt injected when in EXECUTE mode. */
153
- export function buildExecutePrompt(planContent: string): string {
153
+ export function buildExecutePrompt(planContent: string, source?: string): string {
154
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, source);
154
155
  return `\
155
156
  You are in EXECUTE MODE. Execute the plan below step by step.
156
157
 
@@ -159,16 +160,17 @@ After completing ALL steps, call plan_complete() to signal that execution is fin
159
160
  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.
160
161
 
161
162
  Plan:
162
- ${planContent}`;
163
+ ${capped.text}`;
163
164
  }
164
165
 
165
166
  /** System prompt injected when refining a plan in PLAN mode. */
166
- export function buildRefinePrompt(planContent: string): string {
167
+ export function buildRefinePrompt(planContent: string, source?: string): string {
168
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, source);
167
169
  return `\
168
170
  You are in PLAN MODE (refining). The user wants to revise the current plan based on their feedback.
169
171
 
170
172
  Current plan:
171
- ${planContent}
173
+ ${capped.text}
172
174
 
173
175
  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.
174
176
 
@@ -196,6 +198,10 @@ const DEFAULT_CONFIG = {
196
198
  HIDE_NOTIFY: false,
197
199
  HIDE_WIDGET: true,
198
200
  },
201
+ PLAN: {
202
+ MAX_INJECT_BYTES: 24000,
203
+ MAX_ENTRY_BYTES: 4096,
204
+ },
199
205
  SHORTCUTS: {
200
206
  CYCLE_MODE: "ctrl+alt+m",
201
207
  },
@@ -230,15 +236,18 @@ const DEFAULT_CONFIG = {
230
236
  },
231
237
  };
232
238
 
233
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-modes.json");
239
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
240
+ export function configPath(): string {
241
+ return join(getAgentDir(), "configs", "opl-modes.json");
242
+ }
234
243
 
235
244
  function isRecord(value: unknown): value is Record<string, unknown> {
236
245
  return typeof value === "object" && value !== null && !Array.isArray(value);
237
246
  }
238
247
 
239
- export function loadUserConfig(path = CONFIG_PATH): ModeSwitcherUserConfig {
248
+ export function loadUserConfig(file = configPath()): ModeSwitcherUserConfig {
240
249
  try {
241
- const parsed = JSON.parse(readFileSync(path, "utf8"));
250
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
242
251
  return isRecord(parsed) ? parsed as ModeSwitcherUserConfig : {};
243
252
  } catch {
244
253
  return {};
@@ -335,10 +344,55 @@ export function lazyToolsToEnable(
335
344
  return want.filter((n) => lazy.has(n) && (!allowed || allowed.has(n)) && !active.has(n));
336
345
  }
337
346
 
347
+ /** Positive integer from user config, else the default. Anything else is ignored rather than trusted. */
348
+ function positiveInt(value: unknown, fallback: number): number {
349
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
350
+ }
351
+
352
+ export interface CappedPlan {
353
+ text: string;
354
+ truncated: boolean;
355
+ omittedBytes: number;
356
+ }
357
+
358
+ /**
359
+ * Bound a plan body before it is injected into a prompt or stored as an entry.
360
+ * A plan file is user-authored and uncapped: injected whole it is re-sent on
361
+ * every provider request for the run, and stored whole it lives in the session
362
+ * file forever. Truncation is announced so the reader can open the file.
363
+ */
364
+ export function capPlan(plan: string, maxBytes: number, source?: string): CappedPlan {
365
+ const bytes = Buffer.byteLength(plan, "utf8");
366
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || bytes <= maxBytes) {
367
+ return { text: plan, truncated: false, omittedBytes: 0 };
368
+ }
369
+ const budget = Math.floor(maxBytes);
370
+ const marker = (shown: number, location: string) =>
371
+ `\n\n[… plan truncated: showing ${shown} of ${bytes} bytes, ${bytes - shown} omitted - read ${location} for the rest …]`;
372
+ // Reserve the widest possible counts, including a UTF-8 source path.
373
+ const reserve = (location: string) => Buffer.byteLength(marker(0, location), "utf8") + String(bytes).length - 1;
374
+ let location = source ?? "the plan file";
375
+ if (reserve(location) > budget) location = "the plan file";
376
+ if (reserve(location) > budget) {
377
+ return { text: "[plan truncated]".slice(0, budget), truncated: true, omittedBytes: bytes };
378
+ }
379
+ const buffer = Buffer.from(plan, "utf8");
380
+ const limit = budget - reserve(location);
381
+ const newline = buffer.lastIndexOf(10, limit);
382
+ // Keep only complete lines. A single line that exceeds the budget stays in the file.
383
+ const head = newline < 0 ? "" : buffer.subarray(0, newline).toString("utf8");
384
+ const shown = Buffer.byteLength(head, "utf8");
385
+ return { text: head + marker(shown, location), truncated: true, omittedBytes: bytes - shown };
386
+ }
387
+
338
388
  export const USER_CONFIG = {
339
389
  cleanup: {
340
390
  cleanupOnComplete: userConfig.cleanup?.cleanupOnComplete ?? DEFAULT_CONFIG.CLEANUP.CLEANUP_ON_COMPLETE,
341
391
  },
392
+ plan: {
393
+ maxInjectBytes: positiveInt(userConfig.plan?.maxInjectBytes, DEFAULT_CONFIG.PLAN.MAX_INJECT_BYTES),
394
+ maxEntryBytes: positiveInt(userConfig.plan?.maxEntryBytes, DEFAULT_CONFIG.PLAN.MAX_ENTRY_BYTES),
395
+ },
342
396
  ui: {
343
397
  hideNotify: userConfig.ui?.hideNotify ?? DEFAULT_CONFIG.UI.HIDE_NOTIFY,
344
398
  hideWidget: userConfig.ui?.hideWidget ?? DEFAULT_CONFIG.UI.HIDE_WIDGET,
@@ -41,6 +41,7 @@ import {
41
41
  PLAN_DIR,
42
42
  buildExecutePrompt,
43
43
  buildRefinePrompt,
44
+ capPlan,
44
45
  USER_CONFIG,
45
46
  MODE_REGISTRY,
46
47
  getModeDefinition,
@@ -57,6 +58,7 @@ import {
57
58
  extractPlanText,
58
59
  isPlanLike,
59
60
  ensurePlanDir,
61
+ planFilePath,
60
62
  titleFromFilename,
61
63
  listPlanFiles,
62
64
  sanitizePlanName,
@@ -78,6 +80,10 @@ import {
78
80
  resetState,
79
81
  getRefineCount,
80
82
  incrementRefineCount,
83
+ publishModeGlobals,
84
+ clearModeGlobals,
85
+ getSessionCwd,
86
+ setSessionCwd,
81
87
  } from "./state.js";
82
88
  import { showSelectMenu } from "./menus.js";
83
89
 
@@ -260,7 +266,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
260
266
  function getPlanFilePath(): string | null {
261
267
  const file = getActivePlanFile();
262
268
  if (!file) return null;
263
- return join(process.cwd(), PLAN_DIR, file);
269
+ return planFilePath(getSessionCwd(), file);
264
270
  }
265
271
 
266
272
  function getPlanDisplayTitle(): string | null {
@@ -269,7 +275,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
269
275
  const filePath = getPlanFilePath();
270
276
  if (filePath && existsSync(filePath)) {
271
277
  const heading = readFileSync(filePath, "utf-8").match(/^# Plan:\s*(.+)$/m);
272
- if (heading) return heading[1].trim();
278
+ const captured = heading?.[1];
279
+ if (captured) return captured.trim();
273
280
  }
274
281
  return titleFromFilename(file);
275
282
  }
@@ -444,7 +451,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
444
451
  /** Open the mode picker. Handles all mode transitions including cross-mode switches. */
445
452
  async function openModePicker(ctx: ExtensionContext): Promise<void> {
446
453
  const current = getMode();
447
- const planFiles = listPlanFiles();
454
+ const planFiles = listPlanFiles(getSessionCwd());
448
455
 
449
456
  // Build mode list dynamically from registry (skip invisible/disabled modes)
450
457
  const registryModes = Array.from(MODE_REGISTRY.entries())
@@ -551,6 +558,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
551
558
  // ─── Event: session_start ──────────────────────────────────────────────────────
552
559
 
553
560
  pi.on("session_start", async (event, ctx) => {
561
+ // Re-publish the footer seam: a previous session in this process cleared it on shutdown.
562
+ publishModeGlobals();
554
563
  if (event.reason === "startup") {
555
564
  if (pi.getFlag("chat") === true && getMode() === "off") {
556
565
  enterChatMode(ctx);
@@ -565,6 +574,12 @@ export default function modeSwitcher(pi: ExtensionAPI) {
565
574
  await syncStateFromBranch(ctx, event.reason === "resume" || event.reason === "fork");
566
575
  });
567
576
 
577
+ pi.on("session_shutdown", async () => {
578
+ // Hand the seam back: an orphaned mode global would keep rendering a mode that no
579
+ // longer belongs to any live session.
580
+ clearModeGlobals();
581
+ });
582
+
568
583
  // ─── Event: before_agent_start ──────────────────────────────────────────
569
584
 
570
585
  pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult => {
@@ -578,7 +593,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
578
593
  const filePath = getPlanFilePath();
579
594
  if (filePath && existsSync(filePath)) {
580
595
  const planContent = readFileSync(filePath, "utf-8");
581
- return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent) };
596
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent, filePath) };
582
597
  }
583
598
  }
584
599
 
@@ -590,12 +605,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
590
605
  const customTemplate = modeDef.prompt;
591
606
  if (typeof customTemplate === "string" && customTemplate.trim().length > 0) {
592
607
  // User-configured template via modes.execute.prompt — {plan} is replaced with the plan file content.
608
+ // Capped here too: a custom template must not become a way around the injection budget.
609
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, filePath).text;
593
610
  const filled = customTemplate.includes("{plan}")
594
- ? customTemplate.replace("{plan}", planContent)
595
- : `${customTemplate}\n\nPlan:\n${planContent}`;
611
+ ? customTemplate.replace("{plan}", capped)
612
+ : `${customTemplate}\n\nPlan:\n${capped}`;
596
613
  return { systemPrompt: event.systemPrompt + "\n\n" + filled };
597
614
  }
598
- return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent) };
615
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent, filePath) };
599
616
  }
600
617
 
601
618
  // Generic mode prompt from registry
@@ -628,7 +645,13 @@ export default function modeSwitcher(pi: ExtensionAPI) {
628
645
  if (event.toolName !== "bash") return {};
629
646
  if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
630
647
 
631
- const command = event.input.command as string;
648
+ if (typeof event.input.command !== "string") {
649
+ // Uninspectable commands are refused rather than coerced: String(["rm","-rf"]) would
650
+ // reach the pattern matchers as "rm,-rf" and slip past a segment-based safe list.
651
+ return { block: true, reason: `[mode-switcher] Blocked a bash tool call whose command was not a string [${mode} mode].` };
652
+ }
653
+
654
+ const command = event.input.command;
632
655
  const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
633
656
  if (reason) {
634
657
  return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
@@ -699,7 +722,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
699
722
  setRefining(false);
700
723
 
701
724
  if (planText) {
702
- const planDir = ensurePlanDir();
725
+ const planDir = ensurePlanDir(getSessionCwd());
703
726
  const activeFile = getActivePlanFile();
704
727
  let filename = activeFile;
705
728
  if (!filename) {
@@ -850,17 +873,20 @@ export default function modeSwitcher(pi: ExtensionAPI) {
850
873
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
851
874
  }
852
875
 
853
- const filePath = join(process.cwd(), PLAN_DIR, filename);
876
+ const filePath = planFilePath(getSessionCwd(), filename);
854
877
  if (existsSync(filePath)) {
855
878
  const planContent = readFileSync(filePath, "utf-8");
856
- pi.appendEntry("plan-mode", { title: displayName, plan: planContent });
879
+ // The entry copy is display/session only (plain custom entries never reach the
880
+ // model), but it is written to the session file forever, so it gets its own budget.
881
+ const entry = capPlan(planContent, USER_CONFIG.plan.maxEntryBytes, filePath);
882
+ pi.appendEntry("plan-mode", { title: displayName, plan: entry.text, truncated: entry.truncated, file: filePath });
857
883
  }
858
884
  }
859
885
 
860
886
  // ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
861
887
 
862
888
  pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
863
- const data = (entry.data ?? {}) as { title?: string; plan?: string };
889
+ const data = (entry.data ?? {}) as { title?: string; plan?: string; truncated?: boolean; file?: string };
864
890
  const border = new DynamicBorder((s: string) => theme.fg("border", s));
865
891
  const container = new Container();
866
892
  container.addChild(border);
@@ -870,6 +896,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
870
896
  container.addChild(new Spacer());
871
897
  container.addChild(new Text(body));
872
898
  }
899
+ if (data.truncated) {
900
+ container.addChild(new Text(theme.fg("muted", `✂ truncated copy — full plan: ${data.file ?? PLAN_DIR}`)));
901
+ }
873
902
  container.addChild(border);
874
903
  return container;
875
904
  });
@@ -982,7 +1011,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
982
1011
  if (!input) {
983
1012
  const current = getMode();
984
1013
  if (current === "off") {
985
- const files = listPlanFiles();
1014
+ const files = listPlanFiles(getSessionCwd());
986
1015
  if (files.length === 0) {
987
1016
  await promptNameAndEnterPlanMode(ctx);
988
1017
  } else {
@@ -1025,7 +1054,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1025
1054
  return;
1026
1055
  }
1027
1056
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1028
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1057
+ const filePath = planFilePath(getSessionCwd(), filename);
1029
1058
 
1030
1059
  if (existsSync(filePath)) {
1031
1060
  if (getMode() !== "off") enterOffMode(ctx, undefined, true);
@@ -1078,13 +1107,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1078
1107
  pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
1079
1108
  return;
1080
1109
  }
1081
- const files = listPlanFiles();
1110
+ const files = listPlanFiles(getSessionCwd());
1082
1111
  if (files.length === 0) {
1083
1112
  if (ctx.hasUI) ctx.ui.notify("No plan files found. Run /plan first.", "warning");
1084
1113
  return;
1085
1114
  }
1086
1115
  if (files.length === 1) {
1087
- await startExecute(files[0].name);
1116
+ const only = files[0];
1117
+ if (only) await startExecute(only.name);
1088
1118
  return;
1089
1119
  }
1090
1120
  const items: SelectItem[] = files.map(f => ({ value: f.name, label: f.title }));
@@ -1100,7 +1130,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1100
1130
  return;
1101
1131
  }
1102
1132
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1103
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1133
+ const filePath = planFilePath(getSessionCwd(), filename);
1104
1134
  if (!existsSync(filePath)) {
1105
1135
  if (ctx.hasUI) ctx.ui.notify(`Plan "${input}" not found. Check /plan for available plans.`, "warning");
1106
1136
  return;
@@ -1124,6 +1154,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1124
1154
 
1125
1155
  const idx = order.indexOf(current);
1126
1156
  const next = idx === -1 ? order[0] : order[(idx + 1) % order.length];
1157
+ if (!next) return; // order is non-empty above; this is what lets the branches below take a string
1127
1158
  if (next === current) return;
1128
1159
 
1129
1160
  if (next === "off") {
@@ -7,12 +7,17 @@ import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig }
7
7
  const state: {
8
8
  mode: AgentMode;
9
9
  activePlanFile: string | null;
10
+ sessionCwd: string;
10
11
  restoringModel: ModeModelConfig | null;
11
12
  refining: boolean;
12
13
  refineCount: number;
13
14
  } = {
14
15
  mode: "off",
15
16
  activePlanFile: null,
17
+ // Until the first `session_start` hands us the real one, the directory the harness was started
18
+ // in is the only candidate. Plans and the footer path must never be resolved from
19
+ // `process.cwd()` at the call site: a session opened elsewhere would write its plans there.
20
+ sessionCwd: process.cwd(),
16
21
  restoringModel: null,
17
22
  refining: false,
18
23
  refineCount: 0,
@@ -23,6 +28,23 @@ const state: {
23
28
  (globalThis as Record<string, unknown>).__planMode = { mode: "off" };
24
29
  (globalThis as Record<string, unknown>).__chatMode = { mode: "off" };
25
30
 
31
+ /**
32
+ * Cross-extension seam. `opl-footer` publishes `__footerRequestRender` while a footer is
33
+ * mounted; `__agentMode`, `__planMode` and `__chatMode` are read by the footer's mode
34
+ * segment and by `opl-input`. Extensions install one directory at a time and load in any
35
+ * combination, so nothing here may assume the other side exists or is healthy: the value can
36
+ * be missing, be something else entirely, or close over a TUI that was already torn down.
37
+ */
38
+ function requestFooterRender(): void {
39
+ const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
40
+ if (typeof requestRender !== "function") return;
41
+ try {
42
+ (requestRender as () => void)();
43
+ } catch {
44
+ // A dead footer must not fail a mode transition.
45
+ }
46
+ }
47
+
26
48
  /** Derive and sync all globalThis snapshots from unified state. */
27
49
  function syncGlobalThis(): void {
28
50
  const m = state.mode;
@@ -35,8 +57,23 @@ function syncGlobalThis(): void {
35
57
  // plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
36
58
  (globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
37
59
  (globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
38
- const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
39
- if (typeof requestRender === "function") requestRender();
60
+ requestFooterRender();
61
+ }
62
+
63
+ /** Re-publish the seam after a fresh session starts in this process. */
64
+ export function publishModeGlobals(): void {
65
+ syncGlobalThis();
66
+ }
67
+
68
+ /**
69
+ * Drop the seam when the session ends. A leftover value would let a footer in a later
70
+ * session render a mode that belongs to nobody, and the footer's own render trigger is
71
+ * removed with it (see `opl-footer`).
72
+ */
73
+ export function clearModeGlobals(): void {
74
+ delete (globalThis as Record<string, unknown>).__agentMode;
75
+ delete (globalThis as Record<string, unknown>).__planMode;
76
+ delete (globalThis as Record<string, unknown>).__chatMode;
40
77
  }
41
78
 
42
79
  export function getMode(): AgentMode { return state.mode; }
@@ -126,6 +163,7 @@ export function restore(
126
163
  ): boolean {
127
164
  for (let i = entries.length - 1; i >= 0; i--) {
128
165
  const entry = entries[i];
166
+ if (!entry) continue; // a hole in the persisted array is not a restore candidate
129
167
  if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
130
168
 
131
169
  const data = entry.data as AgentModeBlob | undefined;
@@ -145,6 +183,7 @@ export function restore(
145
183
  // Backward compat: restore from legacy entries written by the old extensions.
146
184
  for (let i = entries.length - 1; i >= 0; i--) {
147
185
  const entry = entries[i];
186
+ if (!entry) continue; // a hole in the persisted array is not a restore candidate
148
187
  if (entry.type !== "custom") continue;
149
188
 
150
189
  if (entry.customType === "plan-mode") {
@@ -181,3 +220,12 @@ export function resetState(): void {
181
220
  state.refineCount = 0;
182
221
  syncGlobalThis();
183
222
  }
223
+
224
+ /** The directory the current session lives in (`.pi/plans/` is resolved against this). */
225
+ export function getSessionCwd(): string {
226
+ return state.sessionCwd;
227
+ }
228
+
229
+ export function setSessionCwd(cwd: string): void {
230
+ if (cwd) state.sessionCwd = cwd;
231
+ }
@@ -154,6 +154,12 @@ export interface ModeSwitcherUserConfig {
154
154
  shortcuts?: ShortcutsUserConfig;
155
155
  /** @deprecated Use `modes` registry instead. Kept for backward compat. */
156
156
  labels?: LabelsUserConfig;
157
+ plan?: {
158
+ /** Ceiling for a plan body injected into a system prompt, in bytes (default 24000). Larger plans truncate with a marker pointing at the file. */
159
+ maxInjectBytes?: number;
160
+ /** Ceiling for the plan copy kept in the `plan-mode` session entry, in bytes (default 4096). */
161
+ maxEntryBytes?: number;
162
+ };
157
163
  bashPatterns?: BashPatternsUserConfig;
158
164
  /** Replace-only: provide a list of tool names to replace the default CHAT mode tool set. Omit to keep defaults. */
159
165
  chatAllowedTools?: string[];