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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/codexometer
/error.log
/dist/
/web/node_modules/
/web/test-results/
Expand Down
85 changes: 82 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,11 @@ available and no missing response cost is invented.
Without effective daemon feature state, relevant local configuration is checked
read-only. Enabled flags, including flags in potentially selected profiles, are
conservative coverage warnings rather than proof of the current runtime mode.
Missing optional configs, including paths underneath a regular `.codex` file
instead of a directory, are treated as absent rather than unreadable. Scanning
continues to higher ancestors, and a real config added later is checked again.
Permission/I/O errors, symlink loops and malformed existing configuration still
produce a coverage warning; Codexometer does not delete or rewrite `.codex` entries.
This fallback cannot establish hidden CLI overrides, remote configuration or
unobserved usage from another machine. Those existing observation limits remain;
Codexometer does not change Codex's compaction configuration.
Expand Down Expand Up @@ -1227,7 +1232,9 @@ The other top-level views are:
conservatively excluded from timestamp-based freshness; later complete records
are still checked. These cancellable, bounded activity scans do not recount tokens.
Confirmed failed turns instead enter **ERRORS**, with an amber **MODEL BUSY**
badge for server-reported model capacity and **TURN FAILED** for other failures.
badge for server-reported model capacity, **MODEL UNAVAILABLE** when the provider
cannot find the selected model, **CAPACITY RESTRICTED** for policy-restricted
capacity, and **TURN FAILED** for other failures.
The row, full detail and attention pill retain the observed error message;
capacity is not interpreted as quota exhaustion. Both the shared app-server's
failed-turn/error events and local rollout error records are supported. Errors
Expand All @@ -1237,6 +1244,14 @@ The other top-level views are:
stale events from older identified turns cannot fail the new one. Errors from
linked agents propagate to the parent row, below pending approvals and questions.
No prompt is automatically retried and no model is automatically switched.
With a shared app-server, working-command headings also show elapsed time or
completed duration when Codex supplies valid item lifecycle timestamps.
Codex 0.166.0's item fields and older notification-envelope fields are supported;
missing, future or inconsistent times do not produce a guessed duration.
Browser timers describe the last successful observation and freeze if the feed
becomes stale. These display-only times do not change freshness or API-EQ totals.
Experimental attached forks remain independent session rows rather than spawned
sub-agents; their inherited parent response records are not billed again.
**INPUT** is reserved for observed blocking questions/requests, not a completed
turn without a final reply. Normal completion remains **TURN COMPLETE**.
Codexometer never guesses `APPROVAL NEEDED` from inactivity. Closing the CLI
Expand Down Expand Up @@ -1800,6 +1815,14 @@ writable mode. Browse-only entries do not execute tools or change configuration.
| `/skills`, `/apps`, `/mcp`, `/hooks`, `/experimental` | **Browse-only** live inventories and supplied help, including MCP tool descriptions. Invoke/configure these through Codex. |
| `/help` | Available command families. Unsupported or empty catalogues are omitted. |

On Codex 0.166.0, `/hooks` queries the selected loaded thread, including hooks
from its execution environments, and identifies their environment when supplied.
Older servers retain directory-based discovery. A real thread/permission/transport
error is not silently replaced with a different inventory, and a successful empty
thread inventory remains empty rather than falling back to directory hooks.
Model/reasoning, permission and Plan changes remain guarded by the existing idle-state checks;
this compatibility update does not broaden write permissions.

The detail footer uses theme-highlighted text in the bottom border in terminal mode, and
the last line of full session detail on web. Its default fields are observed
model/reasoning, speed, total session tokens (including linked agents) and
Expand Down Expand Up @@ -2696,6 +2719,8 @@ open directly.
```text
--codex PATH path to the Codex CLI (default: codex)
--check-auth verify the current Codex login and exit
--error-log PATH opt into bounded JSON operational diagnostics (off by default)
--verbose add diagnostic context to --error-log; never raw payloads
--demo preview simulated quota, Sessions, Usage, and benchmark data
--inline render inline instead of using the alternate screen
--web experimental read-only browser interface (loopback only)
Expand Down Expand Up @@ -2950,8 +2975,9 @@ available from the existing reader.
outside scrollable reply text; available commands are separated from their
justification. Missing commands are explicitly labelled, never reconstructed.
- **INPUT NEEDED / APPROVAL NEEDED** represent observed signals; **MODEL BUSY /
TURN FAILED** represent confirmed turn errors and appear in **ERRORS**, with
the observed explanation in detail. Retrying errors do not raise failure pills.
MODEL UNAVAILABLE / CAPACITY RESTRICTED / TURN FAILED** represent confirmed turn
errors and appear in **ERRORS**, with the observed explanation in detail.
Retrying errors do not raise failure pills.
**CHECK SESSION**
is labelled inferred inactivity and is not proof that a reply is required.
**TURN COMPLETE** is informational. Disconnection or failed session refresh
Expand Down Expand Up @@ -3391,6 +3417,47 @@ establishing a new baseline.

## Troubleshooting

### Capture operational errors

Logging is off by default. Enable it before reproducing an issue:

```sh
codexometer --error-log ./error.log
codexometer --error-log ./error.log --verbose
# The same switches work in browser mode:
codexometer --web --error-log ./error.log --verbose
```

The standard Go structured logger writes JSON lines directly to the requested
file, never over the terminal UI. Errors include timestamp, source, operation,
safe error type/category and RPC code when available. Configuration-access
failures include the exact attempted path, OS error (such as permission denied)
and a short session identifier. Verbose mode additionally identifies whether
coverage used effective daemon state or local configuration, missing optional
files (normal), successful config checks and application start/stop.

For `INCOMPLETE COVERAGE // SESSION …: background usage configuration could not
be read`, find the corresponding `local-config` entry: `msg` identifies `stat`,
`open` or `read`, and `path` / `error` identify the failing file and OS cause.
Malformed TOML and oversized files receive separate operation labels without
dumping their contents. Logging does not change the coverage guard or repair
permissions, and cannot reconstruct errors that happened before it was enabled.

The file appends until it would exceed **2 MiB**, then discards its older entries
and continues with recent diagnostics; no backup files are created. Identical
events are rate-limited to once a minute, with the suppressed-repeat count on
the next occurrence. Use a separate path per running instance. The parent
directory must exist; an explicitly requested unavailable log, symlink or
non-regular target fails at startup. File permissions are restricted to `0600`
where supported; on Windows, access also depends on the parent directory's ACL.
Later log write/close errors are reported when the interface exits.

Prompts, replies, config contents, credentials, pairing links, request/response
bodies and arbitrary upstream error messages are not logged. Such errors retain
their type/category and code, not their potentially sensitive text. Paths can
still reveal usernames/project names: review logs before sharing them. `--verbose`
requires `--error-log` and does not enable network access or browser writes.

### `Codex CLI not found`

Confirm that `codex --version` works in the same shell. Otherwise use
Expand Down Expand Up @@ -3536,6 +3603,18 @@ go test -race -cover -timeout 10m -skip '^TestLocalisedScreens$' ./...
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:

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

Use `0.164.0` when that version is on `PATH`; the existing
`CODEXOMETER_TEST_CODEX_164=1` invocation is also retained. The check uses an empty
temporary Codex home, no user credentials and no model requests. Normal CI uses
hermetic protocol fixtures and does not require an installed Codex CLI.

All 17 locale options run the complete localised-screen suite in isolated test
subprocesses, with at most two locale workers active at once. Each child has a
three-minute test timeout and an outer four-minute process deadline. CI uploads
Expand Down
24 changes: 22 additions & 2 deletions internal/codex/benchmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"sync"
"time"

"github.com/merefield/codexometer/internal/diagnostics"
"go.starlark.net/starlark"
)

Expand Down Expand Up @@ -667,7 +668,21 @@ func (s *appServerSession) call(
method string,
params any,
onNotification func(string, json.RawMessage),
) (json.RawMessage, error) {
) (_ json.RawMessage, resultErr error) {
defer func() {
if resultErr == nil {
return
}
fields := diagnostics.Fields{}
if values, ok := params.(map[string]any); ok {
fields.Session, _ = values["threadId"].(string)
}
var rpc *benchmarkMethodError
if errors.As(resultErr, &rpc) {
fields.Code = rpc.code
}
diagnostics.Error("benchmark-rpc", method, resultErr, fields)
}()
s.nextID++
id := s.nextID
request := map[string]any{"method": method, "id": id}
Expand Down Expand Up @@ -1410,7 +1425,12 @@ func fatalBenchmarkError(ctx context.Context, err error) error {
return err
}

func (s *appServerSession) readUntilNotification(ctx context.Context, accept func(string, json.RawMessage) bool) (json.RawMessage, error) {
func (s *appServerSession) readUntilNotification(ctx context.Context, accept func(string, json.RawMessage) bool) (_ json.RawMessage, resultErr error) {
defer func() {
if resultErr != nil {
diagnostics.Error("benchmark-rpc", "read event stream", resultErr, diagnostics.Fields{})
}
}()
for {
if err := ctx.Err(); err != nil {
return nil, err
Expand Down
16 changes: 15 additions & 1 deletion internal/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"

"github.com/merefield/codexometer/internal/diagnostics"
)

const requestTimeout = 15 * time.Second
Expand Down Expand Up @@ -96,7 +98,18 @@ func (c Client) ConsumeResetCredit(ctx context.Context, key, account, creditID s
return attempt.outcome, err
}

func (c Client) fetch(ctx context.Context, reset *resetAttempt, history *AccountUsage) (Snapshot, error) {
func (c Client) fetch(ctx context.Context, reset *resetAttempt, history *AccountUsage) (_ Snapshot, resultErr error) {
defer func() {
if resultErr != nil {
operation := "quota refresh"
if reset != nil {
operation = "quota reset"
} else if history != nil {
operation = "usage refresh"
}
diagnostics.Error("app-server", operation, resultErr, diagnostics.Fields{})
}
}()
binary := c.Binary
if binary == "" {
binary = "codex"
Expand Down Expand Up @@ -282,6 +295,7 @@ func responseFor(decoder *json.Decoder, id int) (json.RawMessage, error) {
continue
}
if response.Error != nil {
diagnostics.Error("app-server-rpc", "request rejected", nil, diagnostics.Fields{Code: response.Error.Code})
return nil, fmt.Errorf("%s (RPC %d)", response.Error.Message, response.Error.Code)
}
return response.Result, nil
Expand Down
17 changes: 12 additions & 5 deletions internal/codex/daemon_native_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,24 @@ import (

// Opt-in integration check: no credentials, user configuration, model requests,
// thread creation or shared daemon are used. CI otherwise uses wire fixtures.
func TestNativeCodex164Compression(t *testing.T) {
if os.Getenv("CODEXOMETER_TEST_CODEX_164") != "1" {
t.Skip("set CODEXOMETER_TEST_CODEX_164=1 with Codex 0.164.0 installed")
func TestNativeCodexCompression(t *testing.T) {
expected := os.Getenv("CODEXOMETER_TEST_CODEX_VERSION")
if expected == "" && os.Getenv("CODEXOMETER_TEST_CODEX_164") == "1" {
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")
}
if expected != "0.164.0" && expected != "0.166.0" {
t.Fatal("native compatibility target must be 0.164.0 or 0.166.0")
}
binary, err := exec.LookPath("codex")
if err != nil {
t.Fatal(err)
}
version, err := exec.Command(binary, "--version").Output()
if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.164.0" {
t.Fatal("expected Codex 0.164.0", string(version), err)
if err != nil || strings.TrimSpace(string(version)) != "codex-cli "+expected {
t.Fatal("expected Codex "+expected, string(version), err)
}
home, err := os.MkdirTemp("/tmp", "cxm-native-")
if err != nil {
Expand Down
26 changes: 25 additions & 1 deletion internal/codex/daemon_status_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"

"github.com/gorilla/websocket"
"github.com/merefield/codexometer/internal/diagnostics"
)

const (
Expand Down Expand Up @@ -72,6 +74,11 @@ func newSessionStatusProvider(codexHome string) sessionStatusProvider {

func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (sessionDaemonSnapshot, bool) {
if err := p.ensureConnected(ctx); err != nil {
if errors.Is(err, os.ErrNotExist) {
diagnostics.Debug("daemon", "not available; using local fallback", diagnostics.Fields{Path: p.socketPath})
} else {
diagnostics.Error("daemon", "connect", err, diagnostics.Fields{Path: p.socketPath})
}
return sessionDaemonSnapshot{}, false
}
if snapshot, ok := p.cachedStatusSnapshot(threadIDs, time.Now()); ok {
Expand Down Expand Up @@ -344,7 +351,24 @@ func (p *daemonStatusProvider) request(ctx context.Context, method string, param
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 {
func (p *daemonStatusProvider) requestOn(ctx context.Context, connection *websocket.Conn, method string, params any, target any) (resultErr error) {
defer func() {
if resultErr == nil {
return
}
fields := diagnostics.Fields{}
if values, ok := params.(map[string]any); ok {
fields.Session, _ = values["threadId"].(string)
}
var rpc *daemonResponseError
if errors.As(resultErr, &rpc) {
var wire struct{ Code int }
if json.Unmarshal([]byte(rpc.payload), &wire) == nil {
fields.Code = wire.Code
}
}
diagnostics.Error("daemon-rpc", method, resultErr, fields)
}()
if err := ctx.Err(); err != nil {
return err
}
Expand Down
12 changes: 11 additions & 1 deletion internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"strings"
"sync"
"time"

"github.com/merefield/codexometer/internal/diagnostics"
)

const (
Expand Down Expand Up @@ -376,7 +378,12 @@ func (r *LiveUsageReader) FetchTokenUsageFresh(ctx context.Context) (LiveUsageSn
return r.fetchTokenUsage(ctx, true)
}

func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscovery bool) (LiveUsageSnapshot, error) {
func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscovery bool) (_ LiveUsageSnapshot, resultErr error) {
defer func() {
if resultErr != nil {
diagnostics.Error("local-rollouts", "session telemetry refresh", resultErr, diagnostics.Fields{})
}
}()
r.mu.Lock()
defer r.mu.Unlock()
if err := contextErr(ctx); err != nil {
Expand Down Expand Up @@ -429,6 +436,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
err = r.restoreCompressedCursor(ctx, path, info, cursor)
}
if err != nil {
diagnostics.Error("local-rollouts", "restore compressed rollout", err, diagnostics.Fields{Path: path, Session: cursor.threadID})
if firstReadErr == nil {
firstReadErr = err
}
Expand All @@ -443,6 +451,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
if errors.Is(err, os.ErrNotExist) {
if info, compressedErr := os.Stat(path + ".zst"); compressedErr == nil {
if compressedErr = r.addCompressedFile(ctx, path+".zst", info); compressedErr != nil {
diagnostics.Error("local-rollouts", "read compressed rollout", compressedErr, diagnostics.Fields{Path: path + ".zst", Session: cursor.threadID})
r.discoveryCoverageIssue = "compressed rollout history could not be verified"
if firstReadErr == nil {
firstReadErr = compressedErr
Expand All @@ -456,6 +465,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
if firstReadErr == nil {
firstReadErr = err
}
diagnostics.Error("local-rollouts", "read rollout", err, diagnostics.Fields{Path: path, Session: cursor.threadID})
continue
}
readableFiles++
Expand Down
Loading
Loading