@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.
- package/README.md +92 -84
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +148 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +157 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +144 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +16 -4
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +48 -17
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- 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 ?
|
|
162
|
-
const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ?
|
|
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 ?
|
|
248
|
-
const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ?
|
|
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:
|
|
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] ??
|
|
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
|
|
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. `
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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(
|
|
248
|
+
export function loadUserConfig(file = configPath()): ModeSwitcherUserConfig {
|
|
240
249
|
try {
|
|
241
|
-
const parsed = JSON.parse(readFileSync(
|
|
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
|
|
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
|
-
|
|
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}",
|
|
595
|
-
: `${customTemplate}\n\nPlan:\n${
|
|
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
|
-
|
|
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 =
|
|
876
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
854
877
|
if (existsSync(filePath)) {
|
|
855
878
|
const planContent = readFileSync(filePath, "utf-8");
|
|
856
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
39
|
-
|
|
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[];
|