use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::subagents::{QueuedApproval, QueuedApprovalOutcome};
use crate::{HarnessEvent, HarnessId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalKind {
Live,
Stored,
Proposal,
}
impl ApprovalKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Live => "live",
Self::Stored => "stored",
Self::Proposal => "proposal",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalStatus {
Pending,
Allowed,
Denied,
Expired,
Cancelled,
}
impl ApprovalStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Allowed => "allowed",
Self::Denied => "denied",
Self::Expired => "expired",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovalOption {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
}
impl ApprovalOption {
fn bare(id: &str) -> Self {
Self {
id: id.to_string(),
label: None,
kind: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovalRow {
pub id: String,
pub harness: HarnessId,
pub kind: ApprovalKind,
pub status: ApprovalStatus,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
pub requested_at_ms: i64,
pub age_ms: i64,
#[serde(default)]
pub options: Vec<ApprovalOption>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ApprovalsQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}
impl ApprovalsQuery {
pub fn matches(&self, row: &ApprovalRow) -> bool {
if let Some(harness) = self.harness.as_deref() {
if row.harness.as_str() != harness {
return false;
}
}
if let Some(session) = self.session.as_deref() {
let hit = row.session_id.as_deref() == Some(session)
|| row.runtime_id.as_deref() == Some(session);
if !hit {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
AllowOnce,
AllowAlways,
Deny,
}
impl ApprovalDecision {
pub const ALL: [Self; 3] = [Self::AllowOnce, Self::AllowAlways, Self::Deny];
pub const fn as_str(self) -> &'static str {
match self {
Self::AllowOnce => "allow_once",
Self::AllowAlways => "allow_always",
Self::Deny => "deny",
}
}
pub fn parse(text: &str) -> Option<Self> {
let normalized = text.trim().to_ascii_lowercase().replace('-', "_");
Self::ALL
.into_iter()
.find(|decision| decision.as_str() == normalized)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ApprovalsResolveParams {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<ApprovalDecision>,
#[serde(skip_serializing_if = "Option::is_none")]
pub option_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalChoice {
Decision(ApprovalDecision),
Option(String),
}
impl ApprovalChoice {
pub fn asked(&self) -> &str {
match self {
Self::Decision(decision) => decision.as_str(),
Self::Option(option) => option.as_str(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalResolution {
pub connection: String,
pub request_id: Value,
pub option_id: String,
pub response: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalResolveError {
UnknownId(String),
QueuedSubagentRow(String),
NotOffered {
asked: String,
offered: Vec<String>,
},
NoOptions {
door: &'static str,
},
}
impl std::fmt::Display for ApprovalResolveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownId(id) => write!(
formatter,
"no approval request `{id}` is waiting on this service — a live request \
exists only inside the process driving the runtime whose turn it blocks, \
and only until it is answered. Answer it there: the SDK client or TUI that \
started the runtime, or `harness.v1.approvals.resolve` over that same \
`supercode harness serve` stdio session (SUP-62: no cross-process relay)"
),
Self::QueuedSubagentRow(id) => write!(
formatter,
"`{id}` is a queued subagent record — supercode's own audit trail of a \
request the parent's own handler answers (the terminal's modal, or the \
frontend request broker). Answer it on the door that raised it: the \
`request` envelope row of the joined supercode runtime"
),
Self::NotOffered { asked, offered } => write!(
formatter,
"this request does not offer `{asked}` — it offers: {}",
if offered.is_empty() {
"(nothing)".to_string()
} else {
offered.join(", ")
}
),
Self::NoOptions { door } => write!(
formatter,
"this `{door}` request enumerates no answers, so there is no option to \
select — answer it with `harness.v1.runtimes.respond` and that door's own \
reply body"
),
}
}
}
impl std::error::Error for ApprovalResolveError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDoor {
Acp,
Opencode,
Codex,
ClaudeCode,
SupercodeFrontend,
}
impl ApprovalDoor {
pub const fn as_str(self) -> &'static str {
match self {
Self::Acp => "acp",
Self::Opencode => "opencode",
Self::Codex => "codex",
Self::ClaudeCode => "claude-code",
Self::SupercodeFrontend => "supercode-frontend",
}
}
const fn spellings(self, decision: ApprovalDecision) -> &'static [&'static str] {
match (self, decision) {
(Self::Acp, ApprovalDecision::AllowOnce) => &["allow_once"],
(Self::Acp, ApprovalDecision::AllowAlways) => &["allow_always"],
(Self::Acp, ApprovalDecision::Deny) => &["reject_once", "reject_always"],
(Self::Opencode, ApprovalDecision::AllowOnce) => &["once"],
(Self::Opencode, ApprovalDecision::AllowAlways) => &["always"],
(Self::Opencode, ApprovalDecision::Deny) => &["reject"],
(Self::ClaudeCode, ApprovalDecision::AllowOnce) => &["allow"],
(Self::ClaudeCode, ApprovalDecision::AllowAlways) => &[],
(Self::ClaudeCode, ApprovalDecision::Deny) => &["deny"],
(Self::SupercodeFrontend, ApprovalDecision::AllowOnce) => &["allow"],
(Self::SupercodeFrontend, ApprovalDecision::AllowAlways) => &["allow_for_session"],
(Self::SupercodeFrontend, ApprovalDecision::Deny) => &["deny"],
(Self::Codex, _) => &[],
}
}
fn reply(self, option_id: &str) -> Option<Value> {
match self {
Self::Acp => Some(serde_json::json!({
"outcome": {"outcome": "selected", "optionId": option_id},
})),
Self::Opencode => Some(serde_json::json!({"response": option_id})),
Self::SupercodeFrontend => Some(serde_json::json!({"decision": option_id})),
Self::ClaudeCode if option_id == "deny" => Some(serde_json::json!({
"behavior": "deny",
"message": "denied through supercode approvals",
})),
Self::ClaudeCode => Some(serde_json::json!({"behavior": option_id})),
Self::Codex => None,
}
}
}
pub fn plan_reply(
door: ApprovalDoor,
options: &[ApprovalOption],
choice: &ApprovalChoice,
) -> Result<(String, Value), ApprovalResolveError> {
if options.is_empty() {
return Err(ApprovalResolveError::NoOptions {
door: door.as_str(),
});
}
let offered = || {
options
.iter()
.map(|option| option.id.clone())
.collect::<Vec<_>>()
};
let chosen = match choice {
ApprovalChoice::Option(option_id) => options
.iter()
.find(|option| &option.id == option_id)
.ok_or_else(|| ApprovalResolveError::NotOffered {
asked: option_id.clone(),
offered: offered(),
})?,
ApprovalChoice::Decision(decision) => door
.spellings(*decision)
.iter()
.find_map(|spelling| {
options.iter().find(|option| {
option.kind.as_deref() == Some(*spelling) || option.id == *spelling
})
})
.ok_or_else(|| ApprovalResolveError::NotOffered {
asked: decision.as_str().to_string(),
offered: offered(),
})?,
};
let response = door
.reply(&chosen.id)
.ok_or(ApprovalResolveError::NoOptions {
door: door.as_str(),
})?;
Ok((chosen.id.clone(), response))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveRequest {
pub request_id: Value,
pub door: ApprovalDoor,
pub session_id: Option<String>,
pub subject: String,
pub options: Vec<ApprovalOption>,
}
pub fn classify_live_request(kind: &str, payload: &Value) -> Option<LiveRequest> {
match kind {
"session/request_permission" => acp_request(payload),
"permission.asked" => opencode_request(payload),
"request" => supercode_request(payload),
"control_request" => claude_code_request(payload),
_ if kind.ends_with("Approval") => codex_request(payload),
_ => None,
}
}
fn request_id(payload: &Value) -> Option<Value> {
payload
.get("id")
.filter(|id| !id.is_null())
.filter(|id| id.is_string() || id.is_number())
.cloned()
}
fn acp_request(payload: &Value) -> Option<LiveRequest> {
let request_id = request_id(payload)?;
let params = payload.get("params").unwrap_or(&Value::Null);
let tool_call = params.get("toolCall");
let subject = tool_call
.and_then(|call| call.get("title"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
tool_call
.and_then(|call| call.get("rawInput"))
.and_then(command_line)
})
.or_else(|| {
tool_call
.and_then(|call| call.get("kind"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| "permission request".to_string());
let options = params
.get("options")
.and_then(Value::as_array)
.map(|options| {
options
.iter()
.filter_map(|option| {
Some(ApprovalOption {
id: option.get("optionId").and_then(Value::as_str)?.to_string(),
label: option
.get("name")
.and_then(Value::as_str)
.map(str::to_string),
kind: option
.get("kind")
.and_then(Value::as_str)
.map(str::to_string),
})
})
.collect()
})
.unwrap_or_default();
Some(LiveRequest {
request_id,
door: ApprovalDoor::Acp,
session_id: params
.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string),
subject: one_line(&subject),
options,
})
}
fn opencode_request(payload: &Value) -> Option<LiveRequest> {
let properties = payload.get("properties").unwrap_or(payload);
let permission = properties
.get("permission")
.filter(|value| value.is_object())
.unwrap_or(properties);
let id = permission.get("id").and_then(Value::as_str)?;
let subject = permission
.get("title")
.and_then(Value::as_str)
.or_else(|| permission.get("pattern").and_then(Value::as_str))
.or_else(|| permission.get("type").and_then(Value::as_str))
.unwrap_or("permission request");
Some(LiveRequest {
request_id: Value::String(id.to_string()),
door: ApprovalDoor::Opencode,
session_id: permission
.get("sessionID")
.and_then(Value::as_str)
.map(str::to_string),
subject: one_line(subject),
options: ["once", "always", "reject"]
.into_iter()
.map(ApprovalOption::bare)
.collect(),
})
}
fn supercode_request(payload: &Value) -> Option<LiveRequest> {
let request = payload.get("request")?;
if request.get("kind").and_then(Value::as_str) != Some("approval") {
return None;
}
let request_id = request
.get("id")
.filter(|id| id.is_number())
.cloned()
.filter(|id| !id.is_null())?;
let inner = request.get("payload").unwrap_or(&Value::Null);
let subject = inner
.get("subject")
.and_then(Value::as_str)
.filter(|subject| !subject.is_empty())
.or_else(|| inner.get("tool").and_then(Value::as_str))
.unwrap_or("permission request");
Some(LiveRequest {
request_id,
door: ApprovalDoor::SupercodeFrontend,
session_id: inner
.get("child_agent_id")
.and_then(Value::as_str)
.map(str::to_string),
subject: one_line(subject),
options: FRONTEND_DECISIONS
.into_iter()
.map(ApprovalOption::bare)
.collect(),
})
}
fn claude_code_request(payload: &Value) -> Option<LiveRequest> {
let request = payload.get("request")?;
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
return None;
}
let request_id = payload
.get("request_id")
.filter(|id| id.is_string())
.cloned()?;
let tool = request
.get("tool_name")
.and_then(Value::as_str)
.or_else(|| request.get("display_name").and_then(Value::as_str))
.unwrap_or("tool");
let detail = request
.get("input")
.and_then(command_line)
.or_else(|| {
request
.get("description")
.and_then(Value::as_str)
.map(str::to_string)
})
.or_else(|| {
request
.get("blocked_path")
.and_then(Value::as_str)
.map(str::to_string)
});
let subject = match detail {
Some(detail) if !detail.is_empty() => format!("{tool} {detail}"),
_ => tool.to_string(),
};
Some(LiveRequest {
request_id,
door: ApprovalDoor::ClaudeCode,
session_id: None,
subject: one_line(&subject),
options: CLAUDE_CODE_BEHAVIORS
.into_iter()
.map(ApprovalOption::bare)
.collect(),
})
}
fn codex_request(payload: &Value) -> Option<LiveRequest> {
let request_id = request_id(payload)?;
let params = payload.get("params").unwrap_or(&Value::Null);
let subject = params
.get("command")
.and_then(command_line)
.or_else(|| {
params
.get("fileChanges")
.and_then(Value::as_object)
.map(|changes| {
let files = changes.keys().cloned().collect::<Vec<_>>().join(", ");
if files.is_empty() {
"apply patch".to_string()
} else {
format!("apply patch: {files}")
}
})
})
.or_else(|| {
params
.get("reason")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| "approval request".to_string());
Some(LiveRequest {
request_id,
door: ApprovalDoor::Codex,
session_id: ["threadId", "conversationId", "sessionId"]
.into_iter()
.find_map(|key| params.get(key).and_then(Value::as_str))
.map(str::to_string),
subject: one_line(&subject),
options: Vec::new(),
})
}
fn command_line(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let joined = parts
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ");
(!joined.is_empty()).then_some(joined)
}
Value::Object(object) => object
.get("command")
.or_else(|| object.get("cmd"))
.and_then(command_line),
_ => None,
}
}
fn one_line(text: &str) -> String {
let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flattened.is_empty() {
"permission request".to_string()
} else {
flattened
}
}
fn id_segment(request_id: &Value) -> String {
match request_id {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
#[derive(Debug, Clone)]
struct LiveEntry {
request_id: Value,
door: ApprovalDoor,
harness: HarnessId,
runtime_id: String,
session_id: Option<String>,
subject: String,
options: Vec<ApprovalOption>,
requested_at_ms: i64,
}
#[derive(Debug, Default)]
pub struct ApprovalRegistry {
entries: BTreeMap<String, Vec<LiveEntry>>,
}
impl ApprovalRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn observe(
&mut self,
connection: &str,
harness: &HarnessId,
runtime_id: &str,
event: &HarnessEvent,
now_ms: i64,
) -> bool {
let Some(request) = classify_live_request(&event.kind, &event.payload) else {
return false;
};
let entries = self.entries.entry(connection.to_string()).or_default();
if entries
.iter()
.any(|entry| entry.request_id == request.request_id)
{
return false;
}
entries.push(LiveEntry {
request_id: request.request_id,
door: request.door,
harness: harness.clone(),
runtime_id: runtime_id.to_string(),
session_id: request.session_id,
subject: request.subject,
options: request.options,
requested_at_ms: now_ms,
});
true
}
pub fn answered(&mut self, connection: &str, request_id: &Value) -> bool {
let Some(entries) = self.entries.get_mut(connection) else {
return false;
};
let before = entries.len();
entries.retain(|entry| &entry.request_id != request_id);
let removed = entries.len() < before;
if entries.is_empty() {
self.entries.remove(connection);
}
removed
}
pub fn forget(&mut self, connection: &str) {
self.entries.remove(connection);
}
pub fn is_empty(&self) -> bool {
self.entries.values().all(Vec::is_empty)
}
pub fn resolution(
&self,
row_id: &str,
choice: &ApprovalChoice,
) -> Result<ApprovalResolution, ApprovalResolveError> {
if row_id.starts_with(SUBAGENT_ROW_PREFIX) {
return Err(ApprovalResolveError::QueuedSubagentRow(row_id.to_string()));
}
let (connection, entry) = self
.entries
.iter()
.flat_map(|(connection, entries)| entries.iter().map(move |entry| (connection, entry)))
.find(|(connection, entry)| {
format!("{connection}/{}", id_segment(&entry.request_id)) == row_id
})
.ok_or_else(|| ApprovalResolveError::UnknownId(row_id.to_string()))?;
let (option_id, response) = plan_reply(entry.door, &entry.options, choice)?;
Ok(ApprovalResolution {
connection: connection.clone(),
request_id: entry.request_id.clone(),
option_id,
response,
})
}
pub fn rows(&self, now_ms: i64) -> Vec<ApprovalRow> {
self.entries
.iter()
.flat_map(|(connection, entries)| {
entries.iter().map(move |entry| ApprovalRow {
id: format!("{connection}/{}", id_segment(&entry.request_id)),
harness: entry.harness.clone(),
kind: ApprovalKind::Live,
status: ApprovalStatus::Pending,
subject: entry.subject.clone(),
session_id: entry.session_id.clone(),
runtime_id: Some(entry.runtime_id.clone()),
requested_at_ms: entry.requested_at_ms,
age_ms: now_ms.saturating_sub(entry.requested_at_ms).max(0),
options: entry.options.clone(),
})
})
.collect()
}
}
const FRONTEND_DECISIONS: [&str; 3] = ["allow", "allow_for_session", "deny"];
const CLAUDE_CODE_BEHAVIORS: [&str; 2] = ["allow", "deny"];
const SUBAGENT_ROW_PREFIX: &str = "supercode/subagent/";
pub fn subagent_rows(queued: &[QueuedApproval], now_ms: i64) -> Vec<ApprovalRow> {
queued
.iter()
.enumerate()
.map(|(index, record)| {
let status = match record.outcome {
None => ApprovalStatus::Pending,
Some(QueuedApprovalOutcome::Allowed) => ApprovalStatus::Allowed,
Some(QueuedApprovalOutcome::Denied) => ApprovalStatus::Denied,
};
let subject = match record.subject.as_deref() {
Some(subject) if !subject.is_empty() => {
format!("{} {}", record.tool, subject)
}
_ => record.tool.clone(),
};
ApprovalRow {
id: format!(
"{SUBAGENT_ROW_PREFIX}{}/{}/{index}",
record.child_agent_id, record.queued_at_ms
),
harness: HarnessId::from(HarnessId::SUPERCODE),
kind: ApprovalKind::Live,
status,
subject: one_line(&subject),
session_id: Some(record.child_agent_id.clone()),
runtime_id: None,
requested_at_ms: record.queued_at_ms,
age_ms: now_ms.saturating_sub(record.queued_at_ms).max(0),
options: if status == ApprovalStatus::Pending {
FRONTEND_DECISIONS
.into_iter()
.map(ApprovalOption::bare)
.collect()
} else {
Vec::new()
},
}
})
.collect()
}
pub fn lists_approvals(harness: &str) -> bool {
crate::support::harness_support(harness)
.is_some_and(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
}
pub fn approval_harnesses() -> Vec<String> {
crate::support::harness_support_registry()
.harnesses
.into_iter()
.filter(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
.map(|descriptor| descriptor.id.as_str().to_string())
.collect()
}
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as i64)
.unwrap_or_default()
}