From a20154ae35acd880186dcbbb107d3894c8dc912a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:39:06 -0700 Subject: [PATCH 1/3] fix(feed): an agent's question posts as an answerable choice, named for its agent The Mac's feed bridge posted every acpmux question (Claude Code's AskUserQuestion, a Codex request_user_input) as a bare notice: "Agent asks: ", no options, poster "Agent". Only a Chief subagent's report, which arrives as a message of its own, showed what an agent said; every other agent's events read as one thin line. A question that fits a feed choice (1-4 questions, 2-8 options) now posts as a choice request: every question and option whole in its body, positional ids (q0/o0) in its prompt, the session's name as poster. The person's choice answer maps back onto the same request (re-read, same digest) and goes to acpmux as the question's own answer (CmuxAgentQuestion reply). Session answers are refused; a question is not a permission, so no presence proof is needed, as on the chat pane and the dialog socket. Other questions stay notices, now named for the agent; approvals name the agent too. Tests: CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests. Co-Authored-By: Claude Opus 5.5 --- .../Approvals/AcpmuxFeedPost.swift | 125 +++ .../Resources/Localizable.xcstrings | 792 ++++++++++++++++++ .../AgentQuestionFeedChoice.swift | 103 +++ .../Feed/AcpmuxPermissionFeedBridge.swift | 153 ++-- .../Resources/Localizable.xcstrings | 792 ------------------ .../AcpmuxFeedBridgeQuestionTests.swift | 73 +- 6 files changed, 1161 insertions(+), 877 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift new file mode 100644 index 000000000000..2402c14eb589 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift @@ -0,0 +1,125 @@ +public import Foundation + +/// The feed `feed.post` op for one pending acpmux prompt (the app's feed bridge posts it): what +/// the person must see to decide, named for the agent that asks. +/// +/// - A tool permission posts as an `approve` request: tool, the exact command, a short input +/// summary; secret shapes scrubbed, env never sent. Anything cut or redacted is marked +/// `truncated` (cx-aocz): the phone shows it shortened and never allows it. +/// - A question (`acpmuxIsQuestion`) is never a permission (Leo 2026-10-21): no Allow or Deny. +/// With a `choice` (the question as a feed choice, every question and option whole) it posts as +/// a `choice` request the person answers inline; without one it posts as a notice naming the +/// agent and its question, answered in the chat. +public struct AcpmuxFeedPost { + /// A question as a feed choice (CmuxNextAgentQuestion `AgentQuestionFeedChoice`). + public struct Choice { + public var title: String + public var body: String + public var prompt: [String: Any] + + public init(title: String, body: String, prompt: [String: Any]) { + self.title = title + self.body = body + self.prompt = prompt + } + } + + public var session: String + public var permission: String + public var request: [String: Any] + /// This Mac's install id (`context.host`), the install the phone's proof names. + public var host: String + /// The session's name ("claude", "Fix login"), shown as who posted it. + public var agent: String? + public var choice: Choice? + + public init(session: String, permission: String, request: [String: Any], host: String, agent: String? = nil, choice: Choice? = nil) { + self.session = session + self.permission = permission + self.request = request + self.host = host + self.agent = agent + self.choice = choice + } + + /// Agent text as the feed may show it: secret shapes scrubbed, invisible characters dropped. + public static func shown(_ text: String) -> String { text.secretsScrubbed.acpmuxVisible } + + /// The op: `{op: feed.post, params, idempotency_key, origin}`. + public var body: [String: Any] { + var params = request.acpmuxIsQuestion ? questionParams : approveParams + params["context"] = ["host": String(host.prefix(128)), "acp_session": String(session.prefix(128))] + params["dedupe_key"] = String(key.prefix(200)) + params["thread"] = String("acpmux:\(session)".prefix(200)) + return ["op": "feed.post", "params": params, "idempotency_key": String(key.prefix(200)), "origin": "script"] + } + + private var key: String { "acpmux:\(permission)" } + + /// Who posted: the agent's own name, else "Agent". + private func poster(_ name: String?) -> [String: Any] { + let name = name.map { $0.acpmuxVisible.acpmuxCut(80) }.flatMap { $0.isEmpty ? nil : $0 } + return ["label": name ?? String(localized: "feed.bridge.poster", defaultValue: "Agent", bundle: .module)] + } + + private var approveParams: [String: Any] { + let call = request["toolCall"] as? [String: Any] ?? [:] + let rawTitle = call["title"] as? String ?? "" + let title = rawTitle.secretsScrubbed.acpmuxVisible + let kind = (call["kind"] as? String ?? "").acpmuxVisible + let input = call["rawInput"] as? [String: Any] ?? [:] + let rawCommand = input.acpmuxShownCommand + let command = rawCommand.map { $0.secretsScrubbed.acpmuxVisible } + let summary = input.acpmuxInputSummary + // Anything cut, dropped or redacted: the phone shows it shortened and never allows it + // (cx-aocz): a redaction must never hide what the person signs. + let redacted = (rawCommand.map { $0.secretsScrubbed != $0 } ?? false) + || rawTitle.secretsScrubbed != rawTitle + let truncated = (command?.count ?? 0) > 8000 || title.count > 500 || kind.count > 200 + || redacted || (summary?.truncated ?? false) + var action: [String: Any] = [ + "type": kind == "execute" ? "command" : kind == "edit" || kind == "delete" || kind == "move" ? "edit" : "tool", + "summary": (title.isEmpty ? String(localized: "feed.bridge.summary", defaultValue: "An agent asks to run a tool", bundle: .module) : title).acpmuxCut(500), + ] + if !kind.isEmpty { action["tool"] = kind.acpmuxCut(200) } + if let command, !command.isEmpty { action["command"] = command.acpmuxCut(8000) } + if let summary { action["input"] = summary.fields } + if truncated { action["truncated"] = true } + let heading = String(localized: "feed.bridge.title", defaultValue: "Allow \((title.isEmpty ? kind : title).acpmuxCut(120))?", bundle: .module) + var params: [String: Any] = [ + "type": "request", + "kind": "approve", + "title": heading.acpmuxCut(200), + // The phone reads the Mac it answers from `context` (FeedApproveRequest). + "prompt": ["action": action, "scopes": ["once"]], + "poster": poster(agent), + ] + if let command, !command.isEmpty { params["body"] = command.acpmuxCut(4096) } + return params + } + + private var questionParams: [String: Any] { + // The agent waits on the answer: push it as soon as a permission request, and let it + // lapse with the request's 24 hours rather than a notice's week. + var params: [String: Any] = ["priority": "high", "expires_in_ms": 86_400_000] + if let choice { + params.merge(["type": "request", "kind": "choice", "title": choice.title, "body": choice.body, + "prompt": choice.prompt, "poster": poster(agent)]) { _, new in new } + return params + } + let call = request["toolCall"] as? [String: Any] ?? [:] + let normalized = request.acpmuxQuestion + let firstItem = (normalized?["items"] as? [[String: Any]])?.first + let asked = ((call["rawInput"] as? [String: Any])?["questions"] as? [[String: Any]])?.first + let text = (firstItem?["prompt"] as? String ?? asked?["question"] as? String ?? call["title"] as? String ?? "") + .secretsScrubbed.acpmuxVisible.acpmuxCut(160) + let named = agent ?? (normalized?["agent"] as? String) + let name = named.map { $0.acpmuxVisible.acpmuxCut(60) }.flatMap { $0.isEmpty ? nil : $0 } + ?? String(localized: "feed.bridge.question.agent", defaultValue: "An agent", bundle: .module) + let heading = text.isEmpty + ? String(localized: "feed.bridge.question.untitled", defaultValue: "\(name) asks a question", bundle: .module) + : String(localized: "feed.bridge.question.title", defaultValue: "\(name) asks: \(text)", bundle: .module) + params.merge(["type": "notice", "kind": "notice", "title": heading.acpmuxCut(200), "poster": poster(named)]) { _, new in new } + return params + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index cde0e73c13cb..d16ae5c38f11 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -1,6 +1,798 @@ { "sourceLanguage": "en", "strings": { + "feed.bridge.title": { + "comment": "A phone banner title for an agent's permission request. %@ is the tool's title or kind (not translated).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow %@?" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "%@ erlauben?" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "¿Permitir %@?" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Autoriser %@ ?" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Consentire %@?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を許可しますか?" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%@을(를) 허용할까요?" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Permitir %@?" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Разрешить %@?" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Allow %@?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "允许 %@?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "允許 %@?" + } + } + } + }, + "feed.bridge.question.title": { + "comment": "A phone feed title for a question an agent asks the person (never a permission). %1$@ is the agent's name, %2$@ its question (not translated).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ asks: %2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ يسأل: %2$@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$@ pita: %2$@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$@ spørger: %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ fragt: %2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ pregunta: %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ demande : %2$@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$@ chiede: %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ からの質問: %2$@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%1$@ សួរ៖ %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@의 질문: %2$@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$@ spør: %2$@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%1$@ pyta: %2$@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$@ pergunta: %2$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%1$@ спрашивает: %2$@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%1$@ ถาม: %2$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ soruyor: %2$@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%1$@ питає: %2$@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%1$@ hỏi: %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 提问:%2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 提問:%2$@" + } + } + } + }, + "feed.bridge.question.untitled": { + "comment": "A phone feed title for a question an agent asks the person when the question has no text (never a permission). %@ is the agent's name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ asks a question" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ يطرح سؤالًا" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ postavlja pitanje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ stiller et spørgsmål" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ stellt eine Frage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ hace una pregunta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ pose une question" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ fa una domanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ からの質問があります" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@ មានសំណួរ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 질문이 있습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ stiller et spørsmål" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ zadaje pytanie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ faz uma pergunta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ задаёт вопрос" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ มีคำถาม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ bir soru soruyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ ставить запитання" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%@ đặt một câu hỏi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 提了一个问题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 提了一個問題" + } + } + } + }, + "feed.bridge.question.agent": { + "comment": "Stands in for the agent's name in a phone feed question title when the agent has no name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An agent" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وكيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "En agent" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Agent" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un agente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un agent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un agente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្នាក់ងារ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "En agent" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um agente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Агент" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir ajan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Một tác tử" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一个智能体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一個智慧代理" + } + } + } + }, + "feed.bridge.summary": { + "comment": "The summary of an agent's permission request that has no tool title.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An agent asks to run a tool" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Ein Agent möchte ein Werkzeug ausführen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Un agente pide ejecutar una herramienta" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Un agent demande à exécuter un outil" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Un agente chiede di eseguire uno strumento" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントがツールの実行を求めています" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "에이전트가 도구 실행을 요청합니다" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Um agente pede para executar uma ferramenta" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Агент просит запустить инструмент" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "An agent asks to run a tool" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "代理请求运行工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "代理請求執行工具" + } + } + } + }, + "feed.bridge.poster": { + "comment": "Who posted an agent's permission request in the feed.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Agente" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Agente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "에이전트" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Agente" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Агент" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Agent" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "代理" + } + } + } + }, "agentPane.nativeNewTab.agent": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift new file mode 100644 index 000000000000..44c295d28606 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift @@ -0,0 +1,103 @@ +public import CmuxAgentQuestion +public import Foundation + +/// A question an agent asks in its chat (Claude Code's AskUserQuestion, a Codex +/// `request_user_input`), as a feed `choice` request the person answers inline, +/// in the Mac's feed or on the phone. +/// +/// The feed owner bounds a choice (backend `feed-kinds.ts`): one to four +/// questions of two to eight options, ids of at most 40 characters, a header of +/// 40, a question of 1000, a label of 200, a description of 1000. A question's +/// own ids can be its text (Claude keys answers by question text), so the feed +/// sees positional ids (`q0`, `o0`) and an answer maps back by position onto the +/// same request. A question outside those bounds is not a choice. +public struct AgentQuestionFeedChoice: Equatable, Sendable { + public let question: AgentQuestion + + public static let maxQuestions = 4 + public static let optionRange = 2...8 + + /// The question in an acpmux `session/request_permission` body, when it fits + /// a feed choice. A plain ACP permission dressed as a question (`.acp`: its + /// options are permission options) is never one. + public init?(request: [String: Any], session: String, permission: String) { + guard let data = try? JSONSerialization.data(withJSONObject: request), + let json = try? AgentQuestionJSON(data: data), + let question = AgentQuestion(permissionRequest: json, permissionId: permission, session: session), + question.source.harness != .acp, + (1...Self.maxQuestions).contains(question.items.count), + question.items.allSatisfy({ Self.optionRange.contains($0.options.count) && !$0.prompt.isEmpty }) + else { return nil } + self.question = question + } + + /// The feed `choice` prompt (`{questions: [{id, question, header?, options, multi, allow_other}]}`). + /// `clean` makes agent text fit to show (secret shapes scrubbed, invisible characters dropped). + public func prompt(clean: (String) -> String) -> [String: Any] { + ["questions": question.items.enumerated().map { index, item -> [String: Any] in + var out: [String: Any] = [ + "id": "q\(index)", + "question": Self.cut(clean(item.prompt), 1000), + "options": item.options.enumerated().map { optionIndex, option -> [String: Any] in + var out: [String: Any] = ["id": "o\(optionIndex)", "label": Self.cut(clean(option.label), 200)] + if let detail = option.detail.map(clean), !detail.isEmpty { out["description"] = Self.cut(detail, 1000) } + return out + }, + "multi": item.multiSelect, + "allow_other": item.allowsOther, + ] + if let header = item.header.map(clean), !header.isEmpty { out["header"] = Self.cut(header, 40) } + return out + }] + } + + /// What the card shows above its controls: every question, whole, with its options. + public func body(clean: (String) -> String) -> String { + let text = question.items.map { item -> String in + var lines: [String] = [] + if let header = item.header.map(clean), !header.isEmpty { lines.append("**\(header)**") } + lines.append(clean(item.prompt)) + for option in item.options { + let detail = option.detail.map(clean).flatMap { $0.isEmpty ? nil : " — \($0)" } ?? "" + lines.append("- \(clean(option.label))\(detail)") + } + return lines.joined(separator: "\n") + }.joined(separator: "\n\n") + return Self.cut(text, 4096) + } + + /// The headline: the first question. + public func title(clean: (String) -> String) -> String { + Self.cut(clean(question.items.first?.prompt ?? ""), 200) + } + + /// The person's feed answer (question id -> selected option ids and Other text) as the + /// question's own answer; nil when it names a question or option this one does not have. + public func answer(_ answers: [String: (selected: [String], other: String?)]) -> AgentQuestionAnswer? { + var selections: [String: AgentQuestionAnswer.Selection] = [:] + for (key, selection) in answers { + guard key.hasPrefix("q"), let index = Int(key.dropFirst()), question.items.indices.contains(index) else { return nil } + let item = question.items[index] + var ids: [String] = [] + for picked in selection.selected { + guard picked.hasPrefix("o"), let optionIndex = Int(picked.dropFirst()), + item.options.indices.contains(optionIndex) else { return nil } + ids.append(item.options[optionIndex].id) + } + let other = selection.other?.trimmingCharacters(in: .whitespacesAndNewlines) + selections[item.id] = .init(optionIDs: ids, other: other?.isEmpty == false ? other : nil) + } + return AgentQuestionAnswer(selections: selections) + } + + /// The `_acpmux/permission_respond` params that submit `answers`, nil when they do not fit. + public func respondParams(_ answers: [String: (selected: [String], other: String?)]) -> [String: Any]? { + guard let answer = answer(answers), let reply = try? question.reply(answer), + let data = try? reply.params.data() else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + static func cut(_ text: String, _ limit: Int) -> String { + text.count <= limit ? text : String(text.prefix(limit - 1)) + "…" + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift index 86bc3d723e7a..05dda85c1faa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift @@ -1,4 +1,6 @@ +import CmuxAgentQuestion import CmuxNextAgentActivity +import CmuxNextAgentQuestion import CmuxNextAgentPane import CmuxNextFeed import CmuxNextWakeups @@ -14,8 +16,10 @@ import os /// not the agent pane's relay: no page frame ever reaches it. /// - Each pending prompt becomes one feed `approve` request (tool, the exact /// command, a short input summary; secret shapes scrubbed, env never sent). -/// A question (`acpmuxIsQuestion`) becomes a notice instead, with no answer: -/// the person answers it in the chat. +/// A question (`acpmuxIsQuestion`) becomes a `choice` request +/// (``AgentQuestionFeedChoice``): every question and option shown whole, answered +/// inline from the feed. A question that does not fit a choice stays a notice the +/// person answers in the chat. Every item names its agent (the session's name). /// The ids of the items it posted are kept in memory for this launch only: /// a look-alike item another local client posted through `feed.request` is /// never trusted, whatever its poster says. @@ -52,6 +56,8 @@ final class AcpmuxPermissionFeedBridge { let truncated: Bool /// A question's notice: withdrawn when answered elsewhere, never answered from the feed. var notice = false + /// A question posted as a choice: a choice answer answers it. + var choice = false } /// This Mac's context and the owner's presence keys, read fresh for each @@ -226,79 +232,16 @@ final class AcpmuxPermissionFeedBridge { // MARK: Feed - /// The feed.post body for one prompt: what the person must see to decide. - /// `host`: this Mac's install id (`context.host`), the install the phone's proof names. - nonisolated static func postBody(session: String, permission: String, request: [String: Any], host: String) -> [String: Any] { - if request.acpmuxIsQuestion { return questionBody(session: session, permission: permission, request: request, host: host) } - let call = request["toolCall"] as? [String: Any] ?? [:] - let rawTitle = call["title"] as? String ?? "" - let title = rawTitle.secretsScrubbed.acpmuxVisible - let kind = (call["kind"] as? String ?? "").acpmuxVisible - let input = call["rawInput"] as? [String: Any] ?? [:] - let rawCommand = input.acpmuxShownCommand - let command = rawCommand.map { $0.secretsScrubbed.acpmuxVisible } - let summary = input.acpmuxInputSummary - // Anything cut, dropped or redacted: the phone shows it shortened and never allows it - // (cx-aocz): a redaction must never hide what the person signs. - let redacted = (rawCommand.map { $0.secretsScrubbed != $0 } ?? false) - || rawTitle.secretsScrubbed != rawTitle - let truncated = (command?.count ?? 0) > 8000 || title.count > 500 || kind.count > 200 - || redacted || (summary?.truncated ?? false) - var action: [String: Any] = [ - "type": kind == "execute" ? "command" : kind == "edit" || kind == "delete" || kind == "move" ? "edit" : "tool", - "summary": (title.isEmpty ? String(localized: "feed.bridge.summary", defaultValue: "An agent asks to run a tool", bundle: .module) : title).acpmuxCut(500), - ] - if !kind.isEmpty { action["tool"] = kind.acpmuxCut(200) } - if let command, !command.isEmpty { action["command"] = command.acpmuxCut(8000) } - if let summary { action["input"] = summary.fields } - if truncated { action["truncated"] = true } - let key = "acpmux:\(permission)" - let heading = String(localized: "feed.bridge.title", defaultValue: "Allow \((title.isEmpty ? kind : title).acpmuxCut(120))?", bundle: .module) - var params: [String: Any] = [ - "type": "request", - "kind": "approve", - "title": heading.acpmuxCut(200), - "prompt": ["action": action, "scopes": ["once"]], - // The phone reads the Mac it answers from here (FeedApproveRequest). - "context": ["host": String(host.prefix(128)), "acp_session": String(session.prefix(128))], - "dedupe_key": String(key.prefix(200)), - "thread": String("acpmux:\(session)".prefix(200)), - "poster": ["label": String(localized: "feed.bridge.poster", defaultValue: "Agent", bundle: .module)], - ] - if let command, !command.isEmpty { params["body"] = command.acpmuxCut(4096) } - return ["op": "feed.post", "params": params, "idempotency_key": String(key.prefix(200)), "origin": "script"] - } - - /// A question an agent asks (`acpmuxIsQuestion`) is never a permission (Leo 2026-10-21): it - /// posts as a notice that names the agent and its question and opens the chat that asks, - /// with no Allow or Deny. The person answers it in the chat. - nonisolated static func questionBody(session: String, permission: String, request: [String: Any], host: String) -> [String: Any] { - let call = request["toolCall"] as? [String: Any] ?? [:] - let normalized = request.acpmuxQuestion - let firstItem = (normalized?["items"] as? [[String: Any]])?.first - let asked = ((call["rawInput"] as? [String: Any])?["questions"] as? [[String: Any]])?.first - let text = (firstItem?["prompt"] as? String ?? asked?["question"] as? String ?? call["title"] as? String ?? "") - .secretsScrubbed.acpmuxVisible.acpmuxCut(160) - let agent = (normalized?["agent"] as? String).map { $0.acpmuxVisible.acpmuxCut(60) }.flatMap { $0.isEmpty ? nil : $0 } - ?? String(localized: "feed.bridge.question.agent", defaultValue: "An agent", bundle: .module) - let key = "acpmux:\(permission)" - let heading = text.isEmpty - ? String(localized: "feed.bridge.question.untitled", defaultValue: "\(agent) asks a question", bundle: .module) - : String(localized: "feed.bridge.question.title", defaultValue: "\(agent) asks: \(text)", bundle: .module) - let params: [String: Any] = [ - "type": "notice", - "kind": "notice", - "title": heading.acpmuxCut(200), - // The agent waits on the answer: push it as soon as a permission request, and let it - // lapse with the request's 24 hours rather than a notice's week. - "priority": "high", - "expires_in_ms": 86_400_000, - "context": ["host": String(host.prefix(128)), "acp_session": String(session.prefix(128))], - "dedupe_key": String(key.prefix(200)), - "thread": String("acpmux:\(session)".prefix(200)), - "poster": ["label": String(localized: "feed.bridge.poster", defaultValue: "Agent", bundle: .module)], - ] - return ["op": "feed.post", "params": params, "idempotency_key": String(key.prefix(200)), "origin": "script"] + /// The feed.post op for one prompt (``AcpmuxFeedPost``): a question that fits a feed choice + /// posts as one (``AgentQuestionFeedChoice``), answered inline. + nonisolated static func postBody(session: String, permission: String, request: [String: Any], host: String, + agent: String? = nil) -> [String: Any] { + let choice = request.acpmuxIsQuestion + ? AgentQuestionFeedChoice(request: request, session: session, permission: permission).map { + AcpmuxFeedPost.Choice(title: $0.title(clean: AcpmuxFeedPost.shown), body: $0.body(clean: AcpmuxFeedPost.shown), + prompt: $0.prompt(clean: AcpmuxFeedPost.shown)) + } : nil + return AcpmuxFeedPost(session: session, permission: permission, request: request, host: host, agent: agent, choice: choice).body } private func post(session: String, permission: String, request: [String: Any]) async { @@ -309,17 +252,21 @@ final class AcpmuxPermissionFeedBridge { postedPermissions.remove(permission) return logger.error("feed bridge: this Mac's install id is not known yet; \(permission, privacy: .public) not posted") } - let body = Self.postBody(session: session, permission: permission, request: request, host: host) + let info = await call("_acpmux/info", ["sessionId": session]) + let agent = (info?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? info?["harness"] as? String + let body = Self.postBody(session: session, permission: permission, request: request, host: host, agent: agent) + let params = body["params"] as? [String: Any] ?? [:] let reply = try await owner("v1/ops", body) guard let id = ((reply["value"] as? [String: Any])?["item"] as? [String: Any])?["id"] as? String else { postedPermissions.remove(permission) return logger.error("feed bridge: the owner returned no item for \(permission, privacy: .public)") } - let action = ((body["params"] as? [String: Any])?["prompt"] as? [String: Any])?["action"] as? [String: Any] ?? [:] + let action = (params["prompt"] as? [String: Any])?["action"] as? [String: Any] ?? [:] posted[id] = Posted(session: session, permission: permission, digest: request.acpmuxDigest, shown: FeedApproveProofCheck.shownSHA256(action: action), truncated: action["truncated"] as? Bool ?? false, - notice: (body["params"] as? [String: Any])?["type"] as? String == "notice") + notice: params["type"] as? String == "notice", + choice: params["kind"] as? String == "choice") logger.info("feed bridge: posted \(id, privacy: .public) for \(permission, privacy: .public)") } catch { postedPermissions.remove(permission) @@ -346,6 +293,14 @@ final class AcpmuxPermissionFeedBridge { func itemsChanged(_ items: [FeedItem]) { for item in items where item.state != .open { guard let entry = posted.removeValue(forKey: item.id), !entry.notice else { continue } + if entry.choice { + guard let answer = item.answer, case .choice(let answers) = answer.value else { + logger.info("feed bridge: \(item.id, privacy: .public) closed without an answer; nothing answered") + continue + } + Task { await self.answer(item: item.id, entry: entry, answers: answers, record: answer) } + continue + } guard let answer = item.answer, case .approve(let decision) = answer.value else { logger.info("feed bridge: \(item.id, privacy: .public) closed without an answer; nothing answered") continue @@ -380,6 +335,48 @@ final class AcpmuxPermissionFeedBridge { return plain ? .success(()) : .failure(.byNotPlain) } + /// The `_acpmux/permission_respond` params that answer the question in `request` with the + /// person's choice answer (positional `q0`/`o0` ids, as posted); nil when it does not fit. + nonisolated static func respondParams(request: [String: Any], session: String, permission: String, + answers: [String: FeedChoiceSelection]) -> [String: Any]? { + AgentQuestionFeedChoice(request: request, session: session, permission: permission)? + .respondParams(answers.mapValues { ($0.selected, $0.other) }) + } + + /// Whether `by` may answer an agent's question from the feed: an install, this Mac's own + /// included (the person answering in this Mac's feed, as they could in the chat), never a + /// session answer (`session:`, which a browser or a process on this Mac could hold). + /// A question is not a permission (Leo 2026-10-21), so it needs no phone presence proof. + nonisolated static func admitsQuestionAnswer(by: String) -> Result { + if case .failure(let refusal) = admits(by: by, ownInstall: nil) { return .failure(refusal) } + return .success(()) + } + + /// Answers a question posted as a choice with the person's feed answer, on the same + /// request it was posted for (still pending, same digest). + private func answer(item: String, entry: Posted, answers: [String: FeedChoiceSelection], record: FeedAnswerRecord) async { + if case .failure(let refusal) = Self.admitsQuestionAnswer(by: record.by) { + return logger.error("feed bridge: \(item, privacy: .public) answered by \(record.by, privacy: .public) refused: \(String(describing: refusal), privacy: .public)") + } + guard let now = await pending(of: entry.session).first(where: { $0["permissionId"] as? String == entry.permission }), + let request = now["request"] as? [String: Any] else { + return logger.info("feed bridge: \(item, privacy: .public) answered after the question ended; nothing answered") + } + guard request.acpmuxDigest == entry.digest else { + return logger.error("feed bridge: \(item, privacy: .public): the question changed since it was posted; nothing answered") + } + guard var params = Self.respondParams(request: request, session: entry.session, permission: entry.permission, + answers: answers) else { + return logger.error("feed bridge: \(item, privacy: .public): the answer does not fit the question; nothing answered") + } + params["_meta"] = ["acpmux": ["answeredBy": ["device": record.by, "feedItem": item]]] + if await call("_acpmux/permission_respond", params) == nil { + logger.error("feed bridge: acpmux refused the answer for \(item, privacy: .public)") + } else { + logger.info("feed bridge: answered question \(entry.permission, privacy: .public) for \(record.by, privacy: .public) (\(record.device, privacy: .public))") + } + } + private func answer(item: String, entry: Posted, decision: FeedAnswerValue.Decision, record: FeedAnswerRecord) async { let (by, device) = (record.by, record.device) let own = await ownInstall() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index 2722aa7c9f59..92ace050a6b6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -529,798 +529,6 @@ } } }, - "feed.bridge.title": { - "comment": "A phone banner title for an agent's permission request. %@ is the tool's title or kind (not translated).", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Allow %@?" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%@ erlauben?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Permitir %@?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Autoriser %@ ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Consentire %@?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ を許可しますか?" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "%@을(를) 허용할까요?" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Permitir %@?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Разрешить %@?" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Allow %@?" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "允许 %@?" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "needs_review", - "value": "允許 %@?" - } - } - } - }, - "feed.bridge.question.title": { - "comment": "A phone feed title for a question an agent asks the person (never a permission). %1$@ is the agent's name, %2$@ its question (not translated).", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ asks: %2$@" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%1$@ يسأل: %2$@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pita: %2$@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "%1$@ spørger: %2$@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$@ fragt: %2$@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pregunta: %2$@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$@ demande : %2$@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$@ chiede: %2$@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%1$@ からの質問: %2$@" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "%1$@ សួរ៖ %2$@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%1$@의 질문: %2$@" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "%1$@ spør: %2$@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pyta: %2$@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pergunta: %2$@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$@ спрашивает: %2$@" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "%1$@ ถาม: %2$@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%1$@ soruyor: %2$@" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%1$@ питає: %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@ hỏi: %2$@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 提问:%2$@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 提問:%2$@" - } - } - } - }, - "feed.bridge.question.untitled": { - "comment": "A phone feed title for a question an agent asks the person when the question has no text (never a permission). %@ is the agent's name.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ asks a question" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ يطرح سؤالًا" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "%@ postavlja pitanje" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "%@ stiller et spørgsmål" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ stellt eine Frage" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ hace una pregunta" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ pose une question" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ fa una domanda" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ からの質問があります" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "%@ មានសំណួរ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@의 질문이 있습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "%@ stiller et spørsmål" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%@ zadaje pytanie" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ faz uma pergunta" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ задаёт вопрос" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "%@ มีคำถาม" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ bir soru soruyor" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ ставить запитання" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ đặt một câu hỏi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 提了一个问题" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 提了一個問題" - } - } - } - }, - "feed.bridge.question.agent": { - "comment": "Stands in for the agent's name in a phone feed question title when the agent has no name.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "An agent" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "وكيل" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Agent" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "En agent" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ein Agent" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Un agente" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Un agent" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Un agente" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ភ្នាក់ងារ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "En agent" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Agent" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Um agente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Агент" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เอเจนต์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bir ajan" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Агент" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Một tác tử" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "一个智能体" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "一個智慧代理" - } - } - } - }, - "feed.bridge.summary": { - "comment": "The summary of an agent's permission request that has no tool title.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "An agent asks to run a tool" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ein Agent möchte ein Werkzeug ausführen" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Un agente pide ejecutar una herramienta" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un agent demande à exécuter un outil" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un agente chiede di eseguire uno strumento" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントがツールの実行を求めています" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "에이전트가 도구 실행을 요청합니다" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Um agente pede para executar uma ferramenta" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Агент просит запустить инструмент" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "代理请求运行工具" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "needs_review", - "value": "代理請求執行工具" - } - } - } - }, - "feed.bridge.poster": { - "comment": "Who posted an agent's permission request in the feed.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Agente" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Agente" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "에이전트" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Agente" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Агент" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Agent" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "代理" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "needs_review", - "value": "代理" - } - } - } - }, "menu.debug": { "comment": "Menu bar menu, DEV and NIGHTLY builds only: debug actions.", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift index 3f40f4eb827a..9824410cbc6b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift @@ -1,4 +1,5 @@ @testable import CmuxNextApp +import CmuxNextFeed import Foundation import Testing @@ -46,21 +47,79 @@ struct AcpmuxFeedBridgeQuestionTests { return try #require(body["params"] as? [String: Any]) } - @Test func aQuestionIsNeverPostedAsAPermission() throws { - let params = try Self.params(Self.question) - #expect(params["kind"] as? String != "approve") - #expect(params["type"] as? String == "notice") - #expect(params["prompt"] == nil) + /// The question posts as a `choice` the person answers in the feed, with every option shown + /// and the agent named, never as a permission and never as a bare "asks:" notice. + @Test func aQuestionPostsAsAnAnswerableChoice() throws { + let body = AcpmuxPermissionFeedBridge.postBody(session: "sess-1", permission: "perm-1", request: Self.question, + host: "inst_mac", agent: "claude") + let params = try #require(body["params"] as? [String: Any]) + #expect(params["kind"] as? String == "choice") + #expect(params["type"] as? String == "request") let title = try #require(params["title"] as? String) #expect(!title.hasPrefix("Allow")) - #expect(title.contains("Claude Code")) - #expect(title.contains("Which database should I use?")) + #expect(title == "Which database should I use?") + #expect((params["poster"] as? [String: Any])?["label"] as? String == "claude") + let questions = try #require((params["prompt"] as? [String: Any])?["questions"] as? [[String: Any]]) + #expect(questions.count == 1) + #expect(questions[0]["id"] as? String == "q0") + #expect(questions[0]["header"] as? String == "Database") + #expect((questions[0]["options"] as? [[String: Any]])?.map { $0["label"] as? String } == ["Postgres", "SQLite"]) + #expect((questions[0]["options"] as? [[String: Any]])?.map { $0["id"] as? String } == ["o0", "o1"]) + #expect(questions[0]["allow_other"] as? Bool == true) + let text = try #require(params["body"] as? String) + #expect(text.contains("Which database should I use?") && text.contains("- Postgres") && text.contains("- SQLite")) // It still opens the chat that asks, and reaches the phone as fast as a permission did. #expect((params["context"] as? [String: Any])?["acp_session"] as? String == "sess-1") #expect(params["priority"] as? String == "high") #expect(params["expires_in_ms"] as? Int == 86_400_000) } + /// A feed answer maps back onto the question's own option ids, as acpmux's reply. + @Test func aChoiceAnswerAnswersTheQuestion() throws { + func params(_ answers: [String: FeedChoiceSelection]) -> [String: Any]? { + AcpmuxPermissionFeedBridge.respondParams(request: Self.question, session: "sess-1", permission: "perm-1", answers: answers) + } + let reply = try #require(params(["q0": FeedChoiceSelection(selected: ["o1"])])) + #expect(reply["permissionId"] as? String == "perm-1") + #expect(reply["sessionId"] as? String == "sess-1") + let picked = try JSONSerialization.data(withJSONObject: reply) + #expect(String(data: picked, encoding: .utf8)?.contains("SQLite") == true) + // An option or question the posted choice never had answers nothing. + #expect(params(["q0": FeedChoiceSelection(selected: ["o7"])]) == nil) + #expect(params(["q3": FeedChoiceSelection(selected: ["o0"])]) == nil) + } + + /// A session answer (a browser, or a process holding the person's session) never answers. + @Test func aSessionAnswerNeverAnswersAQuestion() { + func refusal(_ by: String) -> AcpmuxPermissionFeedBridge.Refusal? { + if case .failure(let refusal) = AcpmuxPermissionFeedBridge.admitsQuestionAnswer(by: by) { return refusal } + return nil + } + #expect(refusal("session:usr_1") == .sessionAnswer) + #expect(refusal("inst_phone") == nil) + // This Mac's own install: the person answering in this Mac's feed, as in the chat. + #expect(refusal("inst_mac") == nil) + } + + /// A question that does not fit a choice (one option) stays a notice naming the agent. + @Test func aQuestionThatDoesNotFitAChoiceStaysANotice() throws { + var request = Self.question + var call = try #require(request["toolCall"] as? [String: Any]) + var meta = try #require(call["_meta"] as? [String: Any]) + var acpmux = try #require(meta["acpmux"] as? [String: Any]) + var normalized = try #require(acpmux["question"] as? [String: Any]) + normalized["items"] = [["id": "q0", "prompt": "Proceed?", "options": [["id": "o0", "label": "Yes"]], + "multiSelect": false, "allowsOther": false]] + acpmux["question"] = normalized + meta["acpmux"] = acpmux + call["_meta"] = meta + call["rawInput"] = ["questions": [["question": "Proceed?", "options": [["label": "Yes"]]]]] + request["toolCall"] = call + let params = try Self.params(request) + #expect(params["type"] as? String == "notice") + #expect((params["title"] as? String ?? "").contains("asks")) + } + @Test func aQuestionWithNoTextStillReadsAsAQuestion() throws { var request = Self.question var call = try #require(request["toolCall"] as? [String: Any]) From f17fd368d6d8028c4057eaac89886682ac56622d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:13:14 -0700 Subject: [PATCH 2/3] fix(feed): answer question choices from this Mac directly; bound choices as the owner counts Review follow-ups: - An answer the person gives in this Mac's own feed goes to acpmux through the bridge's person-proven connection (FeedModel.onAnswer -> answeredHere), as an answer in the chat would; the Mac feed's own answers reach the owner as a session and would be refused. Owner answers are admitted only from another install, as a deny (never a session, never this Mac's own install). - Choice text is cut by UTF-16 units (the owner counts JS string length), and a choice whose cleaned question or label is empty posts as the notice. - _acpmux/info for the agent's name does not wait for the session log. Co-Authored-By: Claude Opus 5.5 --- .../AgentQuestionFeedChoice.swift | 25 ++++++++- .../Feed/AcpmuxPermissionFeedBridge.swift | 52 +++++++++---------- ...cationCenterService+PermissionBridge.swift | 2 + .../Sources/CmuxNextFeed/FeedModel.swift | 8 ++- .../AcpmuxFeedBridgeQuestionTests.swift | 10 ++-- 5 files changed, 63 insertions(+), 34 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift index 44c295d28606..96d4c6c5736d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift @@ -97,7 +97,30 @@ public struct AgentQuestionFeedChoice: Equatable, Sendable { return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] } + /// The choice's title, body and prompt, or nil when a question or label is empty once + /// `clean` made it fit to show (the owner would refuse it, and nothing would be posted at all). + public func parts(clean: (String) -> String) -> (title: String, body: String, prompt: [String: Any])? { + fits(clean: clean) ? (title(clean: clean), body(clean: clean), prompt(clean: clean)) : nil + } + + func fits(clean: (String) -> String) -> Bool { + question.items.allSatisfy { item in + !clean(item.prompt).isEmpty && item.options.allSatisfy { !clean($0.label).isEmpty } + } + } + + /// `text` within `limit` UTF-16 units (the owner counts JavaScript string length), cut on a + /// character boundary with an ellipsis. static func cut(_ text: String, _ limit: Int) -> String { - text.count <= limit ? text : String(text.prefix(limit - 1)) + "…" + guard text.utf16.count > limit else { return text } + var out = "" + var units = 0 + for character in text { + let width = character.utf16.count + if units + width > limit - 1 { break } + out.append(character) + units += width + } + return out + "…" } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift index 05dda85c1faa..f403a3c41658 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift @@ -237,10 +237,8 @@ final class AcpmuxPermissionFeedBridge { nonisolated static func postBody(session: String, permission: String, request: [String: Any], host: String, agent: String? = nil) -> [String: Any] { let choice = request.acpmuxIsQuestion - ? AgentQuestionFeedChoice(request: request, session: session, permission: permission).map { - AcpmuxFeedPost.Choice(title: $0.title(clean: AcpmuxFeedPost.shown), body: $0.body(clean: AcpmuxFeedPost.shown), - prompt: $0.prompt(clean: AcpmuxFeedPost.shown)) - } : nil + ? AgentQuestionFeedChoice(request: request, session: session, permission: permission)?.parts(clean: AcpmuxFeedPost.shown) + .map { AcpmuxFeedPost.Choice(title: $0.title, body: $0.body, prompt: $0.prompt) } : nil return AcpmuxFeedPost(session: session, permission: permission, request: request, host: host, agent: agent, choice: choice).body } @@ -252,7 +250,7 @@ final class AcpmuxPermissionFeedBridge { postedPermissions.remove(permission) return logger.error("feed bridge: this Mac's install id is not known yet; \(permission, privacy: .public) not posted") } - let info = await call("_acpmux/info", ["sessionId": session]) + let info = await call("_acpmux/info", ["sessionId": session, "waitLogs": false]) let agent = (info?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? info?["harness"] as? String let body = Self.postBody(session: session, permission: permission, request: request, host: host, agent: agent) let params = body["params"] as? [String: Any] ?? [:] @@ -293,19 +291,21 @@ final class AcpmuxPermissionFeedBridge { func itemsChanged(_ items: [FeedItem]) { for item in items where item.state != .open { guard let entry = posted.removeValue(forKey: item.id), !entry.notice else { continue } - if entry.choice { - guard let answer = item.answer, case .choice(let answers) = answer.value else { - logger.info("feed bridge: \(item.id, privacy: .public) closed without an answer; nothing answered") - continue + switch item.answer.map({ ($0, $0.value) }) { + case let (record, .approve(decision))? where !entry.choice: + Task { await self.answer(item: item.id, entry: entry, decision: decision, record: record) } + // From the owner a question is answered as a deny is: by another install, never a session + // answer or this Mac's own install (the person answers here through `answeredHere`). + case let (record, .choice(answers))? where entry.choice: + Task { + guard case .success = Self.admits(by: record.by, ownInstall: await self.ownInstall()) else { + return self.logger.error("feed bridge: \(item.id, privacy: .public) answered by \(record.by, privacy: .public) refused") + } + await self.answer(item: item.id, entry: entry, answers: answers, by: record.by, device: record.device) } - Task { await self.answer(item: item.id, entry: entry, answers: answers, record: answer) } - continue - } - guard let answer = item.answer, case .approve(let decision) = answer.value else { + default: logger.info("feed bridge: \(item.id, privacy: .public) closed without an answer; nothing answered") - continue } - Task { await self.answer(item: item.id, entry: entry, decision: decision, record: answer) } } } @@ -343,21 +343,17 @@ final class AcpmuxPermissionFeedBridge { .respondParams(answers.mapValues { ($0.selected, $0.other) }) } - /// Whether `by` may answer an agent's question from the feed: an install, this Mac's own - /// included (the person answering in this Mac's feed, as they could in the chat), never a - /// session answer (`session:`, which a browser or a process on this Mac could hold). - /// A question is not a permission (Leo 2026-10-21), so it needs no phone presence proof. - nonisolated static func admitsQuestionAnswer(by: String) -> Result { - if case .failure(let refusal) = admits(by: by, ownInstall: nil) { return .failure(refusal) } - return .success(()) + /// The person answered one of this bridge's question choices in this Mac's own feed: it goes to + /// acpmux on the bridge's person-proven connection, as an answer typed in the chat would. + func answeredHere(_ id: String, _ value: FeedAnswerValue) { + guard let entry = posted[id], entry.choice, case .choice(let answers) = value else { return } + posted[id] = nil + Task { await self.answer(item: id, entry: entry, answers: answers, by: await self.ownInstall() ?? "mac", device: "Mac") } } /// Answers a question posted as a choice with the person's feed answer, on the same /// request it was posted for (still pending, same digest). - private func answer(item: String, entry: Posted, answers: [String: FeedChoiceSelection], record: FeedAnswerRecord) async { - if case .failure(let refusal) = Self.admitsQuestionAnswer(by: record.by) { - return logger.error("feed bridge: \(item, privacy: .public) answered by \(record.by, privacy: .public) refused: \(String(describing: refusal), privacy: .public)") - } + private func answer(item: String, entry: Posted, answers: [String: FeedChoiceSelection], by: String, device: String) async { guard let now = await pending(of: entry.session).first(where: { $0["permissionId"] as? String == entry.permission }), let request = now["request"] as? [String: Any] else { return logger.info("feed bridge: \(item, privacy: .public) answered after the question ended; nothing answered") @@ -369,11 +365,11 @@ final class AcpmuxPermissionFeedBridge { answers: answers) else { return logger.error("feed bridge: \(item, privacy: .public): the answer does not fit the question; nothing answered") } - params["_meta"] = ["acpmux": ["answeredBy": ["device": record.by, "feedItem": item]]] + params["_meta"] = ["acpmux": ["answeredBy": ["device": by, "feedItem": item]]] if await call("_acpmux/permission_respond", params) == nil { logger.error("feed bridge: acpmux refused the answer for \(item, privacy: .public)") } else { - logger.info("feed bridge: answered question \(entry.permission, privacy: .public) for \(record.by, privacy: .public) (\(record.device, privacy: .public))") + logger.info("feed bridge: answered question \(entry.permission, privacy: .public) for \(by, privacy: .public) (\(device, privacy: .public))") } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+PermissionBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+PermissionBridge.swift index 83087b7358d0..4a6a5fdd3394 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+PermissionBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+PermissionBridge.swift @@ -20,9 +20,11 @@ extension NotificationCenterService { presenceKeys: { try await principal.presenceKeys() }) self.permissionBridge = bridge feed?.onConfirmedItems = { [weak bridge] items in bridge?.itemsChanged(items) } + feed?.model.onAnswer = { [weak bridge] id, value in bridge?.answeredHere(id, value) } bridge.start() } else if !on, self.permissionBridge != nil { feed?.onConfirmedItems = nil + feed?.model.onAnswer = nil self.permissionBridge = nil } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedModel.swift index 93647b36ac32..2fe931b17848 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFeed/FeedModel.swift @@ -53,6 +53,9 @@ public final class FeedModel { /// Refreshes integration sources such as GitHub. The owner remains the /// authority for feed state; this is only a user-triggered source wakeup. public var onRefresh: (@MainActor () -> Void)? + /// Told each answer the person gives in this client, as it is sent (the agent question feed + /// bridge answers its own items here, the way the chat would, not through the owner's echo). + public var onAnswer: (@MainActor (String, FeedAnswerValue) -> Void)? /// Routes a supported GitHub action to the host. Unsupported actions stay hidden. public var onGitHubAction: (@MainActor (FeedItem, FeedGitHubAction, String?) -> Void)? /// Host capability check keeps unsupported actions out of the detail pane. @@ -183,7 +186,10 @@ public final class FeedModel { return nil } let intent = send(.answer(item: id, value: value)) - if intent != nil { drafts.clear(id) } + if intent != nil { + drafts.clear(id) + onAnswer?(id, value) + } return intent } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift index 9824410cbc6b..3c89e2b3d7a1 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AcpmuxFeedBridgeQuestionTests.swift @@ -89,16 +89,18 @@ struct AcpmuxFeedBridgeQuestionTests { #expect(params(["q3": FeedChoiceSelection(selected: ["o0"])]) == nil) } - /// A session answer (a browser, or a process holding the person's session) never answers. + /// From the owner, only another install answers a question (as a deny): never a session answer + /// (a browser, or a process holding the person's session), never this Mac's own install. @Test func aSessionAnswerNeverAnswersAQuestion() { func refusal(_ by: String) -> AcpmuxPermissionFeedBridge.Refusal? { - if case .failure(let refusal) = AcpmuxPermissionFeedBridge.admitsQuestionAnswer(by: by) { return refusal } + if case .failure(let refusal) = AcpmuxPermissionFeedBridge.admits(by: by, ownInstall: "inst_mac") { return refusal } return nil } #expect(refusal("session:usr_1") == .sessionAnswer) #expect(refusal("inst_phone") == nil) - // This Mac's own install: the person answering in this Mac's feed, as in the chat. - #expect(refusal("inst_mac") == nil) + // This Mac's own install never answers through the owner: the person answers here + // through the bridge directly (`answeredHere`), as in the chat. + #expect(refusal("inst_mac") == .ownInstall) } /// A question that does not fit a choice (one option) stays a notice naming the agent. From 8b3a043182e81d35df6aefe62f518619931744bb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:23:01 -0700 Subject: [PATCH 3/3] fix(feed): the feed choice and post builders are nonisolated (called from the bridge's nonisolated statics) Co-Authored-By: Claude Opus 5.5 --- .../Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift | 4 ++-- .../CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift index 2402c14eb589..198c6b2b8767 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxFeedPost.swift @@ -10,9 +10,9 @@ public import Foundation /// With a `choice` (the question as a feed choice, every question and option whole) it posts as /// a `choice` request the person answers inline; without one it posts as a notice naming the /// agent and its question, answered in the chat. -public struct AcpmuxFeedPost { +public nonisolated struct AcpmuxFeedPost { /// A question as a feed choice (CmuxNextAgentQuestion `AgentQuestionFeedChoice`). - public struct Choice { + public nonisolated struct Choice { public var title: String public var body: String public var prompt: [String: Any] diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift index 96d4c6c5736d..48d16bca42cf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentQuestion/AgentQuestionFeedChoice.swift @@ -11,7 +11,7 @@ public import Foundation /// own ids can be its text (Claude keys answers by question text), so the feed /// sees positional ids (`q0`, `o0`) and an answer maps back by position onto the /// same request. A question outside those bounds is not a choice. -public struct AgentQuestionFeedChoice: Equatable, Sendable { +public nonisolated struct AgentQuestionFeedChoice: Equatable, Sendable { public let question: AgentQuestion public static let maxQuestions = 4