@openlines/opl-pi-sht 0.1.18 → 0.1.20
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 +14 -8
- package/configs/opl-footer.json.sample +29 -10
- package/configs/opl-modes.json.sample +6 -5
- package/extensions/opl-browser/README.md +5 -0
- package/extensions/opl-browser/browser.ts +4 -3
- package/extensions/opl-browser/validate.ts +29 -0
- package/extensions/opl-footer/README.md +9 -4
- package/extensions/opl-footer/config.ts +131 -8
- package/extensions/opl-footer/configure-navigation.ts +13 -0
- package/extensions/opl-footer/configure.ts +157 -0
- package/extensions/opl-footer/index.ts +20 -0
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/status.ts +37 -0
- package/extensions/opl-footer/segments/tokens.ts +2 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-input/README.md +1 -1
- package/extensions/opl-input/index.ts +22 -6
- package/extensions/opl-input/utils.ts +5 -0
- package/extensions/opl-modes/README.md +8 -5
- package/extensions/opl-modes/config.ts +64 -25
- package/extensions/opl-modes/index.ts +17 -16
- package/extensions/opl-modes/state.ts +12 -3
- package/extensions/opl-modes/types.ts +2 -0
- package/extensions/opl-modes/utils.ts +20 -1
- package/extensions/opl-webaccess/README.md +1 -0
- package/extensions/opl-webaccess/extract.ts +38 -5
- package/extensions/opl-webaccess/providers/gemini.ts +2 -2
- package/extensions/opl-webaccess/utils.ts +14 -0
- package/package.json +2 -2
|
@@ -5,10 +5,12 @@ import type { TUI } from "@earendil-works/pi-tui";
|
|
|
5
5
|
|
|
6
6
|
import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js";
|
|
7
7
|
import { renderSegment } from "./segments/index.js";
|
|
8
|
+
import { createAgentStatusTracker } from "./segments/status.js";
|
|
8
9
|
import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
|
|
9
10
|
import { getEffectiveConfig } from "./config.js";
|
|
10
11
|
import { getIcons } from "./icons.js";
|
|
11
12
|
import { getDefaultColors, fg } from "./theme.js";
|
|
13
|
+
import { showFooterConfigurator } from "./configure.js";
|
|
12
14
|
|
|
13
15
|
const GIT_BRANCH_PATTERNS: RegExp[] = [
|
|
14
16
|
/\bgit\s+(checkout|switch|branch\s+-[dDmM]|merge|rebase|pull|reset|worktree)/,
|
|
@@ -112,8 +114,16 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
112
114
|
let ttftRecordedThisTurn = false;
|
|
113
115
|
let agentStartMs = 0;
|
|
114
116
|
let lastTurnaroundMs = 0;
|
|
117
|
+
let statusTracker = createAgentStatusTracker();
|
|
115
118
|
const toolStartTimes = new Map<string, number>();
|
|
116
119
|
|
|
120
|
+
pi.registerCommand("configure-opl", {
|
|
121
|
+
description: "Interactively configure the OPL footer layout",
|
|
122
|
+
handler: async (_args, ctx) => {
|
|
123
|
+
await showFooterConfigurator(ctx, () => tuiRef?.requestRender());
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
117
127
|
// Track session start
|
|
118
128
|
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
119
129
|
sessionStartTime = Date.now();
|
|
@@ -128,6 +138,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
128
138
|
ttftRecordedThisTurn = false;
|
|
129
139
|
agentStartMs = 0;
|
|
130
140
|
lastTurnaroundMs = 0;
|
|
141
|
+
statusTracker = createAgentStatusTracker();
|
|
131
142
|
toolStartTimes.clear();
|
|
132
143
|
|
|
133
144
|
if (ctx.hasUI) {
|
|
@@ -141,6 +152,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
141
152
|
// authoritative "fully done, no auto-continuation" signal.
|
|
142
153
|
pi.on("agent_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
143
154
|
if (agentStartMs === 0) agentStartMs = Date.now();
|
|
155
|
+
statusTracker.agentStarted();
|
|
156
|
+
tuiRef?.requestRender();
|
|
144
157
|
});
|
|
145
158
|
|
|
146
159
|
pi.on("agent_settled", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
@@ -148,6 +161,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
148
161
|
lastTurnaroundMs = Date.now() - agentStartMs;
|
|
149
162
|
agentStartMs = 0;
|
|
150
163
|
}
|
|
164
|
+
statusTracker.agentSettled();
|
|
165
|
+
tuiRef?.requestRender();
|
|
151
166
|
});
|
|
152
167
|
|
|
153
168
|
pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
@@ -163,6 +178,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
163
178
|
|
|
164
179
|
pi.on("tool_execution_start", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
|
|
165
180
|
toolStartTimes.set(event.toolCallId, Date.now());
|
|
181
|
+
statusTracker.toolStarted(event.toolCallId);
|
|
182
|
+
tuiRef?.requestRender();
|
|
166
183
|
});
|
|
167
184
|
|
|
168
185
|
pi.on("tool_execution_end", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
|
|
@@ -173,6 +190,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
173
190
|
toolMsThisTurn += elapsed;
|
|
174
191
|
toolStartTimes.delete(event.toolCallId);
|
|
175
192
|
}
|
|
193
|
+
statusTracker.toolEnded(event.toolCallId);
|
|
194
|
+
tuiRef?.requestRender();
|
|
176
195
|
});
|
|
177
196
|
|
|
178
197
|
pi.on("message_update", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
@@ -298,6 +317,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
298
317
|
colors,
|
|
299
318
|
icons: getIcons(effectiveConfig.icons),
|
|
300
319
|
sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
|
|
320
|
+
agentStatus: statusTracker.status(),
|
|
301
321
|
};
|
|
302
322
|
}
|
|
303
323
|
|
|
@@ -13,6 +13,7 @@ import { planModeSegment } from "./plan-mode.js";
|
|
|
13
13
|
import { chatModeSegment } from "./chat-mode.js";
|
|
14
14
|
import { modeSwitcherSegment } from "./mode-switcher.js";
|
|
15
15
|
import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
|
|
16
|
+
import { statusSegment } from "./status.js";
|
|
16
17
|
|
|
17
18
|
const SEGMENTS = {
|
|
18
19
|
pi: piSegment,
|
|
@@ -35,6 +36,7 @@ const SEGMENTS = {
|
|
|
35
36
|
mode_switcher: modeSwitcherSegment,
|
|
36
37
|
session_stats: sessionStatsSegment,
|
|
37
38
|
perf_stats: perfStatsSegment,
|
|
39
|
+
status: statusSegment,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AgentStatus, RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export function deriveAgentStatus(agentActive: boolean, activeToolCount: number): AgentStatus {
|
|
5
|
+
if (!agentActive) return "ready";
|
|
6
|
+
return activeToolCount > 0 ? "waiting" : "working";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createAgentStatusTracker() {
|
|
10
|
+
let agentActive = false;
|
|
11
|
+
const activeToolIds = new Set<string>();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
agentStarted() { agentActive = true; },
|
|
15
|
+
agentSettled() {
|
|
16
|
+
agentActive = false;
|
|
17
|
+
activeToolIds.clear();
|
|
18
|
+
},
|
|
19
|
+
toolStarted(id: string) { activeToolIds.add(id); },
|
|
20
|
+
toolEnded(id: string) { activeToolIds.delete(id); },
|
|
21
|
+
status() { return deriveAgentStatus(agentActive, activeToolIds.size); },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_DISPLAY = {
|
|
26
|
+
working: ["Working", "accent"],
|
|
27
|
+
waiting: ["Waiting", "warning"],
|
|
28
|
+
ready: ["Ready", "success"],
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const statusSegment = {
|
|
32
|
+
id: "status" as const,
|
|
33
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
34
|
+
const [label, color] = STATUS_DISPLAY[ctx.agentStatus];
|
|
35
|
+
return { content: applyColor(ctx.theme, color, label), visible: true };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -52,7 +52,7 @@ export const cacheReadSegment = {
|
|
|
52
52
|
render(ctx: SegmentContext): RenderedSegment {
|
|
53
53
|
const { cacheRead } = ctx.usageStats;
|
|
54
54
|
if (!cacheRead) return { content: "", visible: false };
|
|
55
|
-
return { content: val(ctx, formatTokens(cacheRead)), visible: true };
|
|
55
|
+
return { content: lbl(ctx, "Read ") + val(ctx, formatTokens(cacheRead)), visible: true };
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
58
|
|
|
@@ -61,6 +61,6 @@ export const cacheWriteSegment = {
|
|
|
61
61
|
render(ctx: SegmentContext): RenderedSegment {
|
|
62
62
|
const { cacheWrite } = ctx.usageStats;
|
|
63
63
|
if (!cacheWrite) return { content: "", visible: false };
|
|
64
|
-
return { content: val(ctx, formatTokens(cacheWrite)), visible: true };
|
|
64
|
+
return { content: lbl(ctx, "Write ") + val(ctx, formatTokens(cacheWrite)), visible: true };
|
|
65
65
|
},
|
|
66
66
|
};
|
|
@@ -53,6 +53,7 @@ export type StatusLineSegmentId =
|
|
|
53
53
|
| "mode_switcher"
|
|
54
54
|
| "session_stats"
|
|
55
55
|
| "perf_stats"
|
|
56
|
+
| "status"
|
|
56
57
|
| "separator"
|
|
57
58
|
| `text:${string}`;
|
|
58
59
|
|
|
@@ -109,6 +110,8 @@ export interface SessionStats {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
// Context passed to segment render functions
|
|
113
|
+
export type AgentStatus = "working" | "waiting" | "ready";
|
|
114
|
+
|
|
112
115
|
export interface SegmentContext {
|
|
113
116
|
model: { id: string; name?: string; reasoning?: boolean; contextWindow?: number; provider?: string; baseUrl?: string } | undefined;
|
|
114
117
|
isLocalModel: boolean;
|
|
@@ -126,6 +129,7 @@ export interface SegmentContext {
|
|
|
126
129
|
colors: ColorScheme;
|
|
127
130
|
icons: IconSet;
|
|
128
131
|
sessionStats: SessionStats;
|
|
132
|
+
agentStatus: AgentStatus;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
// Minimal event shapes used by the opl-footer handlers
|
|
@@ -80,4 +80,4 @@ Invalid theme tokens fall back to the theme's `border` token; invalid hex render
|
|
|
80
80
|
|
|
81
81
|
## Architecture
|
|
82
82
|
|
|
83
|
-
`index.ts` installs the editor integration; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
|
|
83
|
+
`index.ts` installs the editor integration and disposes companion render timers on editor replacement or session shutdown; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
|
|
@@ -4,7 +4,7 @@ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
|
|
6
6
|
import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
|
|
7
|
-
import { applyColor, CompanionAnimator } from "./utils.js";
|
|
7
|
+
import { applyColor, CompanionAnimator, startRenderTimer } from "./utils.js";
|
|
8
8
|
|
|
9
9
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
10
10
|
const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\[0?m/g;
|
|
@@ -28,7 +28,7 @@ class ChatInput extends CustomEditor {
|
|
|
28
28
|
private uiTheme: Theme;
|
|
29
29
|
private inputTheme: EditorTheme;
|
|
30
30
|
private animator = new CompanionAnimator();
|
|
31
|
-
private
|
|
31
|
+
private stopCompanionTimer: (() => void) | null = null;
|
|
32
32
|
|
|
33
33
|
constructor(
|
|
34
34
|
tui: TUI,
|
|
@@ -43,10 +43,15 @@ class ChatInput extends CustomEditor {
|
|
|
43
43
|
this.inputTheme = theme;
|
|
44
44
|
|
|
45
45
|
// Animate companion even when idle — tick drives state machine
|
|
46
|
-
this.
|
|
46
|
+
this.stopCompanionTimer = startRenderTimer(() => {
|
|
47
47
|
this.animator.tick(Date.now());
|
|
48
48
|
this.tui.requestRender();
|
|
49
|
-
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
dispose(): void {
|
|
53
|
+
this.stopCompanionTimer?.();
|
|
54
|
+
this.stopCompanionTimer = null;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
private isBashMode(): boolean {
|
|
@@ -275,10 +280,21 @@ class ChatInput extends CustomEditor {
|
|
|
275
280
|
|
|
276
281
|
// ─── Extension entry ──────────────────────────────────────────────────────
|
|
277
282
|
export default function (pi: ExtensionAPI) {
|
|
283
|
+
let activeEditor: ChatInput | null = null;
|
|
284
|
+
|
|
278
285
|
pi.on("session_start", async (_event, ctx) => {
|
|
286
|
+
activeEditor?.dispose();
|
|
287
|
+
activeEditor = null;
|
|
279
288
|
ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, kb: KeybindingsManager) => {
|
|
289
|
+
activeEditor?.dispose();
|
|
280
290
|
const companionColorFn = (s: string) => applyColor(ctx.ui.theme, CONFIG.COMPANION_COLOR, s);
|
|
281
|
-
|
|
291
|
+
activeEditor = new ChatInput(tui, theme, kb, companionColorFn, ctx.ui.theme);
|
|
292
|
+
return activeEditor;
|
|
282
293
|
});
|
|
283
294
|
});
|
|
284
|
-
|
|
295
|
+
|
|
296
|
+
pi.on("session_shutdown", async () => {
|
|
297
|
+
activeEditor?.dispose();
|
|
298
|
+
activeEditor = null;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
@@ -56,6 +56,11 @@ function hexToAnsi(hex: string): string {
|
|
|
56
56
|
return `\x1b[38;2;${r};${g};${b}m`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export function startRenderTimer(render: () => void): () => void {
|
|
60
|
+
const timer = setInterval(render, 100);
|
|
61
|
+
return () => clearInterval(timer);
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
export function applyColor(theme: Theme, color: string, text: string): string {
|
|
60
65
|
if (isHexColor(color)) {
|
|
61
66
|
return `${hexToAnsi(color)}${text}\x1b[0m`;
|
|
@@ -17,9 +17,11 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `p
|
|
|
17
17
|
|
|
18
18
|
### Mode behavior
|
|
19
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.
|
|
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. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
The shared destructive base also blocks file mutation primitives (`rm`, `find -delete`/`-exec`, `truncate`, `git clean`/`update-ref`/`tag -d`, `sudo`, shell spawns, and output redirects). `env` and `printenv` are no longer safe-listed, since they could dump provider API keys into the model context. Destructive matching also runs against a quote/backslash-stripped command skeleton, so `r"m"` cannot dodge `\brm\b`; this is still a heuristic backstop, not a process-isolation boundary.
|
|
23
|
+
|
|
24
|
+
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. A custom mode that omits its own `safePatterns`/`destructivePatterns` inherits the whole shared base; a mode overrides a component with its own array or disables all gating with `unrestrictedBash: true`. Built-in `off` and `execute` stay unrestricted.
|
|
23
25
|
|
|
24
26
|
### Execute handoff
|
|
25
27
|
|
|
@@ -31,7 +33,7 @@ Any mode can start plan execution via the mode picker's `Execute:` entries or `/
|
|
|
31
33
|
|
|
32
34
|
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
35
|
|
|
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.
|
|
36
|
+
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. 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/`.
|
|
35
37
|
|
|
36
38
|
## Configuration
|
|
37
39
|
|
|
@@ -80,7 +82,8 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
|
|
|
80
82
|
| `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
|
|
81
83
|
| `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
|
|
82
84
|
| `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` |
|
|
85
|
+
| `bashPatterns.safePatterns` / `bashPatterns.destructivePatterns` | Shared Bash policy applied to every mode by default; a mode overrides with its own `modes.<name>.safePatterns`/`destructivePatterns` or disables gating with `modes.<name>.unrestrictedBash: true`. |
|
|
86
|
+
| `modes.<name>.unrestrictedBash` | Set `true` to exempt a custom mode from the Bash policy entirely (no safe/destructive patterns). |
|
|
84
87
|
| `lazyTools` | Tool names withheld from the active set at rest and enabled on demand (see Lazy tool loading below). |
|
|
85
88
|
| `modes.<name>` | Add or override a mode, including `model`, `tools`, patterns, `allowPlanComplete`, `allowExecute`, `visible`, `enabled`, `prompt`, `labels`, and `appearance`. |
|
|
86
89
|
|
|
@@ -105,7 +108,7 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
|
|
|
105
108
|
|
|
106
109
|
`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
110
|
|
|
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.
|
|
111
|
+
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.
|
|
109
112
|
|
|
110
113
|
### Lazy tool loading
|
|
111
114
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import type { ModeSwitcherUserConfig, ModeDefinition } from "./types.js";
|
|
6
|
+
import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition } from "./types.js";
|
|
7
7
|
|
|
8
8
|
// ─── Plan File Constants ────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -46,13 +46,13 @@ const DEFAULT_CHAT_MODE_TOOLS: string[] = [
|
|
|
46
46
|
// ─── Bash Safety ─────────────────────────────────────────────────────────────
|
|
47
47
|
|
|
48
48
|
/** Default safe command patterns — only these are allowed in read-only modes (chat and plan). */
|
|
49
|
-
const DEFAULT_SAFE_PATTERNS: RegExp[] = [
|
|
49
|
+
export const DEFAULT_SAFE_PATTERNS: RegExp[] = [
|
|
50
50
|
/^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/,
|
|
51
51
|
/^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*cd\b/,
|
|
52
52
|
/^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/,
|
|
53
53
|
/^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/,
|
|
54
54
|
/^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/,
|
|
55
|
-
/^\s*
|
|
55
|
+
/^\s*uname\b/, /^\s*whoami\b/,
|
|
56
56
|
/^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*free\b/,
|
|
57
57
|
/^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*jq\b/,
|
|
58
58
|
/^\s*git\s+(status|log|diff|show|branch|remote)/i,
|
|
@@ -63,15 +63,16 @@ const DEFAULT_SAFE_PATTERNS: RegExp[] = [
|
|
|
63
63
|
];
|
|
64
64
|
|
|
65
65
|
/** Default destructive command patterns — always blocked in read-only modes, even if matching a safe pattern. */
|
|
66
|
-
const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
66
|
+
export const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
67
67
|
/\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i,
|
|
68
68
|
/\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i,
|
|
69
|
-
/\btee\b/i, /\bdd\b/i, /\bshred\b/i,
|
|
69
|
+
/\btee\b/i, /\bdd\b/i, /\bshred\b/i, /\btruncate\b/i,
|
|
70
|
+
/\s-(delete|exec|execdir)\b/i,
|
|
70
71
|
/(^|[^<])>(?!>|&)/, />>/,
|
|
71
72
|
/\bnpm\s+(install|uninstall|update|ci)/i,
|
|
72
73
|
/\byarn\s+(add|remove|install)/i,
|
|
73
74
|
/\bpip\s+(install|uninstall)/i,
|
|
74
|
-
/\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s
|
|
75
|
+
/\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s+-|clean|update-ref|tag\s+-|cherry-pick|revert|am|apply)/i,
|
|
75
76
|
/\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i,
|
|
76
77
|
/\b(sh|bash|zsh)\b/i,
|
|
77
78
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
@@ -207,10 +208,14 @@ const DEFAULT_CONFIG = {
|
|
|
207
208
|
|
|
208
209
|
const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-modes.json");
|
|
209
210
|
|
|
211
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
212
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
213
|
+
}
|
|
214
|
+
|
|
210
215
|
export function loadUserConfig(path = CONFIG_PATH): ModeSwitcherUserConfig {
|
|
211
216
|
try {
|
|
212
|
-
const
|
|
213
|
-
return
|
|
217
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
218
|
+
return isRecord(parsed) ? parsed as ModeSwitcherUserConfig : {};
|
|
214
219
|
} catch {
|
|
215
220
|
return {};
|
|
216
221
|
}
|
|
@@ -356,10 +361,49 @@ export const USER_CONFIG = {
|
|
|
356
361
|
/** Mode Registry — stores all registered mode definitions. */
|
|
357
362
|
export const MODE_REGISTRY = new Map<string, ModeDefinition>();
|
|
358
363
|
|
|
359
|
-
/**
|
|
360
|
-
function compilePatterns(patterns:
|
|
361
|
-
if (!patterns) return undefined;
|
|
362
|
-
|
|
364
|
+
/** Compile a valid pattern array. Undefined means malformed input or no usable patterns. */
|
|
365
|
+
export function compilePatterns(patterns: unknown): RegExp[] | undefined {
|
|
366
|
+
if (!Array.isArray(patterns)) return undefined;
|
|
367
|
+
if (patterns.length === 0) return [];
|
|
368
|
+
const compiled: RegExp[] = [];
|
|
369
|
+
for (const pattern of patterns) {
|
|
370
|
+
if (typeof pattern !== "string") return undefined;
|
|
371
|
+
try {
|
|
372
|
+
compiled.push(new RegExp(pattern, "i"));
|
|
373
|
+
} catch {
|
|
374
|
+
console.warn(`[opl-modes] Invalid pattern: "${pattern}" — ignoring the override`);
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return compiled;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Resolve the Bash pattern pair for a newly registered custom mode from the shared
|
|
382
|
+
* base (top-level bashPatterns), with per-mode overrides and an explicit opt-out.
|
|
383
|
+
* Omitted components inherit; explicit arrays (even empty) replace; `unrestrictedBash`
|
|
384
|
+
* disables Bash gating entirely for that mode. */
|
|
385
|
+
export function resolveCustomPatterns(def: {
|
|
386
|
+
safePatterns?: unknown;
|
|
387
|
+
destructivePatterns?: unknown;
|
|
388
|
+
unrestrictedBash?: boolean;
|
|
389
|
+
}): { safe?: RegExp[]; destructive?: RegExp[] } {
|
|
390
|
+
if (def.unrestrictedBash) return { safe: undefined, destructive: undefined };
|
|
391
|
+
return {
|
|
392
|
+
safe: compilePatterns(def.safePatterns) ?? SAFE_COMMAND_PATTERNS,
|
|
393
|
+
destructive: compilePatterns(def.destructivePatterns) ?? DESTRUCTIVE_PATTERNS,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function mergeModeDefinition(existing: ModeDefinition, def: PartialModeDefinition): ModeDefinition {
|
|
398
|
+
return {
|
|
399
|
+
...existing,
|
|
400
|
+
...def,
|
|
401
|
+
tools: def.tools ?? existing.tools,
|
|
402
|
+
safePatterns: compilePatterns(def.safePatterns) ?? existing.safePatterns,
|
|
403
|
+
destructivePatterns: compilePatterns(def.destructivePatterns) ?? existing.destructivePatterns,
|
|
404
|
+
labels: { ...existing.labels, ...def.labels },
|
|
405
|
+
appearance: def.appearance ?? existing.appearance,
|
|
406
|
+
};
|
|
363
407
|
}
|
|
364
408
|
|
|
365
409
|
/** Register a mode with the registry. Called by extensions at startup. */
|
|
@@ -433,25 +477,20 @@ function initModeRegistry(): void {
|
|
|
433
477
|
});
|
|
434
478
|
|
|
435
479
|
// Merge user-defined modes from config (can override built-ins or add new ones).
|
|
436
|
-
if (userConfig.modes) {
|
|
437
|
-
for (const [name,
|
|
480
|
+
if (isRecord(userConfig.modes)) {
|
|
481
|
+
for (const [name, rawDefinition] of Object.entries(userConfig.modes)) {
|
|
482
|
+
if (!isRecord(rawDefinition)) continue;
|
|
483
|
+
const def = rawDefinition as PartialModeDefinition;
|
|
438
484
|
const existing = MODE_REGISTRY.get(name);
|
|
439
485
|
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
|
-
});
|
|
486
|
+
registerMode(name, mergeModeDefinition(existing, def));
|
|
449
487
|
} else {
|
|
488
|
+
const patterns = resolveCustomPatterns(def);
|
|
450
489
|
registerMode(name, {
|
|
451
490
|
prompt: def.prompt ?? "",
|
|
452
491
|
tools: def.tools,
|
|
453
|
-
safePatterns:
|
|
454
|
-
destructivePatterns:
|
|
492
|
+
safePatterns: patterns.safe,
|
|
493
|
+
destructivePatterns: patterns.destructive,
|
|
455
494
|
allowPlanComplete: def.allowPlanComplete ?? false,
|
|
456
495
|
allowExecute: def.allowExecute ?? true,
|
|
457
496
|
visible: def.visible ?? true,
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from "./config.js";
|
|
53
53
|
import {
|
|
54
54
|
isSafeCommand,
|
|
55
|
+
isDestructive,
|
|
55
56
|
extractPlanText,
|
|
56
57
|
isPlanLike,
|
|
57
58
|
ensurePlanDir,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
sanitizePlanName,
|
|
61
62
|
extractTextFromMessage,
|
|
62
63
|
applyLabelColor,
|
|
64
|
+
createLatestModelQueue,
|
|
63
65
|
} from "./utils.js";
|
|
64
66
|
import {
|
|
65
67
|
getMode,
|
|
@@ -117,6 +119,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
117
119
|
let savedToolNames: string[] | null = null;
|
|
118
120
|
// ─── Saved model for restoring ─────────────────────────────────────────────
|
|
119
121
|
let savedModel: Model<any> | null = null;
|
|
122
|
+
const queueModelChange = createLatestModelQueue();
|
|
120
123
|
const MAX_REFINE_CYCLES = 5;
|
|
121
124
|
|
|
122
125
|
function saveAndSetActiveTools(toolNames: string[]): void {
|
|
@@ -126,22 +129,22 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
126
129
|
pi.setActiveTools(applyLazyPolicy(toolNames));
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
/** Queue model changes so a superseded async switch cannot finish after its restore. */
|
|
133
|
+
function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<void> {
|
|
134
|
+
return queueModelChange(async () => {
|
|
135
|
+
const success = await pi.setModel(model);
|
|
136
|
+
if (!success && ctx.hasUI) ctx.ui.notify(`[mode-switcher] No API key for model ${label}`, "error");
|
|
137
|
+
updateStatus(ctx);
|
|
138
|
+
}).then(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
134
141
|
async function switchToModel(ctx: ExtensionContext, modelRef: { provider: string; id: string }): Promise<void> {
|
|
135
142
|
const resolved = ctx.modelRegistry.find(modelRef.provider, modelRef.id);
|
|
136
143
|
if (!resolved) {
|
|
137
144
|
if (ctx.hasUI) ctx.ui.notify(`[mode-switcher] Model not found: ${modelRef.provider}/${modelRef.id}`, "warning");
|
|
138
145
|
return;
|
|
139
146
|
}
|
|
140
|
-
|
|
141
|
-
if (!success && ctx.hasUI) {
|
|
142
|
-
ctx.ui.notify(`[mode-switcher] No API key for model ${modelRef.provider}/${modelRef.id}`, "error");
|
|
143
|
-
}
|
|
144
|
-
updateStatus(ctx);
|
|
147
|
+
await setQueuedModel(ctx, resolved, `${modelRef.provider}/${modelRef.id}`);
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/**
|
|
@@ -166,9 +169,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
166
169
|
if (savedModel === null) return;
|
|
167
170
|
const toRestore = savedModel;
|
|
168
171
|
savedModel = null;
|
|
169
|
-
|
|
170
|
-
updateStatus(ctx);
|
|
171
|
-
});
|
|
172
|
+
void setQueuedModel(ctx, toRestore, `${toRestore.provider}/${toRestore.id}`);
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
/**
|
|
@@ -575,10 +576,10 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
575
576
|
}
|
|
576
577
|
}
|
|
577
578
|
|
|
578
|
-
// Check destructive patterns (if defined, must not match any)
|
|
579
|
+
// Check destructive patterns (if defined, must not match any) — also against
|
|
580
|
+
// a quote/backslash-stripped skeleton so r"m"/r\m obfuscation can't dodge \brm\b.
|
|
579
581
|
if (modeDef.destructivePatterns && modeDef.destructivePatterns.length > 0) {
|
|
580
|
-
|
|
581
|
-
if (matchesDestructive) {
|
|
582
|
+
if (isDestructive(command, modeDef.destructivePatterns)) {
|
|
582
583
|
return {
|
|
583
584
|
block: true,
|
|
584
585
|
reason: `[mode-switcher] Command blocked — destructive pattern in ${mode} mode: ${command}`,
|
|
@@ -88,6 +88,13 @@ function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): v
|
|
|
88
88
|
} satisfies AgentModeBlob);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Reject activePlanFile values that could escape PLAN_DIR via path components or traversal. */
|
|
92
|
+
function normalizePlanFile(file: string | null | undefined): string | null {
|
|
93
|
+
if (!file) return null;
|
|
94
|
+
if (file.includes("/") || file.includes("\\") || file.includes("..")) return null;
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
|
|
91
98
|
/**
|
|
92
99
|
* Restore state from the most recent relevant session entry on the current branch.
|
|
93
100
|
* Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries
|
|
@@ -102,9 +109,11 @@ export function restore(
|
|
|
102
109
|
|
|
103
110
|
if (entry.customType === ENTRY_TYPE) {
|
|
104
111
|
const data = entry.data as AgentModeBlob | undefined;
|
|
105
|
-
|
|
112
|
+
// Fail closed: only apply modes the registry actually knows, otherwise a stale or
|
|
113
|
+
// foreign-branch entry would leave the session unrestricted (no tools, no patterns).
|
|
114
|
+
if (data?.mode && getModeDefinition(data.mode)) {
|
|
106
115
|
state.mode = data.mode;
|
|
107
|
-
state.activePlanFile = data.activePlanFile
|
|
116
|
+
state.activePlanFile = normalizePlanFile(data.activePlanFile);
|
|
108
117
|
state.refining = false;
|
|
109
118
|
syncGlobalThis();
|
|
110
119
|
return true;
|
|
@@ -121,7 +130,7 @@ export function restore(
|
|
|
121
130
|
const data = entry.data as { mode?: string; activePlanFile?: string | null } | undefined;
|
|
122
131
|
if (data?.mode === "plan" || data?.mode === "execute") {
|
|
123
132
|
state.mode = data.mode as AgentMode;
|
|
124
|
-
state.activePlanFile = data.activePlanFile
|
|
133
|
+
state.activePlanFile = normalizePlanFile(data.activePlanFile);
|
|
125
134
|
state.refining = false;
|
|
126
135
|
syncGlobalThis();
|
|
127
136
|
return true;
|
|
@@ -125,6 +125,8 @@ export interface UserModeDefinition {
|
|
|
125
125
|
tools?: string[];
|
|
126
126
|
safePatterns?: string[];
|
|
127
127
|
destructivePatterns?: string[];
|
|
128
|
+
/** Set true to skip Bash gating entirely for this mode (no safe/destructive patterns). */
|
|
129
|
+
unrestrictedBash?: boolean;
|
|
128
130
|
labels?: ModeLabelConfig;
|
|
129
131
|
appearance?: ModeAppearanceConfig;
|
|
130
132
|
allowPlanComplete?: boolean;
|
|
@@ -7,10 +7,29 @@ import { join } from "node:path";
|
|
|
7
7
|
import { SAFE_COMMAND_PATTERNS, DESTRUCTIVE_PATTERNS, PLAN_DIR, PLAN_FILE_PREFIX } from "./config.js";
|
|
8
8
|
import type { PlanFileSummary } from "./types.js";
|
|
9
9
|
|
|
10
|
+
/** Serialize async model changes so the final requested model wins. */
|
|
11
|
+
export function createLatestModelQueue() {
|
|
12
|
+
let latest = 0;
|
|
13
|
+
let tail = Promise.resolve();
|
|
14
|
+
|
|
15
|
+
return <T>(change: () => Promise<T>): Promise<T | undefined> => {
|
|
16
|
+
const request = ++latest;
|
|
17
|
+
tail = tail.catch(() => undefined).then(() => request === latest ? change() : undefined);
|
|
18
|
+
return tail as Promise<T | undefined>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
/** Check if command matches safe patterns and not destructive patterns. */
|
|
11
23
|
export function isSafeCommand(command: string): boolean {
|
|
12
24
|
return SAFE_COMMAND_PATTERNS.some((p) => p.test(command))
|
|
13
|
-
&& !
|
|
25
|
+
&& !isDestructive(command, DESTRUCTIVE_PATTERNS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a command, or its quote/backslash-stripped skeleton, matches any destructive
|
|
29
|
+
* pattern. Stripping defeats shell expands like `r"m"` -> `rm` that dodge \brm\b. */
|
|
30
|
+
export function isDestructive(command: string, patterns: RegExp[]): boolean {
|
|
31
|
+
const skeleton = command.replace(/["'\\]/g, "");
|
|
32
|
+
return patterns.some((p) => p.test(command) || p.test(skeleton));
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
const PLAN_ACTION_VERB = /^\s*\d+\.\s+(?:\*{1,2})?(?:add|creat|updat|fix|remov|refactor|implement|modif|chang|edit|writ|build|run|install|configur|set\s+up|delet|mov|renam|inject|migrat|replac|extract|test|deploy|integrat|convert)/i;
|
|
@@ -14,6 +14,7 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
|
|
|
14
14
|
|
|
15
15
|
- Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
|
|
16
16
|
- Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
|
|
17
|
+
- Restricts `fetch_content` to http/https, caps each response at 10 MB, and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
|
|
17
18
|
- Caps initial tool output at 30,000 characters, then keeps it for retrieval for one hour or until the session ends.
|
|
18
19
|
- Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
|
|
19
20
|
|