@openlines/opl-pi-sht 0.1.21 → 0.2.1

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.
@@ -4,36 +4,40 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
4
4
 
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
- - `/mode` opens the picker; `/mode chat`, `/mode plan`, and `/mode normal` select built-in modes.
7
+ - `/mode` opens the picker; `/mode chat`, `/mode plan`, and `/mode normal` select built-in modes, and `/mode <name>` selects any registered custom mode (for example `/mode review`). `execute` is excluded because it needs an active plan; use `/execute`.
8
8
  - `/chat` toggles chat mode; `/chat off` exits it.
9
9
  - `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
10
10
  - `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
- - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan.
12
+ - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `shift+tab` is also Pi's built-in `app.thinking.cycle`, so both fire on one press; rebind either action in `~/.pi/agent/keybindings.json` (or pick another `shortcuts.cycleMode`) if you want them separate.
13
13
 
14
- Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `plan_complete` is available only in execute mode. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically.
14
+ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. `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 — but only when a turn actually finished: an aborted (ESC) or errored turn keeps execute mode active so the plan can be resumed.
15
15
 
16
16
  ## Extension features
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. 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.
20
+ Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. 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
- 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.
22
+ The shared destructive base blocks file mutation primitives (`rm`, `rmdir`, `mv`, `cp`, `mkdir`, `touch`, `chmod`, `chown`, `tee`, `dd`, `shred`, `truncate`), `find -delete`/`-exec`/`-execdir`/`--exec-batch` and find's file-writing predicates (`-fprint`, `-fprint0`, `-fprintf`, `-fls`), any `--output` file (`sort --output`, `git log/diff/show --output`), `sort -o`/`tree -o` in any argument position, `fd -x`/`-X` (and clusters like `-Hx`), `rg --pre`, `jq`'s environment access (`jq -n env`, `$ENV`), `npm`/`yarn`/`pip` install and update forms including `npm audit fix`, state-changing `git` subcommands (including `branch -d`/`-D`/`-m`/`-M`/`-c`/`-C`/`--delete`/`--unset-upstream` and the writing `git remote` subcommands), `sudo`/`su`, `kill`/`pkill`, shell spawns, editors, `uniq`'s output operand (`uniq -c in.txt out.txt`; flags are not operands, so `uniq -c sorted.txt` stays a read), and every redirect that names a file - while descriptor duplication (`2>&1`, `>&2`, `>&-`) and a redirect whose target is exactly `/dev/null` are not writes and stay allowed (`> /dev/null/../tmp/x` is a write, so a path that merely starts with that prefix is blocked). `env` and `printenv` are not safe-listed, since they could dump provider API keys into the model context. Program-name entries are anchored to command position and matched per segment, so a read-only command that merely *mentions* a dangerous word in an argument (`du -sh`, `find . -name '*.sh'`, `ls cp/`, `cat mv.sh`, `git log --grep=rm`, `grep -rn 'touch' src`) stays allowed, and descriptor duplication (`2>&1`, `>&2`, `>&-`) is not mistaken for a file write. Every destructive pattern is tested against the command and each of its segments, with and without quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. That blocklist split deliberately ignores quotes, so an operator inside a quoted string (`echo "a > b"`) is over-blocked rather than trusted. This is still a heuristic backstop, not a process-isolation boundary: `npx` and any command whose *name* comes from an expansion are outside what a pattern list can catch.
23
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.
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`. `visible: false` hides a mode from the picker and the cycle list but leaves it reachable through `/mode <name>`, on purpose: it is the escape hatch for a mode you want available without cluttering the menu. Appearance is published to `opl-input` and `opl-footer`, so every mode owns its input prefix/border and footer identity color. A 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` — which works for built-in overrides (`modes.chat`, `modes.plan`, ...) as well as new custom modes. Built-in `off` and `execute` stay unrestricted unless you give them their own `tools` list.
25
25
 
26
26
  ### Execute handoff
27
27
 
28
- Any mode can start plan execution via the mode picker's `Execute:` entries or `/execute`, unless it sets `allowExecute: false`. When blocked, the picker hides the `Execute:` entries and `/execute` reports that execution is unavailable from the current mode. The plan-mode action menu (`Execute / Refine / Save & Exit / Discard & Exit`) is the designed plan-to-execute pipeline and is always available in plan mode regardless of this flag.
28
+ Any mode can start plan execution via the mode picker's `Execute:` entries or `/execute`, unless it sets `allowExecute: false`. When blocked, the picker hides the `Execute:` entries and `/execute` reports that execution is unavailable from the current mode. The plan-mode action menu (`Execute / Refine / Save & Exit / Discard & Exit`) is the designed plan-to-execute pipeline and is always available in plan mode regardless of this flag. The Refine entry counts cycles (`Refine (3 cycles so far)`) as information only; nothing caps how often you refine.
29
29
 
30
- `allowPlanComplete: true` on a custom mode appends the `plan_complete` tool to that mode's tool list, letting a custom mode finish and exit through the same completion path as execute mode (the `tool_call` gate enforces the flag).
30
+ `allowPlanComplete: true` on a custom mode appends the `plan_complete` tool to that mode's tool list, letting a custom mode finish and exit through the same completion path as execute mode: the `tool_call` gate, the tool body, and the result handler all use one `planCompleteAllowed()` predicate, so calling it from such a mode deletes the plan file (when cleanup is enabled) and reports `Plan implemented. Plan mode OFF.` exactly like execute mode.
31
31
 
32
32
  ### Tool inheritance warning
33
33
 
34
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.
35
35
 
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/`.
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
+
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
+
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/`.
37
41
 
38
42
  ## Configuration
39
43
 
@@ -82,8 +86,9 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
82
86
  | `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
83
87
  | `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
84
88
  | `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`. |
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). |
89
+ | `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`. Every segment of a command must satisfy the allowlist. |
90
+ | `modes.<name>.unrestrictedBash` | Set `true` to exempt a mode from the Bash policy entirely (no safe/destructive patterns). Works for custom modes and built-in overrides. |
91
+ | `modes.off.tools` | Optional: pin the resting tool set for OFF mode. Omit to restore whatever was active before the mode. |
87
92
  | `lazyTools` | Tool names withheld from the active set at rest and enabled on demand (see Lazy tool loading below). |
88
93
  | `modes.<name>` | Add or override a mode, including `model`, `tools`, patterns, `allowPlanComplete`, `allowExecute`, `visible`, `enabled`, `prompt`, `labels`, and `appearance`. |
89
94
 
@@ -106,7 +111,7 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
106
111
  }
107
112
  ```
108
113
 
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.
114
+ `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`).
110
115
 
111
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.
112
117
 
@@ -127,9 +132,10 @@ Behavior:
127
132
  - `load_tools` is bounded by the current mode's tool policy: it can only enable a lazy tool the active mode already permits, so a read-only mode cannot enable a write-capable tool.
128
133
  - Core built-ins (`read`, `edit`, `write`, `bash`, `powershell`, `grep`, `find`, `ls`), `plan_complete`, and `load_tools` itself are protected and silently ignored if listed in `lazyTools`.
129
134
  - Switching modes re-applies the policy, so a lazy tool enabled earlier returns to inactive on the next mode change and must be re-enabled.
135
+ - A mode `prompt` that instructs the model to use a lazy tool must also tell it to call `load_tools` first, otherwise the instruction names a tool the model cannot see. The bundled `research` mode does exactly that.
130
136
 
131
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).
132
138
 
133
139
  ## Architecture
134
140
 
135
- `config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state and is the single publisher of `globalThis.__agentMode`. `index.ts` wires commands, picker, lifecycle hooks, tool replacement, Bash interception, and `plan_complete`; `utils.ts` handles plan files and shared helpers.
141
+ `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.
@@ -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, PartialModeDefinition } from "./types.js";
6
+ import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition, ModeModelConfig } from "./types.js";
7
7
 
8
8
  // ─── Plan File Constants ────────────────────────────────────────────────────
9
9
 
@@ -50,32 +50,56 @@ 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
+ // Pure stdout filters. uniq can write its second operand, so it is gated in the blocklist.
54
+ /^\s*uniq\b/, /^\s*tr\b/, /^\s*cut\b/,
53
55
  /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/,
54
56
  /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/,
55
57
  /^\s*uname\b/, /^\s*whoami\b/,
56
58
  /^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*free\b/,
57
59
  /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*jq\b/,
58
- /^\s*git\s+(status|log|diff|show|branch|remote)/i,
60
+ /^\s*git\s+(status|log|diff|show|branch|remote|rev-parse)/i,
59
61
  /^\s*node\s+--version/i, /^\s*python\s+--version/i,
60
62
  /^\s*(npx\s+)?tsc\b.*--noEmit/i,
61
63
  /^\s*npm\s+(list|ls|view|info|outdated|audit)/i,
62
64
  /^\s*yarn\s+(list|info|why|audit)/i,
63
65
  ];
64
66
 
65
- /** Default destructive command patterns — always blocked in read-only modes, even if matching a safe pattern. */
67
+ /** Destructive command patterns — always blocked in read-only modes, even if matching a safe pattern.
68
+ * Program names are anchored to command position (`^\s*`) and matched per shell segment, so a
69
+ * read-only command that merely mentions `rm`/`cp`/`sh` in an argument (`du -sh`, `ls cp/`,
70
+ * `git log --grep=rm`) stays allowed. Flag and redirect patterns stay unanchored on purpose. */
66
71
  export const DEFAULT_DESTRUCTIVE_PATTERNS: RegExp[] = [
67
- /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i,
68
- /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i,
69
- /\btee\b/i, /\bdd\b/i, /\bshred\b/i, /\btruncate\b/i,
70
- /\s-(delete|exec|execdir)\b/i,
71
- /(^|[^<])>(?!>|&)/, />>/,
72
- /\bnpm\s+(install|uninstall|update|ci)/i,
72
+ /^\s*(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee|dd|shred|truncate)\b/i,
73
+ // Write flags that ride a safe-listed command: find's file-writing predicates and --output
74
+ // on sort/git log/git diff/git show. `-print`/`-printf` stay allowed (they go to stdout).
75
+ /\s-{1,2}(delete|exec|execdir|exec-batch|fprint0?|fprintf|fls|output)\b/i,
76
+ // `fd -x`/`-X` (also inside a short-flag cluster like `-Hx`) execute a command per match.
77
+ // The long forms are already caught above; the anchor keeps `grep -x` and `cut -d` legal.
78
+ /^\s*fd\b[^\n]*\s-[A-Za-z]*[xX]\b/,
79
+ // `rg --pre CMD` runs CMD on every file before searching.
80
+ /^\s*rg\b[^\n]*\s--pre\b/,
81
+ // `sort -o`/`tree -o` write their output operand in any argument position.
82
+ /^\s*(sort|tree)\b[^\n]*\s-[A-Za-z]*o\b/i,
83
+ // jq can read the process environment, which is why `env`/`printenv` are not safe-listed.
84
+ // Over-blocks a JSON key literally named `env`; a leaked API key costs more.
85
+ /^\s*jq\b[^\n]*(\$ENV|\benv\b)/i,
86
+ // Any redirect that names a file. `>&<fd>`/`>&-` duplication is not a write, so 2>&1 survives.
87
+ // The /dev/null exception needs a token boundary: `> /dev/null/../tmp/pwn` and `> /dev/nullfoo`
88
+ // are writes to real paths that merely start with that prefix.
89
+ /(^|[^<])>(?!>)(?!&[0-9-])(?! *\/dev\/null(?![^\s;&|)]))/, />>(?! *\/dev\/null(?![^\s;&|)]))/,
90
+ // uniq writes its second operand (`uniq -c in.txt out.txt`); flags don't count, so
91
+ // `uniq -c sorted.txt` stays a read.
92
+ /^\s*uniq\b(?:\s+-\S+)*\s+[^-\s]\S*(?:\s+[^-\s]\S*)+/,
93
+ /\bnpm\s+(install|uninstall|update|upgrade|ci)/i,
94
+ /\bnpm\s+audit\b.*\bfix\b/i,
73
95
  /\byarn\s+(add|remove|install)/i,
74
96
  /\bpip\s+(install|uninstall)/i,
75
- /\bgit\s+(add|commit|push|merge|rebase|reset|checkout|branch\s+-|clean|update-ref|tag\s+-|cherry-pick|revert|am|apply)/i,
76
- /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i,
77
- /\b(sh|bash|zsh)\b/i,
78
- /\b(vim?|nano|emacs|code|subl)\b/i,
97
+ /\bgit\s+(add|commit|push|merge|rebase|reset|checkout|clean|update-ref|cherry-pick|revert|am|apply|branch\s+(-{1,2}[dDmMcC]|--(unset-upstream|edit-description))|tag\s+-)/i,
98
+ // Subcommands that hide behind the safe `git remote` prefix and still write .git.
99
+ /\bgit\s+remote\s+(add|remove|rename|set-url|set-head|setbranches|prune|update)\b/i,
100
+ /^\s*(sudo|su|kill|pkill)\b/i,
101
+ /^\s*(sh|bash|zsh)\b/i,
102
+ /^\s*(vim?|nano|emacs|code|subl)\b/i,
79
103
  ];
80
104
 
81
105
  // ─── Prompt Templates ────────────────────────────────────────────────────────
@@ -394,13 +418,29 @@ export function resolveCustomPatterns(def: {
394
418
  };
395
419
  }
396
420
 
421
+ /**
422
+ * Normalize a configured model reference. A blank or partial `{ provider, id }` means
423
+ * "no override" — Pi's registry cannot resolve empty strings, so passing one through
424
+ * would only warn "Model not found: /" and leave the model untouched. Treating it as
425
+ * unset makes the mode keep (and later restore) the current model instead.
426
+ */
427
+ export function resolveModeModel(model: unknown): ModeModelConfig | undefined {
428
+ if (!isRecord(model)) return undefined;
429
+ const provider = typeof model.provider === "string" ? model.provider.trim() : "";
430
+ const id = typeof model.id === "string" ? model.id.trim() : "";
431
+ return provider && id ? { provider, id } : undefined;
432
+ }
433
+
397
434
  export function mergeModeDefinition(existing: ModeDefinition, def: PartialModeDefinition): ModeDefinition {
435
+ // unrestrictedBash is an opt-out for built-in overrides too, not just new modes.
436
+ const unrestricted = def.unrestrictedBash === true;
398
437
  return {
399
438
  ...existing,
400
439
  ...def,
401
440
  tools: def.tools ?? existing.tools,
402
- safePatterns: compilePatterns(def.safePatterns) ?? existing.safePatterns,
403
- destructivePatterns: compilePatterns(def.destructivePatterns) ?? existing.destructivePatterns,
441
+ model: "model" in def ? resolveModeModel(def.model) : existing.model,
442
+ safePatterns: unrestricted ? undefined : (compilePatterns(def.safePatterns) ?? existing.safePatterns),
443
+ destructivePatterns: unrestricted ? undefined : (compilePatterns(def.destructivePatterns) ?? existing.destructivePatterns),
404
444
  labels: { ...existing.labels, ...def.labels },
405
445
  appearance: def.appearance ?? existing.appearance,
406
446
  };
@@ -429,6 +469,14 @@ export function withPlanComplete(mode: string, tools: string[], registry: Map<st
429
469
  return tools;
430
470
  }
431
471
 
472
+ /**
473
+ * Whether `plan_complete` may be called while `mode` is active: execute mode and any mode
474
+ * with allowPlanComplete. Same predicate that decides the tool's presence in the tool list.
475
+ */
476
+ export function planCompleteAllowed(mode: string, registry: Map<string, ModeDefinition> = MODE_REGISTRY): boolean {
477
+ return registry.get(mode)?.allowPlanComplete === true;
478
+ }
479
+
432
480
  /** Initialize MODE_REGISTRY with built-in defaults, then merge user-defined modes. */
433
481
  function initModeRegistry(): void {
434
482
  const customNotifyTemplate = userConfig.defaultNotifyTemplate ?? "✓ {Name} mode ON";
@@ -495,7 +543,7 @@ function initModeRegistry(): void {
495
543
  allowExecute: def.allowExecute ?? true,
496
544
  visible: def.visible ?? true,
497
545
  enabled: def.enabled ?? true,
498
- model: def.model,
546
+ model: resolveModeModel(def.model),
499
547
  labels: {
500
548
  notify: def.labels?.notify ?? customNotifyTemplate.replace("{Name}", name.charAt(0).toUpperCase() + name.slice(1)),
501
549
  notifyType: def.labels?.notifyType ?? "info",