From 06aa1c433c9d7993d17d295434da7fda6d01872f Mon Sep 17 00:00:00 2001 From: Nikolay Leonovets Date: Tue, 28 Jul 2026 06:54:24 +0300 Subject: [PATCH] Fix subagent context reporting --- .codex-plugin/plugin.json | 2 +- .github/release-notes/v0.2.1.md | 29 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 1 + docs/ru/README.md | 1 + hooks/hooks.json | 12 +++ src/hook.rs | 111 ++++++++++++-------------- src/lib.rs | 15 +--- src/main.rs | 9 +-- src/rollout.rs | 135 +------------------------------- 11 files changed, 104 insertions(+), 215 deletions(-) create mode 100644 .github/release-notes/v0.2.1.md diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 83616a9..2a0a82d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-context-window", - "version": "0.2.0", + "version": "0.2.1", "description": "Inject Codex context-window limits and usage through lifecycle hooks.", "author": { "name": "Nikolai Leon" diff --git a/.github/release-notes/v0.2.1.md b/.github/release-notes/v0.2.1.md new file mode 100644 index 0000000..6f14235 --- /dev/null +++ b/.github/release-notes/v0.2.1.md @@ -0,0 +1,29 @@ +Codex Context Window v0.2.1 fixes context-window reporting for subagents. + +Subagents now receive their own effective context-window limit through `SubagentStart`, including subagents created without inherited turns. The hook reads only the `transcript_path` supplied for the current hook invocation, so it can no longer mistake a parent session for the active subagent. + +## What changed + +- **Subagent startup coverage** — `SubagentStart` reports the effective context-window limit from the subagent's own `task_started` event. +- **Correct transcript selection** — the hook relies exclusively on the hook-provided `transcript_path`. +- **No unsafe session fallback** — the recursive `session_id` lookup was removed because parent and subagent identifiers are not interchangeable at hook time. +- **Updated documentation** — the English and Russian READMEs describe the new lifecycle coverage and transcript behavior. + +Existing `SessionStart`, usage-update, and post-compaction signals are unchanged. + +## Upgrade + +Refresh the marketplace and reinstall the plugin: + +```bash +codex plugin marketplace upgrade codex-context-window +codex plugin add codex-context-window@codex-context-window +``` + +Then open a new chat or restart Codex so the updated hook registration is loaded. + +## Compatibility + +This release has no breaking configuration changes and supports the same macOS, Linux, and Windows targets as v0.2.0. + +**Full Changelog**: https://github.com/nleononline/codex-context-window/compare/v0.2.0...v0.2.1 diff --git a/Cargo.lock b/Cargo.lock index f9fff67..eb16441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "codex-context-window" -version = "0.2.0" +version = "0.2.1" dependencies = [ "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index 979034e..0b54bbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-context-window" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Native Codex hook for reporting context-window limits and usage" license = "MIT" diff --git a/README.md b/README.md index 27440d6..2eb25dd 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The plugin includes prebuilt binaries for macOS, Linux, and Windows on Arm64 and ### Hook coverage - `SessionStart` with source `startup`: provides the effective context-window limit and warns that automatic compaction may happen before reported usage reaches it. +- `SubagentStart`: provides the effective context-window limit for the new subagent from its own session file. - `UserPromptSubmit` and `PostToolUse`: provide the latest available context-window usage. - `SessionStart` with source `compact`: reminds the model to verify that the task goal, requirements, decisions, and current progress were not lost. diff --git a/docs/ru/README.md b/docs/ru/README.md index 36d9546..e435eea 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -50,6 +50,7 @@ codex plugin add codex-context-window@codex-context-window ### Покрытие хуков - `SessionStart` с источником `startup`: передает эффективный лимит контекстного окна и предупреждает, что автоматическое сжатие может произойти раньше, чем отображаемая заполненность достигнет лимита. +- `SubagentStart`: передает эффективный лимит контекстного окна нового субагента из его собственного файла сессии. - `UserPromptSubmit` и `PostToolUse`: передают последнюю доступную информацию о заполненности контекстного окна. - `SessionStart` с источником `compact`: напоминает модели проверить, что цель задачи, требования, принятые решения и текущий прогресс не были потеряны. diff --git a/hooks/hooks.json b/hooks/hooks.json index e4d6567..e0e07c6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -14,6 +14,18 @@ ] } ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "/bin/sh \"${PLUGIN_ROOT}/hooks/run-context-window.sh\"", + "commandWindows": "cmd.exe /d /c call \"%PLUGIN_ROOT%\\hooks\\run-context-window.cmd\"", + "timeout": 5 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/src/hook.rs b/src/hook.rs index 566180e..9366a1c 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -1,8 +1,6 @@ -use crate::rollout::{ - find_session_file, read_context_window_limit, read_last_token_usage, TokenUsage, -}; +use crate::rollout::{read_context_window_limit, read_last_token_usage, TokenUsage}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; const META_OPEN: &str = ""; const META_CLOSE: &str = ""; @@ -11,7 +9,6 @@ pub const CONTEXT_COMPACTED_MESSAGE: &str = "YOUR CONTEXT WAS JUST COMPACT #[derive(Debug, Default, Deserialize)] pub struct HookInput { - pub session_id: Option, pub transcript_path: Option, pub hook_event_name: Option, pub source: Option, @@ -57,54 +54,41 @@ fn append_debug_hook(message: String, hook_event_name: &str) -> String { format!("{content} (hook: {hook_event_name}){META_CLOSE}") } -fn session_file(input: &HookInput, codex_home: Option<&Path>) -> Option { - find_session_file( - input.transcript_path.as_deref(), - input.session_id.as_deref(), - codex_home, - ) -} +fn context_window_message(input: &HookInput) -> Option { + let session_file = input.transcript_path.as_deref()?; -fn context_window_message(input: &HookInput, codex_home: Option<&Path>) -> Option { - let session_file = session_file(input, codex_home)?; - - match read_last_token_usage(&session_file) { + match read_last_token_usage(session_file) { Ok(Some(usage)) => Some(format_context_window(usage)), Ok(None) | Err(_) => None, } } -fn context_window_limit_message(input: &HookInput, codex_home: Option<&Path>) -> Option { - let session_file = session_file(input, codex_home)?; +fn context_window_limit_message(input: &HookInput) -> Option { + let session_file = input.transcript_path.as_deref()?; - match read_context_window_limit(&session_file) { + match read_context_window_limit(session_file) { Ok(Some(limit)) => Some(format_context_window_limit(limit)), Ok(None) | Err(_) => None, } } -pub fn message_for_hook(input: &HookInput, codex_home: Option<&Path>) -> Option { +pub fn message_for_hook(input: &HookInput) -> Option { match (input.hook_event_name.as_deref(), input.source.as_deref()) { - (Some("SessionStart"), Some("startup")) => context_window_limit_message(input, codex_home), + (Some("SessionStart"), Some("startup")) => context_window_limit_message(input), + (Some("SubagentStart"), _) => context_window_limit_message(input), (Some("SessionStart"), Some("compact")) => Some(CONTEXT_COMPACTED_MESSAGE.to_owned()), - (Some("UserPromptSubmit"), _) | (Some("PostToolUse"), _) => { - context_window_message(input, codex_home) - } + (Some("UserPromptSubmit"), _) | (Some("PostToolUse"), _) => context_window_message(input), _ => None, } } -pub fn create_hook_output(input: &HookInput, codex_home: Option<&Path>) -> Option { - create_hook_output_with_debug(input, codex_home, false) +pub fn create_hook_output(input: &HookInput) -> Option { + create_hook_output_with_debug(input, false) } -pub fn create_hook_output_with_debug( - input: &HookInput, - codex_home: Option<&Path>, - debug_enabled: bool, -) -> Option { +pub fn create_hook_output_with_debug(input: &HookInput, debug_enabled: bool) -> Option { let hook_event_name = input.hook_event_name.as_deref()?; - let mut message = message_for_hook(input, codex_home)?; + let mut message = message_for_hook(input)?; if debug_enabled { message = append_debug_hook(message, hook_event_name); } @@ -159,11 +143,10 @@ mod tests { transcript_path: Some(rollout), hook_event_name: Some("SessionStart".to_owned()), source: Some("startup".to_owned()), - ..HookInput::default() }; assert_eq!( - serde_json::to_value(create_hook_output(&startup, None).unwrap()).unwrap(), + serde_json::to_value(create_hook_output(&startup).unwrap()).unwrap(), json!({ "hookSpecificOutput": { "hookEventName": "SessionStart", @@ -174,6 +157,29 @@ mod tests { ); } + #[test] + fn returns_subagent_context_limit() { + let temp = TempDirectory::new(); + let rollout = temp.path().join("rollout-test-subagent.jsonl"); + fs::write(&rollout, format!("{}\n", task_started(258_400))).unwrap(); + let subagent_start = HookInput { + transcript_path: Some(rollout), + hook_event_name: Some("SubagentStart".to_owned()), + source: None, + }; + + assert_eq!( + serde_json::to_value(create_hook_output(&subagent_start).unwrap()).unwrap(), + json!({ + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": + "YOUR CONTEXT WINDOW LIMIT IS 258400 TOKENS. CODEX MAY AUTO-COMPACT BEFORE REPORTED USAGE REACHES THE LIMIT, SO PRESERVE IMPORTANT TASK STATE IN ADVANCE." + } + }) + ); + } + #[test] fn returns_compaction_output() { let compact = HookInput { @@ -182,7 +188,7 @@ mod tests { ..HookInput::default() }; assert_eq!( - serde_json::to_value(create_hook_output(&compact, None).unwrap()).unwrap(), + serde_json::to_value(create_hook_output(&compact).unwrap()).unwrap(), json!({ "hookSpecificOutput": { "hookEventName": "SessionStart", @@ -201,10 +207,8 @@ mod tests { ..HookInput::default() }; assert_eq!( - serde_json::to_value( - create_hook_output_with_debug(&session_start, None, true).unwrap() - ) - .unwrap(), + serde_json::to_value(create_hook_output_with_debug(&session_start, true).unwrap()) + .unwrap(), json!({ "hookSpecificOutput": { "hookEventName": "SessionStart", @@ -224,26 +228,23 @@ mod tests { ..HookInput::default() }; - assert_eq!(create_hook_output(&input, None), None); + assert_eq!(create_hook_output(&input), None); } } #[test] - fn injects_usage_for_model_visible_hook() { + fn injects_usage_from_hook_transcript() { let temp = TempDirectory::new(); - let session_id = "session-123"; - let session_directory = temp.path().join("sessions/2026/07/27"); - fs::create_dir_all(&session_directory).unwrap(); - let rollout = session_directory.join(format!("rollout-current-{session_id}.jsonl")); + let rollout = temp.path().join("transcript.jsonl"); fs::write(&rollout, format!("{}\n", token_count(250, 1_000))).unwrap(); let input = HookInput { - session_id: Some(session_id.to_owned()), + transcript_path: Some(rollout), hook_event_name: Some("UserPromptSubmit".to_owned()), ..HookInput::default() }; assert_eq!( - serde_json::to_value(create_hook_output(&input, Some(temp.path())).unwrap()).unwrap(), + serde_json::to_value(create_hook_output(&input).unwrap()).unwrap(), json!({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", @@ -257,41 +258,33 @@ mod tests { #[test] fn emits_nothing_when_usage_is_unknown() { let input = HookInput { - session_id: Some("missing-session".to_owned()), hook_event_name: Some("PostToolUse".to_owned()), ..HookInput::default() }; - assert_eq!( - create_hook_output(&input, Some(Path::new("/definitely/missing"))), - None - ); + assert_eq!(create_hook_output(&input), None); } #[test] fn emits_nothing_when_startup_limit_is_unknown() { let input = HookInput { - session_id: Some("missing-session".to_owned()), hook_event_name: Some("SessionStart".to_owned()), source: Some("startup".to_owned()), ..HookInput::default() }; - assert_eq!( - create_hook_output(&input, Some(Path::new("/definitely/missing"))), - None - ); + assert_eq!(create_hook_output(&input), None); } #[test] fn ignores_unconfigured_hook_events() { - for hook_event_name in ["PreToolUse", "SubagentStart"] { + for hook_event_name in ["PreToolUse", "SessionEnd"] { let input = HookInput { hook_event_name: Some(hook_event_name.to_owned()), ..HookInput::default() }; - assert_eq!(create_hook_output(&input, None), None); + assert_eq!(create_hook_output(&input), None); } } } diff --git a/src/lib.rs b/src/lib.rs index 165875a..4f58384 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,23 +9,10 @@ pub use hook::{ format_context_window_limit, message_for_hook, HookInput, HookOutput, HookSpecificOutput, CONTEXT_COMPACTED_MESSAGE, }; -pub use rollout::{ - find_session_file, read_context_window_limit, read_last_token_usage, TokenUsage, -}; +pub use rollout::{read_context_window_limit, read_last_token_usage, TokenUsage}; use std::env; use std::ffi::OsString; -use std::path::PathBuf; - -pub(crate) fn non_empty_env_path(name: &str) -> Option { - env::var_os(name) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) -} - -pub fn environment_codex_home() -> Option { - non_empty_env_path("CODEX_HOME") -} pub fn environment_debug_enabled() -> bool { env::var_os("CODEX_CONTEXT_WINDOW_DEBUG") diff --git a/src/main.rs b/src/main.rs index ae55d68..b508413 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,4 @@ -use codex_context_window::{ - create_hook_output_with_debug, environment_codex_home, environment_debug_enabled, HookInput, -}; +use codex_context_window::{create_hook_output_with_debug, environment_debug_enabled, HookInput}; use std::io::{self, Read, Write}; fn debug(message: &str) { @@ -24,10 +22,7 @@ fn main() { } }; - let codex_home = environment_codex_home(); - let Some(output) = - create_hook_output_with_debug(&input, codex_home.as_deref(), environment_debug_enabled()) - else { + let Some(output) = create_hook_output_with_debug(&input, environment_debug_enabled()) else { return; }; diff --git a/src/rollout.rs b/src/rollout.rs index 5e69165..76058bf 100644 --- a/src/rollout.rs +++ b/src/rollout.rs @@ -1,9 +1,8 @@ -use crate::non_empty_env_path; use memchr::memrchr_iter; use serde_json::Value; -use std::fs::{self, File}; +use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; -use std::path::{Path, PathBuf}; +use std::path::Path; const READ_CHUNK_SIZE: usize = 64 * 1024; @@ -112,88 +111,12 @@ pub fn read_context_window_limit(file_path: &Path) -> io::Result> { read_last_relevant(file_path, context_window_limit_from_line) } -fn matches_rollout_name(file_path: &Path, session_id: &str) -> bool { - let Some(name) = file_path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - - name.starts_with("rollout-") && name.ends_with(&format!("-{session_id}.jsonl")) -} - -fn default_codex_home() -> Option { - if let Some(codex_home) = non_empty_env_path("CODEX_HOME") { - return Some(codex_home); - } - - #[cfg(windows)] - let home = non_empty_env_path("USERPROFILE"); - - #[cfg(not(windows))] - let home = non_empty_env_path("HOME"); - - home.map(|home| home.join(".codex")) -} - -pub fn find_session_file( - transcript_path: Option<&Path>, - session_id: Option<&str>, - codex_home: Option<&Path>, -) -> Option { - if let Some(transcript_path) = transcript_path { - if transcript_path - .metadata() - .map(|metadata| metadata.is_file()) - .unwrap_or(false) - { - return Some(transcript_path.to_path_buf()); - } - } - - let session_id = session_id?; - let sessions_root = codex_home - .map(Path::to_path_buf) - .or_else(default_codex_home)? - .join("sessions"); - - let mut directories = vec![sessions_root]; - let mut matched_file = None; - - while let Some(directory) = directories.pop() { - let Ok(entries) = fs::read_dir(directory) else { - continue; - }; - - for entry in entries.flatten() { - let path = entry.path(); - let Ok(file_type) = entry.file_type() else { - continue; - }; - - if file_type.is_dir() { - directories.push(path); - continue; - } - - if !file_type.is_file() || !matches_rollout_name(&path, session_id) { - continue; - } - - if matched_file.is_some() { - return None; - } - matched_file = Some(path); - } - } - - matched_file -} - #[cfg(test)] mod tests { use super::*; use crate::test_support::{task_started, token_count, TempDirectory}; use serde_json::json; - use std::io::Write; + use std::{fs, io::Write}; #[test] fn reads_newest_token_count_from_end() { @@ -313,56 +236,4 @@ mod tests { assert_eq!(read_context_window_limit(&rollout).unwrap(), None); } - - #[test] - fn finds_unique_rollout_by_session_id() { - let temp = TempDirectory::new(); - let session_id = "session-123"; - let session_directory = temp.path().join("sessions/2026/07/27"); - fs::create_dir_all(&session_directory).unwrap(); - - let rollout = session_directory.join(format!("rollout-current-{session_id}.jsonl")); - fs::write(&rollout, "").unwrap(); - - assert_eq!( - find_session_file(None, Some(session_id), Some(temp.path())), - Some(rollout) - ); - } - - #[test] - fn rejects_ambiguous_rollout_matches() { - let temp = TempDirectory::new(); - let session_id = "session-123"; - let older_directory = temp.path().join("sessions/2026/07/26"); - let newer_directory = temp.path().join("sessions/2026/07/27"); - fs::create_dir_all(&older_directory).unwrap(); - fs::create_dir_all(&newer_directory).unwrap(); - - let older = older_directory.join(format!("rollout-old-{session_id}.jsonl")); - let newer = newer_directory.join(format!("rollout-new-{session_id}.jsonl")); - fs::write(&older, "").unwrap(); - fs::write(&newer, "").unwrap(); - - assert_eq!( - find_session_file(None, Some(session_id), Some(temp.path())), - None - ); - } - - #[test] - fn trusts_hook_transcript_path_without_matching_its_name() { - let temp = TempDirectory::new(); - let transcript = temp.path().join("transcript.jsonl"); - fs::write(&transcript, "").unwrap(); - - assert_eq!( - find_session_file( - Some(&transcript), - Some("different-session-id"), - Some(&temp.path().join("missing")) - ), - Some(transcript) - ); - } }