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
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1575,10 +1575,10 @@ same action on that session. The presentations are:

**Recent turn history:** `Alt+Left` opens the previous completed turn;
`Alt+Right` moves forwards, eventually returning to live detail. Click
**Previous**, **Next**, or **Live** for the same navigation. On macOS, use
`Option+Left` / `Option+Right`: the default Terminal.app and Ghostty
word-movement encodings are also recognised while the reply editor is
unfocused. While typing, those encodings continue to move between words;
**Previous**, **Next**, or **Live** for the same navigation. On macOS, the
labels show `Opt` for the Option key. Use `Option+Left` / `Option+Right`:
the default Terminal.app and Ghostty word-movement encodings are also
recognised while the reply editor is unfocused. While typing, those encodings continue to move between words;
leave the editor before navigating history. These controls
appear when replies have been retained and space permits; narrow layouts
shorten the labels, and the hotkeys remain available on short terminals.
Expand All @@ -1600,6 +1600,22 @@ same action on that session. The presentations are:
not full command-output logs, and run asynchronously at most every five
seconds while full detail is visible.

**Unread completed results (shared app-server):** when Codex supplies durable
read state, **TURN COMPLETE** remains highlighted and its completion pill stays
visible until the result is reviewed. Deliberately opening partial or full Detail,
or scrolling within its displayed result pane, acknowledges the latest completed
reply. The completion status remains visible in subdued styling afterward.
Rendering a preview, selecting a row, restoring a layout or receiving a new result
in an already-open pane does not acknowledge it. An older history page cannot
acknowledge a newer result; changed receipts require a fresh review action.

These receipts are saved by Codex and shared with supported native Codex views,
so reviewed results remain reviewed across restarts. Codexometer does not save a
second read-state database. Read-only web mode displays receipts without changing
them; `--web-control` enables acknowledgement. When durable receipts are unavailable,
existing completion behaviour remains. Linked-agent previews do not acknowledge
the root's result, and approval/question/error controls keep priority.

Whenever visible **LAST REPLY** or **LAST ACTIVITY** text is available, its box offers **[ (C)OPY ]**
in the bottom-right border (split, expanded and full detail, when space permits).
Click it to copy a snapshot of that session's observed text, including offscreen text,
Expand Down Expand Up @@ -2100,6 +2116,15 @@ visually marked with a vertical rail. Formatting uses validated structured
fields rather than guessing command boundaries from the justification. Legacy
or incomplete requests retain their original bounded text.

**Managed human approvals (Codex 0.167+):** a managed command policy can require
fresh approval each time, even when ordinary approvals have been saved or
reviewed automatically. The terminal and writable web mode offer **APPROVE ONCE**,
**DECLINE**, and **REJECT & STOP TURN**, with a **HUMAN APPROVAL REQUIRED** label.
Session-wide and persistent grants cannot satisfy these checkpoints. `--no-confirm`
can remove the extra confirmation step, but every request still needs an explicit
human action. Passive observation and read-only web mode do not advertise support
for answering these checkpoints; use a capable native Codex client there.

**File-change approvals (shared app-server only):** proposed additions, deletions
and updates are shown as scrollable patches, with file paths, rename destinations,
old/new line numbers and green additions/red removals. An overall `+N / −N`
Expand Down Expand Up @@ -3760,10 +3785,10 @@ go test -race -cover -timeout 10m -run '^TestLocalisedScreens$' ./internal/ui
```

An optional Unix integration check verifies the installed Codex against either
the 0.164.0 baseline or 0.166.0, including negotiated compressed WebSocket RPC:
the 0.164.0 baseline, 0.166.0 or 0.167.0, including negotiated compressed WebSocket RPC:

```sh
CODEXOMETER_TEST_CODEX_VERSION=0.166.0 go test ./internal/codex -run '^TestNativeCodexCompression$' -count=1
CODEXOMETER_TEST_CODEX_VERSION=0.167.0 go test ./internal/codex -run '^TestNativeCodexCompression$' -count=1
```

Use `0.164.0` when that version is on `PATH`; the existing
Expand Down
4 changes: 4 additions & 0 deletions internal/codex/approval_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
// Value selects an advertised option; Wire is the validated server payload.
type ApprovalOption struct{ Kind, Value, Wire, Detail string }

func oneTimeApproval(kind string) bool {
return kind == "accept" || kind == "decline" || kind == "cancel"
}

func (o ApprovalOption) GrantsPermission() bool {
return o.Kind == "accept" || o.Kind == "acceptForSession" || o.Kind == "acceptWithExecpolicyAmendment"
}
Expand Down
11 changes: 11 additions & 0 deletions internal/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,14 @@ func withServerError(action string, err error, stderr string) error {
}
return fmt.Errorf("%s: %w", action, err)
}

// sessionCapability keeps optional live-control adapters consistent when the
// local observer is unavailable (including platforms without a shared daemon).
func sessionCapability[T any](c Client) (T, bool) {
var unavailable T
if c.LiveUsage == nil {
return unavailable, false
}
capability, ok := c.LiveUsage.statusProvider.(T)
return capability, ok
}
6 changes: 3 additions & 3 deletions internal/codex/daemon_native_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ func TestNativeCodexCompression(t *testing.T) {
expected = "0.164.0" // Retain the existing baseline test invocation.
}
if expected == "" {
t.Skip("set CODEXOMETER_TEST_CODEX_VERSION=0.164.0 or 0.166.0 with that Codex installed")
t.Skip("set CODEXOMETER_TEST_CODEX_VERSION=0.164.0, 0.166.0 or 0.167.0 with that Codex installed")
}
if expected != "0.164.0" && expected != "0.166.0" {
t.Fatal("native compatibility target must be 0.164.0 or 0.166.0")
if expected != "0.164.0" && expected != "0.166.0" && expected != "0.167.0" {
t.Fatal("native compatibility target must be 0.164.0, 0.166.0 or 0.167.0")
}
binary, err := exec.LookPath("codex")
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/codex/daemon_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type sessionStatusProvider interface {
}

type sessionDaemonSnapshot struct {
ReadStates map[string]SessionReadState
Goals map[string]*SessionGoal
UsageCoverage map[string]string
Contexts map[string]SessionContext
Expand Down
39 changes: 37 additions & 2 deletions internal/codex/daemon_status_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const (
)

type daemonStatusProvider struct {
readStates map[string]readStateObservation
readStateVersion uint64
humanApprovals bool
commandGeneration uint64
commandTransitions map[string]uint64
queueRevision uint64
Expand Down Expand Up @@ -114,8 +117,12 @@ func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (s
coverage := p.readUsageCoverage(ctx, threadIDs)
statuses := make(map[string]sessionRuntimeStatus, len(threadIDs))
for _, threadID := range threadIDs {
p.mu.Lock()
connection, readVersion := p.connection, p.readStates[threadID].version
p.mu.Unlock()
var result struct {
Thread struct {
ReadState *wireReadState `json:"readState"`
Thread struct {
ID string `json:"id"`
Status struct {
Type string `json:"type"`
Expand All @@ -128,13 +135,24 @@ func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (s
}, &result); err != nil {
continue
}
p.mu.Lock()
if p.connection == connection && p.readStates[threadID].version == readVersion && result.Thread.ID == threadID {
p.observeReadStateLocked(threadID, result.ReadState.snapshot())
}
p.mu.Unlock()
status := parseSessionRuntimeStatus(result.Thread.Status.Type, result.Thread.Status.ActiveFlags)
if status != sessionRuntimeUnknown {
statuses[threadID] = status
}
}

p.mu.Lock()
wanted := stringStructSet(threadIDs)
for id := range p.readStates {
if _, keep := wanted[id]; !keep {
delete(p.readStates, id)
}
}
p.usageCoverage = coverage
p.lastStatusAt = time.Now()
p.statusThreads = stringStructSet(threadIDs)
Expand Down Expand Up @@ -166,13 +184,15 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD
statuses := make(map[string]sessionRuntimeStatus, len(threadIDs))
directories := make(map[string]string, len(threadIDs))
goals := make(map[string]*SessionGoal)
readStates := make(map[string]SessionReadState)
for _, threadID := range threadIDs {
if _, subscribed := p.subscribed[threadID]; subscribed {
if goal := p.goals[threadID].goal; goal != nil {
goals[threadID] = goal.Clone()
}
}
if status, ok := p.statuses[threadID]; ok {
readStates[threadID] = p.readStates[threadID].state
if state := p.contexts[threadID]; status == sessionRuntimeIdle && state != nil && state.completed {
status = sessionRuntimeComplete
}
Expand All @@ -194,6 +214,7 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD
coverage[id] = issue
}
return sessionDaemonSnapshot{
ReadStates: readStates,
Goals: goals,
UsageCoverage: coverage,
Contexts: daemonContextSnapshot(p.contexts, threadIDs, statuses),
Expand Down Expand Up @@ -300,11 +321,13 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error {
return nil
}
p.connection = connection
humanApprovals := p.humanApprovals
p.wire = wire
p.commandGeneration++
p.commandTransitions = make(map[string]uint64)
p.pending = make(map[int64]chan daemonEnvelope)
p.subscribed = make(map[string]struct{})
p.readStates = make(map[string]readStateObservation)
p.reroutedTurns = make(map[daemonTurnKey]string)
p.threadSettings = make(map[string]QuotaSession)
p.threadDirectories = make(map[string]string)
Expand All @@ -318,7 +341,7 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error {
}
if err := p.request(ctx, "initialize", map[string]any{
"clientInfo": codexometerClientInfo(),
"capabilities": map[string]any{"experimentalApi": true},
"capabilities": map[string]any{"experimentalApi": true, "requiredHumanApproval": humanApprovals},
}, &initialized); err != nil {
p.disconnect(connection)
return err
Expand Down Expand Up @@ -499,6 +522,7 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn, wires ...*da
p.queueRevision++
}
p.goalNotificationLocked(envelope.Method, envelope.Params)
p.readStateNotificationLocked(envelope.Method, envelope.Params)
if p.contexts == nil {
p.contexts = map[string]*daemonContextState{}
}
Expand Down Expand Up @@ -665,3 +689,14 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) {
type daemonResponseError struct{ payload string }

func (e *daemonResponseError) Error() string { return e.payload }

func (p *daemonStatusProvider) setHumanApprovalSupport(enabled bool) {
p.mu.Lock()
changed := p.humanApprovals != enabled
p.humanApprovals = enabled
connection := p.connection
p.mu.Unlock()
if changed && connection != nil {
p.disconnect(connection)
}
}
5 changes: 5 additions & 0 deletions internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type LiveUsageSnapshot struct {
// LiveUsageSession is one independently started local Codex session. Token
// usage from explicitly linked spawned descendants is folded into the root.
type LiveUsageSession struct {
ReadState SessionReadState
Goal *SessionGoal
ModelSettings SessionModelSettings
ID string
Expand Down Expand Up @@ -140,6 +141,7 @@ type LiveUsageReader struct {
nameIndexInfo os.FileInfo
daemonContexts map[string]SessionContext
daemonDirectories map[string]string
daemonReadStates map[string]SessionReadState
daemonGoals map[string]*SessionGoal
SessionsRoot string
WriterLocksRoot string
Expand Down Expand Up @@ -409,6 +411,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
r.daemonContexts = nil
r.daemonDirectories = nil
r.daemonGoals = nil
r.daemonReadStates = nil
appServerUp := false
r.daemonSubscribedThreads = nil
if r.statusProvider != nil {
Expand All @@ -419,6 +422,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
daemonCoverage = daemonSnapshot.UsageCoverage
r.daemonDirectories = daemonSnapshot.WorkingDirectories
r.daemonGoals = daemonSnapshot.Goals
r.daemonReadStates = daemonSnapshot.ReadStates
r.ingestResolvedModelObservations(daemonSnapshot.ModelObservations)
r.daemonSubscribedThreads = daemonSnapshot.SubscribedThreads
}
Expand Down Expand Up @@ -1546,6 +1550,7 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string
anyWorking := false
for _, session := range groups {
session.Goal = r.daemonGoals[session.ID].Clone()
session.ReadState = r.daemonReadStates[session.ID]
if root := byID[session.ID]; root != nil && !session.Unattributed {
session.ModelSettings = root.modelSettings
}
Expand Down
30 changes: 30 additions & 0 deletions internal/codex/session_167_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package codex

import (
"encoding/json"
"testing"
"time"
)

func TestCodex167NetworkExpiryResolvesOnlyItsRequest(t *testing.T) {
states := map[string]*daemonContextState{}
for _, id := range []string{"expired", "remaining"} {
daemonContextEvent(states, "item/commandExecution/requestApproval", rawJSON(id), rawJSON(map[string]any{"threadId": "root", "turnId": "turn", "itemId": "command", "command": "curl https://example.com", "cwd": "/work", "networkApprovalContext": map[string]any{"host": "example.com", "protocol": "https"}}), time.Now())
}
daemonContextEvent(states, "serverRequest/resolved", nil, rawJSON(map[string]any{"threadId": "other", "requestId": "expired"}), time.Now())
if len(states["root"].requests) != 2 {
t.Fatal("resolution crossed threads")
}
daemonContextEvent(states, "serverRequest/resolved", nil, rawJSON(map[string]any{"threadId": "root", "requestId": "expired"}), time.Now())
if _, ok := states["root"].requests[string(rawJSON("expired"))]; ok || len(states["root"].requests) != 1 {
t.Fatal("expired network prompt remained or removed its sibling")
}
c := daemonContextSnapshot(states, []string{"root"}, map[string]sessionRuntimeStatus{"root": sessionRuntimeApproval})["root"]
if c.PendingApprovals != 1 || c.ApprovalBlocked != "network" {
t.Fatal("network expiry changed remaining request semantics", c)
}
daemonContextEvent(states, "serverRequest/resolved", nil, json.RawMessage(`{"threadId":"root","requestId":"remaining"}`), time.Now())
if len(states["root"].requests) != 0 {
t.Fatal("last expired prompt remained")
}
}
18 changes: 10 additions & 8 deletions internal/codex/session_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ type SessionApprovalClient interface {
}

func (c Client) SessionApprovalPending(token string) bool {
if c.LiveUsage == nil {
return false
}
p, ok := c.LiveUsage.statusProvider.(SessionApprovalClient)
p, ok := sessionCapability[SessionApprovalClient](c)
return ok && p.SessionApprovalPending(token)
}

func (c Client) RespondSessionApproval(ctx context.Context, token, decision string) error {
if c.LiveUsage != nil {
if p, ok := c.LiveUsage.statusProvider.(SessionApprovalClient); ok {
return p.RespondSessionApproval(ctx, token, decision)
}
if p, ok := sessionCapability[SessionApprovalClient](c); ok {
return p.RespondSessionApproval(ctx, token, decision)
}
return errors.New("approval unavailable; respond in Codex")
}

// Passive clients must not claim they can present a human decision.
func (c Client) SetSessionHumanApprovalSupport(enabled bool) {
if p, ok := sessionCapability[interface{ setHumanApprovalSupport(bool) }](c); ok {
p.setHumanApprovalSupport(enabled)
}
}
2 changes: 1 addition & 1 deletion internal/codex/session_approval_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func (p *daemonStatusProvider) RespondSessionApproval(ctx context.Context, token
}
var wire string
for _, option := range s.requests[id].ApprovalOptions {
if option.Kind != "" && option.Value == decision {
if option.Kind != "" && option.Value == decision && (!s.requests[id].RequiredHumanApproval || oneTimeApproval(option.Kind)) {
wire = option.Wire
break
}
Expand Down
Loading
Loading