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 .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
29 changes: 29 additions & 0 deletions .github/release-notes/v0.2.1.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ codex plugin add codex-context-window@codex-context-window
### Покрытие хуков

- `SessionStart` с источником `startup`: передает эффективный лимит контекстного окна и предупреждает, что автоматическое сжатие может произойти раньше, чем отображаемая заполненность достигнет лимита.
- `SubagentStart`: передает эффективный лимит контекстного окна нового субагента из его собственного файла сессии.
- `UserPromptSubmit` и `PostToolUse`: передают последнюю доступную информацию о заполненности контекстного окна.
- `SessionStart` с источником `compact`: напоминает модели проверить, что цель задачи, требования, принятые решения и текущий прогресс не были потеряны.

Expand Down
12 changes: 12 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
111 changes: 52 additions & 59 deletions src/hook.rs
Original file line number Diff line number Diff line change
@@ -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 = "<meta>";
const META_CLOSE: &str = "</meta>";
Expand All @@ -11,7 +9,6 @@ pub const CONTEXT_COMPACTED_MESSAGE: &str = "<meta>YOUR CONTEXT WAS JUST COMPACT

#[derive(Debug, Default, Deserialize)]
pub struct HookInput {
pub session_id: Option<String>,
pub transcript_path: Option<PathBuf>,
pub hook_event_name: Option<String>,
pub source: Option<String>,
Expand Down Expand Up @@ -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<PathBuf> {
find_session_file(
input.transcript_path.as_deref(),
input.session_id.as_deref(),
codex_home,
)
}
fn context_window_message(input: &HookInput) -> Option<String> {
let session_file = input.transcript_path.as_deref()?;

fn context_window_message(input: &HookInput, codex_home: Option<&Path>) -> Option<String> {
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<String> {
let session_file = session_file(input, codex_home)?;
fn context_window_limit_message(input: &HookInput) -> Option<String> {
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<String> {
pub fn message_for_hook(input: &HookInput) -> Option<String> {
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<HookOutput> {
create_hook_output_with_debug(input, codex_home, false)
pub fn create_hook_output(input: &HookInput) -> Option<HookOutput> {
create_hook_output_with_debug(input, false)
}

pub fn create_hook_output_with_debug(
input: &HookInput,
codex_home: Option<&Path>,
debug_enabled: bool,
) -> Option<HookOutput> {
pub fn create_hook_output_with_debug(input: &HookInput, debug_enabled: bool) -> Option<HookOutput> {
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);
}
Expand Down Expand Up @@ -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",
Expand All @@ -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":
"<meta>YOUR CONTEXT WINDOW LIMIT IS 258400 TOKENS. CODEX MAY AUTO-COMPACT BEFORE REPORTED USAGE REACHES THE LIMIT, SO PRESERVE IMPORTANT TASK STATE IN ADVANCE.</meta>"
}
})
);
}

#[test]
fn returns_compaction_output() {
let compact = HookInput {
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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);
}
}
}
15 changes: 1 addition & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}

pub fn environment_codex_home() -> Option<PathBuf> {
non_empty_env_path("CODEX_HOME")
}

pub fn environment_debug_enabled() -> bool {
env::var_os("CODEX_CONTEXT_WINDOW_DEBUG")
Expand Down
9 changes: 2 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
};

Expand Down
Loading