Skip to content
Open
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
52 changes: 34 additions & 18 deletions crates/jcode-core/src/stdin_detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn is_waiting_for_stdin(pid: u32) -> StdinState {
#[cfg(target_os = "linux")]
pub mod linux {
use super::*;
use std::collections::{HashMap, HashSet};

pub fn check(pid: u32) -> StdinState {
check_inner(pid, false)
Expand Down Expand Up @@ -93,37 +94,52 @@ pub mod linux {
.ok()
.map(|p| p.to_string_lossy().to_string());

// Check child processes
// Build a parent -> children graph from /proc so we can traverse nested wrappers.
let mut children_by_parent: HashMap<u32, Vec<u32>> = HashMap::new();
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string()
&& let Ok(child_pid) = name.parse::<u32>()
&& let Ok(status) =
std::fs::read_to_string(format!("/proc/{}/status", child_pid))
&& let Ok(proc_pid) = name.parse::<u32>()
&& let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", proc_pid))
{
for line in status.lines() {
if let Some(ppid_str) = line.strip_prefix("PPid:\t")
&& ppid_str.trim().parse::<u32>().ok() == Some(pid)
&& let Ok(ppid) = ppid_str.trim().parse::<u32>()
{
if let Some(ref parent_link) = parent_stdin_link {
let child_link =
std::fs::read_link(format!("/proc/{}/fd/0", child_pid))
.ok()
.map(|p| p.to_string_lossy().to_string());
if child_link.as_deref() != Some(parent_link) {
continue;
}
}
let child_result = check_inner(child_pid, true);
if child_result == StdinState::Reading {
return StdinState::Reading;
}
children_by_parent.entry(ppid).or_default().push(proc_pid);
break;
}
}
}
}
}

// DFS through descendants so chains like bash -> sh -> cat are detected.
let mut stack = vec![pid];
let mut visited = HashSet::new();
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
if let Some(children) = children_by_parent.get(&current) {
for &child_pid in children {
stack.push(child_pid);
if let Some(ref parent_link) = parent_stdin_link {
let child_link = std::fs::read_link(format!("/proc/{}/fd/0", child_pid))
.ok()
.map(|p| p.to_string_lossy().to_string());
if child_link.as_deref() != Some(parent_link) {
continue;
}
}
let child_result = check_inner(child_pid, true);
if child_result == StdinState::Reading {
return StdinState::Reading;
}
}
}
}

result
}
}
Expand Down
27 changes: 27 additions & 0 deletions crates/jcode-core/src/stdin_detect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,33 @@ fn test_child_process_tree_detection() {
);
}

#[cfg(target_os = "linux")]
#[test]
fn test_grandchild_process_tree_detection() {
// Two-layer wrapper: bash -> sh -> cat. Only the grandchild reads stdin.
let mut child = Command::new("bash")
.arg("-c")
.arg("sh -c 'cat'")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn nested shell");

let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(350));

let state = linux::check_process_tree(pid);

child.kill().ok();
child.wait().ok();

assert_eq!(
state,
StdinState::Reading,
"grandchild cat should be detected via recursive process tree walk"
);
}

#[cfg(target_os = "linux")]
#[test]
fn test_process_that_reads_then_exits() {
Expand Down
6 changes: 6 additions & 0 deletions src/config/default_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ openai_reasoning_effort = "low"
# OpenAI service tier override (priority|flex)
# Set `priority` to match Codex /fast behavior (higher speed, higher usage)
# openai_service_tier = "priority"
# Budget helper: set JCODE_CHEAP_MODE=1 to force lower-cost defaults at runtime:
# - features.memory=false
# - features.swarm=false
# - openai_reasoning_effort=none
# - cross_provider_failover=manual
# - same_provider_account_failover=false
# Cross-provider failover when the same prompt would be resent elsewhere.
# countdown = 3-second countdown before retrying on another provider; press Esc to cancel (default)
# manual = show a notice and let you switch yourself
Expand Down
21 changes: 21 additions & 0 deletions src/config/env_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,27 @@ impl Config {
crate::env::set_var("JCODE_COPILOT_PREMIUM", env_val);
}
}

// Cheap mode (opt-in): lower-cost and lower-complexity defaults.
// This is intentionally conservative and does not force a specific model id.
if let Ok(v) = std::env::var("JCODE_CHEAP_MODE")
&& let Some(true) = parse_env_bool(&v)
{
// Reduce agent-side context growth and coordination overhead.
self.features.memory = false;
self.features.swarm = false;

// Keep OpenAI reasoning overhead minimal in cheap mode.
self.provider.openai_reasoning_effort = Some("none".to_string());

// Avoid accidental premium tier usage and duplicate failover spends.
self.provider.openai_service_tier = None;
self.provider.cross_provider_failover = CrossProviderFailoverMode::Manual;
self.provider.same_provider_account_failover = false;

// Keep ambient API-key usage opt-in.
self.ambient.allow_api_keys = false;
}
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,41 @@ fn test_env_override_trusted_external_auth_splits_source_and_path_entries() {
}
}

#[test]
fn test_env_override_cheap_mode_sets_low_cost_defaults() {
let _guard = crate::storage::lock_test_env();
let prev = std::env::var_os("JCODE_CHEAP_MODE");
crate::env::set_var("JCODE_CHEAP_MODE", "1");

let mut cfg = Config::default();
cfg.features.memory = true;
cfg.features.swarm = true;
cfg.provider.openai_reasoning_effort = Some("high".to_string());
cfg.provider.openai_service_tier = Some("priority".to_string());
cfg.provider.cross_provider_failover = super::CrossProviderFailoverMode::Countdown;
cfg.provider.same_provider_account_failover = true;
cfg.ambient.allow_api_keys = true;

cfg.apply_env_overrides();

assert!(!cfg.features.memory);
assert!(!cfg.features.swarm);
assert_eq!(cfg.provider.openai_reasoning_effort.as_deref(), Some("none"));
assert!(cfg.provider.openai_service_tier.is_none());
assert_eq!(
cfg.provider.cross_provider_failover,
super::CrossProviderFailoverMode::Manual
);
assert!(!cfg.provider.same_provider_account_failover);
assert!(!cfg.ambient.allow_api_keys);

if let Some(prev) = prev {
crate::env::set_var("JCODE_CHEAP_MODE", prev);
} else {
crate::env::remove_var("JCODE_CHEAP_MODE");
}
}

#[test]
fn test_external_auth_source_allowed_for_path_matches_saved_entry() {
let _guard = crate::storage::lock_test_env();
Expand Down