Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,082 changes: 1,014 additions & 68 deletions cmux-tui/crates/acpmux/spec/acpmux-protocol.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/src/protocol/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
/// check that reads no keychain, when no newer evidence answers.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SessionAuthParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
Expand Down
141 changes: 78 additions & 63 deletions cmux-tui/crates/acpmux/src/protocol/mod.rs

Large diffs are not rendered by default.

276 changes: 276 additions & 0 deletions cmux-tui/crates/acpmux/src/protocol/params.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//! The params of the methods that take a fixed set of keys and no serde type: each a closed
//! object of the keys its handler reads, every value open (`true`). The Swift pane's own list
//! (`AcpmuxPaneMethods+Params.swift` `knownParams`) is checked against these
//! (`scripts/ci/check-swift-acpmux-protocol.py`), so a key the app sends that acpmux never reads
//! fails CI. Every request also takes `_meta` ([`RequestMeta`]): the local app's token and the
//! person's key ride on any of them (`server/local_app.rs`, `hub/person.rs`).

use std::borrow::Cow;

use schemars::generate::SchemaGenerator;
use schemars::{JsonSchema, Schema};
use serde_json::{Map, Value, json};

/// A closed object of `keys` plus `_meta`, each value any JSON.
fn closed(generator: &mut SchemaGenerator, keys: &[&str]) -> Schema {
let mut properties: Map<String, Value> =
keys.iter().map(|key| ((*key).to_owned(), Value::Bool(true))).collect();
properties.insert("_meta".into(), generator.subschema_for::<RequestMeta>().to_value());
Schema::try_from(
json!({"type": "object", "properties": properties, "additionalProperties": false}),
)
.unwrap_or_default()
}

macro_rules! keyed {
($($(#[$doc:meta])* $name:ident: [$($key:literal),* $(,)?];)*) => {$(
$(#[$doc])*
pub struct $name;

impl JsonSchema for $name {
fn schema_name() -> Cow<'static, str> {
stringify!($name).into()
}

fn json_schema(generator: &mut SchemaGenerator) -> Schema {
closed(generator, &[$($key),*])
}
}
)*};
}

/// A request's `_meta`: open to other vendors' keys, with acpmux's own under `acpmux`.
pub struct RequestMeta;

impl JsonSchema for RequestMeta {
fn schema_name() -> Cow<'static, str> {
"RequestMeta".into()
}

fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let acpmux = generator.subschema_for::<AcpmuxRequestMeta>().to_value();
Schema::try_from(json!({"type": "object", "properties": {"acpmux": acpmux}}))
.unwrap_or_default()
}
}

/// Every key acpmux reads from a request's `params._meta.acpmux`, on any method. Not here: the
/// keys of a reply's or a notification's `_meta.acpmux` (`features`, `ownerCloud`), and a content
/// block's own (`upload`, `uploadPath`, `context: true` on a block).
pub struct AcpmuxRequestMeta;

impl JsonSchema for AcpmuxRequestMeta {
fn schema_name() -> Cow<'static, str> {
"AcpmuxRequestMeta".into()
}

fn json_schema(_: &mut SchemaGenerator) -> Schema {
let properties: Map<String, Value> =
ACPMUX_REQUEST_META.iter().map(|key| ((*key).to_owned(), Value::Bool(true))).collect();
Schema::try_from(
json!({"type": "object", "properties": properties, "additionalProperties": false}),
)
.unwrap_or_default()
}
}

/// [`AcpmuxRequestMeta`]'s keys.
pub const ACPMUX_REQUEST_META: &[&str] = &[
"adopt",
"agentSessionId",
"answeredBy",
"approverDecision",
"approverDecisions",
"argv",
"author",
"beforePromptId",
"callerSession",
"cliHost",
"context",
"cwd",
"decisionNonce",
"dispatchSource",
"env",
"ephemeral",
"forceAdmit",
"forwardProjection",
"fromAgent",
"fromHarness",
"fromSessionId",
"githubBroker",
"harness",
"harnessFrom",
"host",
"inlineParts",
"interrupt",
"localAppToken",
"mcpServers",
"messageId",
"messageSender",
"mode",
"model",
"name",
"notice",
"observer",
"parent",
"peer",
"peerHops",
"personDecision",
"personKey",
"personStop",
"policy",
"pr",
"preset",
"promptFileText",
"promptId",
"promptSuggestion",
"provenance",
"reportTo",
"requestId",
"requireFullAccess",
"resend",
"route",
"spawnToolCallId",
"startLater",
"steer",
"steerNow",
"steerOnly",
"supersede",
"surface",
"teamMessage",
"throughSeq",
"via",
"vmBoundary",
"waitStart",
"workflowAllowedTools",
"workspaceId",
];

keyed! {
/// `_acpmux/agent_hold`.
AgentHoldParams: ["sessionId", "held"];
/// `_acpmux/agent_login/api_key`.
AgentLoginApiKeyParams: ["agent", "key", "sessionId"];
/// `_acpmux/agent_login/cancel`.
AgentLoginCancelParams: ["loginId", "sessionId"];
/// `_acpmux/agent_login/finish`.
AgentLoginFinishParams: ["loginId", "code", "sessionId"];
/// `_acpmux/agent_login/start`.
AgentLoginStartParams: ["agent", "sessionId"];
/// `_acpmux/agent_pair_resume`.
AgentPairResumeParams: ["sessionId", "peerSessionId"];
/// `_acpmux/app_reply`.
AppReplyParams: ["sessionId", "to", "replyTo", "threadId", "text", "replyId"];
/// `_acpmux/archive`.
ArchiveParams: ["sessionId", "archived"];
/// `_acpmux/attachment`.
AttachmentParams: ["sessionId", "id"];
/// `_acpmux/bookmark_get`.
BookmarkGetParams: ["sessionId"];
/// `_acpmux/bookmark_set`.
BookmarkSetParams: ["sessionId", "seq", "bookmarked"];
/// `_acpmux/btw`.
BtwParams: ["sessionId", "question", "model", "inline"];
/// `_acpmux/chat/route.set`.
ChatRouteSetParams: ["sessionId", "routeId", "when"];
/// `_acpmux/draft_get`.
DraftGetParams: ["sessionId"];
/// `_acpmux/draft_set`.
DraftSetParams: ["sessionId", "text"];
/// `_acpmux/forum/rules`.
ForumRulesParams: [];
/// `_acpmux/handoff_discard`.
HandoffDiscardParams: ["handoffId"];
/// `_acpmux/handoff_draft`.
HandoffDraftParams: ["handoffId", "revision", "draftKey", "capsule", "checkpoint"];
/// `_acpmux/handoff_get`.
HandoffGetParams: ["sessionId", "handoffId"];
/// `_acpmux/handoff_prepare`.
HandoffPrepareParams: ["sessionId", "harness", "handoffKey", "memoryRefs"];
/// `_acpmux/handoff_start`.
HandoffStartParams: ["handoffId", "revision", "promptId", "capsule", "checkpoint"];
/// `_acpmux/harness_enable`.
HarnessEnableParams: ["folder", "id"];
/// `_acpmux/harness/health`.
HarnessHealthParams: ["refresh", "rescan", "checkLatest", "autoUpdate", "view"];
/// `_acpmux/harnesses`.
HarnessesParams: ["cwd"];
/// `_acpmux/inbox_thread`.
InboxThreadParams: ["sessionId", "threadId", "markRead"];
/// `initialize`.
InitializeParams: ["protocolVersion", "clientCapabilities", "clientInfo"];
/// `_acpmux/kill`.
KillParams: ["sessionId", "purge"];
/// `_acpmux/mcp/reconnect`.
McpReconnectParams: ["sessionId", "server"];
/// `_acpmux/models`.
ModelsParams: ["refresh", "probe"];
/// `_acpmux/native_history`.
NativeHistoryParams: ["sessionId", "before", "mark"];
/// `_acpmux/offer_respond`.
OfferRespondParams: ["sessionId", "offerId", "outcome", "childSessionId"];
/// `_acpmux/peer_reconnect`.
PeerReconnectParams: ["name", "wait", "force"];
/// `_acpmux/peer_snapshot`.
PeerSnapshotParams: ["sessionId", "host", "limit", "maxBytes"];
/// `_acpmux/peers`.
PeersParams: [];
/// `_acpmux/prewarm`.
PrewarmParams: ["harness", "cwd", "release"];
/// `_acpmux/process/kill`.
ProcessKillParams: ["sessionId", "processId", "signal"];
/// `_acpmux/process/list`.
ProcessListParams: ["sessionId", "cursor", "limit"];
/// `_acpmux/process/tail`.
ProcessTailParams: ["sessionId", "processId", "fromByte", "maxBytes"];
/// `_acpmux/queue_approve`.
QueueApproveParams: ["sessionId", "promptId", "turnId", "revision"];
/// `_acpmux/queue_decline`.
QueueDeclineParams: ["sessionId", "promptId"];
/// `_acpmux/queue_edit`.
QueueEditParams: ["sessionId", "promptId", "op", "text", "images", "index"];
/// `_acpmux/queue_get`.
QueueGetParams: ["sessionId", "promptId"];
/// `_acpmux/queue_remove`.
QueueRemoveParams: ["sessionId", "promptId"];
/// `_acpmux/resolve`.
ResolveParams: ["query", "sessionId", "exactSessionId"];
/// `_acpmux/route/list`.
RouteListParams: ["sessionId"];
/// `_acpmux/secret_submit`.
SecretSubmitParams: ["sessionId", "secretId", "value"];
/// `session/cancel`.
SessionCancelParams: ["sessionId"];
/// `session/new`.
SessionNewParams: ["cwd", "mcpServers", "additionalDirectories"];
/// `session/prompt`.
SessionPromptParams: ["sessionId", "prompt", "steer"];
/// `session/set_config_option`.
SessionSetConfigOptionParams: ["sessionId", "configId", "value"];
/// `session/set_mode`.
SessionSetModeParams: ["sessionId", "modeId"];
/// `session/set_model`.
SessionSetModelParams: ["sessionId", "modelId"];
/// `_acpmux/set_tools`.
SetToolsParams: ["sessionId", "id", "enabled"];
/// `_acpmux/side`.
SideParams: ["sessionId", "chat"];
/// `_acpmux/start_mode`.
StartModeParams: ["harness", "preset", "cwd"];
/// `_acpmux/status`.
StatusParams: [];
/// `_acpmux/subagent_stop`.
SubagentStopParams: ["sessionId", "subagentSessionId"];
/// `_acpmux/subagent_transcript`.
SubagentTranscriptParams: ["sessionId", "runId", "agentId", "before", "after", "limit"];
/// `_acpmux/subsession/limits`.
SubsessionLimitsParams: ["sessionId", "maxRunning", "workspaceId"];
/// `acp.trust.get`.
TrustGetParams: ["cwd", "sessionId", "peer"];
/// `acp.trust.set`.
TrustSetParams: ["cwd", "level", "sessionId", "peer"];
/// `_acpmux/warm`.
WarmParams: ["sessionIds", "limit"];
/// `_acpmux/watch_take_over`.
WatchTakeOverParams: ["sessionId", "ifLive"];
}
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/src/protocol/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use super::require_all;
/// Before-prompt requests require requestId and omit both throughSeq and live.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct ForkParams {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
17 changes: 17 additions & 0 deletions cmux-tui/crates/acpmux/src/protocol/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,20 @@ fn protocol_author_approval_round_trips() {
assert_eq!(ir["types"]["AuthorApproval"]["enum"], json!(["owner"]));
assert!(!schema["required"].as_array().unwrap().contains(&json!("approval")));
}

/// A keyed method's params are closed, and every request's `_meta.acpmux` names
/// [`super::params::AcpmuxRequestMeta`], so the IR's `types` carries it.
#[test]
fn protocol_keyed_params_are_closed_and_name_the_request_meta() {
let ir: serde_json::Value = serde_json::from_str(&ir_text()).expect("IR is JSON");
let agent_hold = &ir["types"]["AgentHoldParams"];
assert_eq!(agent_hold["additionalProperties"], false, "{agent_hold}");
assert_eq!(agent_hold["properties"]["_meta"]["$ref"], "#/types/RequestMeta");
let meta = &ir["types"]["AcpmuxRequestMeta"];
assert_eq!(meta["additionalProperties"], false, "{meta}");
let keys: BTreeSet<&str> =
meta["properties"].as_object().expect("properties").keys().map(String::as_str).collect();
let listed: BTreeSet<&str> = super::params::ACPMUX_REQUEST_META.iter().copied().collect();
assert_eq!(keys, listed);
assert!(keys.contains("localAppToken") && keys.contains("personKey"), "{keys:?}");
}
25 changes: 25 additions & 0 deletions cmux-tui/crates/cmux-tui/src/cli/action_hint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ fn non_verbs() -> &'static HashMap<String, String> {
})
}

/// Whether the app action named `action` (its id or its CLI name) takes an argument called `argument`
/// (plans/cmux-next/action-surfaces.json `args`).
pub(super) fn action_takes_argument(action: &str, argument: &str) -> bool {
static ARGS: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();
ARGS.get_or_init(|| {
let doc: Value = serde_json::from_str(ACTIONS_JSON).unwrap_or(Value::Null);
let mut map = HashMap::new();
for action in doc.get("actions").and_then(Value::as_array).into_iter().flatten() {
let names: Vec<String> = action
.get("args")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|arg| arg.get("name").and_then(Value::as_str).map(str::to_owned))
.collect();
for key in ["id", "cli_name"].into_iter().filter_map(|key| action.get(key)?.as_str()) {
map.insert(key.to_owned(), names.clone());
}
}
map
})
.get(action)
.is_some_and(|names| names.iter().any(|name| name == argument))
}

/// Whether the catalog has an app action that the CLI offers as `cmux <name>`.
pub(super) fn offered_app_action(name: &str) -> bool {
static OFFERED: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
Expand Down
19 changes: 18 additions & 1 deletion cmux-tui/crates/cmux-tui/src/cli/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,18 @@ pub(super) fn run_action(
continue;
}
"focus" => {
params.insert("focus".into(), json!(true));
// An action with its own `focus` argument (`open-remote`) takes the flag as that argument too,
// with an optional `true`/`false` after it; the run's own focus follows it.
let mut value = true;
if super::action_hint::action_takes_argument(action, "focus") {
if let Some(word @ ("true" | "false")) = args.get(index + 1).map(String::as_str)
{
value = word == "true";
index += 1;
}
arguments.insert("focus".into(), json!(value));
}
params.insert("focus".into(), json!(value));
index += 1;
continue;
}
Expand Down Expand Up @@ -584,6 +595,12 @@ pub(super) fn run_action(
.ok_or_else(|| UsageError::new(messages.arg_shape.replace("{value}", text)))?;
arguments.insert(key.into(), json!(value));
}
// `--focus=false` for an action with a `focus` argument: the argument, and the run's own focus.
"focus" if super::action_hint::action_takes_argument(action, "focus") => {
let on = value.as_str().is_none_or(|text| !text.eq_ignore_ascii_case("false"));
params.insert("focus".into(), json!(on));
arguments.insert("focus".into(), json!(on));
}
_ => {
let key = argument_name(&name);
// A file or folder of the caller's working directory, for an app that runs elsewhere.
Expand Down
Loading
Loading