From 5e0b08befbaa6be46f23c12dc31ab22afa01012f Mon Sep 17 00:00:00 2001 From: Adam Dalloul Date: Sun, 16 Aug 2026 09:16:43 -0700 Subject: [PATCH 1/3] feat(delegation): per-call permission_mode for delegated sub-agents A parent agent can already choose WHICH agent runs a delegated task, but not how much that child may do unattended. The child inherits whatever session mode the per-agent delegation default in Settings happens to be. For a one-off delegation that is the wrong granularity: you may want a specific child kept on a prompting or approval mode without changing the global default for that agent. Adds an optional `permission_mode` to `delegate_to_agent`. It is the target agent's own session mode id, the same vocabulary `AgentDelegationDefaults::mode_id` already uses, and it is forwarded verbatim as `ConnectionSpawner::spawn`'s existing `preferred_mode_id`, so no new mechanism is introduced. Behaviour: - omitted: configured default is used unchanged, so there is no behaviour change for existing callers and non-delegated sessions are untouched - provided: overrides the Settings default for that one call - blank or whitespace is treated as omitted, so a model emitting "" cannot clear the configured default by accident - agents exposing no session modes ignore it This is a cooperative permission scope enforced by the agent, not an OS sandbox. The schema description says so rather than implying isolation. Tests: per-call override beats the agent default; omitting keeps the default; override works with no agent default configured. --- src-tauri/src/acp/delegation/broker.rs | 107 +++++++++++++++++- src-tauri/src/acp/delegation/listener.rs | 16 +++ src-tauri/src/acp/delegation/tool_schema.json | 4 + src-tauri/src/acp/delegation/types.rs | 9 ++ src-tauri/src/acp/lifecycle.rs | 1 + 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 6c428f34bf..2b02ecc043 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -2296,11 +2296,15 @@ impl DelegationBroker { // Pull per-agent overrides from the broker config (defaults to empty). // Cloning is cheap — `AgentDelegationDefaults` is at most one Option // and a small BTreeMap, and the spawner consumes both fields by value. - let (preferred_mode_id, preferred_config_values) = cfg + let (configured_mode_id, preferred_config_values) = cfg .agent_defaults .get(&req.agent_type) .map(|d: &AgentDelegationDefaults| (d.mode_id.clone(), d.config_values.clone())) .unwrap_or((None, BTreeMap::new())); + // A per-call `permission_mode` wins over the settings default; when the + // LLM omits it the configured default is used unchanged, so existing + // callers and non-delegated sessions see no behaviour change. + let preferred_mode_id = req.permission_mode.clone().or(configured_mode_id); // Checkpoint #1 (opportunistic): if a parent cancel already landed // during the claim/depth phase, bail before spawning a child the parent // has abandoned. No child exists yet, so there's nothing to tear down. @@ -3757,6 +3761,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, } } @@ -4437,6 +4442,106 @@ mod tests { } } + /// A per-call `permission_mode` overrides the configured per-agent default + /// for that delegation only. This is the whole point of the parameter: a + /// parent can bound one child without changing global settings. + #[tokio::test] + async fn per_call_permission_mode_overrides_agent_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: Some("auto".into()), + config_values: BTreeMap::new(), + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.permission_mode = Some("plan".into()); + let _ = broker.handle_request(req).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("plan")); + } + + /// Omitting `permission_mode` must leave the configured default untouched, + /// so existing callers see no behaviour change. + #[tokio::test] + async fn omitted_permission_mode_keeps_configured_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: Some("auto".into()), + config_values: BTreeMap::new(), + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + // `request()` leaves permission_mode as None. + let _ = broker.handle_request(request(1, "pt-1")).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("auto")); + } + + /// With no configured default and no per-call value, nothing is forced. + #[tokio::test] + async fn per_call_permission_mode_works_without_any_agent_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.permission_mode = Some("plan".into()); + let _ = broker.handle_request(req).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("plan")); + } + #[tokio::test] async fn agent_defaults_are_forwarded_to_spawner() { // Configure broker with per-agent defaults for ClaudeCode and verify diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index f407c33ba0..8d0c38f494 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -631,6 +631,17 @@ impl DelegationListener { .clone() .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + // Optional per-call session mode. Blank/whitespace is treated as + // omitted so a model emitting `""` cannot clear the configured + // default by accident. + let permission_mode = req + .input + .get("permission_mode") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let delegation_req = DelegationRequest { parent_connection_id: req.parent_connection_id, parent_conversation_id, @@ -639,6 +650,7 @@ impl DelegationListener { task, working_dir, requested_working_dir, + permission_mode, external_handle: req.external_handle, }; self.broker.start_delegation(delegation_req).await @@ -1337,6 +1349,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await; @@ -1490,6 +1503,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await @@ -1592,6 +1606,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await; @@ -1643,6 +1658,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: Some("h-1".into()), }; broker.handle_request(req).await diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 08002aaf4b..7ecf88a17b 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -32,6 +32,10 @@ "working_dir": { "type": "string", "description": "Absolute path the sub-agent runs in. Defaults to this session's working directory." + }, + "permission_mode": { + "type": "string", + "description": "Optional. Session mode the sub-agent starts in, for THIS delegation only. Use it to bound what a delegated agent may do without being asked, for example keeping it on a prompting or approval mode instead of running everything unattended. The value is the target agent's own session mode id, the same one shown in that agent's mode selector and used by the per-agent delegation default in Settings. Omit to keep the configured default, so existing callers are unaffected. Agents that expose no session modes ignore it. This is a cooperative permission scope enforced by the agent, not an OS sandbox." } } } diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index b39664bcc2..156b51133e 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -69,6 +69,15 @@ pub struct DelegationRequest { /// the defaulted value the child is actually spawned in. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_working_dir: Option, + /// Session mode the child should start in, as the LLM passed it in the + /// `delegate_to_agent` arguments. Overrides the per-agent + /// `AgentDelegationDefaults::mode_id` from settings for THIS call only; + /// `None` keeps the configured default, so omitting it is a no-op. The + /// value is the target agent's own ACP session mode id (the same + /// vocabulary the settings default uses), forwarded verbatim as + /// `ConnectionSpawner::spawn`'s `preferred_mode_id`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_handle: Option, } diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 2579cecdce..958264969e 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -2736,6 +2736,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, } } From 7ac1da8344fb52a1c7c0547cdde0b91669de1ec8 Mon Sep 17 00:00:00 2001 From: Adam Dalloul Date: Sun, 16 Aug 2026 09:55:10 -0700 Subject: [PATCH 2/3] feat(delegation): show the pinned session mode on the delegation card When a parent pins a session mode for one delegated child, nothing in the transcript records it. Reviewing a conversation afterwards you can see which agent ran and what it was asked to do, but not what it was allowed to do without being asked. Surfaces it as a quiet monospace chip beside the task id, rendered only when the parent actually pinned a mode. A card with no chip means the child used the configured per-agent default, which is the common case, so existing transcripts look unchanged. The value comes from the parsed `permission_mode` argument, so no new wire field is needed. Hosts that strip tool arguments simply show no chip, which reads correctly rather than misleading. Tests: parses and trims the value, treats blank as absent, leaves it null when the argument is omitted. --- .../message/delegated-sub-thread.tsx | 9 ++++++++ src/hooks/use-delegation-card-model.ts | 6 ++++++ src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/delegation-card.test.ts | 21 +++++++++++++++++++ src/lib/delegation-card.ts | 12 ++++++++++- 14 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 993e090cc9..a2408c489c 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -62,6 +62,7 @@ export function DelegatedSubThread({ const [dialogOpen, setDialogOpen] = useState(false) const { agentType, + permissionMode, task, taskId, status, @@ -112,6 +113,14 @@ export function DelegatedSubThread({ #{taskId.slice(0, 8)} )} + {permissionMode && ( + + {permissionMode} + + )} {task && ( diff --git a/src/hooks/use-delegation-card-model.ts b/src/hooks/use-delegation-card-model.ts index 0d0b3eda6e..4bb3971787 100644 --- a/src/hooks/use-delegation-card-model.ts +++ b/src/hooks/use-delegation-card-model.ts @@ -53,6 +53,9 @@ export interface DelegationCardModel { errorCode: string | undefined childConversationId: number | null childConnectionId: string | null + /** Session mode the parent pinned for this delegation, or `null` when it + * used the configured default. */ + permissionMode: string | null /** False when there's no live binding and the input parsed to neither an * agent type nor a task — nothing useful to draw. Callers render null. */ hasModel: boolean @@ -150,6 +153,9 @@ export function useDelegationCardModel( errorCode, childConversationId, childConnectionId, + // Only the parsed arguments carry this; hosts that strip arguments simply + // show no mode, which reads correctly as "the configured default". + permissionMode: parsed.permissionMode, // Broker-stamped meta alone is proof enough of a delegation — the // persisted Cursor shape has empty raw_input and no live binding. hasModel: Boolean(binding || parsed.agentType || parsed.task || parsedMeta), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590db..8d62261c29 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "العميل الفرعي قيد التشغيل…", "noDetail": "No detail available yet.", "unknownAgent": "وكيل فرعي", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "عرض المحادثة", "detailTitle": "محادثة الوكيل الفرعي", "detailDescription": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3e..215eb98d66 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Unteragent läuft…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-Agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Konversation anzeigen", "detailTitle": "Unteragent-Konversation", "detailDescription": "Schreibgeschützte Ansicht der delegierten Unteragent-Konversation.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa09..f9df0711c1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Sub-agent running…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Open conversation", "detailTitle": "Sub-agent conversation", "detailDescription": "Read-only view of the delegated sub-agent's conversation.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a40..db272457dd 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Subagente en ejecución…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-agente", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Ver conversación", "detailTitle": "Conversación del subagente", "detailDescription": "Vista de solo lectura de la conversación del subagente delegado.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d64..d71c91239d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Sous-agent en cours…", "noDetail": "Aucun détail disponible pour le moment.", "unknownAgent": "Sous-agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Voir la conversation", "detailTitle": "Conversation du sous-agent", "detailDescription": "Vue en lecture seule de la conversation du sous-agent délégué.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88da..fd3f873733 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "サブエージェント実行中…", "noDetail": "No detail available yet.", "unknownAgent": "サブエージェント", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "会話を表示", "detailTitle": "サブエージェントの会話", "detailDescription": "委任されたサブエージェントの会話を読み取り専用で表示します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715c..0b8948e59d 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "서브에이전트 실행 중…", "noDetail": "No detail available yet.", "unknownAgent": "하위 에이전트", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "대화 보기", "detailTitle": "서브에이전트 대화", "detailDescription": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9eada..f9a74a30b1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Subagente em execução…", "noDetail": "No detail available yet.", "unknownAgent": "Subagente", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Ver conversa", "detailTitle": "Conversa do subagente", "detailDescription": "Visualização somente leitura da conversa do subagente delegado.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1e..0d0af9db03 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "子智能体运行中…", "noDetail": "暂无详情。", "unknownAgent": "子智能体", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "查看会话", "detailTitle": "子智能体会话", "detailDescription": "只读查看委托给子智能体的会话内容。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eaec..d01904cc0f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "子代理執行中…", "noDetail": "暫無詳情。", "unknownAgent": "子智慧體", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "檢視會話", "detailTitle": "子智慧體會話", "detailDescription": "唯讀檢視委派給子智慧體的會話內容。", diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 9492a7dfc3..1b0de8c08c 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -174,4 +174,25 @@ describe("parseDelegationMeta task fields", () => { expect(parsed?.task).toBeNull() expect(parsed?.taskId).toBeNull() }) + + it("parses permission_mode and trims it", () => { + const parsed = parseInput( + JSON.stringify({ agent_type: "codex", task: "t", permission_mode: " plan " }) + ) + expect(parsed.permissionMode).toBe("plan") + }) + + it("treats a blank permission_mode as absent", () => { + const parsed = parseInput( + JSON.stringify({ agent_type: "codex", task: "t", permission_mode: " " }) + ) + expect(parsed.permissionMode).toBeNull() + }) + + it("leaves permissionMode null when the argument is omitted", () => { + const parsed = parseInput( + JSON.stringify({ agent_type: "codex", task: "t" }) + ) + expect(parsed.permissionMode).toBeNull() + }) }) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 9231dd06d4..40feab8d3f 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -36,6 +36,10 @@ export type ParsedInput = { agentType: AgentType | null task: string | null workingDir: string | null + /** Session mode the parent pinned for this one delegation, from the + * `permission_mode` argument. `null` when omitted, which means the child + * used the configured per-agent default. */ + permissionMode: string | null } // Derived from the canonical `ALL_AGENT_TYPES` so a newly added agent is @@ -116,6 +120,7 @@ const EMPTY_PARSED_INPUT: ParsedInput = { agentType: null, task: null, workingDir: null, + permissionMode: null, } // Wrapper keys that hosts use to nest the actual tool arguments. JSON-RPC @@ -157,7 +162,8 @@ function findDelegationArgs( if ( typeof obj.task === "string" || typeof obj.agent_type === "string" || - typeof obj.working_dir === "string" + typeof obj.working_dir === "string" || + typeof obj.permission_mode === "string" ) { return obj } @@ -240,6 +246,10 @@ export function parseInput(raw: string | null | undefined): ParsedInput { agentType: at && KNOWN_AGENT_TYPES.has(at) ? (at as AgentType) : null, task: typeof obj.task === "string" ? obj.task : null, workingDir: typeof obj.working_dir === "string" ? obj.working_dir : null, + permissionMode: + typeof obj.permission_mode === "string" && obj.permission_mode.trim() + ? obj.permission_mode.trim() + : null, } } From 603a672bbca2839ef27825fc362ed5bc133c28eb Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:18:26 -0700 Subject: [PATCH 3/3] chore: format the delegation-card chip files --- src/lib/delegation-card.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 1b0de8c08c..1d0bab8ec7 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -177,7 +177,11 @@ describe("parseDelegationMeta task fields", () => { it("parses permission_mode and trims it", () => { const parsed = parseInput( - JSON.stringify({ agent_type: "codex", task: "t", permission_mode: " plan " }) + JSON.stringify({ + agent_type: "codex", + task: "t", + permission_mode: " plan ", + }) ) expect(parsed.permissionMode).toBe("plan") })