Reference for Shofer's runtime settings. Most settings now live in
ContextProxy/globalState (the globalSettingsSchema keys), not in
settings.json — they are edited through the Shofer Settings panel and read
via ContextProxy.getValue (or, from @shofer/core, the getHost().config seam,
which resolves them from globalState). Only the two bootstrap keys
that must be read before ContextProxy exists remain as shofer.* VS Code
settings.json entries: shofer.customStoragePath and
shofer.autoImportSettingsPath. Non-secret configuration has a
single file-based source of truth under .shofer/ — see
Layered .shofer/ configuration below.
The sections below describe each setting's meaning; where a setting was migrated
off VS Code config, set it via the Settings panel rather than settings.json.
Every non-secret Shofer configuration item lives in a file under a .shofer/
directory, resolved across three scopes and merged at runtime. Secrets are the
sole exception: provider API keys stay in VS Code SecretStorage, and
settings.json references a provider profile by name/id only, never by key
value (see settings_overlay.md §1 and
shofer_special_files.md).
| Scope | Root | Writable by the workspace? | Who writes it |
|---|---|---|---|
| global | a read-only path outside /home — SHOFER_GLOBAL_DIR if set (SaaS: a ConfigMap mount, e.g. /etc/shofer/), else <globalStorage>/.shofer |
no (RO) | a host/integration (config-manager) |
| user | ~/.shofer/ |
yes | the user |
| project | <workspace>/.shofer/ |
yes | committed to the repo, shared via git |
The roots are resolved by resolveScopeRoots in
scope-roots.ts (shared by the host
config loaders and the portable services — settings, modes, MCP, commands, skills
and rules all resolve the same three roots). The
global root being read-only and outside /home is a hard requirement: it is
what makes org-policy locking (below) enforceable rather than advisory — a global
layer the workspace could edit would make locking meaningless.
Each scope's .shofer/ holds the same file set:
.shofer/
├── settings.json # the globalSettings keys (JSON)
├── locked.json # (global scope only) org-policy lock manifest
├── plugins.json # plugin declarations (see PLUGINS.md)
├── providers.json # provider profiles (non-secret fields; keys stay in SecretStorage)
├── mcp.json # MCP servers
├── shofermodes # custom modes (YAML)
├── skills/ skills-<mode>/ # skills
├── commands/ # slash commands (*.md)
└── rules/ rules-<mode>/ # rules / custom instructions
Every file in the tree is read from all three scopes: settings.json
and plugins.json through their loaders, shofermodes,
mcp.json and providers.json per named entity (slug / server name / profile
name) through the same locked-vs-default rule, and commands/, skills*/,
rules*/ by directory order (org, then user, then project — later wins per
filename). Skills are the one directory namespace that additionally honors the
lock manifest: a skill name the org scope defines and locked.json names
(skills / skills/<name>) is org-final — SkillsManager purges same-name
entries from every other scope and refuses create/delete/move of that name. Provider profiles are the one split store: non-secret fields in
providers.json, locally-entered API keys in SecretStorage (the local key
wins over an org-supplied file default) — see
settings_overlay.md §1.
The effective config is a per-key / per-named-entity merge across the three
scopes, computed by the pure engine mergeLayeredConfig in
layered-config.ts:
- Unlocked key/entity → normal more-specific-wins:
project > user > global, with global as the default. Plain objects deep-merge per leaf; scalars and arrays are replaced wholesale. This is Shofer's existing mode/rules precedence. - Locked key/entity (the global scope marks it — below) → the global value wins and is final; user/project contributions to that key/entity are dropped. For locked keys this inverts the usual "project overrides global": org policy cannot be overridden downstream.
- A user/project may always add keys/entities the global layer does not define; locking a key global never set is inert and falls back to the unlocked merge.
flowchart TD
G["global scope — read-only, outside /home<br/>SHOFER_GLOBAL_DIR, else globalStorage/.shofer"]
U["user scope — ~/.shofer/"]
P["project scope — workspace .shofer/"]
LOCK["locked.json — global scope only<br/>lockedManifestSchema"]
M{"mergeLayeredConfig<br/>per key / per named entity"}
UNL["unlocked: project > user > global<br/>objects deep-merge per leaf;<br/>scalars and arrays replace wholesale"]
LK["locked: the global value wins and is final<br/>user/project contributions are dropped"]
EFF["effective config"]
G --> M
U --> M
P --> M
LOCK -->|"marks keys and named entities"| M
M -->|"key not locked"| UNL
M -->|"key locked"| LK
UNL --> EFF
LK --> EFF
The files are the source of truth; globalState is only the runtime cache.
ContextProxy loads the merged overlay on top of it, and every setValue for a
globalSettings key mirrors the write into the user scope's
~/.shofer/settings.json (via writeScopeSetting), creating the file on the
first write. On activation, values still resident only in globalState (from
before the file layer) are seeded into the user file once (seedScopeSettingsFile
— create-only, never overwrites an existing file). A key the global scope locks
is never persisted downstream (the write is skipped), and a file-layer failure
degrades that write to cache-only rather than failing it.
flowchart LR
MERGED["merged overlay<br/>mergeLayeredConfig"]
GSTATE[("globalState — runtime cache")]
CP["ContextProxy"]
SET["setValue(key, value)"]
USER[("~/.shofer/settings.json — user scope")]
MERGED -->|"loaded on top of"| GSTATE
GSTATE --> CP
SET --> CP
CP -->|"writeScopeSetting — created on first write,<br/>skipped for keys the global scope locks"| USER
locked.json lives in the global scope only (a user/project locked.json is
never read). Its schema (lockedManifestSchema in
layered-config.ts) is a versioned,
fail-closed list of locked paths and named entities:
{
"version": 1,
"locked": ["autoApprovalEnabled", "modes/Code", "providers/default", "plugins/git-guard"]
}Each locked entry is one of:
- a bare settings key (
"autoApprovalEnabled") — locks that top-level key; - a collection namespace (
"modes","providers","plugins") — locks the whole collection; - a
"<namespace>/<id>"entry ("modes/Code","providers/default","plugins/git-guard") — locks a single named entity, leaving its siblings on the unlocked merge; or - a record-entry path (
"alwaysAllowGroups/browser") — locks ONE entry of an open record, leaving the rest of the map on the unlocked merge. The keys that take this form areRECORD_ENTRY_KEYSinlayered-config.ts, todayalwaysAllowGroupsalone; the bare key still locks the whole map. This is what lets an org pin a single tool category's auto-approval —browseroff, say — without freezing every category a user or a server may later add.
Named-entity collections known to the settings engine are modes (→ customModes,
keyed by slug) and providers (→ listApiConfigMeta, keyed by name); plugins
is governed by the same manifest for .shofer/plugins.json (see
PLUGINS.md), mcp for mcp.json (per server name, in
McpHub) and skills for the skill directories (in SkillsManager). A corrupt
or version-mismatched manifest is discarded as "nothing locked" rather than
throwing.
Locked entities are also surfaced: the host assembles the org-locked mode
slugs, MCP server names, provider profile names and skill names into
ExtensionState.orgLockedResources, and the Settings UI disables their
edit/rename/delete affordances (ModesView, the MCP panel, ApiConfigManager).
The managers additionally refuse mutations of locked entities loudly
(CustomModesManager, McpHub, ProviderSettingsManager.deleteConfig,
SkillsManager) instead of letting the write land in a weaker scope where the
merge would silently shadow it. The one deliberate exception: a locked provider
profile still accepts a locally-entered API key (the secret overlay), so a user
can supply their own credential for an org-shipped keyless profile.
A locked plugins entry locks the DECLARATION, not the code. The global scope
being read-only settles which plugins a workspace runs and with what config, but the
resolver materializes a declared source into <globalStorage>/plugins-cache/ — a
writable path under the user's home on a typical host, exactly like the ~/.shofer/ plugins root beside it. A host that needs the code itself to be tamper-proof
provisions it instead into a read-only directory named by SHOFER_PLUGIN_DIRS, which
is scanned last and cannot be shadowed (PLUGINS.md §
"A deployment can decide both, by env"). Locking and provisioning answer different
questions and a deployment enforcing org policy needs both.
A host with no local user has to decide up front which tools auto-approve, and it
decides it from its own .shofer/ scopes. The CLI host seeds exactly one
posture key, on every mode and on both sides of --interactive:
autoApprovalEnabled: false — the master gate at its denying value. Every other
posture key is left absent, and an absent key denies: checkAutoApproval
refuses unless autoApprovalEnabled is true, and isGroupAutoApproved refuses
a group whose toggle is not exactly true.
So a node whose configuration says nothing auto-approves nothing, and every
dangerous tool raises an ask for the controller to broker. A posture key is
auto-approving only where a scope names it true.
Silence means ask, because an absent key is not a decision. It is
indistinguishable from a settings.json nobody wrote, a config materialized
before anyone thought about approvals, and a misspelled key. A host that seeded
those into auto-approve-everything shipped the widest posture it has by
accident — no error, no log — while the same omission today stalls the run,
which is visible and fixable by stating the posture the node was meant to have.
Two consequences worth stating plainly, because both are deliberate:
- The launch flag decides nothing about approvals.
--interactivechanges how asks are surfaced, not which tools raise them. browsergets no exemption. The category holds no native tools — every member arrives over MCP — so an unconfigured node parks on its first browser call. That is the intended answer: a browser clicks, types, fills and submits inside whatever session its executor's profile carries, and "nobody stated a posture" is not consent to that. A deployment that wants a headless agent to browse namesalwaysAllowGroups: { "browser": true }in its own scope.
Why the master gate is stated rather than left absent too. Absent and false
mean the same thing to the gate, but not to globalState, which persists across
restarts on a host with a state directory: a true written there by an earlier
session would otherwise outlive it. Re-asserting the denying value each boot
closes that, and one key suffices — with the master gate off, no per-group toggle
can approve anything.
That seed is still a default, not an override. Every posture key —
APPROVAL_POSTURE_KEYS in
approval-posture.ts:
autoApprovalEnabled, alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace, alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected,
alwaysAllowGroups, alwaysAllowMcp, alwaysAllowModeSwitch,
alwaysAllowSubtasks, alwaysAllowExecute, allowedCommands,
deniedCommands, alwaysAllowUncategorized and
alwaysAllowFollowupQuestions — is an
ordinary settings.json key, so any scope may set it and the scope wins:
- A posture key any scope supplies is not seeded at all. The host omits it
rather than sending a value, because the overlay already wins in
ContextProxy.getValue(a seeded value would be shadowed on read) and because the seed is delivered as a settings write, which writes through to~/.shofer/settings.json— seeding a configured key would overwrite the operator's own file with the host's default. - Which scope wins among the three is the ordinary merge (above): unlocked keys
resolve project > user > global, and a key the global scope names in
locked.jsonis global-final. Lock the key when an org policy must not be overridable by the node's user or project scope — that is the only thing that makes it final.
Two toggles deserve their own note, because they are the ones a stalled run tempts an operator to reach for:
alwaysAllowUncategorized—uncategorizedis not a capability, it is the absence of a declaration. Setting it auto-approves exactly the tools nobody classified, so a posture that deliberately gateswriteis bypassed by any mutating tool whose server declared no group. A tool that parks a headless run is fixed by CLASSIFYING it (tool-categories.md), never by widening this.alwaysAllowFollowupQuestions— its effect is to answer a question with a suggestion after a timeout. A headless node has no one to ask, which is a reason to relay the question, not to fabricate an answer.
alwaysAllowGroups is the one posture key that is a RECORD rather than a
boolean: one entry per dynamic tool category, plus a "*" wildcard meaning
"every category nobody has spoken about". Two things follow for a config author:
- It is taken over at the granularity of the KEY, not the entry. A scope that
declares
alwaysAllowGroupsat all has stated its dynamic-category posture, so its record replaces the seed's wholesale — the wildcard included. Per-entry precedence is a question for the SCOPES, answered bymergeLayeredConfig's deep merge and by the per-entryalwaysAllowGroups/<name>locks above. - An explicit
falsebeats the wildcard, so{ "*": true, "browser": false }is the way to say "auto-approve the categories we have not thought about, but not that one".
flowchart TD
START["ExtensionHost.activate()"] --> SEED["defaultApprovalSeed()<br/>autoApprovalEnabled: false"]
SEED --> READ["loadLayeredOverlay(scope roots)<br/>global · user · project"]
READ --> Q{"key present<br/>in the overlay?"}
Q -->|yes| OMIT["omit from the seed<br/>→ the scope value is the posture"]
Q -->|no| KEEP["leave absent<br/>→ the tool call asks"]
OMIT --> SEND["updateSettings — only the kept keys"]
KEEP --> SEND
SEND --> BANNER["banner: 'approvals: …'<br/>effective posture + source"]
shofer serve prints the resolved posture and its source once at startup, e.g.
approvals: ask (default — no posture configured) versus
approvals: from config (autoApprovalEnabled=true, execute gated, 3 keys from .shofer config).
Both readings matter: a node running an auto-approving posture from a file must
never look, in its logs, like one running the built-in default, and a node
stalling on every tool call must be attributable to the default rather than
hunted for in a config nobody wrote.
Resolution lives in
approval-posture.ts and reads the
scopes through the same @shofer/core loader ContextProxy uses
(layered-settings-file.ts),
so the two cannot disagree about what a scope file says. It falls back to the
seed when a scope root cannot be read, which is the safe direction: an
unreadable config stalls a node rather than un-gating one.
What the posture does not change is what happens to an ask it did not
pre-approve. On a served node those stay outstanding and are brokered to the
controlling client over ShoferApi (--interactive or not — see
shofer-api.md); nothing on the node
answers them.
The overlay is not only read at start. Every host watches the three scopes'
.shofer/ directories (scopeWatcher.ts) and
re-reads settings.json and locked.json when they change, so an edit
made by a person, by another host sharing the volume, or by a ConfigMap rewrite takes
effect without a restart. ContextProxy refreshes the merged overlay and
announces the keys that actually moved (onDidRefreshOverlay); and
ShoferProvider reloads any plugin whose
pluginConfigs entry changed, because a plugin holds the config object it was
handed at load — without the reload the overlay would report the new value while the
plugin kept running on the old one. Only the plugins whose own entry moved are
reloaded: pluginConfigs merges whole-value, so one plugin's edit re-reports the
whole map and reloading all of them would tear down unrelated services for nothing.
A key served by the overlay is not editable in the UI. The overlay wins in
getValue, so a local write would be silently shadowed; surfaces ask
ContextProxy.isManagedByFileLayer(key) and render the value read-only with its
source instead of offering an edit that does nothing (the Plugins panel does this for
pluginConfigs).
Directories are watched rather than files, because both writers that matter replace
rather than mutate: settings are written to a temp file and renamed over the target,
and a Kubernetes ConfigMap update swaps the ..data symlink under the mount. A file
watch would see neither. vscode.workspace.createFileSystemWatcher is deliberately
not used — two of the three scopes live outside the workspace, and the headless
host's shim implements it as an emitter that never fires.
Because everything non-secret is a file under .shofer/, export is simply
"archive the scope's .shofer/ tree" and import is "unpack it into a scope root".
exportScopeArchive / importScopeArchive / listScopeArchiveEntries in
scope-archive.ts produce and
consume a single gzipped tar (.tar.gz); the host wrappers
exportScopeSettingsArchive / importScopeSettingsArchive in
importExport.ts default to the user scope.
Secrets are out of the archive by construction — they live in SecretStorage,
outside .shofer/, and settings.json references profiles by name only — which is
exactly what makes a .shofer/ bundle safe to hand around. A host/integration
provisions org policy by unpacking a bundle into the global (RO) location; the
workspace's own user/project .shofer/ still override anything the global scope
does not lock.
Two different things are documented below, and they are set in different places:
globalSettingskeys (most of them) live in the layered.shofer/settings.jsondescribed above, and are edited through the Settings UI or delivered as org policy. They are not VS Code settings — putting"shofer.allowedCommands"in VS Code'ssettings.jsondoes nothing.- VS Code settings (
shofer.*, two of them) are realcontributes.configurationentries insrc/package.json, set in VS Code's own settings UI/JSON. They are the ones that must be readable before the extension's own config layer exists, or that VS Code itself consumes.
Headings below use each setting's real identity: a bare name is a globalSettings
key, a shofer.-prefixed name is a VS Code setting.
This document is the per-setting reference. For how the storage backends, merge
order, write paths, file watchers and Settings View actually work, see
settings_overlay.md.
| Type | string[] |
| Default | ["git log", "git diff", "git show"] |
| Where | layered .shofer/settings.json (+ Settings UI) |
Commands that can be automatically executed when "Always approve
execute operations" is enabled. Each entry is matched as a prefix —
"git" allows all git commands.
| Type | string[] |
| Default | [] |
| Where | layered .shofer/settings.json (+ Settings UI) |
Command prefixes that are automatically denied without asking for
approval. When conflicting with allowedCommands, the longest
prefix wins. Use "*" to deny all commands.
| Type | number |
| Default | 0 (no timeout) |
| Range | 0–600 seconds |
| Where | layered .shofer/settings.json |
Maximum time to wait for a command to complete. 0 disables the
timeout.
| Type | string[] |
| Default | [] |
| Where | layered .shofer/settings.json |
Command prefixes exempt from the execution timeout. Commands matching these prefixes run without time restrictions.
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json |
When enabled, attempt_completion is refused if the task has
incomplete todo items.
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json (+ Settings UI) |
When enabled, the new_task tool requires a todos parameter.
| Type | number |
| Default | 600 (10 minutes) |
| Range | 0–3600 seconds |
| Where | layered .shofer/settings.json (+ Settings UI) |
Maximum time to wait for API responses. Higher values recommended for local providers (LM Studio, Ollama).
| Type | object |
| Default | {} |
| Where | layered .shofer/settings.json (+ Settings UI) |
Model selector for the VS Code Language Model API. Configures which
vendor and family the vscode-lm provider connects to.
| Child key | Type | Description |
|---|---|---|
vendor |
string |
Provider vendor (e.g., "copilot") |
family |
string |
Model family (e.g., "gpt-4") |
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json (+ Settings UI) |
| Since | 3.56.x |
Enable the companion-extension integration. When enabled, the vscode-lm
provider queries well-known commands for:
llmLocalRouter.getModelPricing— per-token USD rates (Path 1)llmLocalRouter.getRequestCost— per-conversation cumulative cost (Path 2)llmLocalRouter.getModelCapabilities— tool calling, image input, prompt cache
Naming wart: despite the
LlmProviderin the setting name,vscode-lm.tsactually calls thellmLocalRouter.*commands registered by thellm-local-routerextension (extensions/llm-local-router/), not theshofer.llm.*commands ofextensions/llm-provider/. Both register the same logical commands under different namespaces — an unresolved inconsistency (seeimages.mdgaps).
These are required for cost-limit enforcement
(cost-calculation-and-limits.md) and for the API Cost row to show
USD amounts. Without this setting, only token counts are available.
Note: The llm-provider extension must be installed and active for this to work. If enabled but the commands are unavailable, the Shofer output channel will log a one-shot warning.
On the default host a task's conversation/UI messages are stored in SQLite
(node:sqlite) — see
message-store.ts, the
storage engine of the compiled-in SqliteMessagePersistence backend. Rows are
keyed by (task_id, kind, ts) with last-write-wins per ts. This replaced the
prior flat-file (JSONL) layer and its performance machinery (debounced saves,
append logs, tail-window reads, atomic-rewrite compaction).
SQLite is the DEFAULT, not the only store (see SHOFER_TASK_STORE below).
Everything that reads or writes a transcript — Task.getPersistence() and the
taskMessages.ts /
apiMessages.ts free
functions alike — goes through the backend the host SELECTED, so the two agree on
a host running a shared store. Reaching message-store.ts directly is what makes
them disagree, and it fails silently: an existing task reads back as an empty
transcript.
The connection runs journal_mode=WAL with synchronous=NORMAL, which is not a
tuning preference: node:sqlite is synchronous, so a commit's fsyncs are charged
to the host's only event loop, and on a network-backed volume the default
delete-mode journal costs two network round trips per message — enough to stop a
streaming task reading its provider socket. The trade is stated where it is set:
WAL + NORMAL can lose the last committed transaction on a kernel or power
crash, never on an application crash and never as corruption.
| Type | string |
| Default | sqlite / unset |
| Where | process environment (host wiring, not a setting) |
Which backend a host's task store uses —
backend.ts. sqlite is
compiled in and is the messages-in-SQLite + metadata-in-history_item.json pair
described above; it is correct for every host whose task lives where its storage
directory lives, which is the VS Code extension, the CLI and a server with its
own volume.
A host serving a POOL of interchangeable processes, where a task may be driven
by whichever process a request reaches, needs a store all of them share. Core
carries no such backend and no driver for one: the host supplies its own, either
by calling registerTaskPersistenceBackend(name, factory) before the first task
starts, or by setting SHOFER_TASK_STORE to a name and SHOFER_TASK_STORE_MODULE
to a module specifier exporting createTaskPersistence. A backend may also offer
lease support, in which case Task claims the task for the length of a turn and
aborts loudly if the claim is lost — which is what makes more than one candidate
writer safe.
A configured backend that cannot be resolved throws; it never falls back to
sqlite. The two are not interchangeable in the case that matters: a host given a
local store instead of the shared one answers every existing task with an empty
transcript, silently.
| Type | string |
| Default | "" (default location) |
| Scope | window |
Custom storage path for task history, plugin storage, and other
persistent data. Supports absolute paths (e.g.,
"D:\\ShoferStorage").
| Type | boolean |
| Default | true |
| Where | layered .shofer/settings.json (+ Settings UI) |
Enable Shofer Quick Fix code actions in the editor.
| Type | "user" | "project" |
| Default | "user" |
| Where | layered .shofer/settings.json (+ Settings UI → About) |
Which writable .shofer/ scope Settings edits persist to: the user scope
(~/.shofer, this machine only) or the project scope (the workspace's
committed .shofer/, shared via git). The selector itself always persists at
the user scope, and org-locked keys are never persisted downstream either way.
| Type | string |
| Default | "" (disabled) |
| Scope | window |
Path to a scope archive (.tgz, as produced by Settings → Export) to
pre-seed a fresh install from: on activation, when the user scope has no
settings.json yet, the archive is unpacked into ~/.shofer. A materialized
user scope is never overwritten. Supports absolute and home-relative paths
(e.g., "~/seed.tgz"). For standing org policy use the SHOFER_GLOBAL_DIR
mount instead.
| Type | number |
| Default | 10000 |
| Range | 5000–500000 |
| Where | layered .shofer/settings.json (+ Settings UI) |
Maximum number of files to index for the @-file search feature.
Higher values improve search in large projects but consume more memory.
| Type | number |
| Default | 60 |
| Where | pluginConfigs["rag-indexing"].embeddingBatchSize in layered .shofer/settings.json |
Batch size for embedding operations during code indexing. Adjust to
match your API provider's limits. The former
shofer.codeIndex.embeddingBatchSize VS Code setting was read by nothing —
the real knob is the rag-indexing plugin's config.
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json (+ Settings UI) |
Enable debug mode. Shows additional buttons for viewing the API conversation history and UI messages as formatted JSON in temporary files.
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json (+ Settings UI) |
Route all outgoing network requests through a proxy for MITM debugging. Only active in debug mode (F5).
| Type | string |
| Default | "http://127.0.0.1:8888" |
| Where | layered .shofer/settings.json (+ Settings UI) |
Proxy URL. Only used when debugProxy.enabled is true.
| Type | boolean |
| Default | false |
| Where | layered .shofer/settings.json (+ Settings UI) |
Accept self-signed certificates from the proxy. Required for MITM inspection. Use only for local debugging.
These settings are stored via contextProxy.getValue() and are
available in globalSettingsSchema but do not have settings-panel
rows yet. Configure them directly in settings.json.
Default per-root-task USD budget cap applied to all new tasks. To disable
cost limiting, set defaultCostLimit to null — not maxUsd: 0, which the
z.number().positive() schema rejects. See
cost-calculation-and-limits.md for details.
{
"maxConsecutiveApiFailures": 6,
}Ceiling on consecutive failed model API requests before a task gives up and
surfaces the provider's error. Default 6; a value below 1 falls back to the
default — there is deliberately no "unlimited".
The task loop auto-retries a failed request with exponential backoff (base
requestDelaySeconds, capped at 600s). Failures it can recognise as permanent —
a 401, a 403, an intercepting proxy refusing the CONNECT tunnel — abort on the
first attempt. Everything else is indistinguishable from a transient blip on the
error alone, so this bound stops the loop on count: without it, a provider
the agent simply cannot reach retries for hours and presents as a hang rather
than a failure.
Only failures with no successful request in between are counted; any request that completes clears the streak, so a mid-task network blip costs nothing. At the default the give-up lands roughly five minutes in.
{
"disabledTools": ["tool_name_1", "tool_name_2"],
}List of native tool names to globally disable. Tools in this list are excluded from prompt generation and rejected at execution time.
{
"useAgentRules": true,
}Enable loading AGENTS.md files for agent-specific rules. See
agent-rules.org. Defaults to true. The root
AGENTS.md always loads; subdirectory AGENTS.md files are additionally
gated by enableSubfolderRules. Full semantics in
shofer_special_files.md.
{
"enableSubfolderRules": true,
}Discover and load subdirectory rules — <subdir>/AGENTS.md files (no
.shofer/ sibling required) and <subdir>/.shofer/rules* directories — on
demand: a subdirectory's rules enter the system prompt only once the task
has read, mentioned, or edited a file under that subdirectory. Defaults to
true; set to false to load only workspace-root (and global) rules.
Individual rule files can further scope themselves with paths: frontmatter —
see shofer_special_files.md.
This document was verified against src/package.json
contributes.configuration and
globalSettingsSchema on
2026-05-20. The following gaps and issues were identified.
Shofer has two setting storage backends and it is critical to document which one a setting uses:
| Storage | Declaration | Settings UI |
|---|---|---|
| VS Code configuration | contributes.configuration.properties in src/package.json |
Has settings-panel rows (API & Providers, Debug, etc.) |
| ContextProxy GlobalState | globalSettingsSchema in packages/types/src/global-settings.ts |
No settings-panel rows (configure via settings.json directly) |
A setting that appears in both places (e.g. enableLlmProviderIntegration)
is stored in both backends, but the GlobalState copy is what ContextProxy
serves to runtime code. The two copies can drift if a user edits
settings.json directly for one but not the other.
The costLimitSchema requires
maxUsd: z.number().positive() — strictly greater than zero. The
defaultCostLimit example in this doc shows "maxUsd": 0, which would
be rejected by Zod validation. Cost limiting is actually disabled by
setting defaultCostLimit: null (the nullish() wrapper on the
globalSettingsSchema
default).
No source file, changelog entry, or tag in the repository contains
3.56. The version string appears only in this document.
The globalSettingsSchema defines ~80+ keys. Only 5 are documented
here (defaultCostLimit, disabledTools, useAgentRules,
commandExecutionTimeout, commandTimeoutAllowlist — the last two
with the naming confusion noted above). Entire functional areas are
absent:
- Auto-approval —
autoApprovalEnabled,alwaysAllowReadOnly,alwaysAllowWrite,alwaysAllowGroups,alwaysAllowMcp,alwaysAllowExecute,alwaysAllowModeSwitch,alwaysAllowSubtasks - Context window —
autoCondenseContext,autoCondenseContextPercent,maxOpenTabsContext,maxWorkspaceFiles - Terminal —
terminalOutputPreviewSize,terminalShellIntegrationTimeout,terminalCommandDelay - Modes —
mode,customModes,customModePrompts(per-mode API profile assignments live in the SecretStorage provider-profiles blob asmodeApiConfigs, not inglobalSettingsSchema) - Rate limiting —
allowedMaxRequests,allowedMaxCost,rateLimitSeconds - Images —
maxImageFileSize,maxTotalImageSize - Environment details —
includeCurrentTime,includeCurrentCost,maxGitStatusFiles,includeDiagnosticMessages,maxDiagnosticMessages - Write delay —
writeDelayMs
This setting is consumed:
NewTaskTool.ts reads
getConfiguration("shofer").get<boolean>("newTaskRequireTodos", false) and,
when true, rejects new_task calls lacking a todos parameter. (The previous
revision of this gap claimed zero references — it was stale.)
The doc says debugProxy.enabled is "Only active in debug mode (F5)".
The actual gate is
extensionContext.extensionMode === vscode.ExtensionMode.Development,
not the F5 key — these are different concepts (the extension can run in
development mode from a .vsix install too).
The doc does not distinguish between settings that are stored in
contributes.configuration (VS Code schema, UI controls) and settings
that are stored in globalSettingsSchema (ContextProxy, JSON-only).
This distinction is material because Global Settings bypass the VS Code
settings.json schema validation. A typo in a Global Setting value
(e.g. "maxUsd": "0" as a string) passes silently through VS Code
validation and is only caught at runtime by Zod.
{ // Cost limiting ON: maxUsd must be a POSITIVE number (the schema is // z.number().positive() — 0 is rejected by Zod validation). "defaultCostLimit": { "maxUsd": 5.0, // cap in USD (must be > 0) "action": "pause", // "pause" | "abort" | "kill" }, // Cost limiting OFF: use null (not 0). // "defaultCostLimit": null, }