From 6d73fdb8c09ed1bbeafb1bc6998463314ed4268f Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 12:18:38 +0000 Subject: [PATCH 1/7] FIX: improve Codex 0.165 and 0.166 compatibility --- README.md | 31 ++- internal/codex/daemon_native_unix_test.go | 17 +- internal/codex/session_166_test.go | 181 ++++++++++++++++++ internal/codex/session_activity.go | 49 ++++- internal/codex/session_commands_unix.go | 17 +- internal/codex/session_context_daemon.go | 46 +++-- internal/codex/session_failure.go | 20 +- internal/codex/session_hooks_unix.go | 41 ++++ internal/codex/session_hooks_unix_test.go | 68 +++++++ internal/codex/session_settings_unix_test.go | 17 ++ internal/i18n/locales/da.json | 2 + internal/i18n/locales/de.json | 2 + internal/i18n/locales/en-GB.json | 2 + internal/i18n/locales/es.json | 2 + internal/i18n/locales/et.json | 2 + internal/i18n/locales/fi.json | 2 + internal/i18n/locales/fr.json | 2 + internal/i18n/locales/it.json | 2 + internal/i18n/locales/ja.json | 2 + internal/i18n/locales/nb.json | 2 + internal/i18n/locales/nl.json | 2 + internal/i18n/locales/pt-BR.json | 2 + internal/i18n/locales/pt-PT.json | 2 + internal/i18n/locales/ru.json | 2 + internal/i18n/locales/sv.json | 2 + internal/i18n/locales/tr.json | 2 + internal/i18n/locales/zh-Hans.json | 2 + internal/ui/monitor_failure_test.go | 60 +++--- internal/ui/monitor_working.go | 4 + internal/ui/monitor_working_test.go | 23 +++ .../{index-zYOUcPV1.js => index-Cq0BuLLm.js} | 4 +- internal/web/dist/index.html | 2 +- internal/web/server_test.go | 10 +- internal/web/session_failure_test.go | 6 + internal/web/state.go | 2 + intro-post.md | 2 +- web/src/Sessions.svelte | 10 +- web/src/state.svelte.ts | 1 + web/tests/browser.spec.ts | 34 +++- 39 files changed, 609 insertions(+), 70 deletions(-) create mode 100644 internal/codex/session_166_test.go create mode 100644 internal/codex/session_hooks_unix.go create mode 100644 internal/codex/session_hooks_unix_test.go rename internal/web/dist/assets/{index-zYOUcPV1.js => index-Cq0BuLLm.js} (96%) diff --git a/README.md b/README.md index 691c4089..eb010628 100644 --- a/README.md +++ b/README.md @@ -1227,7 +1227,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 @@ -1237,6 +1239,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 @@ -1800,6 +1810,13 @@ 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. 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 @@ -3536,6 +3553,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 diff --git a/internal/codex/daemon_native_unix_test.go b/internal/codex/daemon_native_unix_test.go index 6f8972a6..eabe3a27 100644 --- a/internal/codex/daemon_native_unix_test.go +++ b/internal/codex/daemon_native_unix_test.go @@ -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 { diff --git a/internal/codex/session_166_test.go b/internal/codex/session_166_test.go new file mode 100644 index 00000000..3ad8a2ed --- /dev/null +++ b/internal/codex/session_166_test.go @@ -0,0 +1,181 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +func TestCodex166FailureClassificationAndRecovery(t *testing.T) { + for _, tc := range []struct{ code, local, label string }{ + {"serverOverloaded", "server_overloaded", "MODEL BUSY"}, + {"modelNotFound", "model_not_found", "MODEL UNAVAILABLE"}, + {"policyCapacityUnavailable", "policy_capacity_unavailable", "CAPACITY RESTRICTED"}, + {"futureError", "future_error", "TURN FAILED"}, + } { + t.Run(tc.code, func(t *testing.T) { + for _, code := range []string{tc.code, tc.local} { + for _, tagged := range []bool{false, true} { + var wire any = code + if tagged { + wire = map[string]any{code: map[string]any{}} + } + c := sessionFailureContext(SessionContext{}, rawJSON(map[string]any{"message": "Upstream explanation", "codexErrorInfo": wire})) + if SessionFailureLabel(c) != tc.label || c.Text != "Upstream explanation" { + t.Fatal("lost error classification or message", c) + } + } + } + states := map[string]*daemonContextState{} + emit := func(method string, params any) { daemonContextEvent(states, method, nil, rawJSON(params), time.Now()) } + emit("turn/started", map[string]any{"threadId": "s", "turn": map[string]string{"id": "one"}}) + failure := map[string]any{"threadId": "s", "turnId": "one", "willRetry": true, "error": map[string]string{"message": "Upstream explanation", "codexErrorInfo": tc.code}} + emit("error", failure) + if states["s"].ended { + t.Fatal("automatically retrying response marked failed") + } + failure["willRetry"] = false + emit("error", failure) + if !states["s"].ended || SessionFailureLabel(states["s"].latest) != tc.label { + t.Fatal("terminal failure not classified", states["s"]) + } + emit("turn/started", map[string]any{"threadId": "s", "turn": map[string]string{"id": "two"}}) + emit("error", failure) + if states["s"].ended || states["s"].latest.Kind == SessionContextFailure { + t.Fatal("old error failed the new turn") + } + cursor := &rolloutCursor{threadID: "s"} + c, ok := rolloutContextRecord(contextRecord(t, "event_msg", map[string]string{"type": "error", "message": "Upstream explanation", "codex_error_info": tc.local}), cursor) + if !ok || SessionFailureLabel(c) != tc.label { + t.Fatal("local fallback differs from daemon", c) + } + }) + } +} + +func TestCodex166ItemTimingAndLegacyFallback(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + for _, nested := range []bool{false, true} { + t.Run(fmt.Sprint(nested), func(t *testing.T) { + states := map[string]*daemonContextState{} + emit := func(method, status string, start, end *int64) { + item := map[string]any{"id": "cmd", "type": "commandExecution", "command": "go test", "status": status} + p := map[string]any{"threadId": "s", "turnId": "t", "item": item} + target := p + if nested { + target = item + } + if start != nil { + target["startedAtMs"] = *start + } + if end != nil { + target["completedAtMs"] = *end + } + daemonContextEvent(states, method, nil, rawJSON(p), now) + } + start, end := now.Add(-42*time.Second).UnixMilli(), now.Add(-35*time.Second).UnixMilli() + emit("item/started", "inProgress", &start, nil) + if c := states["s"].latest; c.Activity.CommandTiming(now) != "42s" || c.At != now { + t.Fatal("running duration or observation time wrong", c) + } + // Older envelopes omit the start on completion; keep the matching start. + emit("item/completed", "completed", nil, &end) + a := states["s"].latest.Activity + if a.CommandTiming(now.Add(time.Hour)) != "7s" || a.RunningCommands != 0 { + t.Fatal("completed timer kept advancing or lost its start", a) + } + }) + } + for _, a := range []SessionActivity{ + {CommandStatus: "running"}, + {CommandStatus: "running", CommandStartedAt: now.Add(time.Second)}, + {CommandStatus: "completed", CommandStartedAt: now.Add(-time.Minute)}, + {CommandStatus: "completed", CommandStartedAt: now, CommandCompletedAt: now.Add(-time.Second)}, + {CommandStatus: "completed", CommandStartedAt: now.Add(-time.Minute), CommandCompletedAt: now.Add(time.Second)}, + } { + if a.CommandTiming(now) != "" { + t.Fatal("invented duration", a) + } + } + for _, ms := range []int64{0, -1, now.Add(time.Second).UnixMilli()} { + if !itemLifecycleTime(&ms, nil, now).IsZero() { + t.Fatal("trusted invalid lifecycle time", ms) + } + } + bad, good := int64(0), now.Add(-time.Second).UnixMilli() + if !itemLifecycleTime(&bad, &good, now).IsZero() { + t.Fatal("contradictory envelope hid invalid item time") + } + var activity sessionActivityState + activity.commandTimed("first", "go test", "inProgress", false, now.Add(-time.Minute), time.Time{}) + activity.commandTimed("second", "go vet", "inProgress", false, now.Add(-5*time.Second), time.Time{}) + activity.commandTimed("first", "", "completed", true, time.Time{}, now) + if a := activity.snapshot(); a.Command != "go vet" || a.CommandTiming(now) != "5s" { + t.Fatal("parallel command timing used the wrong item", a) + } + activity.command("unknown", "pwd", "completed", true) + activity.commandTimed("second", "", "completed", true, time.Time{}, now) + activity.command("legacy", "echo done", "completed", true) + if a := activity.snapshot(); a.CommandTiming(now) != "" { + t.Fatal("legacy command inherited another item's timing", a) + } +} + +func TestCodex166ReviewerEventsDoNotCompleteParentBenchmark(t *testing.T) { + message := string(rawJSON(map[string]string{"code": correctStarlarkSubmission})) + usage := BenchmarkUsage{InputTokens: 100, OutputTokens: 20, TotalTokens: 120} + server, _ := newFakeBenchmarkServer( + benchmarkEnvelope{ID: rawJSON(1), Result: rawJSON(map[string]any{"thread": map[string]string{"id": "parent"}, "model": "gpt-6.1-sol"})}, + benchmarkEnvelope{ID: rawJSON(2), Result: rawJSON(map[string]any{"turn": map[string]string{"id": "turn"}})}, + benchmarkEnvelope{Method: "rawResponseItem/completed", Params: rawJSON(map[string]any{"threadId": "reviewer", "turnId": "turn", "item": map[string]string{"type": "message", "text": "private reviewer"}})}, + benchmarkEnvelope{Method: "rawResponse/completed", Params: rawJSON(map[string]any{"threadId": "reviewer", "turnId": "turn", "responseId": "private", "usage": BenchmarkUsage{TotalTokens: 999999}})}, + benchmarkEnvelope{Method: "turn/completed", Params: rawJSON(map[string]any{"threadId": "reviewer", "turn": map[string]string{"id": "turn", "status": "interrupted"}})}, + benchmarkEnvelope{Method: "turn/completed", Params: rawJSON(map[string]any{"threadId": "parent", "turn": map[string]string{"id": "reviewer-turn", "status": "interrupted"}})}, + benchmarkEnvelope{Method: "thread/tokenUsage/updated", Params: rawJSON(map[string]any{"threadId": "parent", "turnId": "turn", "tokenUsage": map[string]any{"total": usage}})}, + benchmarkEnvelope{Method: "item/completed", Params: rawJSON(map[string]any{"threadId": "parent", "turnId": "turn", "item": map[string]string{"type": "agentMessage", "text": message}})}, + benchmarkEnvelope{Method: "turn/completed", Params: rawJSON(map[string]any{"threadId": "parent", "turn": map[string]string{"id": "turn", "status": "completed"}})}, + ) + result, err := server.runBenchmark(context.Background(), benchmarkCombination{model: benchmarkModel{Model: "gpt-6.1-sol"}, effort: "high"}, benchmarkTaskDefinitions[0]) + if err != nil || !result.Correct || !result.UsageKnown || result.Usage.TotalTokens != 120 || result.ToolUsed { + t.Fatalf("reviewer event contaminated parent result/accounting: %+v, %v", result, err) + } +} + +func TestCodex165AttachedForkRemainsIndependentAndDoesNotRecountHistory(t *testing.T) { + home, now := t.TempDir(), time.Now() + root := testRolloutPath(t, home, now, "root") + fork := testRolloutPath(t, home, now, "attached") + var metadata map[string]any + if err := json.Unmarshal([]byte(sessionMetaLine("attached", `"cli"`, "/work", nil)), &metadata); err != nil { + t.Fatal(err) + } + payload := metadata["payload"].(map[string]any) + payload["attached_thread"], payload["forked_from_id"] = true, "root" + payload["forked_from_ordinal_exclusive"] = 20 + u := BenchmarkUsage{InputTokens: 100, OutputTokens: 20, TotalTokens: 120} + baseline := exactLine(t, now, "root", "old", "historical", u) + writeRollout(t, root, sessionMetaLine("root", `"cli"`, "/work", nil)+"\n"+baseline+"\n") + writeRollout(t, fork, string(rawJSON(metadata))+"\n"+baseline+"\n") + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.statusProvider = nil + first, err := r.FetchTokenUsage(context.Background()) + if err != nil || first.TotalTokens != 0 { + t.Fatal("history billed on startup", first, err) + } + appendRollout(t, root, exactLine(t, now, "root", "t", "root-new", u)+"\n") + appendRollout(t, fork, baseline+"\n"+exactLine(t, now, "attached", "t", "fork-new", u)+"\n") + next, err := r.FetchTokenUsage(context.Background()) + if err != nil || next.TotalTokens != 240 || len(next.Sessions) != 2 { + t.Fatal("attached fork merged, omitted or double billed", next, err) + } + for _, s := range next.Sessions { + if s.TotalTokens != 120 || s.AgentCount != 0 { + t.Fatal("attachment treated as spawned subagent", s) + } + } +} diff --git a/internal/codex/session_activity.go b/internal/codex/session_activity.go index 045df8e3..fb23bde4 100644 --- a/internal/codex/session_activity.go +++ b/internal/codex/session_activity.go @@ -1,5 +1,7 @@ package codex +import "time" + // SessionActivity keeps prose separate from shell activity. It never contains // approval capabilities or command output; all display strings are bounded. type SessionActivity struct { @@ -8,12 +10,34 @@ type SessionActivity struct { CommandStatus string RunningCommands int RunningLimited bool + // Server-recorded item times, not observer arrival times. Missing means + // unknown; they neither refresh session freshness nor affect token accounting. + CommandStartedAt time.Time + CommandCompletedAt time.Time +} + +// CommandTiming reports elapsed or completed duration only with valid timing +// evidence. Running timers use the caller's snapshot time, so stale web data +// does not keep pretending that the command is still running. +func (a SessionActivity) CommandTiming(now time.Time) string { + if a.CommandStartedAt.IsZero() || a.CommandStartedAt.After(now) { + return "" + } + end := a.CommandCompletedAt + if a.CommandStatus == "running" { + end = now + } + if end.IsZero() || end.Before(a.CommandStartedAt) || end.After(now) { + return "" + } + return end.Sub(a.CommandStartedAt).Truncate(time.Second).String() } type activityCommand struct { - text string - status string - sequence uint64 + text string + status string + sequence uint64 + started, completed time.Time } type sessionActivityState struct { @@ -26,6 +50,11 @@ type sessionActivityState struct { } func (s *sessionActivityState) command(id, text, status string, finished bool) { + s.commandTimed(id, text, status, finished, time.Time{}, time.Time{}) +} + +func (s *sessionActivityState) commandTimed(id, text, status string, finished bool, started, completed time.Time) { + previous := s.running[id] text = SanitizeSessionContext(text) if text == "" { text = s.running[id].text @@ -48,7 +77,16 @@ func (s *sessionActivityState) command(id, text, status string, finished bool) { status = "unknown" } } - c := activityCommand{text: text, status: status, sequence: s.sequence} + if started.IsZero() { + started = previous.started + } + if !finished { + completed = time.Time{} + } + if !completed.IsZero() && !started.IsZero() && completed.Before(started) { + completed = time.Time{} + } + c := activityCommand{text: text, status: status, sequence: s.sequence, started: started, completed: completed} s.last = c if !finished && id != "" && status == "running" { if s.running == nil { @@ -73,7 +111,8 @@ func (s sessionActivityState) snapshot() SessionActivity { } } return SessionActivity{Prose: s.prose, Command: command.text, CommandStatus: command.status, - RunningCommands: len(s.running), RunningLimited: s.limited} + RunningCommands: len(s.running), RunningLimited: s.limited, + CommandStartedAt: command.started, CommandCompletedAt: command.completed} } func (s sessionActivityState) context(c SessionContext) SessionContext { diff --git a/internal/codex/session_commands_unix.go b/internal/codex/session_commands_unix.go index a65145df..9055dd77 100644 --- a/internal/codex/session_commands_unix.go +++ b/internal/codex/session_commands_unix.go @@ -532,12 +532,18 @@ func (p *daemonStatusProvider) commandCatalogue(ctx context.Context, t commandTh } params := map[string]any{} switch path { - case "skills", "hooks": + case "skills": params["cwds"] = []string{t.Cwd} case "apps", "mcp", "experimental": params["threadId"] = t.ID } - rows, err := p.commandRows(ctx, method, params) + var rows []json.RawMessage + var err error + if path == "hooks" { + rows, err = p.commandHookRows(ctx, t) + } else { + rows, err = p.commandRows(ctx, method, params) + } if err != nil { return m, err } @@ -571,12 +577,17 @@ func (p *daemonStatusProvider) commandCatalogue(ctx context.Context, t commandTh Cwd string Hooks []struct { Key, EventName, StatusMessage, Command, Server, Tool, HandlerType string + EnvironmentID string Enabled bool } } if json.Unmarshal(raw, &entry) == nil && entry.Cwd == t.Cwd { for _, h := range entry.Hooks { - add(h.Key, fmt.Sprintf("%s // %s // enabled=%t\n%s\n%s %s %s", h.EventName, h.HandlerType, h.Enabled, h.StatusMessage, h.Command, h.Server, h.Tool)) + help := fmt.Sprintf("%s // %s // enabled=%t\n%s\n%s %s %s", h.EventName, h.HandlerType, h.Enabled, h.StatusMessage, h.Command, h.Server, h.Tool) + if h.EnvironmentID != "" { + help += "\nEnvironment: " + h.EnvironmentID + } + add(h.Key, help) } } case "apps": diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go index 06450479..9d931b35 100644 --- a/internal/codex/session_context_daemon.go +++ b/internal/codex/session_context_daemon.go @@ -50,6 +50,8 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id RequestedPermissions json.RawMessage `json:"permissions"` Decisions []json.RawMessage `json:"availableDecisions"` ThreadID string `json:"threadId"` + StartedAtMS *int64 `json:"startedAtMs"` + CompletedAtMS *int64 `json:"completedAtMs"` GrantRoot string `json:"grantRoot"` Changes json.RawMessage `json:"changes"` RequestID json.RawMessage `json:"requestId"` @@ -63,15 +65,17 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id Type string `json:"type"` Text string `json:"text"` } `json:"content"` - ID string `json:"id"` - CWD string `json:"cwd"` - Type string `json:"type"` - Text string `json:"text"` - Phase string `json:"phase"` - Command string `json:"command"` - Status string `json:"status"` - ExitCode *int `json:"exitCode"` - Changes json.RawMessage `json:"changes"` + ID string `json:"id"` + CWD string `json:"cwd"` + Type string `json:"type"` + Text string `json:"text"` + Phase string `json:"phase"` + Command string `json:"command"` + Status string `json:"status"` + ExitCode *int `json:"exitCode"` + Changes json.RawMessage `json:"changes"` + StartedAtMS *int64 `json:"startedAtMs"` + CompletedAtMS *int64 `json:"completedAtMs"` } `json:"item"` } if json.Unmarshal(raw, &p) != nil || p.ThreadID == "" { @@ -261,7 +265,9 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id status = "completed" } } - state.activity.command(commandID, p.Item.Command, status, true) + state.activity.commandTimed(commandID, p.Item.Command, status, true, + itemLifecycleTime(p.Item.StartedAtMS, p.StartedAtMS, now), + itemLifecycleTime(p.Item.CompletedAtMS, p.CompletedAtMS, now)) if state.latest.Kind != SessionContextReply { state.latest = state.activity.context(c) } @@ -311,7 +317,8 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id c.Kind, c.Text = SessionContextActivity, p.Item.Command // The lifecycle event establishes this even on older payloads that // omit status. Pending approvals still take presentation precedence. - state.activity.command(commandID, p.Item.Command, "inProgress", false) + state.activity.commandTimed(commandID, p.Item.Command, "inProgress", false, + itemLifecycleTime(p.Item.StartedAtMS, p.StartedAtMS, now), time.Time{}) c = state.activity.context(c) if state.commands == nil { state.commands = map[string]contextCommandItem{} @@ -384,6 +391,23 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id } } +// New servers embed these fields on the item; old lifecycle notifications put +// them on the envelope. A present invalid item value must not be hidden by a +// contradictory envelope. Never trust zero/negative or future wall-clock times. +func itemLifecycleTime(item, envelope *int64, now time.Time) time.Time { + if item == nil { + item = envelope + } + if item == nil || *item <= 0 { + return time.Time{} + } + at := time.UnixMilli(*item) + if at.After(now) { + return time.Time{} + } + return at +} + func (s *daemonContextState) acceptTurn(id string) bool { current := s.activeTurn if current == "" { diff --git a/internal/codex/session_failure.go b/internal/codex/session_failure.go index c9bd4868..6bbdd6c5 100644 --- a/internal/codex/session_failure.go +++ b/internal/codex/session_failure.go @@ -8,14 +8,23 @@ import ( // Failure labels are shared by both interfaces; backend messages remain bounded, // sanitised observations, never commands or permission to retry a write. func SessionFailureLabel(c SessionContext) string { - if c.FailureCode == "serverOverloaded" { + switch normalizedFailureCode(c.FailureCode) { + case "serveroverloaded": return "MODEL BUSY" + case "modelnotfound": + return "MODEL UNAVAILABLE" + case "policycapacityunavailable": + return "CAPACITY RESTRICTED" } return "TURN FAILED" } +func normalizedFailureCode(code string) string { + return strings.ToLower(strings.ReplaceAll(code, "_", "")) +} + func failureAffectsTurn(c SessionContext) bool { - switch strings.ToLower(strings.ReplaceAll(c.FailureCode, "_", "")) { + switch normalizedFailureCode(c.FailureCode) { case "activeturnnotsteerable", "threadrollbackfailed": return false } @@ -43,8 +52,13 @@ func sessionFailureContext(c SessionContext, raw json.RawMessage) SessionContext } } } - if strings.EqualFold(strings.ReplaceAll(name, "_", ""), "serverOverloaded") { + switch normalizedFailureCode(name) { + case "serveroverloaded": name = "serverOverloaded" + case "modelnotfound": + name = "modelNotFound" + case "policycapacityunavailable": + name = "policyCapacityUnavailable" } c.Kind, c.Streaming, c.Activity = SessionContextFailure, false, SessionActivity{} c.FailureCode = SanitizeSessionContext(name) diff --git a/internal/codex/session_hooks_unix.go b/internal/codex/session_hooks_unix.go new file mode 100644 index 00000000..e1c1b457 --- /dev/null +++ b/internal/codex/session_hooks_unix.go @@ -0,0 +1,41 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "errors" + "strings" +) + +// Codex 0.166 adds the loaded thread's effective environment hooks. Older +// servers may reject or silently ignore threadId; keep their cwd inventory, +// without falling back after a real thread, permission or transport failure. +func (p *daemonStatusProvider) commandHookRows(ctx context.Context, t commandThread) ([]json.RawMessage, error) { + rows, err := p.commandRows(ctx, "hooks/list", map[string]any{"threadId": t.ID}) + if err != nil { + var rpc *daemonResponseError + var detail struct { + Code int + Message string + } + if !errors.As(err, &rpc) || json.Unmarshal([]byte(rpc.payload), &detail) != nil || + (detail.Code != -32600 && detail.Code != -32602) || + !strings.Contains(strings.ToLower(detail.Message), "unknown field") || + !strings.Contains(strings.ToLower(detail.Message), "thread") { + return nil, err + } + } else { + for _, raw := range rows { + var entry struct{ Cwd string } + if json.Unmarshal(raw, &entry) != nil || entry.Cwd == "" { + return nil, ErrSessionCommand + } + if entry.Cwd == t.Cwd { + return rows, nil + } + } + } + return p.commandRows(ctx, "hooks/list", map[string]any{"cwds": []string{t.Cwd}}) +} diff --git a/internal/codex/session_hooks_unix_test.go b/internal/codex/session_hooks_unix_test.go new file mode 100644 index 00000000..e3dac115 --- /dev/null +++ b/internal/codex/session_hooks_unix_test.go @@ -0,0 +1,68 @@ +//go:build unix + +package codex + +import ( + "context" + "strings" + "testing" +) + +func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { + for _, mode := range []string{"thread", "ignored-selector", "rejected-selector", "real-thread-error"} { + t.Run(mode, func(t *testing.T) { + p, f := newQuotaDaemon(t) + row := func(cwd, key, environment string) map[string]any { + return map[string]any{"data": []any{map[string]any{"cwd": cwd, "hooks": []any{ + map[string]any{"key": key, "eventName": "PreToolUse", "handlerType": "command", "command": "echo hello", "enabled": true, "environmentId": environment}, + }}}} + } + f.catalogues = map[string]map[string]any{"hooks/list": row("/work", "directory-hook", "")} + f.threadHooks = row("/work", "environment-hook", "remote-env") + switch mode { + case "ignored-selector": + f.threadHooks = row("/server-default-cwd", "wrong-hook", "") + case "rejected-selector": + f.hookError = map[string]any{"code": -32602, "message": "unknown field `threadId`"} + case "real-thread-error": + f.hookError = map[string]any{"code": -32600, "message": "thread not found: one"} + } + m, err := p.SessionCommands(context.Background(), "one", "hooks") + if mode == "real-thread-error" { + if err == nil { + t.Fatal("hid a real thread failure behind directory discovery") + } + } else { + want := "directory-hook" + if mode == "thread" { + want = "environment-hook" + } + if err != nil || len(m.Choices) != 1 || m.Choices[0].Label != want || m.Choices[0].Action { + t.Fatalf("wrong read-only inventory: %+v, %v", m, err) + } + if mode == "thread" && !strings.Contains(m.Choices[0].Help, "Environment: remote-env") { + t.Fatal("environment provenance missing", m) + } + } + f.mu.Lock() + defer f.mu.Unlock() + wantReads := 2 + if mode == "thread" || mode == "real-thread-error" { + wantReads = 1 + } + if len(f.hookReads) != wantReads || f.hookReads[0]["threadId"] != "one" || f.hookReads[0]["cwds"] != nil { + t.Fatal("incorrect thread-scoped request/fallback", f.hookReads) + } + if wantReads == 2 { + last := f.hookReads[1] + cwds, ok := last["cwds"].([]any) + if !ok || len(cwds) != 1 || cwds[0] != "/work" || last["threadId"] != nil { + t.Fatal("directory fallback lost session scope", last) + } + } + if len(f.writes) != 0 { + t.Fatal("browsing hooks changed session settings") + } + }) + } +} diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go index e8fbfc68..ac555184 100644 --- a/internal/codex/session_settings_unix_test.go +++ b/internal/codex/session_settings_unix_test.go @@ -35,6 +35,9 @@ type quotaDaemonFixture struct { environments []any permissions map[string]string resumeReads []map[string]any + hookReads []map[string]any + threadHooks map[string]any + hookError map[string]any } func TestQuotaEffortDefaultFallback(t *testing.T) { @@ -110,6 +113,7 @@ func newQuotaDaemonTransport(t *testing.T, compressed bool) (*daemonStatusProvid fixture.mu.Lock() result := map[string]any{} failure := false + var failureDetail map[string]any var settingsNotification map[string]any id, _ := req.Params["threadId"].(string) switch req.Method { @@ -199,6 +203,16 @@ func newQuotaDaemonTransport(t *testing.T, compressed bool) (*daemonStatusProvid <-release fixture.mu.Lock() } + case "hooks/list": + fixture.hookReads = append(fixture.hookReads, req.Params) + result = fixture.catalogues["hooks/list"] + if id != "" { + if fixture.threadHooks != nil { + result = fixture.threadHooks + } + failureDetail = fixture.hookError + failure = failureDetail != nil + } default: if req.Method == "thread/turns/list" { fixture.historyReads = append(fixture.historyReads, req.Params) @@ -214,6 +228,9 @@ func newQuotaDaemonTransport(t *testing.T, compressed bool) (*daemonStatusProvid if failure { delete(response, "result") response["error"] = map[string]any{"code": -1, "message": "test failure"} + if failureDetail != nil { + response["error"] = failureDetail + } } if wire.writeJSON(response) != nil { return diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 13867806..7d2b04fb 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODEL UTILGÆNGELIG", + "CAPACITY RESTRICTED": "BEGRÆNSET KAPACITET", "ERRORS": "FEJL", "MODEL BUSY": "MODEL OPTAGET", "TURN FAILED": "TUR MISLYKKEDES", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 628e92d0..42e8b868 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELL NICHT VERFÜGBAR", + "CAPACITY RESTRICTED": "KAPAZITÄT BESCHRÄNKT", "ERRORS": "FEHLER", "MODEL BUSY": "MODELL AUSGELASTET", "TURN FAILED": "TURN FEHLGESCHLAGEN", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 54a0c7da..4a909e2a 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODEL UNAVAILABLE", + "CAPACITY RESTRICTED": "CAPACITY RESTRICTED", "ERRORS": "ERRORS", "MODEL BUSY": "MODEL BUSY", "TURN FAILED": "TURN FAILED", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 83b8b30a..a5742345 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELO NO DISPONIBLE", + "CAPACITY RESTRICTED": "CAPACIDAD RESTRINGIDA", "ERRORS": "ERRORES", "MODEL BUSY": "MODELO SATURADO", "TURN FAILED": "TURNO FALLIDO", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index befe37a4..02451d1d 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MUDEL POLE SAADAVAL", + "CAPACITY RESTRICTED": "VÕIMSUS PIIRATUD", "ERRORS": "VEAD", "MODEL BUSY": "MUDEL HÕIVATUD", "TURN FAILED": "VOOR NURJUS", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index 88b48771..0f518da9 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MALLI EI SAATAVILLA", + "CAPACITY RESTRICTED": "KAPASITEETTI RAJOITETTU", "ERRORS": "VIRHEET", "MODEL BUSY": "MALLI VARATTU", "TURN FAILED": "VUORO EPÄONNISTUI", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ebca0cd5..51b2e19b 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODÈLE INDISPONIBLE", + "CAPACITY RESTRICTED": "CAPACITÉ RESTREINTE", "ERRORS": "ERREURS", "MODEL BUSY": "MODÈLE SATURÉ", "TURN FAILED": "TOUR ÉCHOUÉ", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 5b7edae4..0199e9e7 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELLO NON DISPONIBILE", + "CAPACITY RESTRICTED": "CAPACITÀ LIMITATA", "ERRORS": "ERRORI", "MODEL BUSY": "MODELLO OCCUPATO", "TURN FAILED": "TURNO FALLITO", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index d93e30d5..6ed7fa64 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "モデル利用不可", + "CAPACITY RESTRICTED": "ポリシーによる容量制限", "ERRORS": "エラー", "MODEL BUSY": "モデル混雑", "TURN FAILED": "ターン失敗", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index ac7df1ce..f7181801 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELL UTILGJENGELIG", + "CAPACITY RESTRICTED": "BEGRENSET KAPASITET", "ERRORS": "FEIL", "MODEL BUSY": "MODELL OPPTATT", "TURN FAILED": "TUR MISLYKTES", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index d56113cd..84d7a090 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODEL NIET BESCHIKBAAR", + "CAPACITY RESTRICTED": "CAPACITEIT BEPERKT", "ERRORS": "FOUTEN", "MODEL BUSY": "MODEL BEZET", "TURN FAILED": "BEURT MISLUKT", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index 65101083..2d562530 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELO INDISPONÍVEL", + "CAPACITY RESTRICTED": "CAPACIDADE RESTRITA", "ERRORS": "ERROS", "MODEL BUSY": "MODELO OCUPADO", "TURN FAILED": "TURNO FALHOU", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index a393774e..c6df8e62 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELO INDISPONÍVEL", + "CAPACITY RESTRICTED": "CAPACIDADE RESTRITA", "ERRORS": "ERROS", "MODEL BUSY": "MODELO OCUPADO", "TURN FAILED": "TURNO FALHOU", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 8ab6290b..89031ead 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "МОДЕЛЬ НЕДОСТУПНА", + "CAPACITY RESTRICTED": "ДОСТУП К МОЩНОСТИ ОГРАНИЧЕН", "ERRORS": "ОШИБКИ", "MODEL BUSY": "МОДЕЛЬ ЗАНЯТА", "TURN FAILED": "ХОД НЕ УДАЛСЯ", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 1af4a665..fa995c4f 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODELL EJ TILLGÄNGLIG", + "CAPACITY RESTRICTED": "BEGRÄNSAD KAPACITET", "ERRORS": "FEL", "MODEL BUSY": "MODELL UPPTAGEN", "TURN FAILED": "TUR MISSLYCKADES", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 6ff75ea6..5c33a0a0 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "MODEL KULLANILAMIYOR", + "CAPACITY RESTRICTED": "KAPASİTE KISITLI", "ERRORS": "HATALAR", "MODEL BUSY": "MODEL MEŞGUL", "TURN FAILED": "TUR BAŞARISIZ", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 6c852784..cae28a69 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,6 @@ { + "MODEL UNAVAILABLE": "模型不可用", + "CAPACITY RESTRICTED": "容量受策略限制", "ERRORS": "错误", "MODEL BUSY": "模型繁忙", "TURN FAILED": "轮次失败", diff --git a/internal/ui/monitor_failure_test.go b/internal/ui/monitor_failure_test.go index 6b6121b3..6a9dfdf2 100644 --- a/internal/ui/monitor_failure_test.go +++ b/internal/ui/monitor_failure_test.go @@ -9,32 +9,38 @@ import ( ) func TestMonitorFailureSummaryFilterPillAndContext(t *testing.T) { - m := attentionTestModel() - s := &m.monitorSessionData[2] - s.attention, s.working = codex.SessionAttentionError, false - s.preview = codex.SessionContext{Kind: codex.SessionContextFailure, FailureCode: "serverOverloaded", Text: "Selected model is at capacity.", ThreadID: s.id} - if monitorSessionStatus(*s) != monitorFilterErrors || monitorSessionRecentlyActive(*s) { - t.Fatal("failure filter/active state wrong") - } - if monitorSessionAttentionLabel(*s) != "MODEL BUSY" || contextTitle(s.preview) != "TURN ERROR" { - t.Fatal("failure label missing") - } - items := m.monitorAttentionSessions() - if len(items) != 3 || items[0].attention != codex.SessionAttentionApproval || items[1].attention != codex.SessionAttentionInput || items[2].attention != codex.SessionAttentionError { - t.Fatal("failure pill priority", items) - } - buttons, _ := m.monitorAttentionButtons(300, 1) - if len(buttons) != 3 || !strings.Contains(buttons[2].label, "MODEL BUSY") { - t.Fatal("failure pill missing", buttons) - } - id := s.id - m.setMonitorFilter(monitorFilterErrors) - rows, _, _ := m.monitorSessionPage(100) - if len(rows) != 1 || rows[0].id != id { - t.Fatal("error filtering", rows) - } - m.setRowContext(id, contextFull) - if text := ansi.Strip(m.render()); !strings.Contains(text, "MODEL BUSY") || !strings.Contains(text, "Selected model is at capacity.") { - t.Fatal("detail omitted failure") + for code, label := range map[string]string{ + "serverOverloaded": "MODEL BUSY", "modelNotFound": "MODEL UNAVAILABLE", "policyCapacityUnavailable": "CAPACITY RESTRICTED", + } { + t.Run(code, func(t *testing.T) { + m := attentionTestModel() + s := &m.monitorSessionData[2] + s.attention, s.working = codex.SessionAttentionError, false + s.preview = codex.SessionContext{Kind: codex.SessionContextFailure, FailureCode: code, Text: "Upstream failure explanation.", ThreadID: s.id} + if monitorSessionStatus(*s) != monitorFilterErrors || monitorSessionRecentlyActive(*s) { + t.Fatal("failure filter/active state wrong") + } + if monitorSessionAttentionLabel(*s) != label || contextTitle(s.preview) != "TURN ERROR" { + t.Fatal("failure label missing") + } + items := m.monitorAttentionSessions() + if len(items) != 3 || items[0].attention != codex.SessionAttentionApproval || items[1].attention != codex.SessionAttentionInput || items[2].attention != codex.SessionAttentionError { + t.Fatal("failure pill priority", items) + } + buttons, _ := m.monitorAttentionButtons(300, 1) + if len(buttons) != 3 || !strings.Contains(buttons[2].label, label) { + t.Fatal("failure pill missing", buttons) + } + id := s.id + m.setMonitorFilter(monitorFilterErrors) + rows, _, _ := m.monitorSessionPage(100) + if len(rows) != 1 || rows[0].id != id { + t.Fatal("error filtering", rows) + } + m.setRowContext(id, contextFull) + if text := ansi.Strip(m.render()); !strings.Contains(text, label) || !strings.Contains(text, "Upstream failure explanation.") { + t.Fatal("detail omitted failure") + } + }) } } diff --git a/internal/ui/monitor_working.go b/internal/ui/monitor_working.go index a9ccabe2..ecde535a 100644 --- a/internal/ui/monitor_working.go +++ b/internal/ui/monitor_working.go @@ -4,6 +4,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" "github.com/merefield/codexometer/internal/i18n" + "time" ) func hasWorkingCommand(c codex.SessionContext) bool { @@ -23,6 +24,9 @@ func workingCommandTitle(a codex.SessionActivity) string { status = "DECLINED" } title := i18n.Text("COMMAND") + " // " + i18n.Text(status) + if timing := a.CommandTiming(time.Now()); timing != "" { + title += " // " + timing + } extra := a.RunningCommands if a.CommandStatus == "running" { extra-- diff --git a/internal/ui/monitor_working_test.go b/internal/ui/monitor_working_test.go index 8c07fbb4..f0847889 100644 --- a/internal/ui/monitor_working_test.go +++ b/internal/ui/monitor_working_test.go @@ -3,6 +3,7 @@ package ui import ( "strings" "testing" + "time" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" @@ -59,3 +60,25 @@ func TestWorkingContextResponsive(t *testing.T) { t.Fatal("working command overrides final reply") } } + +func TestWorkingCommandRecordedDurationRemainsResponsive(t *testing.T) { + start := time.Now().Add(-time.Minute) + a := codex.SessionActivity{Command: "go test", CommandStatus: "completed", CommandStartedAt: start, CommandCompletedAt: start.Add(7 * time.Second)} + if got := workingCommandTitle(a); got != "COMMAND // COMPLETED // 7s" { + t.Fatal("recorded duration missing", got) + } + c := codex.SessionContext{Kind: codex.SessionContextActivity, Activity: a} + for _, width := range []int{1, 8, 20, 80} { + for _, compact := range []bool{false, true} { + lines := workingContextLines(c, width, 4, compact) + for _, line := range lines { + if ansi.StringWidth(line) > width { + t.Fatal("duration caused layout overflow", line) + } + } + if width == 80 && !strings.Contains(strings.Join(lines, "\n"), "COMPLETED // 7s") { + t.Fatal(lines) + } + } + } +} diff --git a/internal/web/dist/assets/index-zYOUcPV1.js b/internal/web/dist/assets/index-Cq0BuLLm.js similarity index 96% rename from internal/web/dist/assets/index-zYOUcPV1.js rename to internal/web/dist/assets/index-Cq0BuLLm.js index 70ec690d..af907cef 100644 --- a/internal/web/dist/assets/index-zYOUcPV1.js +++ b/internal/web/dist/assets/index-Cq0BuLLm.js @@ -25,12 +25,12 @@ only; Codexometer must stay running. Uses session model, reasoning and speed.`,U(e=>X(r,`Will send: ${e??``}`),[()=>K(T)===`quota`?`when quota is available and this session is idle`:Number.isFinite(K(me))?ge(K(me)):`choose a date and time`]),Y(e,t)};Z(p,e=>{K(T)!==`now`&&e(m)});var h=H(p,2),g=e=>{Y(e,os())};Z(h,e=>{K(x)&&e(g)}),hi(r,()=>K(T),e=>R(T,e)),Y(e,t)};Z(r,e=>{K(u).questions?.length||e(i)}),Xr(H(r,2),17,()=>K(fe),Kr,(e,n,r)=>{var i=ms(),o=B(i),s=e=>{{let n=F(()=>K(d)[0]||``);Ii(Po(e,{get session(){return t.session},get oncopy(){return l()},get query(){return K(n)},oncomplete:e=>{R(d,[e],!0),lr().then(()=>K(k)?.focus())},onchange:()=>{R(d,[``],!0),lr().then(()=>K(k)?.focus())}}),e=>R(re,e,!0),()=>K(re))}};Z(o,e=>{r===0&&a()!==`profile`&&K(u).kind===`prompt`&&!K(u).questions?.length&&e(s)});var c=H(o,2),f=z(c),p=H(f),m=e=>{var t=cs();xi(t),Ei(t,()=>K(d)[r],e=>K(d)[r]=e),Y(e,t)},h=e=>{var t=us(),i=z(t);i.value=i.__value=``,Xr(H(i),17,()=>K(n).options||[],Kr,(e,t)=>{var n=ls(),r=V(n,!0),i={};U(()=>{X(r,K(t)),i!==(i=K(t))&&(n.value=(n.__value=i)??``)}),Y(e,n)}),P(t),mi(t),hi(t,()=>K(d)[r],e=>K(d)[r]=e),Y(e,t)},g=e=>{var t=ds(),i=B(t);tt(i),Ii(i,e=>R(k,e),()=>K(k));var a=H(i,2),o=e=>{var t=Yo(),r=V(t);U(e=>X(r,`Suggested answers: ${e??``}`),[()=>K(n).options.join(` · `)]),Y(e,t)};Z(a,e=>{K(n).options?.length&&e(o)}),q(`keydown`,i,e=>{K(u)?.kind===`prompt`&&!K(u).questions?.length&&K(re)?.handleKey(e)}),Ei(i,()=>K(d)[r],e=>K(d)[r]=e),Y(e,t)};Z(p,e=>{K(n).secret?e(m):K(n).freeText?e(g,-1):e(h,1)}),P(c);var _=H(c,2),v=e=>{var t=ps(),r=H(z(t),4);Xr(r,21,()=>K(n).options||[],Kr,(e,t)=>{var n=fs(),r=V(n,!0);U(()=>X(r,K(t))),Y(e,n)}),P(r),P(t),Y(e,t)};Z(_,e=>{K(n).secret&&!K(n).freeText&&e(v)}),U(()=>X(f,`${K(n).text??``} `)),Y(e,i)}),Y(e,n)};Z(v,e=>{K(u).kind===`approval`||K(u).kind===`profile`?e(y):e(b,-1)}),P(s);var S=H(s,2),C=e=>{var t=gs(),n=B(t),r=e=>{var t=hs(),n=V(t);U(()=>X(n,`TRIGGER // ${K(te)??``}`)),Y(e,t)};Z(n,e=>{K(ee)&&e(r)});var i=H(n,2),a=V(i),o=H(i,2),s=V(o),c=H(o,2);U(e=>{X(a,`Check the target and ${K(u).kind===`profile`?`profile above`:K(u).kind===`approval`?K(u).fileChanges?.length?`proposed file changes above`:`exact command and permission scope`:`message above`}. ${K(ee)?`This schedules a follow-up that will send automatically when eligible`:`This will send to Codex`}; it may start work using your quota. Confirmation expires in ${e??``}s.`),o.disabled=K(g)||K(A),X(s,`CONFIRM ${(K(u).kind===`approval`||K(u).kind===`profile`?K(u).choices?.[K(f)]?.label:K(ee)?K(x)?`SAVE CHANGES`:`SCHEDULE`:`SEND`)??``}`)},[()=>Math.max(0,Math.ceil((K(m)-K(h))/1e3))]),q(`click`,o,N),q(`click`,c,()=>{R(p,``)}),Y(e,t)},w=e=>{var t=_s(),n=B(t),r=e=>{var t=zo(),n=V(t,!0);U(()=>X(n,K(he))),Y(e,t)};Z(n,e=>{K(he)&&e(r)});var i=H(n,2),a=V(i,!0);U(()=>{i.disabled=K(g)||K(A)||!K(j)||!!K(he),X(a,K(g)?`SENDING…`:K(x)?`REVIEW CHANGES`:`REVIEW BEFORE SENDING`)}),q(`click`,i,()=>ve()),Y(e,t)};Z(S,e=>{K(de)?e(C):e(w,-1)});var O=H(S,2),ne=e=>{var t=vs();q(`click`,t,()=>{R(x,!1),R(p,``),R(d,[],!0)}),Y(e,t)};Z(O,e=>{K(x)&&e(ne)}),U(()=>{X(o,`TARGET // ${K(u).thread??``} // ${(K(u).directory||`Directory unavailable`)??``}`),s.disabled=K(g)||K(de)||K(A),X(_,K(u).kind===`approval`||K(u).kind===`profile`?`Choose a decision`:K(x)?`EDIT SCHEDULED FOLLOW-UP`:`Reply to this session`)}),Y(e,r)};Z(ce,e=>{K(A)||K(y)?e(pe):K(u)?K(u).id?K(b)!==K(u).id&&e(ye,3):e(M,2):e(_e,1)}),P(r),U(()=>X(s,a()===`profile`?`QUOTA THRESHOLD`:`SESSION CONTROL // EXPERIMENTAL`)),Y(e,r)};Z(we,e=>{!K(ce)&&(!K(ne)||K(x)||a()||K(u)?.kind!==`prompt`||K(u).questions?.length)&&e(Te)}),Y(e,ye),We()}Sr([`click`,`keydown`]);var Ss=J(` `,1),Cs=J(`
`),ws=J(`

STATUS LINE // CODEXOMETER

Choose multiple fields and their order. These display preferences do not - change Codex CLI configuration. Missing values are omitted.

`),Ts=J(`

`,1);function Es(e,t){Ue(t,!0);let n=Wi(t,`stale`,3,!1),r=L(Yt([])),i=L(!1),a=F(()=>$.data?.statusLineFields||[]),o=e=>[...new Set(e??K(a).filter(e=>e.default).map(e=>e.id))].filter(e=>K(a).some(t=>t.id===e)),s=e=>o(e).map(e=>e===`run-state`&&(n()||!$.connected||$.data?.sessionsError)?`STALE`:t.session.statusLine?.[e]).filter(Boolean).join(` · `),c=F(()=>[...K(r).map(e=>K(a).find(t=>t.id===e)).filter(Boolean),...K(a).filter(e=>!K(r).includes(e.id))]);vn(()=>{oa.open&&!K(i)?(R(r,o(aa.statusLine),!0),R(i,!0)):oa.open||R(i,!1)});function l(e,t){R(r,t?[...K(r),e]:K(r).filter(t=>t!==e),!0)}function u(e,t){let n=K(r).indexOf(e),i=n+t;if(n<0||i<0||i>=K(r).length)return;let a=[...K(r)];[a[n],a[i]]=[a[i],a[n]],R(r,a,!0)}var d=Ts(),f=B(d),p=e=>{var t=ws(),n=H(z(t),4);Xr(n,21,()=>K(c),e=>e.id,(e,t)=>{var n=Cs(),i=z(n),a=z(i);xi(a);var o=H(a,1,!0),s=V(H(o),!0);P(i);var c=H(i,2),d=e=>{var n=Ss(),i=B(n),a=H(i,2);U((e,n)=>{Q(i,`aria-label`,`Move `+K(t).label+` earlier`),i.disabled=e,Q(a,`aria-label`,`Move `+K(t).label+` later`),a.disabled=n},[()=>K(r).indexOf(K(t).id)===0,()=>K(r).indexOf(K(t).id)===K(r).length-1]),q(`click`,i,()=>u(K(t).id,-1)),q(`click`,a,()=>u(K(t).id,1)),Y(e,n)},f=F(()=>K(r).includes(K(t).id));Z(c,e=>{K(f)&&e(d)}),P(n),U(e=>{Q(i,`title`,K(t).help),Si(a,e),X(o,K(t).label),X(s,K(t).help)},[()=>K(r).includes(K(t).id)]),q(`change`,a,e=>l(K(t).id,e.currentTarget.checked)),Y(e,n)}),P(n);var i=H(n,2),a=V(i),o=H(i,2),d=H(o,2);P(t),U(e=>X(a,`Preview // ${e??``}`),[()=>s(K(r))||`—`]),q(`click`,o,()=>{aa.statusLine=[...K(r)],ca(),oa.open=!1}),q(`click`,d,()=>{oa.open=!1}),Y(e,t)};Z(f,e=>{oa.open&&e(p)});var m=H(f,2),h=z(m),g=V(h,!0),_=H(h,2);P(m),U((e,t)=>{Q(h,`title`,e),X(g,t)},[()=>s(aa.statusLine),()=>s(aa.statusLine)]),q(`click`,_,()=>{oa.open=!oa.open}),Y(e,d),We()}Sr([`change`,`click`]);var Ds=J(`
`);function Os(e,t){Ue(t,!0);let n=Wi(t,`active`,3,!1),r=F(()=>ao(t.session)),i=F(()=>!!K(r)),a=L(!1),o=L(``),s=L(!1);vn(()=>{if(!K(s))return;let e=setTimeout(()=>{R(s,!1)},150);return()=>clearTimeout(e)}),vn(()=>{K(r),t.session.status,R(o,``)});async function c(){if(!K(i)||K(a))return;let e=K(r);R(a,!0),R(s,!0),R(o,``);try{await navigator.clipboard.writeText(e),K(r)===e&&R(o,`Copied.`)}catch{K(r)===e&&R(o,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{R(a,!1)}}function l(e){!n()||!K(i)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),c())}var u={copy:c},d=Nr();xr(`keydown`,Qt,l);var f=B(d),p=e=>{var t=Ds(),n=z(t),r=V(n,!0),i=H(n,2);let l;P(t),U(()=>{X(r,K(o)),i.disabled=K(a),l=ci(i,1,`svelte-543j00`,null,l,{flashed:K(s)})}),q(`click`,i,c),Y(e,t)};return Z(f,e=>{K(i)&&e(p)}),Y(e,d),We(u)}Sr([`click`]);var ks=J(`

 
`,1),As=J(`

LATEST GUIDANCE

 
`,1),js=J(`

CONTEXT

 

`,1),Ms=J(`

`),Ns=J(`

`),Ps=J(`
 
`),Fs=J(`

 
`,1),Is=J(`
 
`),Ls=J(`

Command unavailable from this observation. Open Codex to inspect the + change Codex CLI configuration. Missing values are omitted.

`),Ts=J(`

`,1);function Es(e,t){Ue(t,!0);let n=Wi(t,`stale`,3,!1),r=L(Yt([])),i=L(!1),a=F(()=>$.data?.statusLineFields||[]),o=e=>[...new Set(e??K(a).filter(e=>e.default).map(e=>e.id))].filter(e=>K(a).some(t=>t.id===e)),s=e=>o(e).map(e=>e===`run-state`&&(n()||!$.connected||$.data?.sessionsError)?`STALE`:t.session.statusLine?.[e]).filter(Boolean).join(` · `),c=F(()=>[...K(r).map(e=>K(a).find(t=>t.id===e)).filter(Boolean),...K(a).filter(e=>!K(r).includes(e.id))]);vn(()=>{oa.open&&!K(i)?(R(r,o(aa.statusLine),!0),R(i,!0)):oa.open||R(i,!1)});function l(e,t){R(r,t?[...K(r),e]:K(r).filter(t=>t!==e),!0)}function u(e,t){let n=K(r).indexOf(e),i=n+t;if(n<0||i<0||i>=K(r).length)return;let a=[...K(r)];[a[n],a[i]]=[a[i],a[n]],R(r,a,!0)}var d=Ts(),f=B(d),p=e=>{var t=ws(),n=H(z(t),4);Xr(n,21,()=>K(c),e=>e.id,(e,t)=>{var n=Cs(),i=z(n),a=z(i);xi(a);var o=H(a,1,!0),s=V(H(o),!0);P(i);var c=H(i,2),d=e=>{var n=Ss(),i=B(n),a=H(i,2);U((e,n)=>{Q(i,`aria-label`,`Move `+K(t).label+` earlier`),i.disabled=e,Q(a,`aria-label`,`Move `+K(t).label+` later`),a.disabled=n},[()=>K(r).indexOf(K(t).id)===0,()=>K(r).indexOf(K(t).id)===K(r).length-1]),q(`click`,i,()=>u(K(t).id,-1)),q(`click`,a,()=>u(K(t).id,1)),Y(e,n)},f=F(()=>K(r).includes(K(t).id));Z(c,e=>{K(f)&&e(d)}),P(n),U(e=>{Q(i,`title`,K(t).help),Si(a,e),X(o,K(t).label),X(s,K(t).help)},[()=>K(r).includes(K(t).id)]),q(`change`,a,e=>l(K(t).id,e.currentTarget.checked)),Y(e,n)}),P(n);var i=H(n,2),a=V(i),o=H(i,2),d=H(o,2);P(t),U(e=>X(a,`Preview // ${e??``}`),[()=>s(K(r))||`—`]),q(`click`,o,()=>{aa.statusLine=[...K(r)],ca(),oa.open=!1}),q(`click`,d,()=>{oa.open=!1}),Y(e,t)};Z(f,e=>{oa.open&&e(p)});var m=H(f,2),h=z(m),g=V(h,!0),_=H(h,2);P(m),U((e,t)=>{Q(h,`title`,e),X(g,t)},[()=>s(aa.statusLine),()=>s(aa.statusLine)]),q(`click`,_,()=>{oa.open=!oa.open}),Y(e,d),We()}Sr([`change`,`click`]);var Ds=J(`
`);function Os(e,t){Ue(t,!0);let n=Wi(t,`active`,3,!1),r=F(()=>ao(t.session)),i=F(()=>!!K(r)),a=L(!1),o=L(``),s=L(!1);vn(()=>{if(!K(s))return;let e=setTimeout(()=>{R(s,!1)},150);return()=>clearTimeout(e)}),vn(()=>{K(r),t.session.status,R(o,``)});async function c(){if(!K(i)||K(a))return;let e=K(r);R(a,!0),R(s,!0),R(o,``);try{await navigator.clipboard.writeText(e),K(r)===e&&R(o,`Copied.`)}catch{K(r)===e&&R(o,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{R(a,!1)}}function l(e){!n()||!K(i)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),c())}var u={copy:c},d=Nr();xr(`keydown`,Qt,l);var f=B(d),p=e=>{var t=Ds(),n=z(t),r=V(n,!0),i=H(n,2);let l;P(t),U(()=>{X(r,K(o)),i.disabled=K(a),l=ci(i,1,`svelte-543j00`,null,l,{flashed:K(s)})}),q(`click`,i,c),Y(e,t)};return Z(f,e=>{K(i)&&e(p)}),Y(e,d),We(u)}Sr([`click`]);var ks=J(`

 
`,1),As=J(`

LATEST GUIDANCE

 
`,1),js=J(`

CONTEXT

 

`,1),Ms=J(`

`),Ns=J(`

`),Ps=J(`
 
`),Fs=J(`

 
`,1),Is=J(`
 
`),Ls=J(`

Command unavailable from this observation. Open Codex to inspect the request.

`),Rs=J(`

`,1),zs=J(`

`),Bs=J(` `,1),Vs=J(`
`),Hs=J(`

Session connection or refresh unavailable. Context and telemetry may be stale.

`),Us=J(`

Some quota profile checks are unavailable. Only sessions with freshly verified quota and settings can be updated; previous outcome notices remain visible.

`),Ws=J(` `),Gs=J(` `),Ks=J(` `),qs=J(``),Js=J(`

Read only — reply or approve in Codex.

`),Ys=J(`

`),Xs=J(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Zs=J(`
`),Qs=J(`
`),$s=J(`

This session is no longer in the current observation. Return to sessions.

`),ec=J(`

`),tc=J(``),nc=J(`

`),rc=J(` `),ic=J(`

`),ac=J(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),oc=J(` `,1),sc=J(`

`),cc=J(`

TOKEN ACTIVITY // 30 SECOND SAMPLES

`),lc=J(`

TOKENS

FULL DETAIL →
`),uc=J(`

No locally observed sessions yet. Keep Codex running alongside - Codexometer.

`),dc=J(`

↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

`,1),fc=J(`

SESSION TOTALS

`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(a,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(o)});var s=H(a,2),c=e=>{Y(e,Mr(`+`))};Z(s,e=>{t().runningLimited&&e(c)}),P(r);var l=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(l,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return[`MODEL BUSY`,`TURN FAILED`].includes(e.status)}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``} + Codexometer.

`),dc=J(`

↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

`,1),fc=J(`

SESSION TOTALS

`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.contextKind===`TURN ERROR`}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``} ${o??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>K(t).status===`APPROVAL NEEDED`||K(t).status===`INPUT NEEDED`||y(K(t)),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)});var r=H(n,2);Xr(r,17,()=>K(o).filter(e=>e.pending&&K(a).some(t=>t.id===e.session)),Kr,(e,t)=>{var n=Gs(),r=V(n);U((e,a,o)=>{Q(n,`title`,i().id&&i().id!==K(t).session&&K(u)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),X(r,`QUOTA THRESHOLD ${a??``} // ${o??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session)+`?review=profile`,()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory||K(t).session]),q(`click`,n,e=>d(e,K(t).session)),Y(e,n)});var l=H(r,2);Xr(l,17,()=>K(C),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,i)=>{Q(n,`href`,e),X(r,`TURN COMPLETE ${i??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)}),Xr(H(l,2),17,()=>K(s),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,t,i)=>{Q(n,`href`,e),X(r,`TRIGGER SET ${t??``} // ${i??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session),()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory]),q(`click`,n,e=>c(e,K(t).session)),Y(e,n)}),P(t),Y(e,t)},de=F(()=>(K(x).length||K(C).length||K(o).some(e=>e.pending)||K(s).length)&&!K(v));Z(le,e=>{K(de)&&e(ue)});var fe=H(le,2),j=e=>{var t=Nr(),a=B(t),s=e=>{var t=Qs(),a=z(t),s=z(a);let c;var d=z(s);let f;var h=H(d);P(s);var g=H(s,2);P(a);var _=H(a,2),b=e=>{var t=zs(),n=V(t,!0);U(()=>X(n,K(p).directory)),Y(e,t)};Z(_,e=>{K(p).name&&K(p).directory&&e(b)});var x=H(_,2),S=V(x),C=H(x,2);let T;var E=z(C),D=z(E);n(D,()=>K(p),()=>!0,()=>!0),P(E);var ee=H(E,2),te=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{{let t=F(()=>new URLSearchParams(Yi.querystring).get(`schedule`)===`edit`),n=F(()=>K(p).fileChanges||[]);xs(e,{get session(){return K(p).id},oncopy:()=>K(r)?.copy(),get openScheduler(){return K(t)},get observedCommand(){return K(p).command},get observedFileChanges(){return K(n)},get suspended(){return K(m)},onProtectedChange:e=>{R(u,e,!0)}})}}),Y(e,t)},O=e=>{var t=Js();Gr(H(z(t),2),()=>K(p).id,e=>{Po(e,{get session(){return K(p).id},localOnly:!0,oncopy:()=>K(r)?.copy()})}),P(t),Y(e,t)};Z(ee,e=>{$.data?.control?e(te):e(O,-1)}),P(C);var ne=H(C,2);Xr(ne,17,()=>K(o).filter(e=>e.session===K(p).id),e=>e.session,(e,t)=>{var n=Zs(),r=z(n),i=e=>{var n=Ys(),r=V(n,!0);U(()=>X(r,K(t).notice)),Y(e,n)};Z(r,e=>{K(t).notice&&e(i)});var a=H(r,2),o=e=>{xs(e,{get session(){return K(p).id},review:`profile`})},s=e=>{var t=Xs();U(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(p).id)+`?review=profile`]),Y(e,t)};Z(a,e=>{K(t).pending&&K(m)?e(o):K(t).pending&&e(s,1)}),P(n),U(()=>Q(n,`id`,`quota-profile-`+K(p).id)),Y(e,n)});var k=H(ne,2),re=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{Ii(Os(e,{get session(){return K(p)},active:!0}),e=>R(r,e,!0),()=>K(r))}),Y(e,t)};Z(k,e=>{K(m)||e(re)}),Gr(H(k,2),()=>K(p).id,e=>{Es(e,{get session(){return K(p)},get stale(){return K(v)}})}),P(t),U((e,t,n,r)=>{c=ci(s,1,`svelte-mm4wfe`,null,c,{failed:e}),f=ci(d,1,`lamp lit svelte-mm4wfe`,null,f,{failed:t,working:K(p).status===`WORKING`&&!K(v)}),X(h,`${n??``} // ${(K(p).name||K(p).directory)??``}`),X(S,`${r??``} TOKENS // ${K(p).id??``} // CONTEXT SOURCE // ${(K(p).source||`LOCAL`)??``}`),T=ui(C,``,T,{display:K(m)?`none`:void 0})},[()=>y(K(p))&&!K(v)&&!K(m),()=>y(K(p))&&!K(v),()=>K(v)?`STALE`:K(m)?`QUOTA THRESHOLD`:l(K(p)),()=>pa(K(p).tokens)]),q(`click`,g,()=>{w(i().id),fa(i().id,2)}),Y(e,t)},c=e=>{Y(e,$s())};Z(a,e=>{K(p)?e(s):e(c,-1)}),Y(e,t)},pe=e=>{var t=dc(),r=B(t),i=H(z(r),2),s=z(i),c=H(s,2);P(i),P(r);var u=H(r,2);Xr(u,17,()=>K(a),e=>e.id,(e,t)=>{let r=F(()=>ua(K(t).id));var i=lc();let a;var s=z(i),c=z(s),u=e=>{var n=ec(),r=z(n),i=V(r);P(n),U(e=>{Q(r,`aria-pressed`,K(h)===K(t).id),X(i,`${e??``} // ${K(t).name??``}`)},[()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,r,()=>w(K(t).id)),Y(e,n)};Z(c,e=>{K(t).name&&e(u)});var f=H(c,2),p=z(f);let m;var g=z(p);let _;var b=H(g,1,!0);P(p),P(f);var x=H(f,2),S=e=>{var n=tc(),r=V(n,!0);U(()=>{Q(n,`aria-pressed`,K(h)===K(t).id),X(r,K(t).directory||K(t).id)}),q(`click`,n,()=>w(K(t).id)),Y(e,n)};Z(x,e=>{K(t).name||e(S)});var C=H(x,2),E=z(C);xe(),P(C);var D=H(C,2),te=e=>{var n=zs(),r=V(n,!0);U(()=>X(r,K(t).directory)),Y(e,n)};Z(D,e=>{K(t).name&&K(t).directory&&e(te)});var O=H(D,2),ne=V(O),k=H(O,2),re=V(k),ie=H(k,2),ae=z(ie),oe=H(ae,2),se=V(oe,!0),A=H(oe,2);P(ie);var ce=H(ie,2),le=H(ce,2),ue=e=>{var n=nc(),r=V(n,!0);U(e=>X(r,e),[()=>K(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:y(K(t))?`OPEN DETAIL FOR THE TURN ERROR`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:K(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`]),Y(e,n)},de=F(()=>!K(v)&&([`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(K(t).status)||y(K(t))));Z(le,e=>{K(de)&&e(ue)}),P(s);var fe=H(s,2),j=e=>{var r=sc(),i=z(r),a=z(i),s=V(a,!0),c=H(a,2),l=e=>{var n=rc(),r=V(n,!0);U(e=>{Q(n,`href`,e),X(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id)]),Y(e,n)};Z(c,e=>{!K(v)&&K(t).status===`APPROVAL NEEDED`&&e(l)}),P(i);var u=H(i,2);n(u,()=>K(t),()=>!1);var f=H(u,2);Xr(f,17,()=>K(o).filter(e=>e.session===K(t).id),Kr,(e,n)=>{var r=oc(),i=B(r),a=e=>{var t=ic(),r=V(t,!0);U(()=>X(r,K(n).notice)),Y(e,t)};Z(i,e=>{K(n).notice&&e(a)});var o=H(i,2),s=e=>{var n=ac();U(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(t).id)+`?review=profile`]),q(`click`,n,e=>d(e,K(t).id)),Y(e,n)};Z(o,e=>{K(n).pending&&e(s)}),Y(e,r)});var p=H(f,2);{let e=F(()=>K(h)===K(t).id);Os(p,{get session(){return K(t)},get active(){return K(e)}})}P(r),U(e=>X(s,e),[()=>ee(K(t))]),Y(e,r)};Z(fe,e=>{K(r)>0&&e(j)});var pe=H(fe,2),me=e=>{var n=cc(),r=H(z(n),2);{let e=F(()=>(K(t).samples||[]).map(e=>e.tokens));io(r,{get values(){return K(e)},capacity:120})}var i=V(H(r,2));P(n),U(()=>X(i,`LAST ${(K(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),Y(e,n)};Z(pe,e=>{K(r)<2&&e(me)}),P(i),U((e,n,o,s,c,l,u)=>{a=ci(i,1,`session-row`,null,a,{selected:K(h)===K(t).id,wide:K(r)===2,split:K(r)===1}),Q(i,`aria-label`,`Session `+(K(t).name||K(t).directory||K(t).id)),m=ci(p,1,`status-detail svelte-mm4wfe`,null,m,{failed:e}),Q(p,`href`,n),_=ci(g,1,`lamp lit svelte-mm4wfe`,null,_,{failed:o,working:K(t).status===`WORKING`&&!K(v)}),X(b,s),X(E,`${c??``} `),X(ne,`${K(t).agents??``} LINKED AGENTS`),X(re,`ACTIVE // ${l??``}`),ae.disabled=K(r)===0,Q(oe,`aria-expanded`,K(r)>0),X(se,K(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(ce,`href`,u)},[()=>y(K(t))&&!K(v),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>y(K(t))&&!K(v),()=>K(v)?`STALE`:l(K(t)),()=>pa(K(t).tokens),()=>ma(K(t).activity),()=>`#/sessions/`+encodeURIComponent(K(t).id)]),q(`click`,p,()=>w(K(t).id)),q(`click`,ae,()=>T(K(t).id,-1)),q(`click`,oe,()=>{w(K(t).id),fa(K(t).id,+!K(r))}),q(`click`,A,()=>T(K(t).id,1)),q(`click`,ce,()=>w(K(t).id)),Y(e,i)});var f=H(u,2),p=e=>{Y(e,uc())};Z(f,e=>{K(a).length||e(p)}),q(`click`,s,()=>da(1)),q(`click`,c,()=>da(0)),Y(e,t)};Z(fe,e=>{i().id?e(j):e(pe,-1)}),U(e=>{X(ne,`OBSERVED ${e??``}`),re=ci(k,1,`session-totals`,null,re,{stale:K(v)}),X(ae,`${K(v)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens observed since this server started for currently listed sessions; linked diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index efe56cb0..381d8ec1 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@ Codexometer // Experimental web - + diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 2f6a76da..23d818b8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -336,20 +336,24 @@ func TestDisplayProjectionAndAccountIsolation(t *testing.T) { func TestWorkingContextProjection(t *testing.T) { s := newStore() + now := time.Now() row := codex.LiveUsageSession{ID: "root", Working: true, Context: codex.SessionContext{ Kind: codex.SessionContextActivity, Text: "Explanation", Activity: codex.SessionActivity{ - Prose: "Explanation", Command: "go test", CommandStatus: "running", RunningCommands: 2, + Prose: "Explanation", Command: "go test", CommandStatus: "running", RunningCommands: 2, CommandStartedAt: now.Add(-42 * time.Second), }, }} - s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now()) + s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, now) got := s.state.Sessions[0] if got.Text != "Explanation" || got.WorkingCommand != "go test" || got.CommandStatus != "running" || got.RunningCommands != 2 || got.Command != "" { t.Fatal(got) } + if got.CommandTiming != "42s" { + t.Fatal("recorded timer not projected", got) + } for _, kind := range []codex.SessionContextKind{codex.SessionContextReply, codex.SessionContextApproval, codex.SessionContextQuestion} { row.Context.Kind = kind s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now()) - if s.state.Sessions[0].WorkingCommand != "" { + if s.state.Sessions[0].WorkingCommand != "" || s.state.Sessions[0].CommandTiming != "" { t.Fatal("activity leaked into higher-priority context") } } diff --git a/internal/web/session_failure_test.go b/internal/web/session_failure_test.go index 2fe1d103..73bc9d6a 100644 --- a/internal/web/session_failure_test.go +++ b/internal/web/session_failure_test.go @@ -14,4 +14,10 @@ func TestSessionFailurePresentation(t *testing.T) { if sessionStatus(row) != "TURN FAILED" { t.Fatal("generic failure mislabeled") } + for code, want := range map[string]string{"modelNotFound": "MODEL UNAVAILABLE", "policyCapacityUnavailable": "CAPACITY RESTRICTED"} { + row.Context.FailureCode = code + if sessionStatus(row) != want || contextKind(row.Context.Kind) != "TURN ERROR" { + t.Fatal("new error lost shared failure classification", row) + } + } } diff --git a/internal/web/state.go b/internal/web/state.go index 3b33891d..49181c7f 100644 --- a/internal/web/state.go +++ b/internal/web/state.go @@ -40,6 +40,7 @@ type session struct { WorkingCommand string `json:"workingCommand,omitempty"` ApprovalContext string `json:"approvalContext,omitempty"` CommandStatus string `json:"commandStatus,omitempty"` + CommandTiming string `json:"commandTiming,omitempty"` RunningCommands int `json:"runningCommands,omitempty"` RunningLimited bool `json:"runningLimited,omitempty"` Source string `json:"source"` @@ -338,6 +339,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) { Status: sessionStatus(row), ContextKind: contextKind(row.Context.Kind), Text: text, Command: row.Context.CommandDetails.Command, Source: row.Context.Source, Activity: row.LastActivity, WorkingCommand: activity.Command, CommandStatus: activity.CommandStatus, + CommandTiming: activity.CommandTiming(now), ApprovalContext: approvalContext, FileChanges: codex.FileDiffLines(row.Context.FileChanges), CurrentTask: codex.SanitizeSessionContext(row.Context.CurrentTask), LatestGuidance: codex.SanitizeSessionContext(row.Context.LatestGuidance), Streaming: row.Context.Streaming, diff --git a/intro-post.md b/intro-post.md index 1108c405..4e7f1f9e 100644 --- a/intro-post.md +++ b/intro-post.md @@ -115,7 +115,7 @@ Use `--reset-warning-hours 24` to warn one day ahead, or `--reset-warning-hours Expiry warnings are reminders to review, not instructions to reset immediately. Confirmation warns that unused allowance does not carry over or stack and that the weekly schedule changes. Missing or incomplete expiry information is explicitly flagged: no warning is not proof that nothing expires soon, and undisclosed credits may expire sooner. -Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch. +Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, **MODEL UNAVAILABLE** for a missing model, **CAPACITY RESTRICTED** for policy-restricted capacity, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch. Working-command headings can also show elapsed/completed time when the shared server supplies valid timestamps. ## An experimental browser companion diff --git a/web/src/Sessions.svelte b/web/src/Sessions.svelte index d1f5d639..6a59914c 100644 --- a/web/src/Sessions.svelte +++ b/web/src/Sessions.svelte @@ -66,7 +66,7 @@ ); let stale = $derived(!live.connected || !!live.data?.sessionsError); function isFailure(session: Session) { - return ['MODEL BUSY', 'TURN FAILED'].includes(session.status); + return session.status !== 'STALE' && session.contextKind === 'TURN ERROR'; } // Parent rows already include linked-agent usage. Sum each listed row once; // do not add agent counts or activity samples to its observed token counter. @@ -183,7 +183,11 @@ if (isFailure(session)) return session.status === 'MODEL BUSY' ? 'CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.' - : 'CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.'; + : session.status === 'MODEL UNAVAILABLE' + ? 'CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.' + : session.status === 'CAPACITY RESTRICTED' + ? 'CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.' + : 'CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.'; if ( live.data?.control && ['APPROVAL NEEDED', 'INPUT NEEDED'].includes(session.status) @@ -242,6 +246,8 @@ COMMAND // {stale ? 'UNKNOWN' : (session.commandStatus || 'unknown').toUpperCase()} + {#if session.commandTiming} + // {session.commandTiming}{/if} {#if !stale && (session.runningCommands || 0) > (session.commandStatus === 'running' ? 1 : 0)} // +{(session.runningCommands || 0) - (session.commandStatus === 'running' ? 1 : 0)} RUNNING diff --git a/web/src/state.svelte.ts b/web/src/state.svelte.ts index c73eb01b..f87603c5 100644 --- a/web/src/state.svelte.ts +++ b/web/src/state.svelte.ts @@ -21,6 +21,7 @@ export interface Session { latestGuidance?: string; streaming?: boolean; commandStatus?: string; + commandTiming?: string; runningCommands?: number; runningLimited?: boolean; source: string; diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts index dfcf74fb..f9e6b997 100644 --- a/web/tests/browser.spec.ts +++ b/web/tests/browser.spec.ts @@ -290,6 +290,7 @@ test('working detail preserves prose alongside command state', async ({ activity: '', samples: [], workingCommand: 'go test ./...', + commandTiming: '', commandStatus: 'running', runningCommands: 2, }; @@ -356,6 +357,7 @@ test('working detail preserves prose alongside command state', async ({ .toBe('go vet ./...'); session.text = 'Checking the build '; session.commandStatus = 'completed'; + session.commandTiming = '7s'; session.runningCommands = 0; await page.evaluate( (detail) => @@ -363,7 +365,10 @@ test('working detail preserves prose alongside command state', async ({ snapshot, ); await expect( - page.getByRole('heading', { name: 'COMMAND // COMPLETED', exact: true }), + page.getByRole('heading', { + name: 'COMMAND // COMPLETED // 7s', + exact: true, + }), ).toBeVisible(); await expect(page.getByText(session.text, { exact: true })).toBeVisible(); session.status = 'TURN COMPLETE'; @@ -3524,6 +3529,8 @@ test('session failures have a separate count, ordered pills, error detail and li 'INPUT NEEDED', 'APPROVAL NEEDED', 'TURN FAILED', + 'MODEL UNAVAILABLE', + 'CAPACITY RESTRICTED', 'CHECK SESSION', ].map((status, index) => ({ id: 'failure-test-' + index, @@ -3532,7 +3539,12 @@ test('session failures have a separate count, ordered pills, error detail and li tokens: 10, agents: 0, status, - contextKind: ['MODEL BUSY', 'TURN FAILED'].includes(status) + contextKind: [ + 'MODEL BUSY', + 'TURN FAILED', + 'MODEL UNAVAILABLE', + 'CAPACITY RESTRICTED', + ].includes(status) ? 'TURN ERROR' : 'LAST REPLY', text: @@ -3551,16 +3563,26 @@ test('session failures have a separate count, ordered pills, error detail and li const cell = page .locator('.session-totals > div') .filter({ has: page.locator('dt').filter({ hasText: /^ERRORS$/ }) }); - await expect(cell.locator('dd')).toHaveText('2'); + await expect(cell.locator('dd')).toHaveText('4'); const pills = page .getByRole('navigation', { name: 'Sessions needing attention' }) .getByRole('link'); - await expect(pills).toHaveCount(5); + await expect(pills).toHaveCount(7); await expect(pills.nth(0)).toContainText('APPROVAL NEEDED'); await expect(pills.nth(1)).toContainText('INPUT NEEDED'); await expect(pills.nth(2)).toContainText('MODEL BUSY'); await expect(pills.nth(3)).toContainText('TURN FAILED'); - await expect(pills.nth(4)).toContainText('TURN COMPLETE'); + await expect(pills.nth(4)).toContainText('MODEL UNAVAILABLE'); + await expect(pills.nth(5)).toContainText('CAPACITY RESTRICTED'); + await expect(pills.nth(6)).toContainText('TURN COMPLETE'); + await pills.nth(4).click(); + await expect(page.locator('.detail-context')).toContainText( + 'provider could not find the selected model', + ); + await pills.nth(5).click(); + await expect(page.locator('.detail-context')).toContainText( + 'policy-restricted route', + ); await pills.nth(2).click(); await expect(page.locator('.detail-context')).toContainText( 'Selected model is at capacity.', @@ -3581,7 +3603,7 @@ test('session failures have a separate count, ordered pills, error detail and li ), snapshot, ); - await expect(cell.locator('dd')).toHaveText('1'); + await expect(cell.locator('dd')).toHaveText('3'); await expect(pills.filter({ hasText: 'MODEL BUSY' })).toHaveCount(0); await expect(page.locator('.detail-context')).not.toContainText( 'Selected model is at capacity.', From 27157535c2e81d41069b43e18238df3bc97a6984 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 18:58:21 +0000 Subject: [PATCH 2/7] FEAT: add opt-in privacy-conscious operational error logging --- .gitignore | 1 + README.md | 43 ++++ internal/codex/benchmark.go | 24 +- internal/codex/client.go | 16 +- internal/codex/daemon_status_unix.go | 26 +- internal/codex/live_usage.go | 12 +- internal/codex/usage_coverage.go | 30 ++- internal/codex/usage_coverage_logging_test.go | 104 ++++++++ internal/diagnostics/log.go | 227 ++++++++++++++++++ internal/diagnostics/log_test.go | 181 ++++++++++++++ intro-post.md | 2 + main.go | 26 ++ main_logging_test.go | 53 ++++ 13 files changed, 734 insertions(+), 11 deletions(-) create mode 100644 internal/codex/usage_coverage_logging_test.go create mode 100644 internal/diagnostics/log.go create mode 100644 internal/diagnostics/log_test.go create mode 100644 main_logging_test.go diff --git a/.gitignore b/.gitignore index e56ec82e..6e0ef479 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /codexometer +/error.log /dist/ /web/node_modules/ /web/test-results/ diff --git a/README.md b/README.md index eb010628..45bea6fb 100644 --- a/README.md +++ b/README.md @@ -2713,6 +2713,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) @@ -3408,6 +3410,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 diff --git a/internal/codex/benchmark.go b/internal/codex/benchmark.go index 4fb51d72..9a64fccd 100644 --- a/internal/codex/benchmark.go +++ b/internal/codex/benchmark.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/merefield/codexometer/internal/diagnostics" "go.starlark.net/starlark" ) @@ -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} @@ -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 diff --git a/internal/codex/client.go b/internal/codex/client.go index 4bc94cb4..13223af7 100644 --- a/internal/codex/client.go +++ b/internal/codex/client.go @@ -13,6 +13,8 @@ import ( "strings" "sync" "time" + + "github.com/merefield/codexometer/internal/diagnostics" ) const requestTimeout = 15 * time.Second @@ -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" @@ -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 diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index a59a76ea..4995600b 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -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 ( @@ -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 { @@ -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 } diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index 651e57f8..4b2738b4 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -14,6 +14,8 @@ import ( "strings" "sync" "time" + + "github.com/merefield/codexometer/internal/diagnostics" ) const ( @@ -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 { @@ -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 } @@ -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 @@ -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++ diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go index b32604b7..9b655016 100644 --- a/internal/codex/usage_coverage.go +++ b/internal/codex/usage_coverage.go @@ -9,6 +9,7 @@ import ( "sort" "time" + "github.com/merefield/codexometer/internal/diagnostics" "github.com/pelletier/go-toml/v2" ) @@ -61,7 +62,10 @@ func (r *LiveUsageReader) usageCoverage(ctx context.Context, sessions []LiveUsag continue } if issue == "" && !verified { - issue = r.localUsageCoverage(ctx, session.WorkingDirectory) + diagnostics.Debug("usage coverage", "using local configuration fallback", diagnostics.Fields{Session: session.ID}) + issue = r.localUsageCoverage(ctx, session.WorkingDirectory, session.ID) + } else if verified { + diagnostics.Debug("usage coverage", "using daemon feature result", diagnostics.Fields{Session: session.ID}) } if issue != "" { id := []rune(SanitizeSessionContext(session.ID)) @@ -78,7 +82,7 @@ func (r *LiveUsageReader) usageCoverage(ctx context.Context, sessions []LiveUsag return issues[0] } -func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) string { +func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd, session string) string { paths := []string{filepath.Join(filepath.Dir(r.SessionsRoot), "config.toml")} if filepath.IsAbs(cwd) { for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { @@ -92,7 +96,7 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) st if err := ctx.Err(); err != nil { return "background usage configuration check interrupted" } - enabled, issue := r.localAsyncCompaction(path) + enabled, issue := r.localAsyncCompaction(path, session) if issue != "" { return issue } @@ -103,12 +107,15 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd string) st return "" } -func (r *LiveUsageReader) localAsyncCompaction(path string) (bool, string) { +func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, string) { + fields := diagnostics.Fields{Path: path, Session: session} info, err := os.Stat(path) if errors.Is(err, os.ErrNotExist) { + diagnostics.Debug("local-config", "configuration not present (normal)", fields) return false, "" } if err != nil { + diagnostics.Error("local-config", "stat", err, fields) return false, "background usage configuration could not be read" } if r.localFeatureChecks == nil { @@ -119,16 +126,27 @@ func (r *LiveUsageReader) localAsyncCompaction(path string) (bool, string) { } file, err := os.Open(path) if err != nil { + diagnostics.Error("local-config", "open", err, fields) return false, "background usage configuration could not be read" } data, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) - file.Close() + if closeErr := file.Close(); closeErr != nil { + diagnostics.Error("local-config", "close", closeErr, fields) + } check := localFeatureCheck{info: info} var config map[string]any - if err != nil || len(data) > 1<<20 || toml.Unmarshal(data, &config) != nil { + if err != nil { + diagnostics.Error("local-config", "read", err, fields) + check.issue = "background usage configuration could not be verified" + } else if len(data) > 1<<20 { + diagnostics.Error("local-config", "configuration exceeds 1 MiB limit", nil, fields) + check.issue = "background usage configuration could not be verified" + } else if parseErr := toml.Unmarshal(data, &config); parseErr != nil { + diagnostics.Error("local-config", "parse TOML", parseErr, fields) check.issue = "background usage configuration could not be verified" } else { check.enabled = hasAsyncCompaction(config) + diagnostics.Debug("local-config", "configuration checked", fields) } // Only active-session configuration is relevant; retain a bounded cache. if len(r.localFeatureChecks) >= 256 { diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go new file mode 100644 index 00000000..86c6114b --- /dev/null +++ b/internal/codex/usage_coverage_logging_test.go @@ -0,0 +1,104 @@ +package codex + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/merefield/codexometer/internal/diagnostics" +) + +func TestUsageCoverageLogsFilesystemCauseWithoutChangingTheGuard(t *testing.T) { + home := t.TempDir() + logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml") + close, err := diagnostics.Install(logPath, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + // A directory is unreadable as TOML on every target OS: either open or read + // reports a real filesystem error without depending on root/ACL privileges. + if err := os.Mkdir(config, 0700); err != nil { + t.Fatal(err) + } + r := &LiveUsageReader{} + _, issue := r.localAsyncCompaction(config, "session-123456789") + if !strings.Contains(issue, "could not be") { + t.Fatal("filesystem failure stopped blocking learning", issue) + } + if err := close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"source":"local-config"`, `"session":"session-1234`, `"error_type":`, `config.toml`} { + if !strings.Contains(string(data), want) { + t.Fatalf("missing %s in %s", want, data) + } + } + if strings.Contains(string(data), "details omitted") { + t.Fatal("OS error was hidden", string(data)) + } +} + +func TestUsageCoverageLogsExactUnreadableConfigurationFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating a symlink requires optional Windows privileges") + } + home := t.TempDir() + logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml") + if err := os.Symlink(config, config); err != nil { + t.Fatal(err) + } + close, err := diagnostics.Install(logPath, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + r := &LiveUsageReader{} + _, issue := r.localAsyncCompaction(config, "session-one") + if issue != "background usage configuration could not be read" { + t.Fatal("exact access-failure diagnostic changed", issue) + } + if err := close(); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(logPath) + for _, want := range []string{`"msg":"stat"`, `"path":`, `config.toml`, `"error":`, `"session":"session-one"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("missing %s in %s", want, data) + } + } + if strings.Contains(string(data), "details omitted") { + t.Fatal("actual filesystem cause not captured") + } +} + +func TestUsageCoverageNeverLogsConfigurationContents(t *testing.T) { + home := t.TempDir() + logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml") + close, err := diagnostics.Install(logPath, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + if err := os.WriteFile(config, []byte("secret = PRIVATE_CONFIG_CONTENT ["), 0600); err != nil { + t.Fatal(err) + } + r := &LiveUsageReader{} + _, issue := r.localAsyncCompaction(config, "s") + if issue != "background usage configuration could not be verified" { + t.Fatal(issue) + } + if err := close(); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(logPath) + if strings.Contains(string(data), "PRIVATE_CONFIG_CONTENT") || !strings.Contains(string(data), "parse TOML") { + t.Fatal("unsafe or missing parse diagnostic", string(data)) + } +} diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go new file mode 100644 index 00000000..84fdd28a --- /dev/null +++ b/internal/diagnostics/log.go @@ -0,0 +1,227 @@ +// Package diagnostics provides opt-in, process-local operational logging. +// Never pass prompts, replies, credentials, raw protocol data or upstream error +// text here. Errors are classified, not printed; explicit filesystem paths are +// retained to make local access failures actionable. +package diagnostics + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unicode" +) + +const maxBytes = 2 << 20 + +type Fields struct { + Path, Session string + Code int +} + +type repetition struct { + at time.Time + count int +} + +type logger struct { + mu sync.Mutex + handler slog.Handler + verbose bool + closed bool + writeErr error + seen map[string]repetition +} + +var active atomic.Pointer[logger] + +// Install leaves logging completely off for an empty path. The returned close +// restores the previous logger and reports late write errors after the UI exits. +func Install(path string, verbose bool) (func() error, error) { + if path == "" { + if verbose { + return nil, errors.New("--verbose requires --error-log PATH") + } + return func() error { return nil }, nil + } + output, err := openFile(path) + if err != nil { + return nil, err + } + level := slog.LevelError + if verbose { + level = slog.LevelDebug + } + l := &logger{verbose: verbose, seen: make(map[string]repetition)} + l.handler = slog.NewJSONHandler(output, &slog.HandlerOptions{Level: level}) + previous := active.Swap(l) + var once sync.Once + var closeErr error + return func() error { + once.Do(func() { + active.CompareAndSwap(l, previous) + l.mu.Lock() + defer l.mu.Unlock() + l.closed = true + closeErr = errors.Join(l.writeErr, output.Close()) + }) + return closeErr + }, nil +} + +func Error(source, operation string, err error, fields Fields) { + record(slog.LevelError, source, operation, err, fields) +} + +func Debug(source, operation string, fields Fields) { + record(slog.LevelDebug, source, operation, nil, fields) +} + +func record(level slog.Level, source, operation string, err error, fields Fields) { + l := active.Load() + if l == nil { + return + } + if errors.Is(err, context.Canceled) { + level = slog.LevelDebug + } + if level < slog.LevelError && !l.verbose { + return + } + source, operation = bounded(source, 80), bounded(operation, 120) + path, session := bounded(fields.Path, 2048), bounded(fields.Session, 12) + cause := errorClass(err) + key := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d", level, source, operation, path, session, cause, fields.Code) + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return + } + previous, exists := l.seen[key] + if exists && now.Sub(previous.at) < time.Minute { + previous.count++ + l.seen[key] = previous + return + } + if len(l.seen) >= 128 { + clear(l.seen) + } + l.seen[key] = repetition{at: now} + r := slog.NewRecord(now, level, operation, 0) + r.AddAttrs(slog.String("app", "codexometer"), slog.String("source", source)) + if path != "" { + r.AddAttrs(slog.String("path", path)) + } + if session != "" { + r.AddAttrs(slog.String("session", session)) + } + if fields.Code != 0 { + r.AddAttrs(slog.Int("code", fields.Code)) + } + if err != nil { + r.AddAttrs(slog.String("error", cause), slog.String("error_type", fmt.Sprintf("%T", err))) + } + if previous.count > 0 { + r.AddAttrs(slog.Int("suppressed_repeats", previous.count)) + } + if err := l.handler.Handle(context.Background(), r); err != nil && l.writeErr == nil { + l.writeErr = err + } +} + +// Only trusted OS/context sentinel text is emitted. In particular, fmt-wrapped, +// JSON/TOML and app-server errors can contain secrets or conversation content. +func errorClass(err error) string { + if err == nil { + return "" + } + var errno syscall.Errno + if errors.As(err, &errno) { + return errno.Error() + } + for _, known := range []error{fs.ErrPermission, fs.ErrNotExist, fs.ErrExist, fs.ErrClosed, context.Canceled, context.DeadlineExceeded, io.EOF, io.ErrUnexpectedEOF} { + if errors.Is(err, known) { + return known.Error() + } + } + return "details omitted; inspect operation and error_type" +} + +func bounded(s string, limit int) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, strings.ToValidUTF8(s, "�")) + r := []rune(s) + if len(r) > limit { + return string(r[:limit]) + "…" + } + return s +} + +// A single requested file is bounded: once full, older entries are discarded. +// No backup filenames are overwritten and no extra dependency is needed. +type boundedFile struct { + *os.File + size, limit int64 +} + +func openFile(path string) (*boundedFile, error) { + before, err := os.Lstat(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + if before != nil && !before.Mode().IsRegular() { + return nil, errors.New("error log must be a regular file, not a symlink or device") + } + // Windows append-only handles cannot truncate. One logger serialises its + // writes; seek once on open to append across runs without O_APPEND. + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + info, statErr := f.Stat() + after, pathErr := os.Lstat(path) + if statErr != nil || pathErr != nil || !info.Mode().IsRegular() || !os.SameFile(info, after) || after.Mode()&os.ModeSymlink != 0 { + f.Close() + return nil, errors.New("error log target changed or is not a regular file") + } + if err := f.Chmod(0600); err != nil { + f.Close() + return nil, err + } + if _, err := f.Seek(0, io.SeekEnd); err != nil { + f.Close() + return nil, err + } + return &boundedFile{File: f, size: info.Size(), limit: maxBytes}, nil +} + +func (f *boundedFile) Write(p []byte) (int, error) { + if int64(len(p)) > f.limit { + return 0, errors.New("diagnostic record exceeds log limit") + } + if f.size+int64(len(p)) > f.limit { + if err := f.Truncate(0); err != nil { + return 0, err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return 0, err + } + f.size = 0 + } + n, err := f.File.Write(p) + f.size += int64(n) + return n, err +} diff --git a/internal/diagnostics/log_test.go b/internal/diagnostics/log_test.go new file mode 100644 index 00000000..6f093253 --- /dev/null +++ b/internal/diagnostics/log_test.go @@ -0,0 +1,181 @@ +package diagnostics + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +func records(t *testing.T, path string) []map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var result []map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var record map[string]any + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatal("invalid JSON log record", err) + } + result = append(result, record) + } + return result +} + +func TestLoggingIsOptInAndVerboseRequiresAFile(t *testing.T) { + close, err := Install("", false) + if err != nil { + t.Fatal(err) + } + Error("test", "ignored", errors.New("secret"), Fields{}) + if close() != nil || active.Load() != nil { + t.Fatal("default logging installed a sink") + } + if _, err = Install("", true); err == nil { + t.Fatal("verbose logging without a destination was accepted") + } +} + +func TestFileErrorsAreActionableButArbitraryErrorTextIsNotLogged(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + close, err := Install(path, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + Debug("test", "hidden debug", Fields{}) + Error("local-config", "open", &os.PathError{Op: "open", Path: "/config", Err: syscall.EACCES}, Fields{Path: "/config", Session: "abcdefghijkl-secret"}) + Error("rpc", "turn/start", errors.New("bearer PRIVATE_TOKEN prompt PRIVATE_PROMPT reply PRIVATE_REPLY"), Fields{Code: -32000}) + if err := close(); err != nil { + t.Fatal(err) + } + r := records(t, path) + if len(r) != 2 || r[0]["level"] != "ERROR" || r[0]["path"] != "/config" || r[0]["error"] != syscall.EACCES.Error() || r[0]["session"] != "abcdefghijkl…" || r[1]["code"] != float64(-32000) { + t.Fatal("diagnostic context missing", r) + } + data, _ := os.ReadFile(path) + for _, forbidden := range []string{"PRIVATE_TOKEN", "PRIVATE_PROMPT", "PRIVATE_REPLY", "abcdefghijkl-secret", "hidden debug"} { + if strings.Contains(string(data), forbidden) { + t.Fatal("unsafe or unrequested logging", forbidden) + } + } + info, _ := os.Stat(path) + if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Fatal("log permissions too broad", info.Mode()) + } +} + +func TestVerboseDeduplicatesAndIsConcurrent(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + close, err := Install(path, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + Debug("coverage", "local fallback", Fields{Session: "one"}) + var workers sync.WaitGroup + for range 50 { + workers.Go(func() { Error("config", "stat", os.ErrPermission, Fields{Path: "/config"}) }) + } + workers.Wait() + if got := records(t, path); len(got) != 2 || got[0]["level"] != "DEBUG" { + t.Fatal("missing verbose context or repeat spam", got) + } + l := active.Load() + l.mu.Lock() + for key, value := range l.seen { + value.at = value.at.Add(-2 * time.Minute) + l.seen[key] = value + } + l.mu.Unlock() + Error("config", "stat", os.ErrPermission, Fields{Path: "/config"}) + if err := close(); err != nil { + t.Fatal(err) + } + got := records(t, path) + if len(got) != 3 || got[2]["suppressed_repeats"] != float64(49) { + t.Fatal("suppressed-repeat summary missing", got) + } +} + +func TestLogSizeIsBoundedAndLatestEntrySurvives(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + f, err := openFile(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + f.limit = 64 + for range 20 { + if _, err := f.Write([]byte("{\"msg\":\"old record\"}\n")); err != nil { + t.Fatal(err) + } + } + if _, err := f.Write([]byte("{\"msg\":\"latest record\"}\n")); err != nil { + t.Fatal(err) + } + info, _ := f.Stat() + if info.Size() > f.limit { + t.Fatal("log grew beyond its limit", info.Size()) + } + got := records(t, path) + if got[len(got)-1]["msg"] != "latest record" { + t.Fatal("latest diagnostic was lost", got) + } +} + +func TestLogAppendsAcrossRuns(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + for _, operation := range []string{"first run", "second run"} { + close, err := Install(path, false) + if err != nil { + t.Fatal(err) + } + Error("test", operation, os.ErrPermission, Fields{}) + if err := close(); err != nil { + t.Fatal(err) + } + } + got := records(t, path) + if len(got) != 2 || got[0]["msg"] != "first run" || got[1]["msg"] != "second run" { + t.Fatal("log was overwritten instead of appended", got) + } +} + +func TestInvalidTargetsFailWithoutChangingThem(t *testing.T) { + dir := t.TempDir() + if _, err := Install(dir, false); err == nil { + t.Fatal("directory accepted as log") + } + if _, err := Install(filepath.Join(dir, "missing", "error.log"), false); err == nil { + t.Fatal("inaccessible parent accepted") + } + if runtime.GOOS == "windows" { + return + } + target, link := filepath.Join(dir, "important"), filepath.Join(dir, "link") + if err := os.WriteFile(target, []byte("keep me"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := Install(link, false); err == nil { + t.Fatal("symlink accepted as log") + } + data, _ := os.ReadFile(target) + if string(data) != "keep me" { + t.Fatal("symlink target changed") + } +} diff --git a/intro-post.md b/intro-post.md index 4e7f1f9e..7c6ad099 100644 --- a/intro-post.md +++ b/intro-post.md @@ -117,6 +117,8 @@ Expiry warnings are reminders to review, not instructions to reset immediately. Confirmed turn failures also get their own **ERRORS** category: **MODEL BUSY** for model-capacity failures, **MODEL UNAVAILABLE** for a missing model, **CAPACITY RESTRICTED** for policy-restricted capacity, or **TURN FAILED** with the observed explanation. Click the warning pill to inspect the session. Retrying errors stay **WORKING**; **INPUT NEEDED** means an actual blocking question, not merely a completed turn. The terminal and browser use the same failure detection, with no automatic retry or model switch. Working-command headings can also show elapsed/completed time when the shared server supplies valid timestamps. +Troubleshooting a gremlin? Opt into `--error-log ./error.log`, optionally with `--verbose`, in terminal or web mode. Bounded diagnostics capture actionable filesystem failures and safe operational context—not prompts, replies, credentials or raw API payloads. Paths can reveal project names, so review logs before sharing. + ## An experimental browser companion Prefer a browser window? `codexometer --web` now offers an **experimental, diff --git a/main.go b/main.go index 87263e4f..60484bdc 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/diagnostics" "github.com/merefield/codexometer/internal/digbench" "github.com/merefield/codexometer/internal/ui" "github.com/merefield/codexometer/internal/version" @@ -386,6 +387,8 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { var quotaSteps quotaStepFlags var ( codexPath = flags.String("codex", "codex", "path to the Codex CLI") + errorLog = flags.String("error-log", "", "write bounded operational diagnostics to this file (off by default)") + verbose = flags.Bool("verbose", false, "include diagnostic context in --error-log (never raw payloads)") refresh = flags.Duration("refresh", time.Minute, "quota refresh interval") demo = flags.Bool("demo", false, "show the UI with simulated quota data") inline = flags.Bool("inline", false, "render inline instead of using the alternate screen") @@ -436,6 +439,22 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { fmt.Fprintf(stderr, "codexometer: --reset-warning-hours must be between 0 and %d\n", ui.MaxResetWarningHours) return 2 } + if *verbose && *errorLog == "" { + fmt.Fprintln(stderr, "codexometer: --verbose requires --error-log PATH") + return 2 + } + closeLog, err := diagnostics.Install(*errorLog, *verbose) + if err != nil { + fmt.Fprintln(stderr, "codexometer: cannot open error log:", err) + return 1 + } + defer func() { + if err := closeLog(); err != nil { + fmt.Fprintln(stderr, "codexometer: error log write/close failed:", err) + } + }() + diagnostics.Debug("application", "started", diagnostics.Fields{}) + defer diagnostics.Debug("application", "stopped", diagnostics.Fields{}) // Capture credentials for the benchmark components, then remove them before // any path that can launch Codex. This keeps auth checks and quota reads on // the user's prevailing Codex login instead of leaking or implicitly using a @@ -454,6 +473,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { snapshot, err := deps.checkAuth(context.Background(), *codexPath) if err != nil { fmt.Fprintln(stderr, "Codex auth check failed:", err) + diagnostics.Error("application", "auth check", err, diagnostics.Fields{}) return 1 } fmt.Fprintf(stdout, "Codex auth OK // %d limit meter(s) online\n", len(snapshot.Meters())) @@ -501,12 +521,15 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { client := codex.Client{Binary: *codexPath, QuotaSteps: quotaSteps} if liveUsage, err := codex.NewLiveUsageReader(""); err == nil { client.LiveUsage = liveUsage + } else { + diagnostics.Error("local-rollouts", "initialise session reader", err, diagnostics.Fields{}) } var source web.Source = client if *demo { source = &demoFetcher{quotaSteps: quotaSteps} } if err := deps.startWeb(source, *refresh, *webPort, stdout, *webControl); err != nil { + diagnostics.Error("application", "web interface", err, diagnostics.Fields{}) fmt.Fprintln(stderr, "codexometer:", err) return 1 } @@ -527,6 +550,8 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { client := codex.Client{Binary: *codexPath, BenchmarkAPIKey: benchmarkAPIKey, DigBenchToken: digBenchToken, DigBenchGames: digBenchGames, QuotaSteps: quotaSteps} if liveUsage, err := codex.NewLiveUsageReader(""); err == nil { client.LiveUsage = liveUsage + } else { + diagnostics.Error("local-rollouts", "initialise session reader", err, diagnostics.Fields{}) } var fetcher ui.Fetcher = client if *demo { @@ -534,6 +559,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { } if err := deps.startUI(fetcher, *refresh, *inline, *resetThreshold, *resetWarningHours); err != nil { + diagnostics.Error("application", "terminal interface", err, diagnostics.Fields{}) fmt.Fprintln(stderr, "codexometer:", err) return 1 } diff --git a/main_logging_test.go b/main_logging_test.go new file mode 100644 index 00000000..8015e492 --- /dev/null +++ b/main_logging_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/merefield/codexometer/internal/ui" + "github.com/merefield/codexometer/internal/web" +) + +func TestLoggingFlagsAndLaunchFailures(t *testing.T) { + for _, browser := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "error.log") + deps := dependencies{ + startUI: func(ui.Fetcher, time.Duration, bool, int, int) error { return errors.New("PRIVATE_UPSTREAM_BODY") }, + startWeb: func(web.Source, time.Duration, int, io.Writer, bool) error { + return errors.New("PRIVATE_UPSTREAM_BODY") + }, + } + args := []string{"--demo", "--error-log", path, "--verbose"} + if browser { + args = append(args, "--web") + } + var out, stderr bytes.Buffer + if code := run(args, &out, &stderr, deps); code != 1 { + t.Fatal("launch failure hidden", code) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"msg":"started"`, `"level":"ERROR"`, `"msg":"stopped"`} { + if !strings.Contains(string(data), want) { + t.Fatal("launch diagnostics missing", string(data)) + } + } + if strings.Contains(string(data), "PRIVATE_UPSTREAM_BODY") { + t.Fatal("upstream body leaked to log") + } + } + for _, args := range [][]string{{"--verbose"}, {"--error-log", t.TempDir()}, {"--error-log", filepath.Join(t.TempDir(), "missing", "log")}} { + var out, stderr bytes.Buffer + if run(args, &out, &stderr, dependencies{}) == 0 || stderr.Len() == 0 { + t.Fatal("invalid logging flags did not fail clearly", args) + } + } +} From 107cdd606115193c21fc681493888bb59f03ad2a Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 19:44:47 +0000 Subject: [PATCH 3/7] FIX: preserve empty session hook inventories and address review notes --- README.md | 10 ++++++---- internal/codex/session_hooks_unix.go | 5 +++++ internal/codex/session_hooks_unix_test.go | 12 ++++++++++-- internal/ui/monitor_working.go | 3 ++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 45bea6fb..7603dd98 100644 --- a/README.md +++ b/README.md @@ -1813,8 +1813,9 @@ writable mode. Browse-only entries do not execute tools or change configuration. 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. Model/reasoning, -permission and Plan changes remain guarded by the existing idle-state checks; +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 @@ -2969,8 +2970,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 diff --git a/internal/codex/session_hooks_unix.go b/internal/codex/session_hooks_unix.go index e1c1b457..89f528f0 100644 --- a/internal/codex/session_hooks_unix.go +++ b/internal/codex/session_hooks_unix.go @@ -27,6 +27,11 @@ func (p *daemonStatusProvider) commandHookRows(ctx context.Context, t commandThr return nil, err } } else { + // A successful empty inventory is a valid thread result, not evidence + // of an unsupported selector. Never replace it with directory hooks. + if len(rows) == 0 { + return rows, nil + } for _, raw := range rows { var entry struct{ Cwd string } if json.Unmarshal(raw, &entry) != nil || entry.Cwd == "" { diff --git a/internal/codex/session_hooks_unix_test.go b/internal/codex/session_hooks_unix_test.go index e3dac115..b6240773 100644 --- a/internal/codex/session_hooks_unix_test.go +++ b/internal/codex/session_hooks_unix_test.go @@ -9,7 +9,7 @@ import ( ) func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { - for _, mode := range []string{"thread", "ignored-selector", "rejected-selector", "real-thread-error"} { + for _, mode := range []string{"thread", "empty-inventory", "empty-thread-hooks", "ignored-selector", "rejected-selector", "real-thread-error"} { t.Run(mode, func(t *testing.T) { p, f := newQuotaDaemon(t) row := func(cwd, key, environment string) map[string]any { @@ -20,6 +20,10 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { f.catalogues = map[string]map[string]any{"hooks/list": row("/work", "directory-hook", "")} f.threadHooks = row("/work", "environment-hook", "remote-env") switch mode { + case "empty-inventory": + f.threadHooks = map[string]any{"data": []any{}} + case "empty-thread-hooks": + f.threadHooks = map[string]any{"data": []any{map[string]any{"cwd": "/work", "hooks": []any{}}}} case "ignored-selector": f.threadHooks = row("/server-default-cwd", "wrong-hook", "") case "rejected-selector": @@ -32,6 +36,10 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { if err == nil { t.Fatal("hid a real thread failure behind directory discovery") } + } else if strings.HasPrefix(mode, "empty-") { + if err != nil || len(m.Choices) != 0 { + t.Fatalf("successful empty thread inventory was replaced with directory hooks: %+v, %v", m, err) + } } else { want := "directory-hook" if mode == "thread" { @@ -47,7 +55,7 @@ func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { f.mu.Lock() defer f.mu.Unlock() wantReads := 2 - if mode == "thread" || mode == "real-thread-error" { + if mode == "thread" || mode == "real-thread-error" || strings.HasPrefix(mode, "empty-") { wantReads = 1 } if len(f.hookReads) != wantReads || f.hookReads[0]["threadId"] != "one" || f.hookReads[0]["cwds"] != nil { diff --git a/internal/ui/monitor_working.go b/internal/ui/monitor_working.go index ecde535a..cb63cb6c 100644 --- a/internal/ui/monitor_working.go +++ b/internal/ui/monitor_working.go @@ -1,10 +1,11 @@ package ui import ( + "time" + "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" "github.com/merefield/codexometer/internal/i18n" - "time" ) func hasWorkingCommand(c codex.SessionContext) bool { From 2c6de017d8a513b662b35561e437d5e2a1016f4e Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 20:00:10 +0000 Subject: [PATCH 4/7] FIX: ignore non-directory optional Codex configuration paths --- README.md | 5 ++ internal/codex/usage_coverage.go | 7 +- internal/codex/usage_coverage_logging_test.go | 27 ++++++ internal/codex/usage_coverage_test.go | 85 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7603dd98..4dec6885 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go index 9b655016..a9774711 100644 --- a/internal/codex/usage_coverage.go +++ b/internal/codex/usage_coverage.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "syscall" "time" "github.com/merefield/codexometer/internal/diagnostics" @@ -110,7 +111,11 @@ func (r *LiveUsageReader) localUsageCoverage(ctx context.Context, cwd, session s func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, string) { fields := diagnostics.Fields{Path: path, Session: session} info, err := os.Stat(path) - if errors.Is(err, os.ErrNotExist) { + // ENOTDIR means the optional file cannot exist: for example, a project's + // .codex entry is a regular file rather than a directory. This is absence, + // not unreadable configuration. Permission, I/O and symlink-loop failures + // still reach the error path below. + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { diagnostics.Debug("local-config", "configuration not present (normal)", fields) return false, "" } diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go index 86c6114b..02eac35c 100644 --- a/internal/codex/usage_coverage_logging_test.go +++ b/internal/codex/usage_coverage_logging_test.go @@ -102,3 +102,30 @@ func TestUsageCoverageNeverLogsConfigurationContents(t *testing.T) { t.Fatal("unsafe or missing parse diagnostic", string(data)) } } + +func TestUsageCoverageNonDirectoryOptionalConfigIsDebugNotError(t *testing.T) { + home := t.TempDir() + marker, logPath := filepath.Join(home, ".codex"), filepath.Join(home, "error.log") + if err := os.WriteFile(marker, nil, 0600); err != nil { + t.Fatal(err) + } + close, err := diagnostics.Install(logPath, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + r := &LiveUsageReader{} + if enabled, issue := r.localAsyncCompaction(filepath.Join(marker, "config.toml"), "thread"); enabled || issue != "" { + t.Fatal("optional file under non-directory is not absent", enabled, issue) + } + if err := close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"level":"ERROR"`) || !strings.Contains(string(data), "configuration not present (normal)") { + t.Fatal("non-directory optional config still logged as a failure", string(data)) + } +} diff --git a/internal/codex/usage_coverage_test.go b/internal/codex/usage_coverage_test.go index 7a29242b..8604f3fa 100644 --- a/internal/codex/usage_coverage_test.go +++ b/internal/codex/usage_coverage_test.go @@ -39,6 +39,91 @@ func TestUsageCoverageDetectsFileOnlyConfigurationAndPrefersEffectiveState(t *te } } +func TestUsageCoverageIgnoresNonDirectoryOptionalConfigAndKeepsScanning(t *testing.T) { + for _, nested := range []bool{false, true} { + t.Run(map[bool]string{false: "working directory", true: "ancestor directory"}[nested], func(t *testing.T) { + home := t.TempDir() + project := filepath.Join(home, "workspace") + cwd := project + if nested { + cwd = filepath.Join(project, "nested") + } + if err := os.MkdirAll(cwd, 0700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(project, ".codex") + if err := os.WriteFile(marker, nil, 0600); err != nil { + t.Fatal(err) + } + r := &LiveUsageReader{SessionsRoot: filepath.Join(home, "sessions")} + sessions := []LiveUsageSession{{ID: "thread", WorkingDirectory: cwd, Active: true}} + if got := r.usageCoverage(context.Background(), sessions, nil); got != "" { + t.Fatal("a .codex file was mistaken for inaccessible optional configuration", got) + } + // A blocked path is absence, not permission to stop scanning: a real + // config in a higher ancestor must still qualify coverage. + ancestor := filepath.Join(home, ".codex") + if err := os.Mkdir(ancestor, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ancestor, "config.toml"), []byte("[features]\nasync_compaction = true\n"), 0600); err != nil { + t.Fatal(err) + } + if got := r.usageCoverage(context.Background(), sessions, nil); !strings.Contains(got, "background compaction") { + t.Fatal("non-directory path hid a real ancestor configuration", got) + } + }) + } +} + +func TestUsageCoverageDoesNotCacheNonDirectoryOptionalConfig(t *testing.T) { + home := t.TempDir() + marker := filepath.Join(home, ".codex") + if err := os.WriteFile(marker, nil, 0600); err != nil { + t.Fatal(err) + } + r := &LiveUsageReader{} + path := filepath.Join(marker, "config.toml") + if enabled, issue := r.localAsyncCompaction(path, "thread"); enabled || issue != "" { + t.Fatal("non-directory optional config should be absent", enabled, issue) + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(marker, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("[features]\nasync_compaction = true\n"), 0600); err != nil { + t.Fatal(err) + } + if enabled, issue := r.localAsyncCompaction(path, "thread"); !enabled || issue != "" { + t.Fatal("real config added later was hidden by cached absence", enabled, issue) + } +} + +func TestUsageCoveragePermissionDeniedConfigStillBlocks(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte("[features]\nasync_compaction = false\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0600) }) + file, err := os.Open(path) + if err == nil { + file.Close() + t.Skip("current user/OS can read despite mode bits; cannot exercise denied read") + } + if !os.IsPermission(err) { + t.Fatal("expected an actual permission failure", err) + } + r := &LiveUsageReader{} + if _, issue := r.localAsyncCompaction(path, "thread"); issue != "background usage configuration could not be read" { + t.Fatal("permission failure was incorrectly treated as optional absence", issue) + } +} + func TestUsageCoverageKeepsKnownRiskAcrossDisconnectAndIncludesChildren(t *testing.T) { home := t.TempDir() r := &LiveUsageReader{SessionsRoot: filepath.Join(home, "sessions"), files: map[string]*rolloutCursor{"child": {threadID: "child", workingDirectory: home, lastModified: time.Now()}}} From 50e284d406e3ce53699c96ce9313b8a07b34898d Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 20:18:34 +0000 Subject: [PATCH 5/7] FIX: bound existing diagnostic logs during installation --- internal/diagnostics/log.go | 10 +++++- internal/diagnostics/log_test.go | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go index 84fdd28a..026ca998 100644 --- a/internal/diagnostics/log.go +++ b/internal/diagnostics/log.go @@ -201,11 +201,19 @@ func openFile(path string) (*boundedFile, error) { f.Close() return nil, err } + size := info.Size() + if size > maxBytes { + if err := f.Truncate(0); err != nil { + f.Close() + return nil, err + } + size = 0 + } if _, err := f.Seek(0, io.SeekEnd); err != nil { f.Close() return nil, err } - return &boundedFile{File: f, size: info.Size(), limit: maxBytes}, nil + return &boundedFile{File: f, size: size, limit: maxBytes}, nil } func (f *boundedFile) Write(p []byte) (int, error) { diff --git a/internal/diagnostics/log_test.go b/internal/diagnostics/log_test.go index 6f093253..1be25383 100644 --- a/internal/diagnostics/log_test.go +++ b/internal/diagnostics/log_test.go @@ -3,6 +3,7 @@ package diagnostics import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "runtime" @@ -153,6 +154,64 @@ func TestLogAppendsAcrossRuns(t *testing.T) { } } +func TestInstallNormalizesExistingLogWithoutWritingRecords(t *testing.T) { + for _, size := range []int{maxBytes - 1, maxBytes, maxBytes + 1} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + original := strings.Repeat("x", size) + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + close, err := Install(path, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close() }) + // No records are emitted: installation itself must enforce the cap. + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := original + if size > maxBytes { + want = "" + } + if string(got) != want { + t.Fatalf("installed log size = %d; want %d with original content preserved below the cap", len(got), len(want)) + } + if err := close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestNormalizedLogTracksSizeAndAppendsAtStart(t *testing.T) { + path := filepath.Join(t.TempDir(), "error.log") + if err := os.WriteFile(path, make([]byte, maxBytes+1), 0600); err != nil { + t.Fatal(err) + } + f, err := openFile(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if f.size != 0 { + t.Fatal("oversized log retained stale tracked size", f.size) + } + const record = "{\"msg\":\"after normalization\"}\n" + if _, err := f.Write([]byte(record)); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != record || f.size != int64(len(record)) { + t.Fatal("normalized log did not restart at byte zero", len(got), f.size) + } +} + func TestInvalidTargetsFailWithoutChangingThem(t *testing.T) { dir := t.TempDir() if _, err := Install(dir, false); err == nil { From 10316514a9f4b7bb83081f8492170dea4d060875 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 20:18:34 +0000 Subject: [PATCH 6/7] FIX: retry usage configuration after read or close failures --- internal/codex/usage_coverage.go | 22 ++++-- internal/codex/usage_coverage_logging_test.go | 67 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/internal/codex/usage_coverage.go b/internal/codex/usage_coverage.go index a9774711..2ea5f48f 100644 --- a/internal/codex/usage_coverage.go +++ b/internal/codex/usage_coverage.go @@ -134,16 +134,26 @@ func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, stri diagnostics.Error("local-config", "open", err, fields) return false, "background usage configuration could not be read" } + return r.readLocalAsyncCompaction(file, info, fields) +} + +func (r *LiveUsageReader) readLocalAsyncCompaction(file io.ReadCloser, info os.FileInfo, fields diagnostics.Fields) (bool, string) { data, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) - if closeErr := file.Close(); closeErr != nil { + closeErr := file.Close() + if closeErr != nil { diagnostics.Error("local-config", "close", closeErr, fields) } - check := localFeatureCheck{info: info} - var config map[string]any if err != nil { diagnostics.Error("local-config", "read", err, fields) - check.issue = "background usage configuration could not be verified" - } else if len(data) > 1<<20 { + } + // Transient filesystem failures must be retried, not cached as a valid + // configuration (or as a persistent failure for otherwise unchanged bytes). + if err != nil || closeErr != nil { + return false, "background usage configuration could not be verified" + } + check := localFeatureCheck{info: info} + var config map[string]any + if len(data) > 1<<20 { diagnostics.Error("local-config", "configuration exceeds 1 MiB limit", nil, fields) check.issue = "background usage configuration could not be verified" } else if parseErr := toml.Unmarshal(data, &config); parseErr != nil { @@ -157,7 +167,7 @@ func (r *LiveUsageReader) localAsyncCompaction(path, session string) (bool, stri if len(r.localFeatureChecks) >= 256 { clear(r.localFeatureChecks) } - r.localFeatureChecks[path] = check + r.localFeatureChecks[fields.Path] = check return check.enabled, check.issue } diff --git a/internal/codex/usage_coverage_logging_test.go b/internal/codex/usage_coverage_logging_test.go index 02eac35c..44f88d85 100644 --- a/internal/codex/usage_coverage_logging_test.go +++ b/internal/codex/usage_coverage_logging_test.go @@ -1,15 +1,82 @@ package codex import ( + "io" "os" "path/filepath" "runtime" "strings" "testing" + "testing/iotest" "github.com/merefield/codexometer/internal/diagnostics" ) +type coverageReadCloser struct { + io.Reader + closeErr error + closed bool +} + +func (f *coverageReadCloser) Close() error { + f.closed = true + return f.closeErr +} + +func TestUsageCoverageReadAndCloseFailuresAreLoggedAndRetried(t *testing.T) { + for _, failure := range []string{"read", "close", "read and close"} { + t.Run(failure, func(t *testing.T) { + dir := t.TempDir() + path, logPath := filepath.Join(dir, "config.toml"), filepath.Join(dir, "error.log") + const config = "[features]\nasync_compaction = true\n" + if err := os.WriteFile(path, []byte(config), 0600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + closeLog, err := diagnostics.Install(logPath, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeLog() }) + file := &coverageReadCloser{Reader: strings.NewReader(config)} + if strings.Contains(failure, "read") { + file.Reader = iotest.ErrReader(os.ErrPermission) + } + if strings.Contains(failure, "close") { + file.closeErr = os.ErrPermission + } + r := &LiveUsageReader{localFeatureChecks: make(map[string]localFeatureCheck)} + enabled, issue := r.readLocalAsyncCompaction(file, info, diagnostics.Fields{Path: path, Session: "thread"}) + if enabled || issue != "background usage configuration could not be verified" || !file.closed { + t.Fatal("filesystem failure did not block verification and close the file", enabled, issue, file.closed) + } + if len(r.localFeatureChecks) != 0 { + t.Fatal("failed filesystem operation was cached", r.localFeatureChecks) + } + if err := closeLog(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + for _, operation := range []string{"read", "close"} { + if strings.Contains(failure, operation) && !strings.Contains(string(data), `"msg":"`+operation+`"`) { + t.Fatal("filesystem failure was not logged", operation, string(data)) + } + } + // Retry the same path and unchanged metadata after the transient + // failure. A successful read must now detect and cache the flag. + if enabled, issue := r.localAsyncCompaction(path, "thread"); !enabled || issue != "" || !r.localFeatureChecks[path].enabled { + t.Fatal("unchanged configuration was not successfully retried", enabled, issue) + } + }) + } +} + func TestUsageCoverageLogsFilesystemCauseWithoutChangingTheGuard(t *testing.T) { home := t.TempDir() logPath, config := filepath.Join(home, "error.log"), filepath.Join(home, "config.toml") From 2f0acff70eee9d10e51f475be0e9a8a7651fd471 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 30 Oct 2026 20:37:21 +0000 Subject: [PATCH 7/7] FIX: distinguish current session failures and stale command timing --- internal/ui/monitor_context.go | 2 +- internal/ui/monitor_context_modes.go | 2 +- internal/ui/monitor_detail.go | 2 +- internal/ui/monitor_working.go | 14 +++-- internal/ui/monitor_working_test.go | 56 +++++++++++++++++-- .../{index-Cq0BuLLm.js => index-DdTUC-03.js} | 2 +- internal/web/dist/index.html | 2 +- internal/web/session_failure_test.go | 45 ++++++++++++++- internal/web/state.go | 3 + web/src/Sessions.svelte | 4 +- web/src/state.svelte.ts | 1 + web/tests/browser.spec.ts | 23 ++++++++ 12 files changed, 140 insertions(+), 16 deletions(-) rename internal/web/dist/assets/{index-Cq0BuLLm.js => index-DdTUC-03.js} (97%) diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index a1a9bf95..7eae67fe 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -158,7 +158,7 @@ func (m Model) renderMonitorContextRow(width, height int, metrics string, s moni lines[textRows-1] = ansi.Truncate(lines[textRows-1], max(inner-1, 0), "") + "…" } if hasWorkingCommand(s.preview) { - lines = workingContextLines(s.preview, inner, contentRows, true) + lines = m.workingContextLines(s.preview, inner, contentRows, true) } else if contentRows > textRows { lines = append(lines, terminalLabel(s.preview.Source)+" // "+shortSessionID(s.preview.ThreadID)+" // "+contextAge(s.preview)) } diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index a27b3dfc..702dd7c7 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -232,7 +232,7 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors lines = expandedContextLines(width, withoutTask) } if hasWorkingCommand(s.preview) { - lines = workingContextLines(s.preview, max(width-4, 1), max(textRows, 1), false) + lines = m.workingContextLines(s.preview, max(width-4, 1), max(textRows, 1), false) } if len(lines) > textRows { lines = lines[:textRows] diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go index 396c9468..d7e873a6 100644 --- a/internal/ui/monitor_detail.go +++ b/internal/ui/monitor_detail.go @@ -117,7 +117,7 @@ func (m Model) contextDetailDocument(width int) (document []detailLine) { section(i18n.Text("COMMENTARY")) appendText(codex.SanitizeSessionContext(c.Activity.Prose), "body", "") } - section(workingCommandTitle(c.Activity)) + section(m.workingCommandTitle(c.Activity)) prefix := "│ " if width < 3 { prefix = "" diff --git a/internal/ui/monitor_working.go b/internal/ui/monitor_working.go index cb63cb6c..7fe1cb00 100644 --- a/internal/ui/monitor_working.go +++ b/internal/ui/monitor_working.go @@ -12,7 +12,7 @@ func hasWorkingCommand(c codex.SessionContext) bool { return c.Kind == codex.SessionContextActivity && c.Activity.Command != "" } -func workingCommandTitle(a codex.SessionActivity) string { +func (m Model) workingCommandTitle(a codex.SessionActivity) string { status := "UNKNOWN" switch a.CommandStatus { case "running": @@ -25,8 +25,12 @@ func workingCommandTitle(a codex.SessionActivity) string { status = "DECLINED" } title := i18n.Text("COMMAND") + " // " + i18n.Text(status) - if timing := a.CommandTiming(time.Now()); timing != "" { - title += " // " + timing + // Completed duration is historical evidence. Running elapsed time must not + // keep advancing when the retained command is only a stale observation. + if a.CommandStatus != "running" || m.monitorState == monitorRunning && m.monitorError == "" { + if timing := a.CommandTiming(time.Now()); timing != "" { + title += " // " + timing + } } extra := a.RunningCommands if a.CommandStatus == "running" { @@ -42,7 +46,7 @@ func workingCommandTitle(a codex.SessionActivity) string { } // Reserve space for the command on roomy rows; tiny previews prioritise prose. -func workingContextLines(c codex.SessionContext, width, rows int, compact bool) []string { +func (m Model) workingContextLines(c codex.SessionContext, width, rows int, compact bool) []string { width, rows = max(width, 1), max(rows, 1) fit := func(text string, n int, cook bool) []string { lines := wrappedSessionWebText(codex.SanitizeSessionContext(text), width, cook) @@ -68,7 +72,7 @@ func workingContextLines(c codex.SessionContext, width, rows int, compact bool) lines = append(lines, "") } if rows-len(lines) >= 2 { - lines = append(lines, ansi.Truncate(workingCommandTitle(a), width, "…")) + lines = append(lines, ansi.Truncate(m.workingCommandTitle(a), width, "…")) lines = append(lines, fit(a.Command, rows-len(lines), false)...) } else if len(lines) == 0 { lines = fit(a.Command, rows, false) diff --git a/internal/ui/monitor_working_test.go b/internal/ui/monitor_working_test.go index f0847889..270b7299 100644 --- a/internal/ui/monitor_working_test.go +++ b/internal/ui/monitor_working_test.go @@ -10,13 +10,14 @@ import ( ) func TestWorkingContextResponsive(t *testing.T) { + m := approvalTestModel() c := codex.SessionContext{Kind: codex.SessionContextActivity, Text: "Checking the build", Activity: codex.SessionActivity{ Prose: "Checking the build", Command: "go test ./...", CommandStatus: "running", RunningCommands: 2, }} for _, width := range []int{1, 5, 20, 80} { for rows := 1; rows <= 12; rows++ { for _, compact := range []bool{true, false} { - lines := workingContextLines(c, width, rows, compact) + lines := m.workingContextLines(c, width, rows, compact) if len(lines) > rows { t.Fatalf("height overflow: %v", lines) } @@ -36,7 +37,6 @@ func TestWorkingContextResponsive(t *testing.T) { } } } - m := approvalTestModel() m.monitorSessionData[0].preview = c for _, width := range []int{1, 20, 80} { doc := m.contextDetailDocument(width) @@ -62,15 +62,16 @@ func TestWorkingContextResponsive(t *testing.T) { } func TestWorkingCommandRecordedDurationRemainsResponsive(t *testing.T) { + m := contextTestModel() start := time.Now().Add(-time.Minute) a := codex.SessionActivity{Command: "go test", CommandStatus: "completed", CommandStartedAt: start, CommandCompletedAt: start.Add(7 * time.Second)} - if got := workingCommandTitle(a); got != "COMMAND // COMPLETED // 7s" { + if got := m.workingCommandTitle(a); got != "COMMAND // COMPLETED // 7s" { t.Fatal("recorded duration missing", got) } c := codex.SessionContext{Kind: codex.SessionContextActivity, Activity: a} for _, width := range []int{1, 8, 20, 80} { for _, compact := range []bool{false, true} { - lines := workingContextLines(c, width, 4, compact) + lines := m.workingContextLines(c, width, 4, compact) for _, line := range lines { if ansi.StringWidth(line) > width { t.Fatal("duration caused layout overflow", line) @@ -82,3 +83,50 @@ func TestWorkingCommandRecordedDurationRemainsResponsive(t *testing.T) { } } } + +func TestWorkingCommandTimingRespectsTelemetryFreshnessInEveryView(t *testing.T) { + for _, tc := range []struct { + name, observationError, commandStatus string + state monitorState + wantTiming bool + }{ + {"live", "", "running", monitorRunning, true}, + {"failed observation", "telemetry unavailable", "running", monitorRunning, false}, + {"starting", "", "running", monitorStarting, false}, + {"resetting", "", "running", monitorResetting, false}, + {"idle", "", "running", monitorIdle, false}, + {"completed with stale telemetry", "telemetry unavailable", "completed", monitorRunning, true}, + } { + t.Run(tc.name, func(t *testing.T) { + m := contextTestModel() + m.monitorState, m.monitorError = tc.state, tc.observationError + start := time.Now().Add(-time.Minute) + s := m.monitorSessionData[0] + s.preview = codex.SessionContext{Kind: codex.SessionContextActivity, Text: "Checking", Activity: codex.SessionActivity{ + Prose: "Checking", Command: "go test", CommandStatus: tc.commandStatus, + CommandStartedAt: start, CommandCompletedAt: start.Add(7 * time.Second), + }} + m.monitorSessionData[0] = s + colors := paletteFor(m.theme) + m.setRowContext(s.id, contextSplit) + compact := m.renderMonitorContextRow(240, 15, "", s, colors) + m.setRowContext(s.id, contextWide) + wide := m.renderExpandedContext(120, 15, s, colors) + m.setRowContext(s.id, contextFull) + full := strings.Join(m.contextDetailLines(120), "\n") + for view, text := range map[string]string{"compact": compact, "wide": wide, "full": full} { + text = ansi.Strip(text) + heading := "COMMAND // " + strings.ToUpper(tc.commandStatus) + if !strings.Contains(text, heading) { + t.Fatalf("%s lost retained command context: %s", view, text) + } + if strings.Contains(text, heading+" //") != tc.wantTiming { + t.Fatalf("%s: timing freshness mismatch (want timing=%v): %s", view, tc.wantTiming, text) + } + if tc.commandStatus == "completed" && !strings.Contains(text, heading+" // 7s") { + t.Fatalf("%s lost recorded completed duration: %s", view, text) + } + } + }) + } +} diff --git a/internal/web/dist/assets/index-Cq0BuLLm.js b/internal/web/dist/assets/index-DdTUC-03.js similarity index 97% rename from internal/web/dist/assets/index-Cq0BuLLm.js rename to internal/web/dist/assets/index-DdTUC-03.js index af907cef..7de76c01 100644 --- a/internal/web/dist/assets/index-Cq0BuLLm.js +++ b/internal/web/dist/assets/index-DdTUC-03.js @@ -30,7 +30,7 @@ stale.

`),Us=J(`

Some quota profile checks are unavailable. Only sessions with freshly verified quota and settings can be updated; previous outcome notices remain visible.

`),Ws=J(` `),Gs=J(` `),Ks=J(` `),qs=J(``),Js=J(`

Read only — reply or approve in Codex.

`),Ys=J(`

`),Xs=J(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Zs=J(`
`),Qs=J(`
`),$s=J(`

This session is no longer in the current observation. Return to sessions.

`),ec=J(`

`),tc=J(``),nc=J(`

`),rc=J(` `),ic=J(`

`),ac=J(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),oc=J(` `,1),sc=J(`

`),cc=J(`

TOKEN ACTIVITY // 30 SECOND SAMPLES

`),lc=J(`

TOKENS

FULL DETAIL →
`),uc=J(`

No locally observed sessions yet. Keep Codex running alongside - Codexometer.

`),dc=J(`

↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

`,1),fc=J(`

SESSION TOTALS

`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.contextKind===`TURN ERROR`}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``} + Codexometer.

`),dc=J(`

↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

`,1),fc=J(`

SESSION TOTALS

`,1);function pc(e,t){Ue(t,!0);let n=(e,t=f,n,r)=>{let i=pt(()=>g(n?.(),!0)),a=pt(()=>g(r?.(),!1));var o=Bs(),s=B(o),c=e=>{var n=ks(),r=B(n),i=V(r,!0),a=V(H(r,2),!0);U(()=>{X(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),X(a,t().currentTask)}),Y(e,n)};Z(s,e=>{t().currentTask&&e(c)});var l=H(s,2),u=e=>{var n=As(),r=V(H(B(n),2),!0);U(()=>X(r,t().latestGuidance)),Y(e,n)};Z(l,e=>{t().latestGuidance&&e(u)});var d=H(l,2),p=e=>{var n=js(),r=V(H(B(n),2),!0);xe(2),U(()=>X(r,t().approvalContext)),Y(e,n)};Z(d,e=>{K(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=H(d,2),h=e=>{var n=Ms();let r;var i=V(n,!0);U(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),X(i,e)},[()=>D(t())]),Y(e,n)},_=F(()=>D(t())&&(!K(a)||K(v)||t().status===`CHECK SESSION`||y(t())));Z(m,e=>{K(_)&&e(h)});var b=H(m,2),x=e=>{var n=Ns(),r=V(n,!0);U(e=>X(r,e),[()=>ee(t())]),Y(e,n)};Z(b,e=>{K(i)&&e(x)});var S=H(b,2),C=e=>{var n=Ps(),r=V(n,!0);U(()=>X(r,t().text||`No session context available.`)),Y(e,n)};Z(S,e=>{(t().text||!t().workingCommand)&&e(C)});var w=H(S,2),T=e=>{var n=Fs(),r=H(B(n),2),i=z(r),a=H(i),o=e=>{var n=Mr();U(()=>X(n,`// ${t().commandTiming??``}`)),Y(e,n)};Z(a,e=>{t().commandTiming&&e(o)});var s=H(a,2),c=e=>{var n=Mr();U(()=>X(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),Y(e,n)};Z(s,e=>{!K(v)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(c)});var l=H(s,2),u=e=>{Y(e,Mr(`+`))};Z(l,e=>{t().runningLimited&&e(u)}),P(r);var d=V(H(r,2),!0);U(e=>{X(i,`COMMAND // ${e??``} `),X(d,t().workingCommand)},[()=>K(v)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),Y(e,n)};Z(w,e=>{t().workingCommand&&e(T)});var E=H(w,2),te=e=>{Ro(e,{get lines(){return t().fileChanges}})},O=e=>{var n=Rs(),r=H(B(n),2),i=V(r,!0),a=H(r,2),o=e=>{var n=Is(),r=V(n,!0);U(()=>X(r,t().command)),Y(e,n)},s=e=>{Y(e,Ls())};Z(a,e=>{t().command?e(o):e(s,-1)}),U(()=>X(i,K(v)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),Y(e,n)};Z(E,e=>{(!K(a)||!$.data?.control)&&t().fileChanges?.length?e(te):K(a)&&$.data?.control||!t().command&&t().status!==`APPROVAL NEEDED`||e(O,1)});var ne=H(E,2),k=e=>{var n=zs(),r=V(n);U(()=>X(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),Y(e,n)};Z(ne,e=>{K(a)||e(k)}),Y(e,o)},r=L(void 0),i=Wi(t,`params`,19,()=>({})),a=F(()=>$.data?.sessions||[]),o=F(()=>$.data?.control&&$.data.profiles||[]),s=F(()=>($.data?.triggers||[]).filter(e=>K(a).some(t=>t.id===e.session)));function c(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}function l(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&K(s).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let u=L(!1);function d(e,t){i().id&&i().id!==t&&K(u)?e.preventDefault():w(t)}vn(()=>{let e=i().id;e&&fr(()=>{aa.selected=e,fa(e,2)})});let p=F(()=>K(a).find(e=>e.id===i().id)),m=F(()=>new URLSearchParams(Yi.querystring).get(`review`)===`profile`&&K(o).some(e=>e.session===i().id&&e.pending)),h=F(()=>K(a).some(e=>e.id===aa.selected)?aa.selected:K(a)[0]?.id),v=F(()=>!$.connected||!!$.data?.sessionsError);function y(e){return e.status!==`STALE`&&e.failed===!0}let b=F(()=>[[`OBSERVED TOKENS`,pa(K(a).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,pa(K(a).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`ERRORS`,`ERRORS`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,K(v)?`—`:pa(K(a).filter(e=>t===`ERRORS`?y(e):e.status===t).length)])]),x=F(()=>K(a).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`].includes(e.status)||y(e)).sort((e,t)=>S(e)-S(t)));function S(e){return e.status===`APPROVAL NEEDED`?0:e.status===`INPUT NEEDED`?1:2}let C=F(()=>K(a).filter(e=>e.status===`TURN COMPLETE`&&!K(s).some(t=>t.session===e.id)&&!K(o).some(t=>t.session===e.id&&t.pending)));function w(e){aa.selected=e,lr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function T(e,t){if(w(e),i().id){t<0&&(fa(e,2),location.hash=`/sessions`);return}let n=ua(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):fa(e,n)}function E(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&i().id)e.preventDefault(),T(i().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(i().id||K(h)))e.preventDefault(),T(i().id||K(h),e.key===`ArrowLeft`?-1:1);else if(!i().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&K(a).length){e.preventDefault();let t=K(a).findIndex(e=>e.id===K(h));w(K(a)[Math.max(0,Math.min(K(a).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function D(e){return K(v)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:y(e)?e.status===`MODEL BUSY`?`CONFIRMED TURN FAILURE — the selected model was at capacity. Choose another model with /model or retry later in Codex. This is not a quota-exhaustion signal.`:e.status===`MODEL UNAVAILABLE`?`CONFIRMED TURN FAILURE — the provider could not find the selected model. Choose an available model in Codex. No automatic retry or model change.`:e.status===`CAPACITY RESTRICTED`?`CONFIRMED TURN FAILURE — capacity is unavailable on a policy-restricted route. This is not a quota-exhaustion signal. Check Codex for details; no automatic retry or policy change.`:`CONFIRMED TURN FAILURE — inspect the error below before sending another prompt. No automatic retry or model change.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function ee(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var te=fc();xr(`keydown`,Qt,E);var O=B(te),ne=V(H(z(O),2));P(O);var k=H(O,2);let re;Xr(k,21,()=>K(b),Kr,(e,t)=>{var n=F(()=>_(K(t),2));let r=()=>K(n)[0],i=()=>K(n)[1];var a=Vs();let o;var s=z(a),c=V(s,!0),l=V(H(s,2),!0);P(a),U(()=>{o=ci(a,1,`svelte-mm4wfe`,null,o,{failed:r()===`ERRORS`&&i()!==`0`&&i()!==`—`}),X(c,r()),X(l,i())}),Y(e,a)}),P(k);var ie=H(k,2),ae=V(ie),oe=H(ie,2),se=e=>{Y(e,Hs())};Z(oe,e=>{K(v)&&e(se)});var A=H(oe,2),ce=e=>{Y(e,Us())};Z(A,e=>{$.data?.profileError&&e(ce)});var le=H(A,2),ue=e=>{var t=qs(),n=z(t);Xr(n,17,()=>K(x),Kr,(e,t)=>{var n=Ws();let r;var i=V(n);U((e,a,o)=>{r=ci(n,1,`button`,null,r,{approval:e}),Q(n,`href`,a),X(i,`${K(t).status??``} ${o??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>K(t).status===`APPROVAL NEEDED`||K(t).status===`INPUT NEEDED`||y(K(t)),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)});var r=H(n,2);Xr(r,17,()=>K(o).filter(e=>e.pending&&K(a).some(t=>t.id===e.session)),Kr,(e,t)=>{var n=Gs(),r=V(n);U((e,a,o)=>{Q(n,`title`,i().id&&i().id!==K(t).session&&K(u)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),X(r,`QUOTA THRESHOLD ${a??``} // ${o??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session)+`?review=profile`,()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory||K(t).session]),q(`click`,n,e=>d(e,K(t).session)),Y(e,n)});var l=H(r,2);Xr(l,17,()=>K(C),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,i)=>{Q(n,`href`,e),X(r,`TURN COMPLETE ${i??``} // ${(K(t).name||K(t).directory||K(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,n,()=>w(K(t).id)),Y(e,n)}),Xr(H(l,2),17,()=>K(s),Kr,(e,t)=>{var n=Ks(),r=V(n);U((e,t,i)=>{Q(n,`href`,e),X(r,`TRIGGER SET ${t??``} // ${i??``}`)},[()=>`#/sessions/`+encodeURIComponent(K(t).session),()=>Array.from(K(t).session).slice(-5).join(``).toUpperCase(),()=>K(a).find(e=>e.id===K(t).session)?.name||K(a).find(e=>e.id===K(t).session)?.directory]),q(`click`,n,e=>c(e,K(t).session)),Y(e,n)}),P(t),Y(e,t)},de=F(()=>(K(x).length||K(C).length||K(o).some(e=>e.pending)||K(s).length)&&!K(v));Z(le,e=>{K(de)&&e(ue)});var fe=H(le,2),j=e=>{var t=Nr(),a=B(t),s=e=>{var t=Qs(),a=z(t),s=z(a);let c;var d=z(s);let f;var h=H(d);P(s);var g=H(s,2);P(a);var _=H(a,2),b=e=>{var t=zs(),n=V(t,!0);U(()=>X(n,K(p).directory)),Y(e,t)};Z(_,e=>{K(p).name&&K(p).directory&&e(b)});var x=H(_,2),S=V(x),C=H(x,2);let T;var E=z(C),D=z(E);n(D,()=>K(p),()=>!0,()=>!0),P(E);var ee=H(E,2),te=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{{let t=F(()=>new URLSearchParams(Yi.querystring).get(`schedule`)===`edit`),n=F(()=>K(p).fileChanges||[]);xs(e,{get session(){return K(p).id},oncopy:()=>K(r)?.copy(),get openScheduler(){return K(t)},get observedCommand(){return K(p).command},get observedFileChanges(){return K(n)},get suspended(){return K(m)},onProtectedChange:e=>{R(u,e,!0)}})}}),Y(e,t)},O=e=>{var t=Js();Gr(H(z(t),2),()=>K(p).id,e=>{Po(e,{get session(){return K(p).id},localOnly:!0,oncopy:()=>K(r)?.copy()})}),P(t),Y(e,t)};Z(ee,e=>{$.data?.control?e(te):e(O,-1)}),P(C);var ne=H(C,2);Xr(ne,17,()=>K(o).filter(e=>e.session===K(p).id),e=>e.session,(e,t)=>{var n=Zs(),r=z(n),i=e=>{var n=Ys(),r=V(n,!0);U(()=>X(r,K(t).notice)),Y(e,n)};Z(r,e=>{K(t).notice&&e(i)});var a=H(r,2),o=e=>{xs(e,{get session(){return K(p).id},review:`profile`})},s=e=>{var t=Xs();U(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(p).id)+`?review=profile`]),Y(e,t)};Z(a,e=>{K(t).pending&&K(m)?e(o):K(t).pending&&e(s,1)}),P(n),U(()=>Q(n,`id`,`quota-profile-`+K(p).id)),Y(e,n)});var k=H(ne,2),re=e=>{var t=Nr();Gr(B(t),()=>K(p).id,e=>{Ii(Os(e,{get session(){return K(p)},active:!0}),e=>R(r,e,!0),()=>K(r))}),Y(e,t)};Z(k,e=>{K(m)||e(re)}),Gr(H(k,2),()=>K(p).id,e=>{Es(e,{get session(){return K(p)},get stale(){return K(v)}})}),P(t),U((e,t,n,r)=>{c=ci(s,1,`svelte-mm4wfe`,null,c,{failed:e}),f=ci(d,1,`lamp lit svelte-mm4wfe`,null,f,{failed:t,working:K(p).status===`WORKING`&&!K(v)}),X(h,`${n??``} // ${(K(p).name||K(p).directory)??``}`),X(S,`${r??``} TOKENS // ${K(p).id??``} // CONTEXT SOURCE // ${(K(p).source||`LOCAL`)??``}`),T=ui(C,``,T,{display:K(m)?`none`:void 0})},[()=>y(K(p))&&!K(v)&&!K(m),()=>y(K(p))&&!K(v),()=>K(v)?`STALE`:K(m)?`QUOTA THRESHOLD`:l(K(p)),()=>pa(K(p).tokens)]),q(`click`,g,()=>{w(i().id),fa(i().id,2)}),Y(e,t)},c=e=>{Y(e,$s())};Z(a,e=>{K(p)?e(s):e(c,-1)}),Y(e,t)},pe=e=>{var t=dc(),r=B(t),i=H(z(r),2),s=z(i),c=H(s,2);P(i),P(r);var u=H(r,2);Xr(u,17,()=>K(a),e=>e.id,(e,t)=>{let r=F(()=>ua(K(t).id));var i=lc();let a;var s=z(i),c=z(s),u=e=>{var n=ec(),r=z(n),i=V(r);P(n),U(e=>{Q(r,`aria-pressed`,K(h)===K(t).id),X(i,`${e??``} // ${K(t).name??``}`)},[()=>Array.from(K(t).id).slice(-5).join(``).toUpperCase()]),q(`click`,r,()=>w(K(t).id)),Y(e,n)};Z(c,e=>{K(t).name&&e(u)});var f=H(c,2),p=z(f);let m;var g=z(p);let _;var b=H(g,1,!0);P(p),P(f);var x=H(f,2),S=e=>{var n=tc(),r=V(n,!0);U(()=>{Q(n,`aria-pressed`,K(h)===K(t).id),X(r,K(t).directory||K(t).id)}),q(`click`,n,()=>w(K(t).id)),Y(e,n)};Z(x,e=>{K(t).name||e(S)});var C=H(x,2),E=z(C);xe(),P(C);var D=H(C,2),te=e=>{var n=zs(),r=V(n,!0);U(()=>X(r,K(t).directory)),Y(e,n)};Z(D,e=>{K(t).name&&K(t).directory&&e(te)});var O=H(D,2),ne=V(O),k=H(O,2),re=V(k),ie=H(k,2),ae=z(ie),oe=H(ae,2),se=V(oe,!0),A=H(oe,2);P(ie);var ce=H(ie,2),le=H(ce,2),ue=e=>{var n=nc(),r=V(n,!0);U(e=>X(r,e),[()=>K(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:y(K(t))?`OPEN DETAIL FOR THE TURN ERROR`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:K(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`]),Y(e,n)},de=F(()=>!K(v)&&([`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(K(t).status)||y(K(t))));Z(le,e=>{K(de)&&e(ue)}),P(s);var fe=H(s,2),j=e=>{var r=sc(),i=z(r),a=z(i),s=V(a,!0),c=H(a,2),l=e=>{var n=rc(),r=V(n,!0);U(e=>{Q(n,`href`,e),X(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(K(t).id)]),Y(e,n)};Z(c,e=>{!K(v)&&K(t).status===`APPROVAL NEEDED`&&e(l)}),P(i);var u=H(i,2);n(u,()=>K(t),()=>!1);var f=H(u,2);Xr(f,17,()=>K(o).filter(e=>e.session===K(t).id),Kr,(e,n)=>{var r=oc(),i=B(r),a=e=>{var t=ic(),r=V(t,!0);U(()=>X(r,K(n).notice)),Y(e,t)};Z(i,e=>{K(n).notice&&e(a)});var o=H(i,2),s=e=>{var n=ac();U(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(K(t).id)+`?review=profile`]),q(`click`,n,e=>d(e,K(t).id)),Y(e,n)};Z(o,e=>{K(n).pending&&e(s)}),Y(e,r)});var p=H(f,2);{let e=F(()=>K(h)===K(t).id);Os(p,{get session(){return K(t)},get active(){return K(e)}})}P(r),U(e=>X(s,e),[()=>ee(K(t))]),Y(e,r)};Z(fe,e=>{K(r)>0&&e(j)});var pe=H(fe,2),me=e=>{var n=cc(),r=H(z(n),2);{let e=F(()=>(K(t).samples||[]).map(e=>e.tokens));io(r,{get values(){return K(e)},capacity:120})}var i=V(H(r,2));P(n),U(()=>X(i,`LAST ${(K(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),Y(e,n)};Z(pe,e=>{K(r)<2&&e(me)}),P(i),U((e,n,o,s,c,l,u)=>{a=ci(i,1,`session-row`,null,a,{selected:K(h)===K(t).id,wide:K(r)===2,split:K(r)===1}),Q(i,`aria-label`,`Session `+(K(t).name||K(t).directory||K(t).id)),m=ci(p,1,`status-detail svelte-mm4wfe`,null,m,{failed:e}),Q(p,`href`,n),_=ci(g,1,`lamp lit svelte-mm4wfe`,null,_,{failed:o,working:K(t).status===`WORKING`&&!K(v)}),X(b,s),X(E,`${c??``} `),X(ne,`${K(t).agents??``} LINKED AGENTS`),X(re,`ACTIVE // ${l??``}`),ae.disabled=K(r)===0,Q(oe,`aria-expanded`,K(r)>0),X(se,K(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(ce,`href`,u)},[()=>y(K(t))&&!K(v),()=>`#/sessions/`+encodeURIComponent(K(t).id),()=>y(K(t))&&!K(v),()=>K(v)?`STALE`:l(K(t)),()=>pa(K(t).tokens),()=>ma(K(t).activity),()=>`#/sessions/`+encodeURIComponent(K(t).id)]),q(`click`,p,()=>w(K(t).id)),q(`click`,ae,()=>T(K(t).id,-1)),q(`click`,oe,()=>{w(K(t).id),fa(K(t).id,+!K(r))}),q(`click`,A,()=>T(K(t).id,1)),q(`click`,ce,()=>w(K(t).id)),Y(e,i)});var f=H(u,2),p=e=>{Y(e,uc())};Z(f,e=>{K(a).length||e(p)}),q(`click`,s,()=>da(1)),q(`click`,c,()=>da(0)),Y(e,t)};Z(fe,e=>{i().id?e(j):e(pe,-1)}),U(e=>{X(ne,`OBSERVED ${e??``}`),re=ci(k,1,`session-totals`,null,re,{stale:K(v)}),X(ae,`${K(v)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens observed since this server started for currently listed sessions; linked diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 381d8ec1..7d6bfc93 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@ Codexometer // Experimental web - + diff --git a/internal/web/session_failure_test.go b/internal/web/session_failure_test.go index 73bc9d6a..68a76782 100644 --- a/internal/web/session_failure_test.go +++ b/internal/web/session_failure_test.go @@ -1,8 +1,11 @@ package web import ( - "github.com/merefield/codexometer/internal/codex" + "errors" "testing" + "time" + + "github.com/merefield/codexometer/internal/codex" ) func TestSessionFailurePresentation(t *testing.T) { @@ -21,3 +24,43 @@ func TestSessionFailurePresentation(t *testing.T) { } } } + +func TestSessionFailureProjectionUsesCurrentAttentionNotSavedContext(t *testing.T) { + for _, tc := range []struct { + name, status string + active bool + working bool + attention codex.SessionAttention + kind codex.SessionContextKind + failed bool + }{ + {"closed", "INACTIVE", false, false, codex.SessionAttentionNone, codex.SessionContextFailure, false}, + {"idle", "IDLE", true, false, codex.SessionAttentionNone, codex.SessionContextFailure, false}, + {"working", "WORKING", true, true, codex.SessionAttentionNone, codex.SessionContextFailure, false}, + {"current failure", "MODEL BUSY", true, false, codex.SessionAttentionError, codex.SessionContextFailure, true}, + {"failure without saved context", "TURN FAILED", true, false, codex.SessionAttentionError, codex.SessionContextNone, true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newStore() + row := codex.LiveUsageSession{ID: "thread", Active: tc.active, Working: tc.working, Attention: tc.attention, + Context: codex.SessionContext{Kind: tc.kind, FailureCode: "serverOverloaded", Text: "Saved explanation"}} + if tc.kind == codex.SessionContextNone { + row.Context.FailureCode = "" + } + now := time.Now() + snapshot := codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}} + s.live(snapshot, nil, now) + if got := s.state.Sessions[0]; got.Failed != tc.failed || got.Status != tc.status || got.Text != "Saved explanation" { + t.Fatal("current attention and retained context were conflated", got) + } + s.live(codex.LiveUsageSnapshot{}, errors.New("offline"), now.Add(time.Second)) + if got := s.state.Sessions[0]; got.Failed || got.Status != "STALE" || got.Text != "Saved explanation" { + t.Fatal("stale observation retained current failure classification or lost its text", got) + } + s.live(snapshot, nil, now.Add(2*time.Second)) + if got := s.state.Sessions[0]; got.Failed != tc.failed || got.Status != tc.status { + t.Fatal("failure classification did not recover after a successful refresh", got) + } + }) + } +} diff --git a/internal/web/state.go b/internal/web/state.go index 49181c7f..1bf5af42 100644 --- a/internal/web/state.go +++ b/internal/web/state.go @@ -34,6 +34,7 @@ type session struct { Tokens int64 `json:"tokens"` Agents int `json:"agents"` Status string `json:"status"` + Failed bool `json:"failed,omitempty"` ContextKind string `json:"contextKind"` Text string `json:"text"` Command string `json:"command"` @@ -337,6 +338,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) { ID: row.ID, Name: codex.SanitizeSessionContext(row.Name), Directory: row.WorkingDirectory, Tokens: row.TotalTokens, Agents: row.AgentCount, StatusLine: statusline.Values(statusline.Data{Model: row.ModelSettings.Model, Effort: row.ModelSettings.ReasoningEffort, Speed: row.ModelSettings.ServiceTier, Directory: row.WorkingDirectory, Name: row.Name, ID: row.ID, State: sessionStatus(row), Source: row.Context.Source, Tokens: row.TotalTokens, Agents: row.AgentCount}), Status: sessionStatus(row), ContextKind: contextKind(row.Context.Kind), Text: text, + Failed: row.Attention == codex.SessionAttentionError, Command: row.Context.CommandDetails.Command, Source: row.Context.Source, Activity: row.LastActivity, WorkingCommand: activity.Command, CommandStatus: activity.CommandStatus, CommandTiming: activity.CommandTiming(now), @@ -360,6 +362,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) { // Never keep an apparently authoritative approval/working status on failure. for i := range s.state.Sessions { s.state.Sessions[i].Status = "STALE" + s.state.Sessions[i].Failed = false } } s.publish() diff --git a/web/src/Sessions.svelte b/web/src/Sessions.svelte index 6a59914c..169f3571 100644 --- a/web/src/Sessions.svelte +++ b/web/src/Sessions.svelte @@ -66,7 +66,9 @@ ); let stale = $derived(!live.connected || !!live.data?.sessionsError); function isFailure(session: Session) { - return session.status !== 'STALE' && session.contextKind === 'TURN ERROR'; + // Saved error text can outlive current attention (for example, after quit). + // Use the shared backend classification, not historical context or labels. + return session.status !== 'STALE' && session.failed === true; } // Parent rows already include linked-agent usage. Sum each listed row once; // do not add agent counts or activity samples to its observed token counter. diff --git a/web/src/state.svelte.ts b/web/src/state.svelte.ts index f87603c5..00f5cce9 100644 --- a/web/src/state.svelte.ts +++ b/web/src/state.svelte.ts @@ -12,6 +12,7 @@ export interface Session { tokens: number; agents: number; status: string; + failed?: boolean; contextKind: string; text: string; command: string; diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts index f9e6b997..7b0c5222 100644 --- a/web/tests/browser.spec.ts +++ b/web/tests/browser.spec.ts @@ -3532,6 +3532,9 @@ test('session failures have a separate count, ordered pills, error detail and li 'MODEL UNAVAILABLE', 'CAPACITY RESTRICTED', 'CHECK SESSION', + 'INACTIVE', + 'IDLE', + 'WORKING', ].map((status, index) => ({ id: 'failure-test-' + index, name: 'Session ' + index, @@ -3539,11 +3542,21 @@ test('session failures have a separate count, ordered pills, error detail and li tokens: 10, agents: 0, status, + failed: [ + 'MODEL BUSY', + 'TURN FAILED', + 'MODEL UNAVAILABLE', + 'CAPACITY RESTRICTED', + ].includes(status), contextKind: [ 'MODEL BUSY', 'TURN FAILED', 'MODEL UNAVAILABLE', 'CAPACITY RESTRICTED', + // Saved error context is still useful, but is not current attention. + 'INACTIVE', + 'IDLE', + 'WORKING', ].includes(status) ? 'TURN ERROR' : 'LAST REPLY', @@ -3594,6 +3607,7 @@ test('session failures have a separate count, ordered pills, error detail and li 'TURN ERROR', ); snapshot.sessions[1].status = 'WORKING'; + snapshot.sessions[1].failed = false; snapshot.sessions[1].contextKind = 'LAST ACTIVITY'; snapshot.sessions[1].text = 'Trying a new turn'; await page.evaluate( @@ -3609,6 +3623,15 @@ test('session failures have a separate count, ordered pills, error detail and li 'Selected model is at capacity.', ); await expect(page.locator('.detail-heading')).toContainText('WORKING'); + // A closed session can retain its failed reply without becoming an alert. + await page.goto(pairingURL.split('#')[0] + '#/sessions/failure-test-8'); + await expect(page.locator('.detail-context')).toContainText( + 'Observed context', + ); + await expect(page.locator('.detail-context')).not.toContainText( + 'CONFIRMED TURN FAILURE', + ); + await expect(page.locator('.detail-heading h2')).not.toHaveClass(/failed/); }); test('session totals count parent usage once and update for live, stale and empty lists', async ({