@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.
@@ -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 companionTimer: ReturnType<typeof setInterval> | null = null;
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.companionTimer = setInterval(() => {
46
+ this.stopCompanionTimer = startRenderTimer(() => {
47
47
  this.animator.tick(Date.now());
48
48
  this.tui.requestRender();
49
- }, 100);
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
- return new ChatInput(tui, theme, kb, companionColorFn, ctx.ui.theme);
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. User-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them.
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
- 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.
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` | Replace the shared Bash policy for built-in chat and plan. Per-mode pattern fields intentionally override this shared policy when those modes need to differ. |
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*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/,
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+-)/i,
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 raw = readFileSync(path, "utf8");
213
- return JSON.parse(raw) as ModeSwitcherUserConfig;
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
- /** Helper to convert string patterns to RegExp. */
360
- function compilePatterns(patterns: string[] | undefined): RegExp[] | undefined {
361
- if (!patterns) return undefined;
362
- return patterns.map((p) => new RegExp(p, "i"));
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, def] of Object.entries(userConfig.modes)) {
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: compilePatterns(def.safePatterns),
454
- destructivePatterns: compilePatterns(def.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
- * Resolve and switch to a specific model, notifying on failure, then refresh
131
- * the footer via updateStatus. No savedModel bookkeeping — callers that need
132
- * restore semantics use applyModeModel instead.
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
- const success = await pi.setModel(resolved);
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
- pi.setModel(toRestore).then(() => {
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
- const matchesDestructive = modeDef.destructivePatterns.some((pattern) => pattern.test(command));
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
- if (data?.mode) {
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 ?? null;
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 ?? null;
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
- && !DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
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