Summary
The composer's permission mode picker only changes the running session. Every new session starts in interactive again, and there is no way in the app to say "start my sessions in this mode". The only lever is mode in the global config.toml: read once at sidecar start, needs an app restart to change, surfaced nowhere in the UI.
default_model already solves this exact problem for the model picker (prefs entry, POST /v1/settings/default-model, "make default" button). This asks for the same shape for the permission mode.
Verified against 9e145d9 (main, 2026-08-29); code references collapsed below.
Proposal
default_mode preference, mirroring default_model: set_default_mode on the manager, POST /v1/settings/default-mode, applied in __init__ next to default_model, returned by get_settings().
- Precedence: preference, then
config.toml mode, then built-in interactive (same shape as auto_approve()).
- A "set as default" affordance in the composer's mode menu.
- Safety cap, since a sticky default is riskier than a per-session pick: persistable are
discuss and interactive; auto-approve only while its feature flag is on (falls back to interactive when the flag goes off); bypass-approvals not persistable from the UI at all — that stays a deliberate config.toml edit. Unknown values resolve to interactive. Scheduled tasks keep their hardcoded interactive and are untouched.
Question for maintainers
Personas already carry a default_permission_mode field, but today it is display-only; nothing feeds it into the engine. If the intended home for this is the persona rather than a global preference, say so and I will follow that design instead.
Offer
Happy to send the PR: preference + endpoint, settings payload, composer affordance, safety cap, tests along the lines of tests/test_auto_approve_settings.py, plus a small fix to the stale mode line in docs/config.example.toml. Flagging first per the README's roadmap note.
Code references (verified against 9e145d9)
Picker is per-session, no pref written. App.tsx:1195 changeMode → WebSocket set_mode (api.ts:2452), handled at app.py:2637 by setting engine.permissions.mode on the live engine. The value lands on the session record (manager.py:5034) and is restored on resume (manager.py:604) — that is where it stops.
New sessions take SessionManager.mode. manager.py:607: model, mode, messages = self.model, self.mode, None. Set once in __init__ (manager.py:194) from build_app (run.py:102), whose --mode argparse default is cfg.mode (run.py:141,147). The desktop shell passes only --host/--port (lib.rs:751), so the global config.toml reaches GUI sessions only via that argparse default, read once at start.
default_model precedent. Pref applied at manager.py:260, writer set_default_model at manager.py:3560, endpoint app.py:1872, UI button ManageTabs.tsx:204. No mode key exists anywhere in prefs.
Prefs-over-config precedence precedent. auto_approve() at manager.py:3406 and tests/test_auto_approve_settings.py::test_prefs_falls_back_to_config_when_unset.
Scheduled tasks. manager.py:4327 hardcodes mode=Mode.INTERACTIVE, so a default cannot widen unattended runs; worth an explicit regression test.
Persona field is display-only. default_permission_mode in personas/manifest.py:79 (parsed at :293), surfaced via loading.py:37 / manager.py:902, rendered in PersonaView.tsx:356 / PersonasTab.tsx:454. Neither build_engine call site (manager.py:649, manager.py:4323) consults it.
Two adjacent doc/code drifts (would fold fixes into the PR):
- The per-workspace
mode override is dead code: mode is in _WORKSPACE_FIELDS (config.py:108) and tests/test_config.py asserts it resolves, but both readers call load_config() without a workspace (run.py:141, cli.py:19), and agent.py:266 consumes only allowed_commands / auto_allow / allowed_domains. The config.example.toml header promises "per-workspace, overrides global" for a key where that never applies.
docs/config.example.toml:8 documents plan | interactive | auto | custom; actual server choices are discuss, plan, interactive, auto, bypass-approvals, auto-approve (run.py:146), with auto a legacy alias mapped to BYPASS_APPROVALS by Mode._missing_ (permissions.py:232). custom still works as a config value (Mode.CUSTOM, permissions.py:229) but is absent from the choices list and hidden from the picker.
Summary
The composer's permission mode picker only changes the running session. Every new session starts in
interactiveagain, and there is no way in the app to say "start my sessions in this mode". The only lever ismodein the globalconfig.toml: read once at sidecar start, needs an app restart to change, surfaced nowhere in the UI.default_modelalready solves this exact problem for the model picker (prefs entry,POST /v1/settings/default-model, "make default" button). This asks for the same shape for the permission mode.Verified against
9e145d9(main, 2026-08-29); code references collapsed below.Proposal
default_modepreference, mirroringdefault_model:set_default_modeon the manager,POST /v1/settings/default-mode, applied in__init__next todefault_model, returned byget_settings().config.tomlmode, then built-ininteractive(same shape asauto_approve()).discussandinteractive;auto-approveonly while its feature flag is on (falls back tointeractivewhen the flag goes off);bypass-approvalsnot persistable from the UI at all — that stays a deliberateconfig.tomledit. Unknown values resolve tointeractive. Scheduled tasks keep their hardcodedinteractiveand are untouched.Question for maintainers
Personas already carry a
default_permission_modefield, but today it is display-only; nothing feeds it into the engine. If the intended home for this is the persona rather than a global preference, say so and I will follow that design instead.Offer
Happy to send the PR: preference + endpoint, settings payload, composer affordance, safety cap, tests along the lines of
tests/test_auto_approve_settings.py, plus a small fix to the stalemodeline indocs/config.example.toml. Flagging first per the README's roadmap note.Code references (verified against 9e145d9)
Picker is per-session, no pref written.
App.tsx:1195changeMode→ WebSocketset_mode(api.ts:2452), handled atapp.py:2637by settingengine.permissions.modeon the live engine. The value lands on the session record (manager.py:5034) and is restored on resume (manager.py:604) — that is where it stops.New sessions take
SessionManager.mode.manager.py:607:model, mode, messages = self.model, self.mode, None. Set once in__init__(manager.py:194) frombuild_app(run.py:102), whose--modeargparse default iscfg.mode(run.py:141,147). The desktop shell passes only--host/--port(lib.rs:751), so the globalconfig.tomlreaches GUI sessions only via that argparse default, read once at start.default_modelprecedent. Pref applied atmanager.py:260, writerset_default_modelatmanager.py:3560, endpointapp.py:1872, UI buttonManageTabs.tsx:204. No mode key exists anywhere in prefs.Prefs-over-config precedence precedent.
auto_approve()atmanager.py:3406andtests/test_auto_approve_settings.py::test_prefs_falls_back_to_config_when_unset.Scheduled tasks.
manager.py:4327hardcodesmode=Mode.INTERACTIVE, so a default cannot widen unattended runs; worth an explicit regression test.Persona field is display-only.
default_permission_modeinpersonas/manifest.py:79(parsed at:293), surfaced vialoading.py:37/manager.py:902, rendered inPersonaView.tsx:356/PersonasTab.tsx:454. Neitherbuild_enginecall site (manager.py:649,manager.py:4323) consults it.Two adjacent doc/code drifts (would fold fixes into the PR):
modeoverride is dead code:modeis in_WORKSPACE_FIELDS(config.py:108) andtests/test_config.pyasserts it resolves, but both readers callload_config()without a workspace (run.py:141,cli.py:19), andagent.py:266consumes onlyallowed_commands/auto_allow/allowed_domains. Theconfig.example.tomlheader promises "per-workspace, overrides global" for a key where that never applies.docs/config.example.toml:8documentsplan | interactive | auto | custom; actual server choices arediscuss, plan, interactive, auto, bypass-approvals, auto-approve(run.py:146), withautoa legacy alias mapped toBYPASS_APPROVALSbyMode._missing_(permissions.py:232).customstill works as a config value (Mode.CUSTOM,permissions.py:229) but is absent from the choices list and hidden from the picker.