mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
9f2a585153
## Summary - Replace the manual `/notify-owner` flow with an inline confirmation prompt when a usage-based workspace member hits a credits-depleted limit. - Fetch the current workspace role from the live ChatGPT `accounts/check/v4-2023-04-27` endpoint so owner/member behavior matches the desktop and web clients. - Keep owner, member, and spend-cap messaging distinct so we only offer the owner nudge when the workspace is actually out of credits. ## What Changed - `backend-client` - Added a typed fetch for the current account role from `accounts/check`. - Mapped backend role values into a Rust workspace-role enum. - `app-server` and protocol - Added `workspaceRole` to `account/read` and `account/updated`. - Derived `isWorkspaceOwner` from the live role, with a fallback to the cached token claim when the role fetch is unavailable. - `tui` - Removed the explicit `/notify-owner` slash command. - When a member is blocked because the workspace is out of credits, the error now prompts: - `Your workspace is out of credits. Request more from your workspace owner? [y/N]` - Choosing `y` sends the existing owner-notification request. - Choosing `n`, pressing `Esc`, or accepting the default selection dismisses the prompt without sending anything. - Selection popups now honor explicit item shortcuts, which is how the `y` / `n` interaction is wired. ## Reviewer Notes - The main behavior change is scoped to usage-based workspace members whose workspace credits are depleted. - Spend-cap reached should not show the owner-notification prompt. - Owners and admins should continue to see `/usage` guidance instead of the member prompt. - The live role fetch is best-effort; if it fails, we fall back to the existing token-derived ownership signal. ## Testing - Manual verification - Workspace owner does not see the member prompt. - Workspace member with depleted credits sees the confirmation prompt and can send the nudge with `y`. - Workspace member with spend cap reached does not see the owner-notification prompt. ### Workspace member out of usage https://github.com/user-attachments/assets/341ac396-eff4-4a7f-bf0c-60660becbea1 ### Workspace owner <img width="1728" height="1086" alt="Screenshot 2026-04-09 at 11 48 22 AM" src="https://github.com/user-attachments/assets/06262a45-e3fc-4cc4-8326-1cbedad46ed6" />
424 lines
13 KiB
Rust
424 lines
13 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_protocol::approvals::ElicitationAction;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::mcp::RequestId as McpRequestId;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::ConversationAudioParams;
|
|
use codex_protocol::protocol::ConversationStartParams;
|
|
use codex_protocol::protocol::ConversationTextParams;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::ReviewRequest;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_protocol::user_input::UserInput;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
pub(crate) struct AppCommand(Op);
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[allow(dead_code)]
|
|
pub(crate) enum AppCommandView<'a> {
|
|
Interrupt,
|
|
CleanBackgroundTerminals,
|
|
RealtimeConversationStart(&'a ConversationStartParams),
|
|
RealtimeConversationAudio(&'a ConversationAudioParams),
|
|
RealtimeConversationText(&'a ConversationTextParams),
|
|
RealtimeConversationClose,
|
|
RunUserShellCommand {
|
|
command: &'a str,
|
|
},
|
|
UserTurn {
|
|
items: &'a [UserInput],
|
|
cwd: &'a PathBuf,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: &'a Option<ApprovalsReviewer>,
|
|
sandbox_policy: &'a SandboxPolicy,
|
|
model: &'a str,
|
|
effort: Option<ReasoningEffortConfig>,
|
|
summary: &'a Option<ReasoningSummaryConfig>,
|
|
service_tier: &'a Option<Option<ServiceTier>>,
|
|
final_output_json_schema: &'a Option<Value>,
|
|
collaboration_mode: &'a Option<CollaborationMode>,
|
|
personality: &'a Option<Personality>,
|
|
},
|
|
OverrideTurnContext {
|
|
cwd: &'a Option<PathBuf>,
|
|
approval_policy: &'a Option<AskForApproval>,
|
|
approvals_reviewer: &'a Option<ApprovalsReviewer>,
|
|
sandbox_policy: &'a Option<SandboxPolicy>,
|
|
windows_sandbox_level: &'a Option<WindowsSandboxLevel>,
|
|
model: &'a Option<String>,
|
|
effort: &'a Option<Option<ReasoningEffortConfig>>,
|
|
summary: &'a Option<ReasoningSummaryConfig>,
|
|
service_tier: &'a Option<Option<ServiceTier>>,
|
|
collaboration_mode: &'a Option<CollaborationMode>,
|
|
personality: &'a Option<Personality>,
|
|
},
|
|
ExecApproval {
|
|
id: &'a str,
|
|
turn_id: &'a Option<String>,
|
|
decision: &'a ReviewDecision,
|
|
},
|
|
PatchApproval {
|
|
id: &'a str,
|
|
decision: &'a ReviewDecision,
|
|
},
|
|
ResolveElicitation {
|
|
server_name: &'a str,
|
|
request_id: &'a McpRequestId,
|
|
decision: &'a ElicitationAction,
|
|
content: &'a Option<Value>,
|
|
meta: &'a Option<Value>,
|
|
},
|
|
UserInputAnswer {
|
|
id: &'a str,
|
|
response: &'a RequestUserInputResponse,
|
|
},
|
|
RequestPermissionsResponse {
|
|
id: &'a str,
|
|
response: &'a RequestPermissionsResponse,
|
|
},
|
|
ReloadUserConfig,
|
|
ListSkills {
|
|
cwds: &'a [PathBuf],
|
|
force_reload: bool,
|
|
},
|
|
Compact,
|
|
SetThreadName {
|
|
name: &'a str,
|
|
},
|
|
SendAddCreditsNudgeEmail,
|
|
Shutdown,
|
|
ThreadRollback {
|
|
num_turns: u32,
|
|
},
|
|
Review {
|
|
review_request: &'a ReviewRequest,
|
|
},
|
|
Other(&'a Op),
|
|
}
|
|
|
|
impl AppCommand {
|
|
pub(crate) fn interrupt() -> Self {
|
|
Self(Op::Interrupt)
|
|
}
|
|
|
|
pub(crate) fn clean_background_terminals() -> Self {
|
|
Self(Op::CleanBackgroundTerminals)
|
|
}
|
|
|
|
pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self {
|
|
Self(Op::RealtimeConversationStart(params))
|
|
}
|
|
|
|
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
|
pub(crate) fn realtime_conversation_audio(params: ConversationAudioParams) -> Self {
|
|
Self(Op::RealtimeConversationAudio(params))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn realtime_conversation_text(params: ConversationTextParams) -> Self {
|
|
Self(Op::RealtimeConversationText(params))
|
|
}
|
|
|
|
pub(crate) fn realtime_conversation_close() -> Self {
|
|
Self(Op::RealtimeConversationClose)
|
|
}
|
|
|
|
pub(crate) fn run_user_shell_command(command: String) -> Self {
|
|
Self(Op::RunUserShellCommand { command })
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn user_turn(
|
|
items: Vec<UserInput>,
|
|
cwd: PathBuf,
|
|
approval_policy: AskForApproval,
|
|
sandbox_policy: SandboxPolicy,
|
|
model: String,
|
|
effort: Option<ReasoningEffortConfig>,
|
|
summary: Option<ReasoningSummaryConfig>,
|
|
service_tier: Option<Option<ServiceTier>>,
|
|
final_output_json_schema: Option<Value>,
|
|
collaboration_mode: Option<CollaborationMode>,
|
|
personality: Option<Personality>,
|
|
) -> Self {
|
|
Self(Op::UserTurn {
|
|
items,
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer: None,
|
|
sandbox_policy,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
collaboration_mode,
|
|
personality,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn override_turn_context(
|
|
cwd: Option<PathBuf>,
|
|
approval_policy: Option<AskForApproval>,
|
|
approvals_reviewer: Option<ApprovalsReviewer>,
|
|
sandbox_policy: Option<SandboxPolicy>,
|
|
windows_sandbox_level: Option<WindowsSandboxLevel>,
|
|
model: Option<String>,
|
|
effort: Option<Option<ReasoningEffortConfig>>,
|
|
summary: Option<ReasoningSummaryConfig>,
|
|
service_tier: Option<Option<ServiceTier>>,
|
|
collaboration_mode: Option<CollaborationMode>,
|
|
personality: Option<Personality>,
|
|
) -> Self {
|
|
Self(Op::OverrideTurnContext {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
windows_sandbox_level,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode,
|
|
personality,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn exec_approval(
|
|
id: String,
|
|
turn_id: Option<String>,
|
|
decision: ReviewDecision,
|
|
) -> Self {
|
|
Self(Op::ExecApproval {
|
|
id,
|
|
turn_id,
|
|
decision,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self {
|
|
Self(Op::PatchApproval { id, decision })
|
|
}
|
|
|
|
pub(crate) fn resolve_elicitation(
|
|
server_name: String,
|
|
request_id: McpRequestId,
|
|
decision: ElicitationAction,
|
|
content: Option<Value>,
|
|
meta: Option<Value>,
|
|
) -> Self {
|
|
Self(Op::ResolveElicitation {
|
|
server_name,
|
|
request_id,
|
|
decision,
|
|
content,
|
|
meta,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self {
|
|
Self(Op::UserInputAnswer { id, response })
|
|
}
|
|
|
|
pub(crate) fn request_permissions_response(
|
|
id: String,
|
|
response: RequestPermissionsResponse,
|
|
) -> Self {
|
|
Self(Op::RequestPermissionsResponse { id, response })
|
|
}
|
|
|
|
pub(crate) fn reload_user_config() -> Self {
|
|
Self(Op::ReloadUserConfig)
|
|
}
|
|
|
|
pub(crate) fn list_skills(cwds: Vec<PathBuf>, force_reload: bool) -> Self {
|
|
Self(Op::ListSkills { cwds, force_reload })
|
|
}
|
|
|
|
pub(crate) fn compact() -> Self {
|
|
Self(Op::Compact)
|
|
}
|
|
|
|
pub(crate) fn set_thread_name(name: String) -> Self {
|
|
Self(Op::SetThreadName { name })
|
|
}
|
|
|
|
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
|
|
Self(Op::ThreadRollback { num_turns })
|
|
}
|
|
|
|
pub(crate) fn review(review_request: ReviewRequest) -> Self {
|
|
Self(Op::Review { review_request })
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn kind(&self) -> &'static str {
|
|
self.0.kind()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn as_core(&self) -> &Op {
|
|
&self.0
|
|
}
|
|
|
|
pub(crate) fn into_core(self) -> Op {
|
|
self.0
|
|
}
|
|
|
|
pub(crate) fn is_review(&self) -> bool {
|
|
matches!(self.view(), AppCommandView::Review { .. })
|
|
}
|
|
|
|
pub(crate) fn view(&self) -> AppCommandView<'_> {
|
|
match &self.0 {
|
|
Op::Interrupt => AppCommandView::Interrupt,
|
|
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
|
|
Op::RealtimeConversationStart(params) => {
|
|
AppCommandView::RealtimeConversationStart(params)
|
|
}
|
|
Op::RealtimeConversationAudio(params) => {
|
|
AppCommandView::RealtimeConversationAudio(params)
|
|
}
|
|
Op::RealtimeConversationText(params) => {
|
|
AppCommandView::RealtimeConversationText(params)
|
|
}
|
|
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
|
|
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
|
|
Op::UserTurn {
|
|
items,
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
collaboration_mode,
|
|
personality,
|
|
} => AppCommandView::UserTurn {
|
|
items,
|
|
cwd,
|
|
approval_policy: *approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
model,
|
|
effort: *effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
collaboration_mode,
|
|
personality,
|
|
},
|
|
Op::OverrideTurnContext {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
windows_sandbox_level,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode,
|
|
personality,
|
|
} => AppCommandView::OverrideTurnContext {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
windows_sandbox_level,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode,
|
|
personality,
|
|
},
|
|
Op::ExecApproval {
|
|
id,
|
|
turn_id,
|
|
decision,
|
|
} => AppCommandView::ExecApproval {
|
|
id,
|
|
turn_id,
|
|
decision,
|
|
},
|
|
Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision },
|
|
Op::ResolveElicitation {
|
|
server_name,
|
|
request_id,
|
|
decision,
|
|
content,
|
|
meta,
|
|
} => AppCommandView::ResolveElicitation {
|
|
server_name,
|
|
request_id,
|
|
decision,
|
|
content,
|
|
meta,
|
|
},
|
|
Op::UserInputAnswer { id, response } => {
|
|
AppCommandView::UserInputAnswer { id, response }
|
|
}
|
|
Op::RequestPermissionsResponse { id, response } => {
|
|
AppCommandView::RequestPermissionsResponse { id, response }
|
|
}
|
|
Op::ReloadUserConfig => AppCommandView::ReloadUserConfig,
|
|
Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills {
|
|
cwds,
|
|
force_reload: *force_reload,
|
|
},
|
|
Op::Compact => AppCommandView::Compact,
|
|
Op::SetThreadName { name } => AppCommandView::SetThreadName { name },
|
|
Op::Shutdown => AppCommandView::Shutdown,
|
|
Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback {
|
|
num_turns: *num_turns,
|
|
},
|
|
Op::Review { review_request } => AppCommandView::Review { review_request },
|
|
Op::SendAddCreditsNudgeEmail => AppCommandView::SendAddCreditsNudgeEmail,
|
|
op => AppCommandView::Other(op),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<Op> for AppCommand {
|
|
fn from(value: Op) -> Self {
|
|
Self(value)
|
|
}
|
|
}
|
|
|
|
impl From<&Op> for AppCommand {
|
|
fn from(value: &Op) -> Self {
|
|
Self(value.clone())
|
|
}
|
|
}
|
|
|
|
impl From<&AppCommand> for AppCommand {
|
|
fn from(value: &AppCommand) -> Self {
|
|
value.clone()
|
|
}
|
|
}
|
|
|
|
impl From<AppCommand> for Op {
|
|
fn from(value: AppCommand) -> Self {
|
|
value.0
|
|
}
|
|
}
|