Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ includes the explicit backgrounds used for consistent terminal rendering.
graph, with a 6/12-month range and lifetime, peak-day, and streak summaries
when supplied. This server-side history can lag live local telemetry.
- An always-on Sessions view that measures local token activity while Codexometer
is running, with a Reset button for establishing a fresh baseline.
is running, with a Zero button for establishing a fresh baseline. Zero affects
only the local measurement interval and does not use a quota-reset token.
Each independent local root session gets its own metrics and 30-second graph;
explicitly linked spawned agents are included with their root.
- Dismissible session rows that automatically return on fresh activity,
Expand Down Expand Up @@ -470,7 +471,7 @@ Set up the recommended arrangement as follows.
new live context arrives.

5. Leave Codexometer running while you work. Session monitoring starts automatically;
use Reset when you want a fresh measured interval, and keep unrelated Codex
use Zero when you want a fresh measured interval, and keep unrelated Codex
activity quiet while running Benchmarks if you want the cleanest comparisons.

Codexometer subscribes only to thread IDs that are already loaded by the
Expand Down Expand Up @@ -586,7 +587,9 @@ codexometer --codex /path/to/codex
| `Shift+Tab` | Select the previous top-level tab |
| `r` | Refresh account history in Usage; otherwise refresh quota data |
| `v` | Cycle views within Quota or Usage |
| `s` | Reset the Sessions baseline, or open Benchmark Scope |
| `z` | Zero the Sessions baseline |
| `s` | Open Benchmark Scope (Benchmark view only) |
| `o` | Arm Close All in the Sessions overview; press again to confirm, or `Esc` to cancel |
| `g` | Cycle available trend periods in Quota → Pace or Zone |
| `h` | Toggle the trace in Quota → Pace or Zone; in Sessions, reset all rows to graph-only / split detail-and-graph, closing full detail and clearing individual row choices |
| `Left` / `Right` | In Sessions, less / more detail for the selected session: graph ↔ split ↔ wide ↔ full screen; stops at either end |
Expand Down Expand Up @@ -1097,7 +1100,10 @@ The other top-level views are:
parent sessions and are not counted again. Tokens retain the existing measurement
baseline (including previously dismissed sessions); the session/state counts
describe currently visible rows. Elapsed time and average rate
remain underneath when space permits; a clickable Reset control sits beside it.
remain underneath when space permits; clickable Zero and Close All controls
sit beside it, labelled `(Z)ERO` and `CL(O)SE ALL`. Close All dismisses
every visible session row, including those outside the current page, while
keeping the measurement baseline and telemetry.
Account-wide quota details live in Quota, not Sessions.
During unavailable observation, live state counts show **—**, not zero.
Active sessions are checked once per second and the idle cadence relaxes to five seconds.
Expand Down Expand Up @@ -1144,10 +1150,17 @@ The other top-level views are:
when tokens, model calls, turn timing, durable activity, or attention moves
forward, or when an inactive session becomes active again. An alert already
visible when `[×]` is clicked is dismissed with its row; a later new or
changed alert restores it. Resetting session monitoring also restores every dismissed
changed alert restores it. Zeroing session monitoring also restores every dismissed
row. With the keyboard, `Down` initially selects the top row, `Up` initially
selects the bottom row, subsequent arrow presses move the highlight, and `x`
closes the selected row.
closes the selected row. Close All applies the same dismissal and automatic
return behavior to all rows. On narrow terminals its label becomes `[××]`
with `(O)` below it; Zero shows `0` with `(Z)`.
Close All requires confirmation: click or press `o` once to arm it, then click
or press `o` again within five seconds to confirm. `Esc` cancels. A change
to the visible rows requires a fresh confirmation; switching views or Zero
cancels an armed action.
Close All is disabled when no rows are visible.
When the terminal cannot fit every root, use Page Up, Page Down, or the mouse
wheel to page through the rows.
- **Benchmark** — runs the selected scope from the active Core, Extended, or
Expand Down Expand Up @@ -1261,7 +1274,7 @@ and output statistics are omitted rather than filling the card with `N/A`.
**AVG TOK/MIN** is average observed tokens per minute since the measurement began
(or the session joined)—not instantaneous generation speed.
The total and all session averages update together every five seconds, unaffected
by mouse movement or keyboard-driven redraws. Start and reset update them
by mouse movement or keyboard-driven redraws. Start and Zero update them
immediately; token counts and the rest of the interface retain their usual cadence.

`CALLS` counts upstream model-response cycles observed after the current Sessions
Expand Down Expand Up @@ -1289,7 +1302,7 @@ Codex. `NO INTEGER Δ` means no whole-point movement was observed, not necessari
zero consumption; a smaller apportioned estimate is shown as `<1PP`. Stale,
missing, late-baseline, and reset-crossing windows do not produce a per-session
number.
Starting or resetting reads quota before establishing the local token baseline.
Starting or zeroing reads quota before establishing the local token baseline.
These operations are not atomic, so unrelated account activity during the short
boundary read remains another source of uncertainty.

Expand Down Expand Up @@ -1369,7 +1382,7 @@ same action on that session. The presentations are:
**WORKING** badge blinks only its ball, keeping the text and colour steady,
and uses the same observed-work evidence as the animated dots,
not merely recent activity. Completion and attention badges take priority;
starting/resetting monitoring, observation errors, or inactive sessions
starting/zeroing monitoring, observation errors, or inactive sessions
suppress **WORKING**. Compact previews keep their content-type title.
Eligible approval buttons sit below the complete command/request
and source session. If the complete request plus controls cannot fit, a
Expand Down Expand Up @@ -1564,6 +1577,7 @@ are clickable. Writable web mode exposes the same catalogue through

| Command family | Live options and behaviour |
| --- | --- |
| `/rename` | Edit the selected session’s saved name, review it and confirm. Uses Codex’s name API; works during an active turn and does not send a prompt or change model settings. |
| `/model` | Advertised models, their descriptions and supported reasoning levels. Choosing a model resets its explicit speed override to the server default. |
| Advertised speed commands, such as `/fast` | Names, IDs and descriptions come from the current model's service-tier catalogue; enable a tier or clear the explicit override. No speed names or tier IDs are assumed. |
| `/plan` | Advertised collaboration modes, using Codex's built-in mode instructions. |
Expand Down Expand Up @@ -1600,6 +1614,8 @@ scope is process-wide rather than session-local. See the official
[app-server API overview](https://learn.chatgpt.com/docs/app-server#api-overview)
for the upstream catalogues.

Renames require a loaded session and explicit confirmation within 30 seconds; a concurrent rename or reconnect invalidates the confirmation. The updated name appears on the next telemetry refresh.

Codex session-setting changes require a loaded, idle session and explicit confirmation within
30 seconds. The command and options are revalidated before sending; stale
connections, pending approvals and changed options fail closed. A successful
Expand All @@ -1610,6 +1626,16 @@ confirmations retain the draft and error context. These changes do not modify gl
quota thresholds may later supersede model settings. Uncertain requests are
not retried automatically. Read-only web mode offers no command-control endpoint.

**Remaining CLI command gaps** (compared with the official documented CLI list):

| Area | Commands not implemented as Codexometer slash commands |
| --- | --- |
| Session lifecycle and conversation actions | `/new`, `/clear`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, `/review`, `/init`, `/goal`, `/side`, `/btw`, `/approve` |
| CLI navigation and display | `/agent`, `/subagents`, `/copy`, `/diff`, `/status`, `/usage`, `/raw`, `/title`, `/theme`, `/pets`, `/pet`, `/keymap`, `/vim`, `/app`, `/exit`, `/quit` |
| Configuration and tools | `/ide`, `/plugins`, `/memories`, `/import`, `/feedback`, `/logout`, `/mention`, `/personality`, `/ps`, `/stop`, `/setup-default-sandbox`, `/sandbox-add-read-dir`, `/debug-config` |

`/apps`, `/skills`, `/mcp`, `/hooks` and `/experimental` remain **browse-only** here; CLI invocation, installation, authentication, trust and toggle actions are not implemented. `/plan` changes mode but does not implement the CLI's optional prompt argument. `/fast` and other advertised tiers are session overrides, rather than persistent CLI defaults. `/statusline` configures Codexometer's own footer. Existing session navigation, copying and telemetry views provide some analogous actions without those slash names. Availability upstream varies by platform, version and enabled features; see the [official CLI command reference](https://learn.chatgpt.com/docs/developer-commands#built-in-slash-commands).

Slash commands cannot be sent, steered, queued or scheduled as ordinary prompts.
Use `//` to send a literal leading slash (for example `//tmp/file` sends
`/tmp/file`). Structured question answers are unaffected. API help is shown in
Expand Down Expand Up @@ -1824,6 +1850,8 @@ session. Short boxes prioritise readable context and approval controls; the
compact view omits the dots when fewer than three body rows fit. Sent-message
acknowledgement animations remain confined to full detail.

Full approval reviews use a separate **65,536-character** budget, so longer commands and justifications retain their complete text and decision buttons. Ordinary telemetry excerpts remain limited to 4,096 characters. Requests exceeding the approval budget still require Codex; the reason stays pinned below the scrolling Detail text. **Home/End** jump to the first/last Detail line. Approval controls stay fixed while the review scrolls. Tabs, leading/trailing whitespace and Windows line endings do not disable approval buttons; display formatting preserves the original command and directory used for the decision.

Local rollout logs do **not** persist Codex's approval-request events, so a local
preview can show only the message preceding an approval. `INPUT NEEDED` or
`CHECK SESSION` alone never enables these controls. Requests with missing,
Expand Down
6 changes: 5 additions & 1 deletion internal/codex/file_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ func configureFileApproval(c *SessionContext, grantRoot string, state *daemonCon
c.ApprovalBlocked = "permissions"
return
}
if SanitizeSessionContext(c.Text) != c.Text {
if !approvalTextFits(c.Text) {
c.ApprovalBlocked = "truncated"
return
}
if !approvalTextDisplayable(c.Text) {
c.ApprovalBlocked = "sanitised"
return
}
Expand Down
13 changes: 13 additions & 0 deletions internal/codex/file_approval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,16 @@ func TestFileDiffTotals(t *testing.T) {
t.Fatal("empty diff has changes")
}
}

func TestFileApprovalReasonWhitespace(t *testing.T) {
for _, reason := range []string{" Review the patch. ", "Review the patch.\n", "Review\tthis patch.\r\n"} {
_, c := fileApprovalFixture(t, testFilePatch, map[string]any{"reason": reason})
if c.ApprovalToken == "" || c.ApprovalBlocked != "" || c.FileChanges == "" {
t.Fatalf("ordinary reason whitespace disabled decisions: %s", c.ApprovalBlocked)
}
}
_, c := fileApprovalFixture(t, testFilePatch, map[string]any{"reason": strings.Repeat("x", approvalTextLimit+1)})
if c.ApprovalToken != "" || c.ApprovalBlocked != "truncated" {
t.Fatalf("incomplete review: blocked=%s actionable=%v", c.ApprovalBlocked, c.ApprovalToken != "")
}
}
97 changes: 80 additions & 17 deletions internal/codex/session_approval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,22 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) {
t.Fatal("structured fields did not preserve source values", c.CommandDetails)
}
for name, fields := range map[string]map[string]any{
"stdin action": {"kind": "writeStdin"},
"future action": {"kind": "unknownAction"},
"null action": {"kind": nil},
"malformed action": {"kind": 42},
"wrong turn": {"turnId": "other"},
"wrong item": {"itemId": "other"},
"truncated": {"command": strings.Repeat("x", 5000)},
"controls": {"command": "echo \x1b[31mhidden"},
"hidden directory suffix": {"cwd": "/work\t"},
"bidi": {"command": "echo \u202Ehidden"},
"network": {"networkApprovalContext": map[string]string{"host": "example.com", "protocol": "https"}},
"permissions": {"additionalPermissions": map[string]any{"network": true}},
"no decisions": {"availableDecisions": []string{}},
"ambiguous argv": {"command": []string{"echo", "a b"}},
"stdin action": {"kind": "writeStdin"},
"future action": {"kind": "unknownAction"},
"null action": {"kind": nil},
"malformed action": {"kind": 42},
"wrong turn": {"turnId": "other"},
"wrong item": {"itemId": "other"},
"truncated": {"command": strings.Repeat("x", approvalTextLimit+1)},
"expanded tabs exceed budget": {"command": strings.Repeat("\t", approvalTextLimit/4+1)},
"controls": {"command": "echo \x1b[31mhidden"},
"hidden directory suffix": {"cwd": "/work\u202e"},
"bidi": {"command": "echo \u202Ehidden"},
"bare carriage return": {"command": "echo visible\rhidden"},
"network": {"networkApprovalContext": map[string]string{"host": "example.com", "protocol": "https"}},
"permissions": {"additionalPermissions": map[string]any{"network": true}},
"no decisions": {"availableDecisions": []string{}},
"ambiguous argv": {"command": []string{"echo", "a b"}},
} {
t.Run(name, func(t *testing.T) {
_, c := approvalFixture(t, fields)
Expand Down Expand Up @@ -74,7 +76,7 @@ func TestApprovalKindExplicitAndLegacyCommand(t *testing.T) {

func TestApprovalStructuredJustificationIsBounded(t *testing.T) {
_, c := approvalFixture(t, map[string]any{"reason": strings.Repeat(" ", 10000) + "Explanation\nCommand: not the real command"})
if c.ApprovalToken == "" || c.CommandDetails.Command != "git push" || c.CommandDetails.Justification != "Explanation\nCommand: not the real command" {
if c.ApprovalToken == "" || c.CommandDetails.Command != "git push" || c.CommandDetails.Justification != strings.Repeat(" ", 10000)+"Explanation\nCommand: not the real command" {
t.Fatal("justification confused command fields", c.CommandDetails)
}
}
Expand Down Expand Up @@ -108,8 +110,9 @@ func TestApprovalRejectionDiagnostics(t *testing.T) {
{map[string]any{"itemId": "unknown", "command": "pwd"}, "missing-directory"},
{map[string]any{"turnId": "", "command": "pwd", "cwd": "/work"}, "missing-identity"},
{map[string]any{"availableDecisions": []string{}}, "decisions"},
{map[string]any{"command": strings.Repeat("x", 5000)}, "truncated"},
{map[string]any{"command": "pwd\t"}, "sanitised"},
{map[string]any{"command": strings.Repeat("x", approvalTextLimit+1)}, "truncated"},
{map[string]any{"command": strings.Repeat("\t", approvalTextLimit/4+1)}, "truncated"},
{map[string]any{"command": "pwd\x1b[31m"}, "sanitised"},
} {
_, c := approvalFixture(t, tc.fields)
if c.ApprovalBlocked != tc.reason || c.ApprovalToken != "" {
Expand All @@ -121,3 +124,63 @@ func TestApprovalRejectionDiagnostics(t *testing.T) {
t.Fatal("eligible request gained a rejection")
}
}

func TestLongCommandApprovalKeepsCompleteReview(t *testing.T) {
command := "python3 - <<'PY'\n" + strings.Repeat("print('long review')\n", 400) + "# END OF COMMAND\nPY"
reason := strings.Repeat("Long explanation. ", 400) + "END OF JUSTIFICATION"
_, c := approvalFixture(t, map[string]any{"command": command, "reason": reason})
if c.ApprovalToken == "" || c.ApprovalBlocked != "" || c.CommandDetails.Command != command || c.CommandDetails.Justification != reason || !strings.Contains(c.Text, command) || !strings.Contains(c.Text, reason) {
t.Fatal("long approval lost its complete review or controls")
}
if SanitizeSessionContext(reason) == reason {
t.Fatal("fixture does not exceed the ordinary excerpt budget")
}
}

func TestLongApprovalCorrelatesCompleteStartedCommand(t *testing.T) {
states := map[string]*daemonContextState{}
command := strings.Repeat("echo long-command\n", 400) + "END OF COMMAND"
raw, _ := json.Marshal(map[string]any{"threadId": "root", "turnId": "turn", "item": map[string]any{"id": "cmd", "type": "commandExecution", "command": command, "cwd": "/work"}})
daemonContextEvent(states, "item/started", nil, raw, time.Now())
raw, _ = json.Marshal(map[string]any{"threadId": "root", "turnId": "turn", "itemId": "cmd", "reason": "Review command", "availableDecisions": []string{"accept", "decline"}})
daemonContextEvent(states, "item/commandExecution/requestApproval", json.RawMessage(`"req"`), raw, time.Now())
c := states["root"].requests[`"req"`]
if c.ApprovalToken == "" || c.CommandDetails.Command != command {
t.Fatal("started-command fallback truncated the approval")
}
}

func TestApprovalWhitespaceDoesNotDisableDecisions(t *testing.T) {
for _, command := range []string{
"git status\n",
"\n git status \n\n",
"python3 - <<'PY'\nif True:\n\tprint('ok')\nPY\n",
"printf 'a\tb'\n",
"git status\r\n",
} {
t.Run(command, func(t *testing.T) {
reason := " Review this command.\n\tKeep normal formatting.\n"
_, c := approvalFixture(t, map[string]any{"command": command, "reason": reason})
if c.ApprovalToken == "" || c.ApprovalBlocked != "" {
t.Fatalf("ordinary whitespace disabled decisions: %s", c.ApprovalBlocked)
}
if c.CommandDetails.Command != command || c.CommandDetails.Directory != "/work" {
t.Fatal("formatting changed the original command or directory")
}
})
}
}

func TestApprovalDisplayPreservesWhitespace(t *testing.T) {
input := "\n first line \n\tsecond line\r\n\n"
want := "\n first line \n second line\n\n"
if got := SanitizeApprovalText(input); got != want {
t.Fatalf("review whitespace lost: got %q, want %q", got, want)
}
if !approvalTextDisplayable(input) {
t.Fatal("normal display formatting disabled decisions")
}
if approvalTextDisplayable("echo\x1b[2Jhidden") || approvalTextDisplayable("echo\u202ehidden") {
t.Fatal("non-whitespace controls treated as display formatting")
}
}
2 changes: 2 additions & 0 deletions internal/codex/session_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type SessionCommandMenu struct {
Path string `json:"path"`
Revision string `json:"revision"`
Multiple bool `json:"multiple,omitempty"`
Input bool `json:"input,omitempty"`
Value string `json:"value,omitempty"`
Choices []SessionCommandChoice `json:"choices"`
}

Expand Down
Loading
Loading