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
2 changes: 1 addition & 1 deletion LEAN-CTX.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ NAVIGATION PARADOX: reading more ≠ understanding more.

PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles multiple lookups into one call.

Auto: preload/dedup/compress run in background. ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. Full guide: LEAN-CTX.md
Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. ctx_session=memory; full guide: LEAN-CTX.md

RECOVER: compressed output is reversible — never re-read line-by-line. Need full/exact? Read the shown file path with any tool (no MCP), or ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).

Expand Down
2 changes: 1 addition & 1 deletion rust/LEAN-CTX.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ NAVIGATION PARADOX: reading more ≠ understanding more.

PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles multiple lookups into one call.

Auto: preload/dedup/compress run in background. ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. Full guide: LEAN-CTX.md
Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. ctx_session=memory; full guide: LEAN-CTX.md

RECOVER: compressed output is reversible — never re-read line-by-line. Need full/exact? Read the shown file path with any tool (no MCP), or ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).

Expand Down
5 changes: 3 additions & 2 deletions rust/LOCK_ORDERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ All `std::sync::Mutex` unless noted otherwise.
| L64 | `SESSION_READ_ONLY_ROOTS` | `core/pathjail.rs:143` | `OnceLock<Mutex<Vec<PathBuf>>>` | Session-scoped read-only roots auto-detected from language caches (#899); consulted by both read allow-list (jail) and `is_read_only_path` (write-deny) so a cache root is readable but never writable; independent leaf lock, never nested |
| L65 | `SESSIONS` | `proxy/sticky_tools.rs:17` | `OnceLock<Mutex<HashSet<u64>>>` (fn-local static) | CCR active conversation tracking; capacity-bounded (`MAX_TRACKED`), oldest-first eviction on overflow; independent leaf lock, never nested |
| L66 | `JOBS` | `server/background_shell.rs:26` | `LazyLock<Mutex<HashMap<String, Job>>>` | Content-addressed background shell jobs; lock is held only to insert, inspect, or remove a job, then released before executing or joining its worker thread; independent leaf lock, never nested |
| L67 | `BUILD_GATE` | `core/graph_provider.rs:659` | `LazyLock<Mutex<()>>` | Serializes synchronous graph-index builds; acquired with `try_lock()` and held only while the spawned builder is awaited, never nested with another static lock |

### Test / Environment Locks (serialise env-var mutations)

Expand Down Expand Up @@ -208,9 +209,9 @@ Override via `LEAN_CTX_WORKER_THREADS` (positive integer) for environments with
concurrent subagents. Example: `LEAN_CTX_WORKER_THREADS=8`. The blocking thread pool
is always `worker_threads * 4`, clamped to `[8, 32]`.

### Independent Static Locks (L3–L66)
### Independent Static Locks (L3–L67)

All other static locks (L3–L66) — **except the L22 → L4 pair documented above** — are
All other static locks (L3–L67) — **except the L22 → L4 pair documented above** — are
**independent singletons**: they protect isolated subsystem state and are never nested inside
each other. Each should be acquired in isolation:

Expand Down
5 changes: 2 additions & 3 deletions rust/src/core/rules_canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,8 @@ NAVIGATION PARADOX: reading more ≠ understanding more.\n\
• Navigate structure (signatures, symbols) before reading entire files";

/// One-line automation reminder.
pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. \
Full guide: LEAN-CTX.md";
pub const AUTO: &str = "Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. \
ctx_session=memory; full guide: LEAN-CTX.md";

/// Recovery vocabulary (verbose, LONGFORM profile). lean-ctx compression is fully
/// reversible (CCR), but agents otherwise only discover the escape hatch reactively
Expand Down
27 changes: 7 additions & 20 deletions rust/src/hook_handlers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ pub fn handle_codex_pretooluse() {
}
let binary = resolve_binary();
let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
// #809: always emit valid JSON — empty stdout is invalid for Codex CLI.
print!("{}", codex_allow_output());
return;
};
Expand Down Expand Up @@ -79,7 +78,6 @@ pub fn handle_codex_pretooluse() {
if mode == crate::hooks::HookMode::Replace {
print!("{}", codex_deny_output(cmd));
} else {
// #809: always emit valid JSON — Codex CLI requires it.
print!("{}", codex_allow_output());
}
}
Expand All @@ -100,16 +98,12 @@ pub(super) fn codex_deny_output(original_cmd: &str) -> String {
}

/// Allow-passthrough output for the Codex PreToolUse hook (#809).
/// Every code path must emit valid JSON — Codex CLI parses stdout as JSON
/// and reports "invalid pre-tool-use JSON output" on empty/malformed output.
/// Codex treats an exit-0 hook with no stdout as an allowed tool call. Its
/// `permissionDecision: "allow"` form is only valid when paired with
/// `updatedInput`; emitting it for an unchanged command makes current Codex
/// report `unsupported permissionDecision:allow` (#1019).
pub(super) fn codex_allow_output() -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
})
.to_string()
String::new()
}

/// Emit SessionStart guidance through Codex's documented hidden-context channel.
Expand Down Expand Up @@ -189,15 +183,8 @@ mod tests {
// This is the deny message format — verify it exists for native commands
assert!(deny_msg.contains("deny"), "deny output must contain deny");

// The allow output must NOT contain deny
// A successful PreToolUse hook with no output allows the command.
let allow_msg = codex_allow_output();
assert!(
allow_msg.contains("allow"),
"allow output must contain allow"
);
assert!(
!allow_msg.contains("deny"),
"allow output must not contain deny"
);
assert!(allow_msg.is_empty(), "allow output must be empty");
}
}
9 changes: 3 additions & 6 deletions rust/src/hook_handlers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,14 +463,11 @@ fn codex_rewrite_output_uses_native_updated_input_contract() {
);
}

/// #809: codex_allow_output produces valid JSON with allow decision.
/// An unchanged PreToolUse call is allowed by a successful hook with no stdout.
#[test]
fn codex_allow_output_is_valid_json() {
fn codex_allow_output_is_empty() {
let output = codex_allow_output();
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("codex_allow_output must be valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
assert!(output.is_empty());
}

/// #809: codex_deny_output with heavily escaped content is valid JSON.
Expand Down
33 changes: 30 additions & 3 deletions rust/src/tools/registered/ctx_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,21 @@ impl McpTool for CtxShellTool {
})
.unwrap_or_default();

if get_bool(args, "run_in_background").unwrap_or(false) {
let auto_background = should_auto_background(&cmd_clone, timeout_ms);
if get_bool(args, "run_in_background").unwrap_or(false) || auto_background {
let job_id = crate::server::background_shell::start(
cmd_clone, cwd_clone, extra_env, timeout_ms,
);
let mode = if auto_background {
"auto-background"
} else {
"background"
};
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Exit(0)),
content_blocks: None,
..ToolOutput::simple(format!(
"[background:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
"[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
))
});
}
Expand Down Expand Up @@ -566,6 +572,17 @@ fn redact_shell_output_secrets(output: &str) -> String {
redacted
}

/// The Codex MCP client abandons a tool call after five minutes. A Cargo test
/// with an explicit five-minute-or-longer shell timeout cannot reliably finish
/// inside that transport deadline (and can wait on Cargo's target lock), so
/// detach it and return a pollable job instead.
fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
timeout_ms.is_some_and(|timeout| timeout >= 300_000)
&& command
.lines()
.any(|line| line.trim_start().starts_with("cargo test"))
}

/// #842: detect a bare `cat <single_file>` command (no pipes, redirects, flags).
fn detect_bare_cat_file(command: &str) -> Option<String> {
let trimmed = command.trim();
Expand Down Expand Up @@ -595,7 +612,17 @@ fn detect_bare_cat_file(command: &str) -> Option<String> {

#[cfg(test)]
mod tests {
use super::is_timeout_notice_only;
use super::{is_timeout_notice_only, should_auto_background};

#[test]
fn long_cargo_test_is_auto_backgrounded() {
assert!(should_auto_background(
"cargo test --lib a\ncargo test --lib b",
Some(3_600_000)
));
assert!(should_auto_background("cargo test --lib a", Some(300_000)));
assert!(!should_auto_background("cargo test --lib a", Some(299_999)));
}

#[test]
fn timeout_notice_without_child_output_is_not_recoverable() {
Expand Down
5 changes: 5 additions & 0 deletions rust/tests/ctx_compose_scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ fn compose_disambiguates_same_named_symbol_by_task_keywords() {
// SAFETY: the suite runs with --test-threads=1 (see ci.yml).
unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") };
let dir = write_ambiguous_symbol_corpus();
// `ctx_compose` reads symbol bodies from the graph index. Build the tiny
// fixture explicitly so this scenario verifies disambiguation rather than
// depending on whether a prior test happened to warm the index.
lean_ctx::core::graph_provider::build_property_graph(&dir.path().to_string_lossy())
.expect("ambiguous-symbol corpus graph must build");

// "OCPP" in the task keywords points the symbol picker at ocpp.rs.
let (out, _tokens) = ctx_compose::handle(
Expand Down
7 changes: 3 additions & 4 deletions rust/tests/shell_and_agent_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ fn agent_init_lists_grok_in_supported() {
}

#[test]
fn agent_init_grok_writes_mcp_hooks_and_skill() {
fn agent_init_grok_writes_mcp_without_unsupported_hooks() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let home = tmpdir.path();
std::fs::create_dir_all(home.join(".grok")).unwrap();
Expand Down Expand Up @@ -515,10 +515,9 @@ fn agent_init_grok_writes_mcp_hooks_and_skill() {
"MCP section missing: {config}"
);

let hooks = std::fs::read_to_string(home.join(".grok/hooks/lean-ctx.json")).unwrap();
assert!(
hooks.contains("hook rewrite") && hooks.contains("PreToolUse"),
"hooks missing rewrite: {hooks}"
!home.join(".grok/hooks/lean-ctx.json").exists(),
"Grok Build's documented integration is MCP-only; do not install an unverified hook format"
);

let skill = home.join(".grok/skills/lean-ctx/SKILL.md");
Expand Down
Loading