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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.6.1

- Bound compact-mode cancellation to a 250 ms graceful shutdown window, then force-stop the owned process group; a second cancellation forces immediate shutdown while preserving the original signal exit status.
- Report turn-start activity immediately while preserving the final response on stdout.
- Label recoverable Codex diagnostics neutrally instead of presenting experimental-feature warnings as errors.

## 0.6.0

- Add a public Rust library returning typed commit subjects and bodies from supplied public diffs.
Expand Down
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-commit"
version = "0.6.0"
version = "0.6.1"
edition = "2024"
rust-version = "1.85"
description = "Commit Git changes with Codex, with optional push"
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,18 @@ Cargo compiles the published source for your machine and normally installs into

### Prebuilt macOS downloads

Download the matching archive and `SHA256SUMS` from [v0.6.0](https://github.com/syz51/codex-commit/releases/tag/v0.6.0):
Download the matching archive and `SHA256SUMS` from [v0.6.1](https://github.com/syz51/codex-commit/releases/tag/v0.6.1):

| Mac | Archive |
| --- | --- |
| Apple Silicon (M-series) | [`codex-commit-v0.6.0-aarch64-apple-darwin.tar.gz`](https://github.com/syz51/codex-commit/releases/download/v0.6.0/codex-commit-v0.6.0-aarch64-apple-darwin.tar.gz) |
| Intel | [`codex-commit-v0.6.0-x86_64-apple-darwin.tar.gz`](https://github.com/syz51/codex-commit/releases/download/v0.6.0/codex-commit-v0.6.0-x86_64-apple-darwin.tar.gz) |
| Apple Silicon (M-series) | [`codex-commit-v0.6.1-aarch64-apple-darwin.tar.gz`](https://github.com/syz51/codex-commit/releases/download/v0.6.1/codex-commit-v0.6.1-aarch64-apple-darwin.tar.gz) |
| Intel | [`codex-commit-v0.6.1-x86_64-apple-darwin.tar.gz`](https://github.com/syz51/codex-commit/releases/download/v0.6.1/codex-commit-v0.6.1-x86_64-apple-darwin.tar.gz) |

For example, on Apple Silicon:

```sh
release=https://github.com/syz51/codex-commit/releases/download/v0.6.0
archive=codex-commit-v0.6.0-aarch64-apple-darwin.tar.gz
release=https://github.com/syz51/codex-commit/releases/download/v0.6.1
archive=codex-commit-v0.6.1-aarch64-apple-darwin.tar.gz
curl -fLO "$release/$archive"
curl -fLO "$release/SHA256SUMS"
grep " $archive\$" SHA256SUMS | shasum -a 256 -c -
Expand Down Expand Up @@ -136,7 +136,9 @@ The task asks Codex to inspect staged, unstaged, and untracked changes; follow r

With `--push`, Codex can push existing local commits from a clean worktree. With no upstream, it may set one only when `origin` is the sole remote. Both push modes are prompt policies, not OS-enforced network or Git restrictions; review the streamed output.

Default unattended output shows concise progress on stderr and the final response on stdout. It renders Codex's JSON events, hiding the startup banner, echoed prompt, reasoning, routine hook notifications, and successful raw tool output. Failed command output and tool errors remain visible. The task asks for Git status and diff summaries, intended changes, checks, and the observed commit and push results without dumping full files or diffs.
Default unattended output immediately reports when a turn starts, shows concise progress on stderr, and keeps the final response on stdout. Codex JSON messages do not identify commentary separately, so the latest message is held until another message or tool activity establishes it as progress, or turn completion establishes it as the final response. Ctrl-C and other cancellation signals are forwarded immediately. If Codex has not exited after a 250 ms grace period, the supervisor force-stops its process group; a second cancellation forces shutdown immediately. Cancellation preserves the original signal exit status even when output is blocked.

It renders Codex's JSON events, hiding the startup banner, echoed prompt, reasoning, routine hook notifications, and successful raw tool output. Failed command output and tool errors remain visible. The task asks for Git status and diff summaries, intended changes, checks, and the observed commit and push results without dumping full files or diffs.

Use `--verbose` for Codex's full terminal output. Both `--verbose` and `--interactive` replace the Rust process with Codex on Unix, preserving its terminal streams and process identity. Compact mode supervises Codex instead, forwards interrupt and termination signals, and preserves its exit code; invalid or incomplete event streams fail explicitly. Piped or file input is passed to Codex as context; compact mode does not read from an interactive terminal. **Ctrl-C interrupts Codex** in all modes. Cancellation cannot undo an operation that already completed; inspect local and remote state before retrying.

Expand Down Expand Up @@ -195,7 +197,7 @@ responsible for reviewing message accuracy and their public-data boundary.

```toml
[dependencies]
codex-commit = "=0.6.0"
codex-commit = "=0.6.1"
```

```rust,no_run
Expand Down
63 changes: 58 additions & 5 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ impl<W: Write, E: Write> Renderer<W, E> {
"thread.started" => {
writeln!(self.stderr, "Session: {}", string(&event, "thread_id")?)?;
}
"turn.started" => self.completed = false,
"turn.started" => {
self.completed = false;
writeln!(self.stderr, "Codex is working (Ctrl-C to interrupt).")?;
}
"turn.completed" => self.completed = true,
"turn.failed" => {
self.progress()?;
Expand All @@ -65,7 +68,11 @@ impl<W: Write, E: Write> Renderer<W, E> {
self.progress()?;
// Diagnostics can precede a recovered turn. Completion and the
// process status determine success; turn.failed remains decisive.
writeln!(self.stderr, "Codex error: {}", string(&event, "message")?)?;
writeln!(
self.stderr,
"Codex diagnostic: {}",
string(&event, "message")?
)?;
}
"item.started" | "item.updated" | "item.completed" => {
let item = &event["item"];
Expand All @@ -82,7 +89,11 @@ impl<W: Write, E: Write> Renderer<W, E> {
}
"error" if completed => {
self.progress()?;
writeln!(self.stderr, "Codex error: {}", string(item, "message")?)?;
writeln!(
self.stderr,
"Codex diagnostic: {}",
string(item, "message")?
)?;
}
"command_execution" => {
let command = string(item, "command")?;
Expand Down Expand Up @@ -197,6 +208,17 @@ mod tests {
assert_eq!(String::from_utf8(err).unwrap(), "Inspecting changes\n");
}

#[test]
fn turn_start_shows_activity_before_any_message_or_tool() {
let mut renderer = Renderer::new(Vec::new(), Vec::new());
renderer.event(r#"{"type":"turn.started"}"#).unwrap();
assert_eq!(
renderer.stderr,
b"Codex is working (Ctrl-C to interrupt).\n"
);
assert!(renderer.stdout.is_empty());
}

#[test]
fn successful_tool_output_and_reasoning_are_hidden() {
let (success, out, err) = render(&[
Expand Down Expand Up @@ -276,8 +298,39 @@ mod tests {
]);
assert!(success);
assert_eq!(out, "Done\n");
assert!(err.contains("retrying transport"));
assert!(err.contains("experimental feature warning"));
assert_eq!(
err,
"Codex diagnostic: retrying transport\nCodex diagnostic: experimental feature warning\n"
);
}

#[test]
fn chronicle_warning_remains_diagnostic_when_turn_succeeds_or_fails() {
let warning = "Under-development features enabled: chronicle. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /home/user/.codex/config.toml.";
for diagnostic in [
json!({"type":"error","message":warning}),
json!({"type":"item.completed","item":{"type":"error","message":warning}}),
] {
let (success, out, err) = render(&[
diagnostic.clone(),
message("Done"),
json!({"type":"turn.completed"}),
]);
assert!(success);
assert_eq!(out, "Done\n");
assert_eq!(err, format!("Codex diagnostic: {warning}\n"));

let (success, out, err) = render(&[
diagnostic,
json!({"type":"turn.failed","error":{"message":"model unavailable"}}),
]);
assert!(!success);
assert!(out.is_empty());
assert_eq!(
err,
format!("Codex diagnostic: {warning}\nCodex failed: model unavailable\n")
);
}
}

#[test]
Expand Down
35 changes: 32 additions & 3 deletions src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};

const CANCELLATION_GRACE: Duration = Duration::from_millis(250);

const MAX_EVENT: u64 = 16 * 1024 * 1024;

Expand All @@ -35,6 +37,8 @@ pub(crate) fn run(mut command: Command) -> Result<(), CliError> {
let forwarding = Arc::clone(&target);
let cancellation = Arc::new(AtomicI32::new(0));
let forwarded_cancellation = Arc::clone(&cancellation);
let cancellation_deadline = Arc::new(Mutex::new(None));
let forwarded_deadline = Arc::clone(&cancellation_deadline);
let signal_thread = thread::spawn(move || {
for signal in signals.forever() {
let target = forwarding.lock().expect("signal target lock");
Expand All @@ -44,10 +48,28 @@ pub(crate) fn run(mut command: Command) -> Result<(), CliError> {
signal,
Signal::SIGINT | Signal::SIGTERM | Signal::SIGHUP | Signal::SIGQUIT
) {
forwarded_cancellation.store(signal as i32, Ordering::Release);
if forwarded_cancellation
.compare_exchange(0, signal as i32, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
// A second cancellation means stop now, preserving
// the first signal as the wrapper's exit status.
let _ = killpg(group, Signal::SIGKILL);
continue;
}
*forwarded_deadline
.lock()
.expect("cancellation deadline lock") =
Some(Instant::now() + CANCELLATION_GRACE);
let _ = killpg(group, signal);
// Pending cancellation must reach a stopped agent too.
let _ = killpg(group, Signal::SIGCONT);
continue;
}
let _ = killpg(group, signal);
if signal == Signal::SIGTSTP {
if signal == Signal::SIGTSTP
&& forwarded_cancellation.load(Ordering::Acquire) == 0
{
// Stop the supervisor only after its owned group has
// received suspension. SIGCONT will resume both.
let _ = kill(getpid(), Signal::SIGSTOP);
Expand Down Expand Up @@ -102,6 +124,13 @@ pub(crate) fn run(mut command: Command) -> Result<(), CliError> {
loop {
{
let mut target = reaper_target.lock().expect("signal target lock");
if cancellation_deadline
.lock()
.expect("cancellation deadline lock")
.is_some_and(|deadline| Instant::now() >= deadline)
{
let _ = killpg(group, Signal::SIGKILL);
}
if let Some(status) = child.try_wait()? {
let _ = killpg(group, Signal::SIGKILL);
*target = None;
Expand Down
Loading
Loading