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
298 changes: 270 additions & 28 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/merefield/codexometer
go 1.26.6

require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.6
github.com/charmbracelet/x/ansi v0.11.8
Expand All @@ -13,6 +14,7 @@ require (
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
Expand All @@ -21,7 +23,7 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
Expand All @@ -28,8 +32,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
Expand Down
84 changes: 84 additions & 0 deletions internal/codex/approval_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package codex

import (
"bytes"
"encoding/json"
"strings"
)

// Fixed-size, immutable value snapshots keep preview comparisons deterministic.
// Value selects an advertised option; Wire is the validated server payload.
type ApprovalOption struct{ Kind, Value, Wire, Detail string }

func (o ApprovalOption) GrantsPermission() bool {
return o.Kind == "accept" || o.Kind == "acceptForSession" || o.Kind == "acceptWithExecpolicyAmendment"
}

func commandApprovalOptions(raw []json.RawMessage) ([8]ApprovalOption, string) {
var out [8]ApprovalOption
// Older servers omit this optional list. Keep a minimal legacy fallback;
// never invent a persistent or session grant.
if raw == nil {
raw = []json.RawMessage{json.RawMessage(`"accept"`), json.RawMessage(`"cancel"`)}
}
var names []string
n := 0
for _, r := range raw {
var name string
var o ApprovalOption
if json.Unmarshal(r, &name) == nil {
switch name {
case "accept", "acceptForSession", "decline", "cancel":
wire, _ := json.Marshal(name)
o = ApprovalOption{Kind: name, Value: name, Wire: string(wire)}
default:
name = "unsupported-string"
}
} else {
name = "unsupported-object"
var object map[string]json.RawMessage
if json.Unmarshal(r, &object) == nil && len(object) == 1 {
if payload, ok := object["acceptWithExecpolicyAmendment"]; ok {
name = "acceptWithExecpolicyAmendment"
var amendment struct {
Prefix []string `json:"execpolicy_amendment"`
}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if decoder.Decode(&amendment) == nil && len(amendment.Prefix) > 0 {
prefix, _ := json.Marshal(amendment.Prefix)
valid := len(prefix) <= 1024
for _, part := range amendment.Prefix {
valid = valid && part != "" && SanitizeSessionContext(part) == part
}
if valid {
wire, _ := json.Marshal(map[string]any{"acceptWithExecpolicyAmendment": amendment})
o = ApprovalOption{Kind: name, Value: string(wire), Wire: string(wire), Detail: string(prefix)}
} else {
name = "unsupported-execpolicy"
}
} else {
name = "unsupported-execpolicy"
}
}
}
}
if len(names) < 16 {
names = append(names, name)
}
if o.Kind != "" && n < len(out) {
duplicate := false
for _, old := range out {
duplicate = duplicate || old.Value == o.Value
}
if !duplicate {
out[n] = o
n++
}
}
}
if len(raw) > 16 {
names = append(names, "…")
}
return out, strings.Join(names, ", ")
}
50 changes: 50 additions & 0 deletions internal/codex/approval_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package codex

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

func TestAdvertisedApprovalChoices(t *testing.T) {
for _, tc := range []struct {
raw string
kinds []string
}{
{`["accept","cancel"]`, []string{"accept", "cancel"}},
{`["cancel"]`, []string{"cancel"}},
{`["acceptForSession","decline"]`, []string{"acceptForSession", "decline"}},
{`null`, []string{"accept", "cancel"}},
{`[]`, nil},
{`["future",{"other":{}},"cancel","cancel"]`, []string{"cancel"}},
{`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","status"]}},"cancel"]`, []string{"acceptWithExecpolicyAmendment", "cancel"}},
{`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":[],"future":true}}]`, nil},
{`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["pwd\u001b[31m"]}}]`, nil},
} {
var raw []json.RawMessage
if err := json.Unmarshal([]byte(tc.raw), &raw); err != nil {
t.Fatal(err)
}
options, summary := commandApprovalOptions(raw)
for i, o := range options {
want := ""
if i < len(tc.kinds) {
want = tc.kinds[i]
}
if o.Kind != want {
t.Fatalf("%s option %d: %+v want %s", tc.raw, i, o, want)
}
}
if len(tc.kinds) > 0 && summary == "" {
t.Fatal("missing decision metadata")
}
}
_, c := approvalFixture(t, map[string]any{"availableDecisions": []string{"accept", "cancel"}})
if c.ApprovalToken == "" || c.ApprovalBlocked != "" {
t.Fatal("standard accept/cancel prompt still blocked")
}
_, c = approvalFixture(t, map[string]any{"availableDecisions": []any{map[string]any{"acceptWithExecpolicyAmendment": map[string]any{"execpolicy_amendment": []string{"git", "status"}}}}})
if c.ApprovalToken == "" || !strings.Contains(c.Text, `Persistent command-prefix rule: ["git","status"]`) {
t.Fatal("persistent grant lacks displayed rule")
}
}
2 changes: 2 additions & 0 deletions internal/codex/daemon_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ const (
sessionRuntimeIdle
sessionRuntimeInput
sessionRuntimeApproval
sessionRuntimeComplete
)

type sessionStatusProvider interface {
Fetch(context.Context, []string) (sessionDaemonSnapshot, bool)
}

type sessionDaemonSnapshot struct {
Contexts map[string]SessionContext
Statuses map[string]sessionRuntimeStatus
ModelObservations []resolvedModelObservation
SubscribedThreads map[string]struct{}
Expand Down
25 changes: 24 additions & 1 deletion internal/codex/daemon_status_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
)

type daemonStatusProvider struct {
contexts map[string]*daemonContextState
socketPath string

mu sync.Mutex
Expand Down Expand Up @@ -126,6 +127,9 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD
statuses := make(map[string]sessionRuntimeStatus, len(threadIDs))
for _, threadID := range threadIDs {
if status, ok := p.statuses[threadID]; ok {
if state := p.contexts[threadID]; status == sessionRuntimeIdle && state != nil && state.completed {
status = sessionRuntimeComplete
}
statuses[threadID] = status
}
}
Expand All @@ -134,6 +138,7 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD
subscribed[threadID] = struct{}{}
}
return sessionDaemonSnapshot{
Contexts: daemonContextSnapshot(p.contexts, threadIDs, statuses),
Statuses: statuses,
ModelObservations: append([]resolvedModelObservation(nil), p.observations...),
SubscribedThreads: subscribed,
Expand Down Expand Up @@ -188,6 +193,7 @@ func (p *daemonStatusProvider) unsubscribeMissing(ctx context.Context, threadIDs
}
p.mu.Lock()
delete(p.subscribed, threadID)
delete(p.contexts, threadID)
for key := range p.reroutedTurns {
if key.threadID == threadID {
delete(p.reroutedTurns, key)
Expand Down Expand Up @@ -284,7 +290,16 @@ func (p *daemonStatusProvider) isSubscribed(threadID string) bool {
func (p *daemonStatusProvider) request(ctx context.Context, method string, params any, target any) error {
p.mu.Lock()
connection := p.connection
if connection == nil {
p.mu.Unlock()
return p.requestOn(ctx, connection, method, params, target)
}

func (p *daemonStatusProvider) requestOn(ctx context.Context, connection *websocket.Conn, method string, params any, target any) error {
if err := ctx.Err(); err != nil {
return err
}
p.mu.Lock()
if connection == nil || p.connection != connection {
p.mu.Unlock()
return errors.New("daemon connection is closed")
}
Expand Down Expand Up @@ -365,6 +380,13 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) {
}
continue
}
p.mu.Lock()
if p.contexts == nil {
p.contexts = map[string]*daemonContextState{}
}
daemonContextEvent(p.contexts, envelope.Method, envelope.ID, envelope.Params, time.Now())
p.promptLifecycleLocked(envelope.Method, envelope.Params)
p.mu.Unlock()
p.handleNotification(envelope.Method, envelope.Params)
}
}
Expand Down Expand Up @@ -439,6 +461,7 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) {
}
}
p.subscribed = nil
p.contexts = nil
p.reroutedTurns = nil
p.lastStatusAt = time.Time{}
p.statusThreads = nil
Expand Down
5 changes: 4 additions & 1 deletion internal/codex/daemon_status_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func TestDaemonStatusProviderReadsExactThreadStates(t *testing.T) {
}})
if threadID == "working" {
_ = connection.WriteJSON(map[string]any{
"id": "approval-1", "method": "item/commandExecution/requestApproval", "params": map[string]any{},
"id": "approval-1", "method": "item/commandExecution/requestApproval", "params": map[string]any{"threadId": "approval", "reason": "Allow push?", "command": "git push"},
})
_ = connection.WriteJSON(map[string]any{"method": "model/rerouted", "params": map[string]any{
"threadId": threadID, "turnId": "turn-1", "fromModel": "gpt-5.6-sol", "toModel": "gpt-5.6-terra",
Expand Down Expand Up @@ -129,6 +129,9 @@ func TestDaemonStatusProviderReadsExactThreadStates(t *testing.T) {
if len(snapshot.ModelObservations) != 1 {
t.Fatalf("reroute observations = %#v", snapshot.ModelObservations)
}
if c := snapshot.Contexts["approval"]; c.Kind != SessionContextApproval || c.Text != "Allow push?\nCommand: git push\nDirectory:" || c.ApprovalToken != "" {
t.Fatalf("live request context = %+v", c)
}
observation := snapshot.ModelObservations[0]
if observation.ThreadID != "working" || observation.TurnID != "turn-1" ||
observation.Model != "gpt-5.6-terra" || observation.Usage.TotalTokens != 150 || observation.CumulativeTotal != 150 {
Expand Down
Loading