diff --git a/.gitignore b/.gitignore index e56ec82..6e0ef47 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 691c408..4dec688 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. @@ -1227,7 +1232,9 @@ The other top-level views are: conservatively excluded from timestamp-based freshness; later complete records are still checked. These cancellable, bounded activity scans do not recount tokens. Confirmed failed turns instead enter **ERRORS**, with an amber **MODEL BUSY** - badge for server-reported model capacity and **TURN FAILED** for other failures. + badge for server-reported model capacity, **MODEL UNAVAILABLE** when the provider + cannot find the selected model, **CAPACITY RESTRICTED** for policy-restricted + capacity, and **TURN FAILED** for other failures. The row, full detail and attention pill retain the observed error message; capacity is not interpreted as quota exhaustion. Both the shared app-server's failed-turn/error events and local rollout error records are supported. Errors @@ -1237,6 +1244,14 @@ The other top-level views are: stale events from older identified turns cannot fail the new one. Errors from linked agents propagate to the parent row, below pending approvals and questions. No prompt is automatically retried and no model is automatically switched. + With a shared app-server, working-command headings also show elapsed time or + completed duration when Codex supplies valid item lifecycle timestamps. + Codex 0.166.0's item fields and older notification-envelope fields are supported; + missing, future or inconsistent times do not produce a guessed duration. + Browser timers describe the last successful observation and freeze if the feed + becomes stale. These display-only times do not change freshness or API-EQ totals. + Experimental attached forks remain independent session rows rather than spawned + sub-agents; their inherited parent response records are not billed again. **INPUT** is reserved for observed blocking questions/requests, not a completed turn without a final reply. Normal completion remains **TURN COMPLETE**. Codexometer never guesses `APPROVAL NEEDED` from inactivity. Closing the CLI @@ -1800,6 +1815,14 @@ writable mode. Browse-only entries do not execute tools or change configuration. | `/skills`, `/apps`, `/mcp`, `/hooks`, `/experimental` | **Browse-only** live inventories and supplied help, including MCP tool descriptions. Invoke/configure these through Codex. | | `/help` | Available command families. Unsupported or empty catalogues are omitted. | +On Codex 0.166.0, `/hooks` queries the selected loaded thread, including hooks +from its execution environments, and identifies their environment when supplied. +Older servers retain directory-based discovery. A real thread/permission/transport +error is not silently replaced with a different inventory, and a successful empty +thread inventory remains empty rather than falling back to directory hooks. +Model/reasoning, permission and Plan changes remain guarded by the existing idle-state checks; +this compatibility update does not broaden write permissions. + The detail footer uses theme-highlighted text in the bottom border in terminal mode, and the last line of full session detail on web. Its default fields are observed model/reasoning, speed, total session tokens (including linked agents) and @@ -2696,6 +2719,8 @@ open directly. ```text --codex PATH path to the Codex CLI (default: codex) --check-auth verify the current Codex login and exit +--error-log PATH opt into bounded JSON operational diagnostics (off by default) +--verbose add diagnostic context to --error-log; never raw payloads --demo preview simulated quota, Sessions, Usage, and benchmark data --inline render inline instead of using the alternate screen --web experimental read-only browser interface (loopback only) @@ -2950,8 +2975,9 @@ available from the existing reader. outside scrollable reply text; available commands are separated from their justification. Missing commands are explicitly labelled, never reconstructed. - **INPUT NEEDED / APPROVAL NEEDED** represent observed signals; **MODEL BUSY / - TURN FAILED** represent confirmed turn errors and appear in **ERRORS**, with - the observed explanation in detail. Retrying errors do not raise failure pills. + MODEL UNAVAILABLE / CAPACITY RESTRICTED / TURN FAILED** represent confirmed turn + errors and appear in **ERRORS**, with the observed explanation in detail. + Retrying errors do not raise failure pills. **CHECK SESSION** is labelled inferred inactivity and is not proof that a reply is required. **TURN COMPLETE** is informational. Disconnection or failed session refresh @@ -3391,6 +3417,47 @@ establishing a new baseline. ## Troubleshooting +### Capture operational errors + +Logging is off by default. Enable it before reproducing an issue: + +```sh +codexometer --error-log ./error.log +codexometer --error-log ./error.log --verbose +# The same switches work in browser mode: +codexometer --web --error-log ./error.log --verbose +``` + +The standard Go structured logger writes JSON lines directly to the requested +file, never over the terminal UI. Errors include timestamp, source, operation, +safe error type/category and RPC code when available. Configuration-access +failures include the exact attempted path, OS error (such as permission denied) +and a short session identifier. Verbose mode additionally identifies whether +coverage used effective daemon state or local configuration, missing optional +files (normal), successful config checks and application start/stop. + +For `INCOMPLETE COVERAGE // SESSION …: background usage configuration could not +be read`, find the corresponding `local-config` entry: `msg` identifies `stat`, +`open` or `read`, and `path` / `error` identify the failing file and OS cause. +Malformed TOML and oversized files receive separate operation labels without +dumping their contents. Logging does not change the coverage guard or repair +permissions, and cannot reconstruct errors that happened before it was enabled. + +The file appends until it would exceed **2 MiB**, then discards its older entries +and continues with recent diagnostics; no backup files are created. Identical +events are rate-limited to once a minute, with the suppressed-repeat count on +the next occurrence. Use a separate path per running instance. The parent +directory must exist; an explicitly requested unavailable log, symlink or +non-regular target fails at startup. File permissions are restricted to `0600` +where supported; on Windows, access also depends on the parent directory's ACL. +Later log write/close errors are reported when the interface exits. + +Prompts, replies, config contents, credentials, pairing links, request/response +bodies and arbitrary upstream error messages are not logged. Such errors retain +their type/category and code, not their potentially sensitive text. Paths can +still reveal usernames/project names: review logs before sharing them. `--verbose` +requires `--error-log` and does not enable network access or browser writes. + ### `Codex CLI not found` Confirm that `codex --version` works in the same shell. Otherwise use @@ -3536,6 +3603,18 @@ go test -race -cover -timeout 10m -skip '^TestLocalisedScreens$' ./... go test -race -cover -timeout 10m -run '^TestLocalisedScreens$' ./internal/ui ``` +An optional Unix integration check verifies the installed Codex against either +the 0.164.0 baseline or 0.166.0, including negotiated compressed WebSocket RPC: + +```sh +CODEXOMETER_TEST_CODEX_VERSION=0.166.0 go test ./internal/codex -run '^TestNativeCodexCompression$' -count=1 +``` + +Use `0.164.0` when that version is on `PATH`; the existing +`CODEXOMETER_TEST_CODEX_164=1` invocation is also retained. The check uses an empty +temporary Codex home, no user credentials and no model requests. Normal CI uses +hermetic protocol fixtures and does not require an installed Codex CLI. + All 17 locale options run the complete localised-screen suite in isolated test subprocesses, with at most two locale workers active at once. Each child has a three-minute test timeout and an outer four-minute process deadline. CI uploads diff --git a/internal/codex/benchmark.go b/internal/codex/benchmark.go index 4fb51d7..9a64fcc 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 4bc94cb..13223af 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_native_unix_test.go b/internal/codex/daemon_native_unix_test.go index 6f8972a..eabe3a2 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/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index a59a76e..4995600 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 651e57f..4b2738b 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/session_166_test.go b/internal/codex/session_166_test.go new file mode 100644 index 0000000..3ad8a2e --- /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 045df8e..fb23bde 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 a65145d..9055dd7 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 0645047..9d931b3 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 c9bd486..6bbdd6c 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 0000000..89f528f --- /dev/null +++ b/internal/codex/session_hooks_unix.go @@ -0,0 +1,46 @@ +//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 { + // 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 == "" { + 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 0000000..b624077 --- /dev/null +++ b/internal/codex/session_hooks_unix_test.go @@ -0,0 +1,76 @@ +//go:build unix + +package codex + +import ( + "context" + "strings" + "testing" +) + +func TestSessionHooksUseThreadEnvironmentAndOlderServerFallback(t *testing.T) { + 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 { + 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 "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": + 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 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" { + 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" || strings.HasPrefix(mode, "empty-") { + 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 e8fbfc6..ac55518 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/codex/usage_coverage.go b/internal/codex/usage_coverage.go index b32604b..2ea5f48 100644 --- a/internal/codex/usage_coverage.go +++ b/internal/codex/usage_coverage.go @@ -7,8 +7,10 @@ import ( "os" "path/filepath" "sort" + "syscall" "time" + "github.com/merefield/codexometer/internal/diagnostics" "github.com/pelletier/go-toml/v2" ) @@ -61,7 +63,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 +83,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 +97,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 +108,19 @@ 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) { + // 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, "" } if err != nil { + diagnostics.Error("local-config", "stat", err, fields) return false, "background usage configuration could not be read" } if r.localFeatureChecks == nil { @@ -119,22 +131,43 @@ 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" } + 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)) - file.Close() + closeErr := file.Close() + if closeErr != nil { + diagnostics.Error("local-config", "close", closeErr, fields) + } + if err != nil { + diagnostics.Error("local-config", "read", err, fields) + } + // 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 err != nil || len(data) > 1<<20 || toml.Unmarshal(data, &config) != nil { + 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 { 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 new file mode 100644 index 0000000..44f88d8 --- /dev/null +++ b/internal/codex/usage_coverage_logging_test.go @@ -0,0 +1,198 @@ +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") + 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)) + } +} + +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 7a29242..8604f3f 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()}}} diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go new file mode 100644 index 0000000..026ca99 --- /dev/null +++ b/internal/diagnostics/log.go @@ -0,0 +1,235 @@ +// 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 + } + 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: 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 0000000..1be2538 --- /dev/null +++ b/internal/diagnostics/log_test.go @@ -0,0 +1,240 @@ +package diagnostics + +import ( + "encoding/json" + "errors" + "fmt" + "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 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 { + 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/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 1386780..7d2b04f 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 628e92d..42e8b86 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 54a0c7d..4a909e2 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 83b8b30..a574234 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 befe37a..02451d1 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 88b4877..0f518da 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 ebca0cd..51b2e19 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 5b7edae..0199e9e 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 d93e30d..6ed7fa6 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 ac7df1c..f718180 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 d56113c..84d7a09 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 6510108..2d56253 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 a393774..c6df8e6 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 8ab6290..89031ea 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 1af4a66..fa995c4 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 6ff75ea..5c33a0a 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 6c85278..cae28a6 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_context.go b/internal/ui/monitor_context.go index a1a9bf9..7eae67f 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 a27b3df..702dd7c 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 396c946..d7e873a 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_failure_test.go b/internal/ui/monitor_failure_test.go index 6b6121b..6a9dfdf 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 a9ccabe..7fe1cb0 100644 --- a/internal/ui/monitor_working.go +++ b/internal/ui/monitor_working.go @@ -1,6 +1,8 @@ package ui import ( + "time" + "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" "github.com/merefield/codexometer/internal/i18n" @@ -10,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": @@ -23,6 +25,13 @@ func workingCommandTitle(a codex.SessionActivity) string { status = "DECLINED" } title := i18n.Text("COMMAND") + " // " + i18n.Text(status) + // 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" { extra-- @@ -37,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) @@ -63,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 8c07fbb..270b729 100644 --- a/internal/ui/monitor_working_test.go +++ b/internal/ui/monitor_working_test.go @@ -3,19 +3,21 @@ package ui import ( "strings" "testing" + "time" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" ) 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) } @@ -35,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) @@ -59,3 +60,73 @@ func TestWorkingContextResponsive(t *testing.T) { t.Fatal("working command overrides final reply") } } + +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 := 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 := m.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) + } + } + } +} + +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-zYOUcPV1.js b/internal/web/dist/assets/index-DdTUC-03.js similarity index 96% rename from internal/web/dist/assets/index-zYOUcPV1.js rename to internal/web/dist/assets/index-DdTUC-03.js index 70ec690..7de76c0 100644 --- a/internal/web/dist/assets/index-zYOUcPV1.js +++ b/internal/web/dist/assets/index-DdTUC-03.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(`
Choose multiple fields and their order. These display preferences do not - change Codex CLI configuration. Missing values are omitted.
`,1),As=J(`
`,1),js=J(`
`),Ns=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),As=J(`
`,1),js=J(`
`),Ns=J(`
`),Fs=J(`
`,1),Is=J(`
`),Ls=J(`
Command unavailable from this observation. Open Codex to inspect the request.
`),Rs=J(``),Bs=J(` `,1),Vs=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.
`),Xs=J(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Zs=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(`
No locally observed sessions yet. Keep Codex running alongside - Codexometer.
`),dc=J(`↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK
`,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);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 efe56cb..7d6bfc9 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@