diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index 0b0a2e3da2f5..356005452280 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -112033,127 +112033,127 @@ "en": { "stringUnit": { "state": "translated", - "value": "What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again." + "value": "What a new chat may do without asking. A mode you pick in a chat applies to that chat only." } }, "ja": { "stringUnit": { "state": "translated", - "value": "新しいチャットが確認なしで行えることです。チャットでモードを選んでも設定されます。この設定をもう一度変更するまで有効です。" + "value": "新しいチャットが確認なしで行えることです。チャットで選んだモードはそのチャットにだけ適用されます。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Was ein neuer Chat ohne Nachfrage tun darf. Auch die Wahl eines Modus in einem Chat legt ihn fest, bis du diese Einstellung erneut änderst." + "value": "Was ein neuer Chat ohne Nachfrage tun darf. Ein Modus, den du in einem Chat wählst, gilt nur für diesen Chat." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ce qu’une nouvelle discussion peut faire sans demander. Choisir un mode dans une discussion le définit aussi, jusqu’à ce que vous modifiiez à nouveau ce réglage." + "value": "Ce qu’une nouvelle discussion peut faire sans demander. Un mode choisi dans une discussion ne s’applique qu’à cette discussion." } }, "ar": { "stringUnit": { "state": "translated", - "value": "ما يمكن للمحادثة الجديدة فعله دون أن تسأل. اختيار وضع في محادثة يضبطه أيضًا، إلى أن تغيّر هذا الإعداد مرة أخرى." + "value": "ما يمكن للمحادثة الجديدة فعله دون أن تسأل. الوضع الذي تختاره في محادثة ينطبق على تلك المحادثة فقط." } }, "es": { "stringUnit": { "state": "translated", - "value": "Lo que un chat nuevo puede hacer sin preguntar. Elegir un modo en un chat también lo establece, hasta que vuelvas a cambiar este ajuste." + "value": "Lo que un chat nuevo puede hacer sin preguntar. Un modo que elijas en un chat se aplica solo a ese chat." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "新聊天不經詢問即可執行的操作。在聊天中選擇模式也會設定此項,直到你再次變更此設定。" + "value": "新聊天不經詢問即可執行的操作。在聊天中選擇的模式只套用於該聊天。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "新聊天无需询问即可执行的操作。在聊天中选择模式也会设置此项,直到你再次更改此设置。" + "value": "新聊天无需询问即可执行的操作。在聊天中选择的模式只应用于该聊天。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "새 채팅이 묻지 않고 할 수 있는 작업입니다. 채팅에서 모드를 고르면 이 설정을 다시 바꿀 때까지 그 모드가 적용됩니다." + "value": "새 채팅이 묻지 않고 할 수 있는 작업입니다. 채팅에서 고른 모드는 그 채팅에만 적용됩니다." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Šta novi razgovor smije raditi bez pitanja. Odabir načina u razgovoru također ga postavlja, dok ponovo ne promijenite ovu postavku." + "value": "Šta novi razgovor smije raditi bez pitanja. Način koji odaberete u razgovoru važi samo za taj razgovor." } }, "da": { "stringUnit": { "state": "translated", - "value": "Hvad en ny chat må gøre uden at spørge. Når du vælger en tilstand i en chat, gælder den også, indtil du ændrer denne indstilling igen." + "value": "Hvad en ny chat må gøre uden at spørge. En tilstand, du vælger i en chat, gælder kun for den chat." } }, "it": { "stringUnit": { "state": "translated", - "value": "Cosa può fare una nuova chat senza chiedere. Anche scegliere una modalità in una chat la imposta, finché non modifichi di nuovo questa impostazione." + "value": "Cosa può fare una nuova chat senza chiedere. Una modalità scelta in una chat vale solo per quella chat." } }, "km": { "stringUnit": { "state": "translated", - "value": "អ្វីដែលការជជែកថ្មីអាចធ្វើបានដោយមិនសួរ។ ការជ្រើសរើសរបៀបក្នុងការជជែកក៏កំណត់វាដែរ រហូតដល់អ្នកផ្លាស់ប្ដូរការកំណត់នេះម្ដងទៀត។" + "value": "អ្វីដែលការជជែកថ្មីអាចធ្វើបានដោយមិនសួរ។ របៀបដែលអ្នកជ្រើសរើសក្នុងការជជែកមួយ អនុវត្តចំពោះតែការជជែកនោះប៉ុណ្ណោះ។" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Hva en ny chat kan gjøre uten å spørre. Velger du en modus i en chat, gjelder den også, til du endrer denne innstillingen igjen." + "value": "Hva en ny chat kan gjøre uten å spørre. En modus du velger i en chat, gjelder bare for den chatten." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Co nowy czat może robić bez pytania. Wybranie trybu w czacie także go ustawia, dopóki ponownie nie zmienisz tego ustawienia." + "value": "Co nowy czat może robić bez pytania. Tryb wybrany w czacie dotyczy tylko tego czatu." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "O que um novo chat pode fazer sem perguntar. Escolher um modo em um chat também o define, até você alterar este ajuste de novo." + "value": "O que um novo chat pode fazer sem perguntar. Um modo escolhido em um chat vale só para aquele chat." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Что новый чат может делать без вопросов. Выбор режима в чате тоже задаёт его, пока вы снова не измените этот параметр." + "value": "Что новый чат может делать без вопросов. Режим, выбранный в чате, действует только в этом чате." } }, "th": { "stringUnit": { "state": "translated", - "value": "สิ่งที่แชตใหม่ทำได้โดยไม่ต้องถาม การเลือกโหมดในแชตก็ตั้งค่านี้ด้วย จนกว่าคุณจะเปลี่ยนการตั้งค่านี้อีกครั้ง" + "value": "สิ่งที่แชตใหม่ทำได้โดยไม่ต้องถาม โหมดที่คุณเลือกในแชตจะใช้กับแชตนั้นเท่านั้น" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Yeni bir sohbetin sormadan yapabilecekleri. Bir sohbette mod seçmek de bunu ayarlar; bu ayarı yeniden değiştirene kadar geçerlidir." + "value": "Yeni bir sohbetin sormadan yapabilecekleri. Bir sohbette seçtiğiniz mod yalnızca o sohbet için geçerlidir." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Що новий чат може робити без запитань. Вибір режиму в чаті теж задає його, доки ви знову не зміните це налаштування." + "value": "Що новий чат може робити без запитань. Режим, вибраний у чаті, діє лише в цьому чаті." } }, "vi": { "stringUnit": { "state": "translated", - "value": "Những gì cuộc trò chuyện mới được làm mà không cần hỏi. Chọn chế độ trong cuộc trò chuyện cũng đặt giá trị này, cho đến khi bạn đổi lại cài đặt này." + "value": "Những gì cuộc trò chuyện mới được làm mà không cần hỏi. Chế độ bạn chọn trong một cuộc trò chuyện chỉ áp dụng cho cuộc trò chuyện đó." } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift index d1e7ac2885fe..3b6c582b1d8f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift @@ -3,9 +3,9 @@ import CmuxNextDesign /// The defaults a new agent chat starts with (Agents > Agent Chat). acpmux /// reads `agents.defaultPermissionMode` at each new chat /// (`cmux-tui/crates/acpmux/src/hub/start_mode.rs`): a mode picked in a chat -/// wins until this setting changes again. A value MDM or the team policy -/// forces reaches acpmux through the app's settings push (`ChatSettings`) -/// and wins over every pick. +/// applies to that chat only (Leo 2026-10-21). A value MDM or the team +/// policy forces reaches acpmux through the app's settings push +/// (`ChatSettings`) and wins over the settings file. nonisolated enum AgentDefaultsSettingsSchema { static let permissionModePath = ["agents", "defaultPermissionMode"] @@ -15,7 +15,7 @@ nonisolated enum AgentDefaultsSettingsSchema { group: SettingsText.keyed("settings.group.agentChat", "Agent Chat"), title: SettingsText.keyed("settings.agents.defaultPermissionMode", "Permission Mode for New Chats"), help: SettingsText.keyed("settings.agents.defaultPermissionMode.help", - "What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again."), + "What a new chat may do without asking. A mode you pick in a chat applies to that chat only."), kind: .choice([ SettingChoice("fullAccess", SettingsText.keyed("settings.choice.permissionFullAccess", "Full Access")), SettingChoice("standard", SettingsText.keyed("settings.choice.permissionStandard", "Harness Default")), diff --git a/cmux-tui/crates/acpmux/docs/acpmux-schema.json b/cmux-tui/crates/acpmux/docs/acpmux-schema.json index 4d9654904069..fc8caad7a24c 100644 --- a/cmux-tui/crates/acpmux/docs/acpmux-schema.json +++ b/cmux-tui/crates/acpmux/docs/acpmux-schema.json @@ -54,7 +54,7 @@ "_acpmux/stats": {"params": {"days": "number? default 84, at most 366", "tzOffsetMinutes": "number? the caller's UTC offset (east positive), default 0"}, "result": {"since": "number", "until": "number", "tzOffsetMinutes": "number", "sessions": "number (sessions with a turn in range)", "turns": "number", "turnsCompleted": "number", "turnsCancelled": "number", "turnsFailed": "number", "prompts": "number", "steers": "number", "toolCalls": "number", "permissionRequests": "number", "permissionAuto": "number", "workingMs": "number (turn time summed over sessions)", "waitingMs": "number (time waiting on a permission answer)", "peakParallel": "number", "peakParallelAt": "number|null", "harnesses": "{harness, sessions, turns, workingMs}[] most working time first", "branches": "{cwd, branch, sessions, turns, workingMs, costUsd?, inputTokens?, outputTokens?, cacheReadTokens?, cacheWriteTokens?}[] per git branch each turn ended on (its turn_result gitBranch; a turn that recorded none, as in older logs, counts on the branch its session started on, session.json gitBranch) and the session's cwd as given (a subfolder of the same checkout is another row), most working time first, at most 50: the range's turns with the spend their turn_result records carry (never the session totals), a spend field absent when no counted turn reported it; a session whose turns ended on several branches is a session of each; turns with no branch either way are left out", "days": "{day: YYYY-MM-DD, turns, workingMs}[] oldest first, active days only", "hours": "number[24] working ms per hour of the caller's day", "streak": "{current, longest} days in a row with a finished turn, over the last 366 days whatever the range"}, "errors": "Method not found over any WebSocket (Web, LocalApp, Peer)", "note": "Local unix socket only (hq#1920): counts and times, never prompt or tool text. One offset applies to the whole range, so days before a DST change shift by an hour. A turn or wait the daemon lost when it died ends at the session's last record before the loss: at the restart's own record (turn_result detail outcome_unknown, or status idle detail restart, which a restart writes for a session it found waiting), or at the end of the log when this daemon does not hold the session running or waiting; older logs without the restart record end such a wait at the next status. A prompt ends a wait left after its turn at the prompt's time. A prompt that never got turn_started and never settled counts as a prompt, not a turn. A steer sent again as the next prompt (same promptId) counts once, as the prompt. A fork's log starts with copies of its parent's prompts, tool calls and turns before its forked record: they count only in the parent's log, in every total, day, hour, streak and peakParallel, and not at all once that log is gone. branches follows a session that switches branch: each turn counts on the branch its turn_result recorded it ended on, else on the branch the session started on."}, "_acpmux/wait": {"params": {"sessions": "string[]? (empty = every running or waiting session on every host)", "until": "('ready'|'permission'|'closed'|'done'|'running')[]? default ready+permission", "all": "boolean? default false (first to resolve)", "afterSeq": "{sessionId: number}?", "timeoutMs": "number?"}, "result": {"sessions": "SessionSummary[] with resolved: boolean and matched: string[]", "resolved": "SessionSummary[]", "timedOut": "boolean"}}, "_acpmux/warm": {"params": {"sessionIds": "string[]?", "limit": "number?"}, "result": {"warmed": "{sessionId,cwd}[]"}, "note": "Starts and initializes one live agent child for each of the most recent requested project sessions; it does not create or select sessions."}, - "_acpmux/start_mode": {"params": {"harness": "string? (a family or profile; default the default harness)", "preset": "string?", "cwd": "string?"}, "result": {"harness": "string (the profile)", "modeId": "string|null (null: acpmux would set no start mode: a preset, folder-profile or remote-origin chat, a profile that pins its mode in argv, a level that keeps the harness's own mode, or modes not known yet)", "name": "string|null (the mode's display name)", "level": "\"fullAccess\"|\"standard\"|\"ask\"|null", "source": "\"managed\"|\"pick\"|\"config\"|\"settings\"|\"default\"|null"}, "note": "Read only. The mode a plain new chat on that harness would start in now, by the same rules session/new applies (hub/start_mode.rs). A remote-origin (Web, peer) connection gets the all-null answer before its cwd, preset or harness is resolved: its chats get no start mode. Local watchers get _acpmux/start_mode_changed {} when the answer may have changed (a saved pick, a config reload)."}, + "_acpmux/start_mode": {"params": {"harness": "string? (a family or profile; default the default harness)", "preset": "string?", "cwd": "string?"}, "result": {"harness": "string (the profile)", "modeId": "string|null (null: acpmux would set no start mode: a preset, folder-profile or remote-origin chat, a profile that pins its mode in argv, a level that keeps the harness's own mode, or modes not known yet)", "name": "string|null (the mode's display name)", "level": "\"fullAccess\"|\"standard\"|\"ask\"|null", "source": "\"managed\"|\"policy\"|\"config\"|\"settings\"|\"default\"|null"}, "note": "Read only. The mode a plain new chat on that harness would start in now, by the same rules session/new applies (hub/start_mode.rs). A remote-origin (Web, peer) connection gets the all-null answer before its cwd, preset or harness is resolved: its chats get no start mode. Local watchers get _acpmux/start_mode_changed {} when the answer may have changed (the setting, a config reload)."}, "_acpmux/prewarm": {"params": {"harness": "string? (a family or profile; default the default harness)", "preset": "string?", "cwd": "string? (default the newest session's cwd)", "wait": "boolean? default false (answer once the hinted session is ready or failed)"}, "result": {"accepted": "boolean", "reason": "string? (accepted false: the pool is off)", "superseded": "boolean? (wait: a newer hint replaced this one)", "pool": "PoolStatus? (wait)"}, "errors": "invalid_params over a remote-origin connection (never pooled)", "note": "Hints the harness the pane is about to switch to. After the debounce (pool.debounceMs, default 150) only the newest hint counts: a hidden session for it starts in that cwd (agent host, initialize and the harness session done). Per cwd at most two are kept: the harness used before the current one and the hinted one. A session/new of exactly that shape (cwd, harness, preset, argv, system prompt, env and credentials, account) takes it at once. Hidden sessions never appear in _acpmux/sessions or liveAgents and leave nothing in the store unless taken; _acpmux/status pool lists them as {entries: [{harness, preset, cwd, roles, state}], rssBytes}. They exit after pool.idleMinutes (default 10) unused, under pool.maxRssMb (default 1024, oldest first) and at shutdown."}, "_acpmux/rename": {"params": {"sessionId": "string", "newName": "string"}, "result": "SessionSummary"}, "_acpmux/kill": {"params": {"sessionId": "string", "purge": "boolean?"}, "result": {"sessionId": "string", "purged": "boolean"}}, diff --git a/cmux-tui/crates/acpmux/src/config.rs b/cmux-tui/crates/acpmux/src/config.rs index 57a9a82d9913..71900ca0fc3d 100644 --- a/cmux-tui/crates/acpmux/src/config.rs +++ b/cmux-tui/crates/acpmux/src/config.rs @@ -767,24 +767,14 @@ pub struct Config { pub store: StoreConfig, #[serde(default)] pub permission_policy: PermissionPolicy, - /// `defaultPermissionMode`: the permission mode a new chat starts in when - /// the person has not picked one for its harness (`hub/start_mode.rs`): - /// `fullAccess`, `standard` or `ask`. Absent: the cmux setting - /// `agents.defaultPermissionMode`, else `fullAccess`. For acpmux without - /// the cmux app, and for hermetic tests. + /// `defaultPermissionMode`: the permission mode a new chat starts in + /// (`hub/start_mode.rs`): `fullAccess`, `standard` or `ask`. Absent: the + /// cmux setting `agents.defaultPermissionMode`, else `fullAccess`. For + /// acpmux without the cmux app, and for hermetic tests. A + /// `lastPermissionMode` an older build wrote beside it is ignored and + /// dropped at the next save. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_permission_mode: Option, - /// `lastPermissionMode`, `lastPermissionModeChange`: the level of the latest mode pick - /// from the app pane, and how many changes to the cmux setting acpmux had counted then, - /// in which count (`hub/start_mode_state.rs`; `hub/start_mode.rs` `start_level`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_permission_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_permission_mode_change: Option, - /// `lastPermissionModeSetting`: an older build's record beside a pick (the setting's value - /// then). Read once to upgrade the pick (`Hub::start_level`), never written. - #[serde(default, skip_serializing)] - pub last_permission_mode_setting: Option, /// `composerMaxRows`: most rows the TUI composer grows to before it /// scrolls. Env `ACPMUX_COMPOSER_ROWS` overrides. Default 12. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1227,17 +1217,6 @@ impl Config { } } -/// `lastPermissionModeChange`: where acpmux's count of changes to the cmux -/// setting `agents.defaultPermissionMode` stood at a mode pick -/// (`hub/start_mode_state.rs`). `epoch` names the count: a count started -/// again (its state file lost) has another, so an older pick never matches it. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PermissionModeChange { - pub epoch: String, - pub changes: u64, -} - fn is_default_kind(k: &HarnessKind) -> bool { *k == HarnessKind::Acp } diff --git a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs index 364e03fc5af6..7fdeafa82575 100644 --- a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs +++ b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs @@ -73,13 +73,8 @@ impl Hub { current.silent_turn_ms = next.silent_turn_ms; current.stalled_turn_ms = next.stalled_turn_ms; current.stalled_turn_end_ms = next.stalled_turn_end_ms; - let start_changed = current.default_permission_mode != next.default_permission_mode - || current.last_permission_mode != next.last_permission_mode - || current.last_permission_mode_change != next.last_permission_mode_change; + let start_changed = current.default_permission_mode != next.default_permission_mode; current.default_permission_mode = next.default_permission_mode; - current.last_permission_mode = next.last_permission_mode; - current.last_permission_mode_change = next.last_permission_mode_change; - current.last_permission_mode_setting = next.last_permission_mode_setting; if start_changed { self.start_mode_changed(); } diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index 7dcbb04e052f..05f89115fe9f 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -159,8 +159,8 @@ //! `personInstance` is a daemon it enrolled directly (an app follow-up). //! //! The default start mode (`hub/start_mode.rs`, cx-wx93i) is not a request: -//! with no `mode` asked, a new chat starts in the person's latest pick, else -//! `defaultPermissionMode` / `agents.defaultPermissionMode`, else the +//! with no `mode` asked, a new chat starts in `defaultPermissionMode` / +//! `agents.defaultPermissionMode`, else the //! harness's full-access mode, for every non-Web origin (also the unix //! socket CLI). It never widens a proven caller session: one in a mode //! that asks starts its chats in the harness's own mode diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index 49265d8bce7b..177bddf079c0 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -194,13 +194,6 @@ impl Hub { if pick && effort.is_some() && before.as_deref() != effort { self.note_pick(session, None, effort).await; } - // The mode as a config option (Claude Code's `mode`): a mode pick. - if pick - && resolved == "mode" - && let Some(mode) = value.as_str() - { - self.note_mode_pick(session, mode).await; - } Ok(r) } diff --git a/cmux-tui/crates/acpmux/src/hub/start_mode.rs b/cmux-tui/crates/acpmux/src/hub/start_mode.rs index fdd43e805aa8..1a5c4c29afc5 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_mode.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_mode.rs @@ -5,29 +5,27 @@ //! 0. the value MDM or the team policy forces for the cmux setting //! `agents.defaultPermissionMode`, which the app pushes //! (`start_mode_state.rs`): an administrator's choice beats everything -//! below, picks included. A forced value this build does not know is -//! `standard`, never full access; -//! 1. the person's latest mode pick in any chat (config.json -//! `lastPermissionMode`: `fullAccess`, `standard` or `ask`, written by -//! `note_mode_pick` from the app pane only), while no change to the cmux -//! setting has been counted since the pick (`lastPermissionModeChange`, -//! `start_mode_state.rs`): a change to the setting after the pick is the -//! person's latest choice, so it wins, also when it is changed back -//! (Leo 2026-10-18: users set their own default). One pick for every harness -//! and model (Lawrence 2026-10-14: "should just apply to the ones going -//! forward, all harnesses/models, should always use the latest user -//! selected one"); chats that exist keep their mode; -//! 2. acpmux config.json `defaultPermissionMode` (acpmux without the app, +//! below. A forced value this build does not know is `standard`, never +//! full access; +//! 1. acpmux config.json `defaultPermissionMode` (acpmux without the app, //! tests), else the cmux setting `agents.defaultPermissionMode` (the -//! settings file, `user_settings.rs`): `fullAccess` (the default), `standard` (the -//! harness's own start mode: acpmux sets nothing) or `ask` (the mode -//! that asks before each change, `web_modes::ASKING_MODES`, else `ask`); -//! 3. full access: the harness's own mode that skips every approval +//! settings file, `user_settings.rs`, else the value the app pushed): +//! `fullAccess` (the default), `standard` (the harness's own start mode: +//! acpmux sets nothing) or `ask` (the mode that asks before each change, +//! `web_modes::ASKING_MODES`, else `ask`); +//! 2. full access: the harness's own mode that skips every approval //! (`FULL_ACCESS_MODES`: Claude Code `bypassPermissions`, the Codex //! bridge `agent-full-access` = approval never + danger-full-access, //! `yolo` where a harness names it so). A harness with no such mode keeps //! its own start mode. //! +//! A mode picked in a chat holds for that chat only; new chats keep starting +//! in the default (Leo 2026-10-21: "i think starting in full access is ok cuz +//! the default for new chats != whatever u just temp set in an existing +//! running chat"). The default changes only where it is set: Settings, the +//! settings file, MDM or the team policy, or config.json. A +//! `lastPermissionMode` an older build saved in config.json is ignored. +//! //! acpmux sets it with `session/set_mode` once the harness is up. Like the //! start model it is soft: a refusal is logged (`start_default_skipped`) //! and the chat runs the harness's own mode. Not for: a remote-origin @@ -36,8 +34,7 @@ //! adopted conversation, and a profile that pins its mode in its argv //! (`profile_sets_mode`). //! -//! Only the person widens a mode later (`hub/person.rs`). A pick is saved -//! as its level, so it maps onto every harness's own mode ids. +//! Only the person widens a mode later (`hub/person.rs`). //! //! Inherit (Leo 2026-10-25: "shouldn't sub-agents just inherit the bypass //! permission situation?"): a chat an agent starts, by `session/new` from @@ -75,7 +72,6 @@ //! socket since before this). use super::*; -use crate::config::PermissionModeChange; /// The mark on a watchers' harness-feed payload that is a start-mode notice. pub(crate) const START_MODE_NOTE: &str = "startModeChanged"; @@ -87,12 +83,9 @@ pub const SETTING: &str = "agents.defaultPermissionMode"; pub(crate) const FULL_ACCESS_MODES: [&str; 4] = ["bypassPermissions", "agent-full-access", "full-access", "yolo"]; -/// Mode ids that are a temporary toggle, never a saved pick. +/// Mode ids that are a temporary toggle, never inherited by a sub-session. const TOGGLE_MODES: [&str; 1] = ["plan"]; -/// The longest mode id a pick saves. -const MAX_MODE: usize = 128; - /// `agents.defaultPermissionMode`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DefaultMode { @@ -169,12 +162,11 @@ impl DefaultMode { } } -/// What chose a start level: a value policy forces, the person's latest -/// pick, config.json, the cmux setting, or nothing (full access). +/// What chose a start level: a value policy forces, config.json, the cmux +/// setting, or nothing (full access). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Source { Managed, - Pick, Config, Settings, Default, @@ -184,7 +176,6 @@ impl Source { fn as_str(self) -> &'static str { match self { Self::Managed => "managed", - Self::Pick => "pick", Self::Config => "config", Self::Settings => "settings", Self::Default => "default", @@ -209,17 +200,11 @@ pub(crate) fn setting_snapshot() -> Option { } /// The level a new chat starts at (module docs): the forced value -/// (`managed`), else the latest pick (config.json `lastPermissionMode`) -/// while the setting's change count is the one recorded with it -/// (`picked_at`, `now`), else `DefaultMode::resolve` (with `pushed`). The -/// person's latest choice wins, whether it is a pick in a chat or a change to -/// the setting. With `sessions.allowFullAccess` off, full access starts as -/// Standard, whatever chose it. +/// (`managed`), else `DefaultMode::resolve` (with `pushed`). With +/// `sessions.allowFullAccess` off, full access starts as Standard, whatever +/// chose it. fn start_level( managed: Option<&str>, - picked: Option<&str>, - picked_at: Option<&PermissionModeChange>, - now: &PermissionModeChange, configured: Option<&str>, pushed: Option<&str>, ) -> (DefaultMode, Source) { @@ -233,10 +218,7 @@ fn start_level( }); (level, Source::Managed) } else { - match picked.and_then(DefaultMode::parse) { - Some(level) if configured.is_some() || picked_at == Some(now) => (level, Source::Pick), - _ => DefaultMode::resolve(configured, pushed), - } + DefaultMode::resolve(configured, pushed) }; // `sessions.allowFullAccess` off: no chat starts in a mode that skips every approval. if level == DefaultMode::FullAccess && !full_access_allowed() { @@ -655,48 +637,15 @@ impl Hub { /// The level new chats start at now, and what chose it. async fn start_level(&self) -> (DefaultMode, Source) { - let (mut state, mut picked, mut picked_at, legacy, configured) = { + let (state, configured) = { let cfg = self.config.read().await; let path = super::start_mode_state::state_file(&cfg); ( self.update_start_mode_state(path.as_deref(), |_| {}).1, - cfg.last_permission_mode.clone(), - cfg.last_permission_mode_change.clone(), - cfg.last_permission_mode_setting.clone(), cfg.default_permission_mode.clone(), ) }; - // A pick from before acpmux counted changes (an upgrade): it wins - // until the next change, so record the count now. An older build's - // record of the setting at the pick that is not the setting now means - // the setting changed after the pick: the pick is spent. - if picked.is_some() && picked_at.is_none() { - let spent = legacy.is_some_and(|then| then != setting_snapshot().unwrap_or_default()); - let mut cfg = self.config.write().await; - let path = super::start_mode_state::state_file(&cfg); - state = self.update_start_mode_state(path.as_deref(), |_| {}).1; - if cfg.last_permission_mode == picked && cfg.last_permission_mode_change.is_none() { - if spent { - cfg.last_permission_mode = None; - } else { - cfg.last_permission_mode_change = Some(state.mark()); - } - cfg.last_permission_mode_setting = None; - if let Err(e) = cfg.save() { - tracing::warn!("permission pick's change count not saved to config.json: {e}"); - } - } - picked = cfg.last_permission_mode.clone(); - picked_at = cfg.last_permission_mode_change.clone(); - } - start_level( - state.managed.as_deref(), - picked.as_deref(), - picked_at.as_ref(), - &state.mark(), - configured.as_deref(), - state.pushed.as_deref(), - ) + start_level(state.managed.as_deref(), configured.as_deref(), state.pushed.as_deref()) } /// `_acpmux/start_mode`: the mode a plain new chat on `harness` (or the @@ -800,60 +749,6 @@ impl Hub { }) } - /// `session/set_mode` from a client. From the app pane (`pick`), once - /// the harness takes it, its level is the start mode of every new chat. - pub async fn client_set_mode( - self: &Arc, - session: &Arc, - mode: &str, - pick: bool, - ) -> Result { - let r = self.set_mode(session, mode).await?; - if pick { - self.note_mode_pick(session, mode).await; - } - Ok(r) - } - - /// Saves the level of a mode pick on `session` as the start mode of - /// every new chat (config.json `lastPermissionMode`). Not for a preset's - /// chat, a remote-origin chat, a toggle such as Plan, or a profile that - /// pins its mode. - pub(super) async fn note_mode_pick(&self, session: &Session, mode: &str) { - let meta = session.meta(); - if meta.preset.is_some() - || meta.remote_origin - || mode.is_empty() - || mode.len() > MAX_MODE - || TOGGLE_MODES.contains(&mode) - { - return; - } - let family = crate::web_modes::family_of(&meta); - let level = level_of(mode, &family, self.web_modes().modes(&family)).as_str(); - let mut cfg = self.config.write().await; - if cfg.harnesses.get(&meta.harness).is_some_and(profile_sets_mode) { - return; - } - // The setting's change count now, read under the config lock so a - // push counted before this pick is saved never spends it. - let path = super::start_mode_state::state_file(&cfg); - let changes = Some(self.update_start_mode_state(path.as_deref(), |_| {}).1.mark()); - if cfg.last_permission_mode.as_deref() == Some(level) - && cfg.last_permission_mode_change == changes - { - return; - } - cfg.last_permission_mode = Some(level.to_owned()); - cfg.last_permission_mode_change = changes; - cfg.last_permission_mode_setting = None; - if let Err(e) = cfg.save() { - tracing::warn!("mode pick not saved to config.json: {e}"); - } - drop(cfg); - self.start_mode_changed(); - } - /// Tells local watchers a new chat's start mode may have changed /// (`_acpmux/start_mode_changed`), so a pending chat asks again. It /// rides the watchers' harness feed (`server/harness_admin.rs` diff --git a/cmux-tui/crates/acpmux/src/hub/start_mode_state.rs b/cmux-tui/crates/acpmux/src/hub/start_mode_state.rs index 24212467ae81..c8cefbdfca61 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_mode_state.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_mode_state.rs @@ -4,7 +4,7 @@ //! `server/chats.rs`). Only the app reads the MDM and team policy layers: //! //! - the value of `agents.defaultPermissionMode` that MDM or the team policy -//! forces. It beats config.json, the settings file and every pick. A +//! forces. It beats config.json and the settings file. A //! forced value that is not a short string is a value acpmux does not //! know, which is `standard`, never full access. Best effort, not a //! security boundary: any process of the same user can push or edit these @@ -12,17 +12,13 @@ //! - the effective value when nothing forces it, for a file with no key: a //! team default or an MDM recommended value, so new chats start in what //! Settings shows. With the app closed it is the last value pushed. -//! - a count of changes to the setting, for "latest choice wins": a pick -//! records the count and its epoch (`lastPermissionModeChange`) and wins -//! only while no change has been counted since. Counting changes, not -//! comparing values, means a setting changed and changed back still wins -//! over an older pick. acpmux counts a change when it sees the setting (the -//! pushed values and the file's value) differ from the last time it looked: -//! at each push (the app pushes after each settings change it writes) and -//! each time a new chat's start mode is decided. A change and its undo made -//! while neither happens (the app closed, the file edited twice by hand) is -//! not seen. A count started again (the file lost or unreadable) gets a new -//! epoch, so a pick from the old count never wins again. +//! - the setting as acpmux last saw it (the pushed values and the file's +//! value) and a count of the changes it saw, so a push that changes what a +//! new chat starts in tells the watchers (`apply_permission_push`). acpmux +//! looks at each push (the app pushes after each settings change it writes) +//! and each time a new chat's start mode is decided. A mode picked in a +//! chat is not kept here: it holds for that chat only (`start_mode.rs`). +//! A file an older build wrote, with its `epoch`, still loads. //! //! Kept in `start-mode.json` beside config.json (owner-only, written through //! `config::write_atomic`), so a forced value holds across a daemon restart @@ -30,7 +26,6 @@ //! memory. Lock order: the config lock, then the state's mutex. use super::*; -use crate::config::PermissionModeChange; use serde::{Deserialize, Serialize}; /// The state file's name, beside config.json. @@ -49,19 +44,12 @@ pub(crate) struct StartModeState { pub seen: Option, /// Changes to the setting counted so far. pub changes: u64, - /// Names this count; a new one when the count starts again. - pub epoch: String, } impl StartModeState { - /// The saved state, or a new count with a new epoch when there is none or - /// it cannot be read. + /// The saved state, or a new one when there is none or it cannot be read. fn load(path: Option<&Path>) -> Self { - let mut state = path.and_then(Self::read).unwrap_or_default(); - if state.epoch.is_empty() { - state = Self { epoch: uuid::Uuid::now_v7().simple().to_string(), ..state }; - } - state + path.and_then(Self::read).unwrap_or_default() } fn read(path: &Path) -> Option { @@ -96,11 +84,6 @@ impl StartModeState { crate::config::write_atomic(path, &bytes) } - /// Where the count stands now, to record with a pick. - pub(crate) fn mark(&self) -> PermissionModeChange { - PermissionModeChange { epoch: self.epoch.clone(), changes: self.changes } - } - /// Counts a change when the setting is not what it was at the last look. /// The first look counts nothing. fn observe(&mut self) { diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index 6e3e29ee2d6b..d475d583a3bf 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -1037,8 +1037,8 @@ async fn dispatch_request( let set = crate::hub::SetRequest::Mode(mode.to_owned()); return hub.set_for_full_access_device(&s, &device, set).await; } - // The app pane's mode pick is saved for the next new chat (start_mode.rs). - hub.client_set_mode(&s, mode, conn.origin == Origin::LocalApp).await + // A mode pick holds for this chat only; new chats start in the default (start_mode.rs). + hub.set_mode(&s, mode).await } method::SESSION_SET_CONFIG_OPTION => { let s = resolved_session(hub, resolved.as_ref(), ¶ms)?; diff --git a/cmux-tui/crates/acpmux/tests/full_access_setting.rs b/cmux-tui/crates/acpmux/tests/full_access_setting.rs index 6c8c51ffc4cf..8fa4d58477fe 100644 --- a/cmux-tui/crates/acpmux/tests/full_access_setting.rs +++ b/cmux-tui/crates/acpmux/tests/full_access_setting.rs @@ -196,15 +196,15 @@ async fn full_access_off_keeps_every_chat_out_of_full_access() { .await; assert_eq!(r["error"]["data"]["reason"], "full_access_off", "{r}"); - // Turned back on (or the key removed): full access switches again, and a pick of it carries to - // new chats as before (the latest pick wins). + // Turned back on (or the key removed): full access switches again, and new chats start in the + // default, full access, again. std::fs::write(&settings, r#"{"sessions": {}}"#).unwrap(); let r = app .call("session/set_mode", json!({"sessionId": chat, "modeId": "agent-full-access"})) .await; assert!(r.get("error").is_none(), "{r}"); assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); - // Off again: that saved pick no longer starts new chats in full access. + // Off again: the default no longer starts new chats in full access. std::fs::write(&settings, r#"{"sessions": {"allowFullAccess": false}}"#).unwrap(); assert_eq!(app.chat(&dir, "codex").await.1, "agent"); let _ = std::fs::remove_dir_all(&dir); diff --git a/cmux-tui/crates/acpmux/tests/start_mode.rs b/cmux-tui/crates/acpmux/tests/start_mode.rs index 67311dd62087..ef7ee3bc1eb6 100644 --- a/cmux-tui/crates/acpmux/tests/start_mode.rs +++ b/cmux-tui/crates/acpmux/tests/start_mode.rs @@ -1,8 +1,7 @@ //! A new agent chat starts in the harness's full-access mode (Claude Code -//! `bypassPermissions`, the Codex bridge `agent-full-access`); the person's -//! latest mode pick in any chat is the start mode of every new chat on every -//! harness, also after a daemon restart, and chats that exist keep theirs; -//! the setting `defaultPermissionMode` chooses the start mode otherwise; a +//! `bypassPermissions`, the Codex bridge `agent-full-access`) unless the +//! setting `defaultPermissionMode` chooses another; a mode picked in a chat +//! holds for that chat only, also after a daemon restart (Leo 2026-10-21); a //! Web chat never starts in full access, and an unauthenticated connection //! gets nothing (cx-wx93i; Lawrence 2026-10-14). //! Driven through the protocol handler, as the app pane, the unix socket and @@ -154,7 +153,8 @@ impl Client { } } -/// The level of the person's latest mode pick, as config.json holds it. +/// config.json's `lastPermissionMode`, where an older build saved the latest +/// mode pick as every new chat's start mode. Never written now. fn last_pick(dir: &Path) -> Value { let config: Value = serde_json::from_slice(&std::fs::read(dir.join("config.json")).unwrap()).unwrap(); @@ -188,57 +188,51 @@ async fn a_new_chat_starts_in_each_harness_full_access_mode() { let _ = std::fs::remove_dir_all(&dir); } +/// A mode picked in a chat holds for that chat only: new chats, on every +/// harness and after a restart, start in the default (Leo 2026-10-21: "the +/// default for new chats != whatever u just temp set in an existing running +/// chat"). #[tokio::test] -async fn the_latest_pick_is_the_start_mode_of_every_new_chat_after_a_restart() { +async fn a_pick_in_a_chat_holds_for_that_chat_and_new_chats_start_in_the_default() { let dir = home("pick", json!({"defaultPermissionMode": "fullAccess"})); let daemon = hub(&dir); let mut app = Client::app(&daemon).await; let (claude, _) = app.chat(&dir, "claude").await; let (codex, _) = app.chat(&dir, "codex").await; - // The person switches the Claude chat back to a mode that asks: every new - // chat, on every harness, now starts asking; the Codex chat keeps its mode. + // The person switches the Claude chat back to a mode that asks: that chat + // asks; the Codex chat and every new chat stay in full access. let r = app.call("session/set_mode", json!({"sessionId": claude, "modeId": "default"})).await; assert!(r.get("error").is_none(), "{r}"); - assert_eq!(last_pick(&dir), json!("ask")); + assert_eq!(app.mode(&claude).await, "default"); assert_eq!(app.mode(&codex).await, "agent-full-access"); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - assert_eq!(app.chat(&dir, "claude").await.1, "default"); - // Plan is a toggle, never the start mode. - let r = app.call("session/set_mode", json!({"sessionId": claude, "modeId": "plan"})).await; - assert!(r.get("error").is_none(), "{r}"); - assert_eq!(last_pick(&dir), json!("ask")); - // The latest pick wins: Codex's own mode (agent) is "standard". + assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); + assert_eq!(app.chat(&dir, "claude").await.1, "bypassPermissions"); let r = app.call("session/set_mode", json!({"sessionId": codex, "modeId": "agent"})).await; assert!(r.get("error").is_none(), "{r}"); - assert_eq!(last_pick(&dir), json!("standard")); + assert_eq!(app.mode(&codex).await, "agent"); + assert_eq!(last_pick(&dir), Value::Null); drop(app); drop(daemon); - // After a restart (config.json read again) new chats follow the pick. + // After a restart (config.json read again) new chats start in the default too. let daemon = hub(&dir); let mut app = Client::app(&daemon).await; - assert_eq!(app.chat(&dir, "codex").await.1, "agent"); - let (claude, mode) = app.chat(&dir, "claude").await; - assert_eq!(mode, "default"); - // Back to full access: every new chat starts there again. - let r = app - .call("session/set_mode", json!({"sessionId": claude, "modeId": "bypassPermissions"})) - .await; - assert!(r.get("error").is_none(), "{r}"); - assert_eq!(last_pick(&dir), json!("fullAccess")); assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); + let answer = start_mode(&mut app, &dir, "claude").await; + assert_eq!(answer["modeId"], "bypassPermissions", "{answer}"); + assert_eq!(answer["source"], "config", "{answer}"); + assert_eq!(app.chat(&dir, "claude").await.1, "bypassPermissions"); - // A unix socket client (an agent, a script) is no pick, and cannot widen - // a chat to full access. + // A unix socket client (an agent, a script) cannot widen a chat to full + // access. let mut agent = Client::new(&daemon, Origin::Local, ConnAuth::LocalSocket); let (chat, _) = agent.chat(&dir, "claude").await; let r = agent.call("session/set_mode", json!({"sessionId": chat, "modeId": "default"})).await; assert!(r.get("error").is_none(), "{r}"); - assert_eq!(last_pick(&dir), json!("fullAccess")); let r = agent.call("session/set_mode", json!({"sessionId": chat, "modeId": "auto"})).await; assert_eq!(r["error"]["data"]["reason"], "permission.person_required", "{r}"); - assert_eq!(last_pick(&dir), json!("fullAccess")); + assert_eq!(last_pick(&dir), Value::Null); let _ = std::fs::remove_dir_all(&dir); } @@ -324,19 +318,19 @@ async fn start_mode_answer_is_the_mode_a_new_chat_then_starts_in() { // A harness with no full-access mode keeps its own: no prediction. assert_eq!(start_mode(&mut app, &dir, "plain").await["modeId"], Value::Null); - // The person picks Manual in a chat: the answer follows, and so does the - // next new chat. + // The person picks Manual in a chat: the answer, and the next new chat, + // stay in the default. let (claude, _) = app.chat(&dir, "claude").await; let r = app.call("session/set_mode", json!({"sessionId": claude, "modeId": "default"})).await; assert!(r.get("error").is_none(), "{r}"); let answer = start_mode(&mut app, &dir, "claude").await; - assert_eq!(answer["modeId"], "default", "{answer}"); - assert_eq!(answer["level"], "ask", "{answer}"); - assert_eq!(answer["source"], "pick", "{answer}"); - assert_eq!(app.chat(&dir, "claude").await.1, "default"); + assert_eq!(answer["modeId"], "bypassPermissions", "{answer}"); + assert_eq!(answer["level"], "fullAccess", "{answer}"); + assert_eq!(answer["source"], "config", "{answer}"); + assert_eq!(app.chat(&dir, "claude").await.1, "bypassPermissions"); // Asking changes nothing. - assert_eq!(last_pick(&dir), json!("ask")); + assert_eq!(last_pick(&dir), Value::Null); let _ = std::fs::remove_dir_all(&dir); } @@ -407,15 +401,20 @@ async fn start_mode_answer_change_is_announced_to_local_watchers() { let (claude, _) = app.chat(&dir, "claude").await; app.3.clear(); - // A pick acpmux saves: a pane showing a pending chat asks again. + // A pick in a chat changes no new chat's start mode: no notice. let r = app.call("session/set_mode", json!({"sessionId": claude, "modeId": "default"})).await; assert!(r.get("error").is_none(), "{r}"); + assert!(!app.told_start_mode_changed().await); + // A new default in config.json, read at a catalog reload: a pane showing + // a pending chat asks again. + let path = dir.join("config.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + config["defaultPermissionMode"] = json!("ask"); + std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + daemon.reload_catalog().await.unwrap(); assert!(app.told_start_mode_changed().await, "the local watcher is told"); assert!(!web.told_start_mode_changed().await, "a Web watcher's chats get no start mode"); - // The same level again changes nothing, and Plan is no pick: no notice. - let r = app.call("session/set_mode", json!({"sessionId": claude, "modeId": "plan"})).await; - assert!(r.get("error").is_none(), "{r}"); - assert!(!app.told_start_mode_changed().await); + assert_eq!(app.chat(&dir, "claude").await.1, "default"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/cmux-tui/crates/acpmux/tests/start_mode_settings.rs b/cmux-tui/crates/acpmux/tests/start_mode_settings.rs index 3e69db233ade..b463ed36840d 100644 --- a/cmux-tui/crates/acpmux/tests/start_mode_settings.rs +++ b/cmux-tui/crates/acpmux/tests/start_mode_settings.rs @@ -1,15 +1,14 @@ //! The cmux setting `agents.defaultPermissionMode` in the app's settings //! file (JSONC, read at each new chat) chooses a new chat's start mode when //! acpmux config.json names none; a settings file acpmux cannot read, or a -//! value it does not know, never gives full access (cx-wx93i). A change to -//! the setting after the person's latest mode pick wins over that pick, and a -//! later pick wins again; "later" counts changes, not values, so a setting -//! changed and changed back still wins over a pick made before. A value MDM -//! or team policy forces, which the app pushes (`_acpmux/chat_settings` -//! `permissionMode` + `permissionModeManaged`), beats the file, config.json -//! and every pick. Its own test binary: it sets CMUX_NEXT_CONFIG_FILE and -//! ACPMUX_HOME, which every session start reads; `ENV` runs its tests one at -//! a time. +//! value it does not know, never gives full access (cx-wx93i). A mode picked +//! in a chat holds for that chat only (Leo 2026-10-21), and a +//! `lastPermissionMode` an older build saved in config.json is ignored. A +//! value MDM or team policy forces, which the app pushes +//! (`_acpmux/chat_settings` `permissionMode` + `permissionModeManaged`), +//! beats the file and config.json. Its own test binary: it sets +//! CMUX_NEXT_CONFIG_FILE and ACPMUX_HOME, which every session start reads; +//! `ENV` runs its tests one at a time. // Unix only until the Windows port runs the daemon (cmux::local_socket). #![cfg(unix)] @@ -212,91 +211,69 @@ async fn start_mode_answer_and_new_chats_follow_the_settings_file_never_full_acc std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "Ask"}}"#).unwrap(); assert_eq!(app.chat(&dir, "codex").await.1, "agent"); - // The person's latest choice wins, whether it is a pick in a chat or a - // change to the setting (Leo 2026-10-18: users set their own default). + // A pick in a chat holds for that chat only (Leo 2026-10-21: "the default + // for new chats != whatever u just temp set in an existing running chat"). std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "fullAccess"}}"#).unwrap(); let (chat, mode) = app.chat(&dir, "codex").await; assert_eq!(mode, "agent-full-access"); - // A pick after the setting: new chats follow the pick. let r = app.call("session/set_mode", json!({"sessionId": chat, "modeId": "ask"})).await; assert!(r.get("error").is_none(), "{r}"); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - // The setting changes after the pick: new chats follow the setting. + assert_eq!(app.mode(&chat).await, "ask"); + assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); + assert_eq!(app.source(&dir).await, "settings"); + // Changing the setting is what new chats use. std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "standard"}}"#).unwrap(); assert_eq!(app.chat(&dir, "codex").await.1, "agent"); - let r = - app.call("_acpmux/start_mode", json!({"harness": "codex", "cwd": dir.join("work")})).await; - assert_eq!(r["result"]["source"], "settings", "{r}"); - // A pick after that change wins again, also when it picks the level it - // picked before the change. - let r = app.call("session/set_mode", json!({"sessionId": chat, "modeId": "ask"})).await; - assert!(r.get("error").is_none(), "{r}"); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - let r = - app.call("_acpmux/start_mode", json!({"harness": "codex", "cwd": dir.join("work")})).await; - assert_eq!(r["result"]["source"], "pick", "{r}"); - // Removing the key is a change too: the default (full access) applies. + assert_eq!(app.mode(&chat).await, "ask"); + // Removing the key: the default (full access) applies. std::fs::write(&settings, r#"{"agents": {}}"#).unwrap(); assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); + assert_eq!(app.source(&dir).await, "default"); + let _ = std::fs::remove_dir_all(&dir); +} - // A catalog reload reads both: a pick of `standard` saved at the change - // count it is at now (another daemon, or the file restored) wins. - std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "ask"}}"#).unwrap(); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - let config_path = dir.join("config.json"); - let read_config = - || -> Value { serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap() }; - let edit = |pick: Value| { - let mut config = read_config(); - for (k, v) in pick.as_object().unwrap() { - match v { - Value::Null => config.as_object_mut().unwrap().remove(k), - v => config.as_object_mut().unwrap().insert(k.clone(), v.clone()), - }; - } - std::fs::write(&config_path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); - }; - let state: Value = - serde_json::from_slice(&std::fs::read(dir.join("start-mode.json")).unwrap()).unwrap(); - assert!(state["changes"].is_u64(), "{state}"); - let now = json!({"epoch": state["epoch"], "changes": state["changes"]}); - edit(json!({"lastPermissionMode": "standard", "lastPermissionModeChange": now})); - daemon.reload_catalog().await.unwrap(); - assert_eq!(app.chat(&dir, "codex").await.1, "agent"); - drop(app); - drop(daemon); - - // A pick saved before acpmux counted changes (an upgrade) keeps winning, - // and the count as it is now is recorded, so a later change to the - // setting still wins over it. - edit(json!({"lastPermissionMode": "ask", "lastPermissionModeChange": null})); +/// A `lastPermissionMode` an older build saved in config.json (the latest +/// pick it made every new chat's start mode) is ignored: a new chat starts in +/// the default, at start, after a catalog reload and after a restart. +#[tokio::test] +async fn a_last_permission_mode_an_older_build_saved_is_ignored() { + let dir = home( + "legacy-pick", + json!({ + "lastPermissionMode": "ask", + "lastPermissionModeChange": {"epoch": "old", "changes": 0}, + "lastPermissionModeSetting": "\"standard\"", + }), + ); + let settings = dir.join("cmux-next.json"); + std::fs::write(&settings, r#"{"agents": {}}"#).unwrap(); + let _env = ENV.lock().await; + // SAFETY: tests in this binary hold `ENV`; set before the daemon starts. + unsafe { + std::env::set_var("CMUX_NEXT_CONFIG_FILE", &settings); + std::env::set_var("ACPMUX_HOME", &dir); + } let daemon = hub(&dir); let mut app = Client::app(&daemon).await; - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "fullAccess"}}"#).unwrap(); + assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); + assert_eq!(app.source(&dir).await, "default"); + daemon.reload_catalog().await.unwrap(); assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); drop(app); drop(daemon); - // An older build's pick saved beside a setting that is no longer the - // setting: the setting changed after the pick, so the setting wins. - edit(json!({ - "lastPermissionMode": "ask", - "lastPermissionModeChange": null, - "lastPermissionModeSetting": "\"standard\"", - })); let daemon = hub(&dir); let mut app = Client::app(&daemon).await; assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); - assert!(read_config().get("lastPermissionModeSetting").is_none(), "{}", read_config()); + assert_eq!(app.source(&dir).await, "default"); let _ = std::fs::remove_dir_all(&dir); } /// The app pushes the value MDM or team policy forces: it beats the settings -/// file, config.json and the person's latest pick, survives a daemon restart -/// without the app, and new chats follow the file and picks again once the -/// policy is lifted. +/// file and config.json, survives a daemon restart without the app, and new +/// chats follow the file again once the policy is lifted. A pick changes only +/// the chat it is made in, forced or not. #[tokio::test] -async fn a_managed_value_the_app_pushes_beats_the_file_config_and_picks() { +async fn a_managed_value_the_app_pushes_beats_the_file_and_config() { let dir = home("managed", json!({})); let settings = dir.join("cmux-next.json"); std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "fullAccess"}}"#).unwrap(); @@ -311,14 +288,15 @@ async fn a_managed_value_the_app_pushes_beats_the_file_config_and_picks() { let mut socket = Client::new(&daemon, Origin::Local, ConnAuth::LocalSocket); socket.call("initialize", json!({"protocolVersion": 1})).await; socket.push(Some("fullAccess"), false).await; - // A prior pick: the harness's own mode. + // A pick of the harness's own mode: that chat only. let (chat, mode) = app.chat(&dir, "codex").await; assert_eq!(mode, "agent-full-access"); let r = app.call("session/set_mode", json!({"sessionId": chat, "modeId": "agent"})).await; assert!(r.get("error").is_none(), "{r}"); - assert_eq!(app.chat(&dir, "codex").await.1, "agent"); + assert_eq!(app.mode(&chat).await, "agent"); + assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); - // The admin forces `ask`: it beats the file's `fullAccess` and the pick. + // The admin forces `ask`: it beats the file's `fullAccess`. socket.push(Some("ask"), true).await; assert_eq!(app.chat(&dir, "codex").await.1, "ask"); assert_eq!(app.source(&dir).await, "managed"); @@ -327,6 +305,7 @@ async fn a_managed_value_the_app_pushes_beats_the_file_config_and_picks() { .call("session/set_mode", json!({"sessionId": chat, "modeId": "agent-full-access"})) .await; assert!(r.get("error").is_none(), "{r}"); + assert_eq!(app.mode(&chat).await, "agent-full-access"); assert_eq!(app.chat(&dir, "codex").await.1, "ask"); drop(app); drop(socket); @@ -352,22 +331,7 @@ async fn a_managed_value_the_app_pushes_beats_the_file_config_and_picks() { std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); daemon.reload_catalog().await.unwrap(); - // The policy is lifted: a change to the setting, so the pick made under - // it does not come back; the file applies. - socket.push(Some("fullAccess"), false).await; - assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); - assert_eq!(app.source(&dir).await, "settings"); - - // "Latest choice wins" counts changes, not values: a pick, then the - // setting changed and changed back (each change pushed by the app), and - // the setting wins. - let (chat, _) = app.chat(&dir, "codex").await; - let r = app.call("session/set_mode", json!({"sessionId": chat, "modeId": "ask"})).await; - assert!(r.get("error").is_none(), "{r}"); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "standard"}}"#).unwrap(); - socket.push(Some("standard"), false).await; - std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "fullAccess"}}"#).unwrap(); + // The policy is lifted: the file applies. socket.push(Some("fullAccess"), false).await; assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); assert_eq!(app.source(&dir).await, "settings"); @@ -422,62 +386,3 @@ async fn a_forced_value_acpmux_cannot_read_never_gives_full_access_and_unforced_ assert_eq!(app.chat(&dir, "codex").await.1, "agent-full-access"); let _ = std::fs::remove_dir_all(&dir); } - -/// A pick is spent once the setting changes after it, also when the state -/// file that counts changes is lost and the count starts again: a pick from -/// before never comes back when the new count reaches the one saved with it. -#[tokio::test] -async fn a_spent_pick_stays_spent_when_the_change_count_is_lost() { - let dir = home("lost-count", json!({})); - let settings = dir.join("cmux-next.json"); - std::fs::write(&settings, r#"{"agents": {"defaultPermissionMode": "fullAccess"}}"#).unwrap(); - let _env = ENV.lock().await; - // SAFETY: tests in this binary hold `ENV`; set before the daemon starts. - unsafe { - std::env::set_var("CMUX_NEXT_CONFIG_FILE", &settings); - std::env::set_var("ACPMUX_HOME", &dir); - } - let daemon = hub(&dir); - let mut app = Client::app(&daemon).await; - let mut socket = Client::new(&daemon, Origin::Local, ConnAuth::LocalSocket); - socket.call("initialize", json!({"protocolVersion": 1})).await; - let values = ["standard", "fullAccess"]; - // A change as the app makes it: the file written, then pushed. - let write = |value: &str| { - let doc = json!({"agents": {"defaultPermissionMode": value}}); - std::fs::write(&settings, doc.to_string()).unwrap(); - }; - // Some changes, then a pick of `ask` at that count. - for value in values.iter().cycle().take(4) { - write(value); - socket.push(Some(value), false).await; - } - let (chat, _) = app.chat(&dir, "codex").await; - let r = app.call("session/set_mode", json!({"sessionId": chat, "modeId": "ask"})).await; - assert!(r.get("error").is_none(), "{r}"); - assert_eq!(app.chat(&dir, "codex").await.1, "ask"); - let state = |dir: &Path| -> Value { - serde_json::from_slice(&std::fs::read(dir.join("start-mode.json")).unwrap()).unwrap() - }; - let picked_at = state(&dir)["changes"].as_u64().unwrap(); - assert!(picked_at > 0, "{}", state(&dir)); - drop(app); - drop(socket); - drop(daemon); - - // The count is lost; the setting changes until the new count passes the - // pick's. New chats follow the setting at every step. - std::fs::remove_file(dir.join("start-mode.json")).unwrap(); - let daemon = hub(&dir); - let mut app = Client::app(&daemon).await; - let mut socket = Client::new(&daemon, Origin::Local, ConnAuth::LocalSocket); - socket.call("initialize", json!({"protocolVersion": 1})).await; - socket.push(Some("fullAccess"), false).await; - for value in values.iter().cycle().take(picked_at as usize + 1) { - write(value); - socket.push(Some(value), false).await; - let expected = if *value == "standard" { "agent" } else { "agent-full-access" }; - assert_eq!(app.chat(&dir, "codex").await.1, expected, "{}", state(&dir)); - } - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/docs/cmux-next/dotfiles.md b/docs/cmux-next/dotfiles.md index f455948670dc..a95c69d5e69f 100644 --- a/docs/cmux-next/dotfiles.md +++ b/docs/cmux-next/dotfiles.md @@ -119,11 +119,11 @@ agent starts through acpmux: It's also Settings > Agents > Agent Chat > Permission Mode for New Chats, and `cmux config set agents.defaultPermissionMode ask --confirm`. Agents can't -change it without you confirming. Picking a mode in a chat sets the default -too, so the latest choice wins: a pick in a chat, or a change to this setting -(also one you change back). When your organization manages this setting -(MDM or a team policy), its value applies to new chats, over your picks; a -value it forces that cmux doesn't know starts chats in the agent's own mode. +change it without you confirming. A mode you pick in a chat applies to that +chat only; new chats keep starting in this setting's mode. When your +organization manages this setting (MDM or a team policy), its value applies +to new chats; a value it forces that cmux doesn't know starts chats in the +agent's own mode. A harness profile or preset that sets its own mode still uses that mode. The model and reasoning effort a new chat starts with aren't in diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 24798979d6fb..c2c52be85057 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -194,7 +194,7 @@ }, "agents.defaultPermissionMode" : { "default" : "fullAccess", - "description" : "What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again.", + "description" : "What a new chat may do without asking. A mode you pick in a chat applies to that chat only.", "enum" : [ "fullAccess", "standard", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index ebb504219f84..c394e12f8580 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -7709,7 +7709,7 @@ pfm_default fullAccess pfm_description - What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again. + What a new chat may do without asking. A mode you pick in a chat applies to that chat only. pfm_name agents.defaultPermissionMode pfm_range_list diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 1d6b59d9cacf..20f680421f31 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -550,7 +550,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `agentPane.spellChecking` | boolean | `false` | | Check Spelling While Typing. Underlines misspellings and offers system suggestions in the composer. | | `agentPane.attachmentView` | string | `"inline"` | `inline`, `strip` | Attachment View. How pictures and files show in the composer and in sent messages. Inline puts each one in the text where you added it; Strip shows them in a row. The message and its order stay the same. | | `agentPane.zoom` | real | `1` | 0.5 to 2 | Agent Chat Zoom. The display size for agent chat. Cmd-0 resets it. | -| `agents.defaultPermissionMode` | string | `"fullAccess"` | `fullAccess`, `standard`, `ask` | Permission Mode for New Chats. What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again. | +| `agents.defaultPermissionMode` | string | `"fullAccess"` | `fullAccess`, `standard`, `ask` | Permission Mode for New Chats. What a new chat may do without asking. A mode you pick in a chat applies to that chat only. | | `diagnostics.hangReports` | boolean | `false` | | Send Hang Reports. Nightly builds send a daily report of hangs and slow frames: call stacks of cmux and macOS code, timings, your app and macOS versions, your Mac's chip and memory size, and rounded counts of open windows and tabs. It never includes file names, web addresses, terminal text, names or anything you type. View Hang Report shows exactly what is sent. | | `macScreen.systemShortcuts` | boolean | `false` | | Send System Shortcuts to the Remote Mac. While a Mac Screen tab has the keyboard, shortcuts like ⌘Tab, ⌘Space, Mission Control and ⌘Q go to the other Mac. Needs Accessibility. | | `macScreen.debug.relayOnly` | boolean | `false` | | Relay Only. Mac Screen tabs connect through the TURN relay only, never directly, to check the relay path. Tabs that are connected reconnect. | diff --git a/schemas/settings-export/settings-schema.sources.sha256 b/schemas/settings-export/settings-schema.sources.sha256 index c173dc3614e1..c173b470b2b2 100644 --- a/schemas/settings-export/settings-schema.sources.sha256 +++ b/schemas/settings-export/settings-schema.sources.sha256 @@ -218,7 +218,7 @@ f6190cfeca4fa8b6e7a8fc495646e4f1febdbf801e79cac5030ae811aa5e28c1 Packages/macOS 69d7e7fe1d80c27c5ca44664c5b145d3151b9f3374f7c1885d8d66168c2ffdf4 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/LinkRules/BrowserExternalURLWildcardToken.swift -44aa00386556d1e6ed20224dda1fc49ed30968dd9e65d39b84b5d656e7c6e5a4 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +a215fd8eb57cb6c6e2d08f749fb2f537f25d2c383a359ea240f10f55b94e1874 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b666a10fd9886526f6c05f042317cf4c8bdeadfe55df903c3b6e2700e52d330c Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MacScreenDebugSettings.swift @@ -332,7 +332,7 @@ efe2d0e6d4f1b15f89de7c8d4b21e2a8ab069000910c6ba45dff798ec384713b Packages/macOS ecc9448b1fb4b6db84b2375609de9dbe5f7854df0d4d11694fd7f637e667b471 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/RenamedSettingKeys.swift -772028c002f8875159bad86d5a87333cde82a4255bf1b48c73a6b6b03707e67b Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift +19d153b5c9796a1076c7d8f1ffdeeff7a1b827d7f813aa75edbf5f7f8215aed7 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentDefaultsSettingsSchema.swift 2bdb91c09dcc0e48be9538250e56baa3ffe8a200ecc553ac7e2c8346d0f09690 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/AgentPaneDisplaySettingsSchema.swift diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 7db662299add..2d45ad7bceea 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -36186,7 +36186,7 @@ }, "help" : { "key" : "settings.agents.defaultPermissionMode.help", - "text" : "What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again." + "text" : "What a new chat may do without asking. A mode you pick in a chat applies to that chat only." }, "kept_on_reset_all" : false, "key" : "agents.defaultPermissionMode", diff --git a/web/data/next-docs/settings-reference.generated.json b/web/data/next-docs/settings-reference.generated.json index d7bd80bddf37..6b1626b417ca 100644 --- a/web/data/next-docs/settings-reference.generated.json +++ b/web/data/next-docs/settings-reference.generated.json @@ -1,11 +1,23 @@ { "version": 1, - "count": 489, + "count": 491, "sections": [ { "id": "general", "title": "General", "rows": [ + { + "key": "agentPane.attachmentView", + "title": "Attachment View", + "help": "How pictures and files show in the composer and in sent messages. Inline puts each one in the text where you added it; Strip shows them in a row. The message and its order stay the same.", + "kind": "choice", + "default": "inline", + "group": "Agent Chat", + "values": [ + "inline", + "strip" + ] + }, { "key": "agentPane.editedFiles.maxRows", "title": "Edited Files Shown", @@ -142,7 +154,7 @@ { "key": "agents.defaultPermissionMode", "title": "Permission Mode for New Chats", - "help": "What a new chat may do without asking. Picking a mode in a chat sets it too, until you change this setting again.", + "help": "What a new chat may do without asking. A mode you pick in a chat applies to that chat only.", "kind": "choice", "default": "fullAccess", "group": "Agent Chat", @@ -1379,6 +1391,19 @@ "unit": "fraction" } }, + { + "key": "agentPane.display.environment.placement", + "title": "Session Info Chip", + "help": "Left puts it in a row below the tab row; Right keeps it in the header (or leads the tab-row tools); Off hides it.", + "kind": "choice", + "default": "left", + "group": "Agent Chat", + "values": [ + "left", + "right", + "off" + ] + }, { "key": "agentPane.display.font.lineHeight", "title": "Chat Line Height", diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index f23ab7a84ac5..db673d18a87e 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -103,7 +103,7 @@ import { isDefaultChoice } from "./defaultChoice"; import { topModelsFor } from "./topModels"; import { applySwitch, HarnessSwitch, lastOfferedModel, type SwitchPort } from "./harnessSwitch"; import { harnessProfiles } from "./harnessProfiles"; -import { pickedMode, setStartModeSource, startModeChanged } from "./startMode"; +import { setStartModeSource } from "./startMode"; import { setPromptSurfaces } from "./surfaces/promptSurface"; import { MockAcpmuxSocket, mockHost, type MockScript } from "./mock"; import { BridgeSocket } from "./bridgeSocket"; @@ -6962,17 +6962,9 @@ function AcpmuxPane() { ? callNative("chat.persistSession", { sessionId }).catch(() => undefined) : Promise.resolve(); /// Opens a chat this one made in a tab of its own (`chat.openTab`); false when the host opens none. - /// A mode pick on the shown chat. acpmux decides whether it is every new chat's start mode - /// (hub/start_mode.rs); a pending chat's kept answer follows it at once and then asks - /// acpmux again (startMode.ts), as older daemons send no `_acpmux/start_mode_changed`. - const setMode = async (modeId: string, ticket?: string) => { - await client.setMode(modeId, ticket); - const shown = snapshotRef.current; - if (shown?.origin === "remote" || shown?.origin === "peer" || shown?.summary?.host) return; - if (/^plan$/i.test(modeId)) return startModeChanged(); - const name = shown?.summary?.modes?.availableModes.find((mode) => mode.id === modeId)?.name; - pickedMode(client.shownSession()?.harness ?? shown?.summary?.harness, modeId, name); - }; + /// A mode pick on the shown chat: it holds for that chat only, never a new chat's start mode + /// (hub/start_mode.rs; Leo 2026-10-21). + const setMode = (modeId: string, ticket?: string) => client.setMode(modeId, ticket); /// `placement`: where the app opens it (AgentForkDestination: right, left, up, down, tab, /// workspace, window); none is a tab after this chat's. const openOwnTab = (sessionId: string, placement?: string) => { diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 354d223ca826..1fa188dc5414 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1839,7 +1839,7 @@ export class AcpmuxDirectClient { startModeChanged(); } else if (notification.method === "_acpmux/process_update") for (const listener of this.processListeners) listener(notification.params?.process); - // A new chat's start mode may have changed (a pick in any pane, a config reload, the + // A new chat's start mode may have changed (a config reload, the // `agents.defaultPermissionMode` setting): pending chats ask acpmux again (startMode.ts). else if (notification.method === "_acpmux/start_mode_changed") startModeChanged(); } diff --git a/webviews/src/agent-session/acpmux/startMode.ts b/webviews/src/agent-session/acpmux/startMode.ts index 4af127243d99..ab599fe80e67 100644 --- a/webviews/src/agent-session/acpmux/startMode.ts +++ b/webviews/src/agent-session/acpmux/startMode.ts @@ -2,8 +2,8 @@ import { useEffect, useSyncExternalStore } from "react"; /// The permission mode acpmux would start a new chat on a harness in (`_acpmux/start_mode`, /// hub/start_mode.rs, cx-wx93i): the mode id, and its name when acpmux knows it. acpmux alone -/// decides it (the latest pick, a preset, a profile that pins its mode, the settings); the pane -/// only asks, and names no mode it has no answer for. +/// decides it (a preset, a profile that pins its mode, the settings); a mode picked in a chat holds +/// for that chat only. The pane only asks, and names no mode it has no answer for. export type StartMode = { modeId: string; name?: string }; /// Asks the connected acpmux; undefined when it sets no mode, cannot know, or predates the method. @@ -19,8 +19,8 @@ const STORAGE_KEY = "cmux.acpmux.startModes.v1"; let ask: AskStartMode | undefined; /// Bumped whenever an answer may have changed (acpmux's `_acpmux/start_mode_changed`, which it -/// also sends when the `agents.defaultPermissionMode` setting changes; this pane's own pick; a new -/// client): every pending chat asks again. Showing the pane again asks nothing. +/// also sends when the `agents.defaultPermissionMode` setting changes; a new client): every +/// pending chat asks again. Showing the pane again asks nothing. let generation = 0; /// Bumped whenever a kept answer changed: every pending chat draws it. let version = 0; @@ -140,19 +140,6 @@ export function resetStartModesForTests(): void { notify(); } -/// This pane picked `mode` (named `name`) in a local chat on `harness`, and acpmux took it: its -/// kept answers for that harness say so at once, then every pending chat asks acpmux again, which -/// corrects any that acpmux did not take as a pick (a preset's chat, a profile that pins it). -export function pickedMode(harness: string | undefined, mode: string, name?: string): void { - if (harness && mode) { - const all = { ...load() }; - const keys = Object.keys(all).filter((key) => key.startsWith(`${harness}\u0000`)); - for (const key of keys) all[key] = { modeId: mode, ...(name ? { name } : {}) }; - if (keys.length > 0) save(all); - } - startModeChanged(); -} - /// acpmux's answer may have changed: every pending chat asks again. export function startModeChanged(): void { generation += 1; diff --git a/webviews/test/agent-pane-start-mode-chip.test.ts b/webviews/test/agent-pane-start-mode-chip.test.ts index c1467db87332..e3fc1c1fb4f6 100644 --- a/webviews/test/agent-pane-start-mode-chip.test.ts +++ b/webviews/test/agent-pane-start-mode-chip.test.ts @@ -1,10 +1,10 @@ // l10n-allow-file: test fixture prompts, replies and mode names, not shipped UI. // A new chat's mode chip names the mode acpmux will start it in (cx-wx93i), as acpmux answers it -// (`_acpmux/start_mode`), never the pane's own guess. acpmux starts a new chat in the level of the -// person's latest mode pick (hub/start_mode.rs), so after Manual is picked in one pane, a new chat -// in another pane that has not started yet shows Manual, not the Full access its own pane's chat -// last reported. When acpmux would set no mode (a profile that pins it, a preset's chat) the chip -// names none. Two pages of one browser are two panes sharing the viewer's storage, each talking +// (`_acpmux/start_mode`), never the pane's own guess. A mode picked in a chat holds for that chat +// only (hub/start_mode.rs; Leo 2026-10-21), so after Manual is picked in one pane, a new chat in +// another pane that has not started yet still shows the default, Bypass permissions; a change to +// the default (Settings) reaches it. When acpmux would set no mode (a profile that pins it, a +// preset's chat) the chip names none. Two pages of one browser are two panes sharing the viewer's storage, each talking // JSON-RPC to a stand-in acpmux daemon. // // Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). @@ -93,8 +93,8 @@ await requireBrowserLane("agent-pane-start-mode-chip.test.ts", async () => { ? Promise.resolve() : new Promise((resolve) => waiting.set(method, [...(waiting.get(method) ?? []), resolve])); const sockets = new Set>(); - // What the stand-in acpmux starts a new chat in: the latest pick's mode, else full access. - // `pinned`: a harness whose profile pins its mode, so acpmux sets none and takes no pick. + // What the stand-in acpmux starts a new chat in: the default, full access unless a test changes it. + // `pinned`: a harness whose profile pins its mode, so acpmux sets none. const daemonState = { startMode: "bypassPermissions", pinned: false }; const reset = (pinned: boolean) => { calls.length = 0; @@ -120,30 +120,24 @@ await requireBrowserLane("agent-pane-start-mode-chip.test.ts", async () => { case "_acpmux/events": return { events: [], hasMore: false, lastSeq: chats.get(String(params.sessionId))?.events.length ?? 0 }; case "session/set_mode": { - // acpmux takes the pick, then tells every watcher the chat's new mode. + // acpmux switches that chat only, then tells every watcher the chat's new mode. New chats' + // start mode does not change, so it sends no `_acpmux/start_mode_changed`. const entry = chats.get(String(params.sessionId))!; entry.session = { ...entry.session, modes: modes(String(params.modeId)) }; - const takes = !daemonState.pinned && params.modeId !== "plan" && daemonState.startMode !== params.modeId; - if (takes) daemonState.startMode = String(params.modeId); const changed = JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/session_changed", params: { sessionId: entry.session.sessionId, session: entry.session, kind: "mode", recordKind: "mode" }, }); - // A pick it takes as the start mode: every local watcher asks `_acpmux/start_mode` again. - const notice = JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/start_mode_changed", params: {} }); queueMicrotask(() => { - for (const socket of sockets) { - socket.send(changed); - if (takes) socket.send(notice); - } + for (const socket of sockets) socket.send(changed); }); return null; } case "_acpmux/start_mode": { const modeId = daemonState.pinned ? null : daemonState.startMode; const level = modeId === "bypassPermissions" ? "fullAccess" : modeId ? "ask" : null; - return { harness: params.harness, modeId, level, source: modeId ? "pick" : null }; + return { harness: params.harness, modeId, level, source: modeId ? "settings" : null }; } case "session/new": return undefined; @@ -302,23 +296,23 @@ await requireBrowserLane("agent-pane-start-mode-chip.test.ts", async () => { return page.locator(CHIP).first().textContent(); } - test("a new chat shows the mode its pick starts it in", async () => { + test("a new chat shows the default, not a mode picked in another chat", async () => { reset(false); const { context, other, asks } = await newChatAfterPick(); - // acpmux answers the start mode: the pick's, Manual. - expect(await settledChip(other, asks)).toBe("Manual"); + // acpmux answers the start mode: the default, Bypass permissions. + expect(await settledChip(other, asks)).toBe("Bypass permissions"); expect(calls.some((call) => call.method === "_acpmux/start_mode" && call.params.harness === "claude")).toBe(true); await context.close(); }); test("a new chat names no mode when acpmux sets none", async () => { - // The profile pins its mode: acpmux takes no pick and starts the chat in none of its own. + // The profile pins its mode: acpmux starts the chat in none of its own. reset(true); const { context, other, asks } = await newChatAfterPick(); expect(await settledChip(other, asks)).toBe("Mode"); await context.close(); }); - test("a pending chat follows a pick made after it opened", async () => { + test("a pending chat keeps the default when a mode is picked in another chat", async () => { reset(false); const context = await browser!.newContext({ viewport: { width: 900, height: 760 } }); // The new chat opens first, in full access, and acpmux has not started it (session/new held). @@ -340,15 +334,15 @@ await requireBrowserLane("agent-pane-start-mode-chip.test.ts", async () => { .getAttribute("aria-checked"), ).toBe("true"); await other.keyboard.press("Escape"); - // Then the person picks Manual in another pane: the pending chat names Manual before any send. + // Then the person picks Manual in another pane: that chat says Manual, the pending chat still + // names the default. const picked = await pane(context); await picked.goto(url(PICKED)); await picked.getByText("Retry added.").waitFor(); await picked.locator(".acpmux-access-trigger").click(); await picked.locator(".acpmux-access-menu .acpmux-access-item", { hasText: "Manual" }).click(); - await other.waitForFunction((selector) => document.querySelector(selector)?.textContent === "Manual", CHIP, { - timeout: 10_000, - }); + await picked.waitForFunction((selector) => document.querySelector(selector)?.textContent === "Manual", CHIP); + expect(await settledChip(other, asks)).toBe("Bypass permissions"); expect(calls.some((call) => call.method === "session/prompt")).toBe(false); await context.close(); });