Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847
Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847heavygee wants to merge 18 commits into
Conversation
There was a problem hiding this comment.
Findings
- [Major] Codex history import flag is dropped before runner launch — the new web resume flow sends
importHistory: trueand the hub includes it in the machine RPC payload, but the machine-sideSpawnHappySessionhandler never reads or forwards that field intospawnSession. As a result, remote web resumes executehapi codex resume <thread>without--hapi-import-history, so the newly selected Codex transcript history is not imported into the fresh HAPI session. Evidence:hub/src/sync/rpcGateway.ts:142, with the drop atcli/src/api/apiMachine.ts:252-277.
Suggested fix:const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName, importHistory } = params || {} const result = await spawnSession({ directory, sessionId, resumeSessionId, importHistory, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName })
Questions
- None.
Summary
- Review mode: initial
- Found one Major issue in the remote Codex resume/import path. Residual risk: I did not run the full test suite in this automation pass; add a machine RPC boundary test that asserts
importHistoryreachesspawnSession.
Testing
- Not run (automation)
HAPI Bot
…ession The SpawnHappySession RPC handler received importHistory from the hub (hub/src/sync/rpcGateway.ts forwards it) but dropped it before the spawnSession call - so remote web resumes with 'Import history' checked executed `hapi codex resume <thread>` without --hapi-import-history and silently skipped the history import. Fix: destructure importHistory from params and pass it to spawnSession. Chain is now complete: web -> hub RPC -> machine handler -> spawnSession -> buildCliArgs (--hapi-import-history flag). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Codex session listing bypasses workspace-root scoping — the new machine RPC is registered through the shared handler path, then reads every Codex transcript under CODEX_HOME and returns transcript titles plus parsed cwd paths. Runner machines that were started with
--workspace-rootcurrently guard spawn, directory browsing, and scoped model discovery, but this new picker can still expose Codex sessions from projects outside those roots. Evidence:cli/src/modules/common/registerCommonHandlers.ts:19, with the unfiltered scan/return atcli/src/modules/common/codexSessions.ts:407andcli/src/modules/common/codexSessions.ts:433.
Suggested fix:export function registerCommonHandlers( rpcHandlerManager: RpcHandlerManager, workingDirectory: string, options: { codexSessionPathAllowed?: (path: string | null) => Promise<boolean> | boolean } = {} ): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) registerCodexSessionHandlers(rpcHandlerManager, options.codexSessionPathAllowed) // ... } registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd(), { codexSessionPathAllowed: async (path) => { if (!this.normalizedWorkspaceRoots?.length) return true if (!path) return false return this.isWithinWorkspaceRoots(await this.resolveForWorkspaceCheck(path)) } })
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Major issue in the newly added Codex session listing path. The prior
importHistoryforwarding issue appears fixed in the latest head.
Testing
- Not run (automation)
HAPI Bot
…pace-root machines The listCodexSessions RPC handler returned every transcript under CODEX_HOME without checking workspace roots. On machines started with --workspace-root, this let the web Codex session picker enumerate sessions from projects outside the allowed roots - a privacy/scoping leak analogous to the existing guards on spawn and directory browsing. Fix: add an optional pathAllowed callback to registerCodexSessionHandlers and listCodexSessions. apiMachine.ts wires in an isWithinWorkspaceRoots check when normalizedWorkspaceRoots is set; sessions with a null path are also excluded when the filter is active. When no workspace roots are configured, pathAllowed is undefined and the full list is returned unchanged (same behavior as before for single-machine installs). Fixes bot-review Major finding on tiann#847 (follow-up review). Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Codex resume can start in the wrong workspace — selecting a Codex transcript only stores
selectedCodexSessionId; the spawn request still sends the independent directory input asdirectory. The runner launches the child with that directory as cwd, and both Codex local/app-server paths pass it through to Codex, so resuming/importing a transcript from/repo-awhile the form still contains the recent/default/repo-bresumes the old thread against the wrong files. Evidence:web/src/components/NewSession/index.tsx:592, with the transcript path only rendered in the picker label atweb/src/components/NewSession/CodexSessionSelector.tsx:31.
Suggested fix:const selectedCodexSession = codexSessionsState.sessions.find( (session) => session.id === selectedCodexSessionId ) ?? null const spawnDirectory = agent === 'codex' && selectedCodexSessionId ? selectedCodexSession?.path : trimmedDirectory if (!spawnDirectory) { setError('Selected Codex session has no workspace path') return } const existsResult = await checkPathsExists([spawnDirectory]) const directoryExists = existsResult[spawnDirectory] const result = await spawnSession({ machineId, directory: spawnDirectory, agent, resumeSessionId: agent === 'codex' && selectedCodexSessionId ? selectedCodexSessionId : undefined, importHistory: agent === 'codex' && Boolean(selectedCodexSessionId), // ...existing fields })
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Major regression in the new Codex session picker/resume path. The prior workspace-root scoping finding appears addressed in the latest head.
Testing
- Not run (automation)
HAPI Bot
…esume When the user picks a Codex session from the history picker the spawn call still sent the form's directory input as `directory`, so resuming a session from /repo-a while the input showed /repo-b launched Codex in the wrong workspace with the wrong files. Fix: resolve `selectedCodexSession.path` from the sessions list and use that as `spawnDirectory` when a Codex session is selected. Falls back to `trimmedDirectory` for plain new sessions. Also: relax `canCreate` guard to allow spawn when a Codex session is selected even if the directory input is empty (the path comes from the session, not the form). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Metadata-only session updates stale the session list — adding
metadatatoSessionPatchSchemamakes the new hub event payload parse as a patch, souseSSEtakes the patch path instead of invalidating.patchSessionDetailmerges the metadata for an open session, butpatchSessionSummaryonly copies active/thinking/model/effort fields and still returnstrue; that suppressesqueueSessionListInvalidation(). A Codex history import/title update can therefore update the chat header while/sessionskeeps the old title/path/agentSessionId until a full refetch. Evidence:shared/src/schemas.ts:279, with the summary patch path atweb/src/hooks/useSSE.ts:500.
Suggested fix:const toSummaryMetadata = (metadata: Metadata | null): SessionSummaryMetadata | null => metadata ? { name: metadata.name, path: metadata.path, machineId: metadata.machineId ?? undefined, summary: metadata.summary ? { text: metadata.summary.text } : undefined, flavor: metadata.flavor ?? null, worktree: metadata.worktree, agentSessionId: metadata.codexSessionId ?? metadata.claudeSessionId ?? metadata.geminiSessionId ?? metadata.opencodeSessionId ?? metadata.cursorSessionId ?? metadata.kimiSessionId ?? undefined, lifecycleState: metadata.lifecycleState } : null const nextSummary: SessionSummary = { ...current, metadata: Object.prototype.hasOwnProperty.call(patch, "metadata") ? toSummaryMetadata(patch.metadata ?? null) : current.metadata, active: patch.active ?? current.active, thinking: patch.thinking ?? current.thinking, activeAt: patch.activeAt ?? current.activeAt, updatedAt: patch.updatedAt ?? current.updatedAt, backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, "backgroundTaskCount") ? patch.backgroundTaskCount ?? 0 : current.backgroundTaskCount, model: Object.prototype.hasOwnProperty.call(patch, "model") ? patch.model ?? null : current.model, effort: Object.prototype.hasOwnProperty.call(patch, "effort") ? patch.effort ?? null : current.effort }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Major cache regression introduced by the new metadata patch flow. The prior Codex resume workspace issue appears addressed in the latest head.
Testing
- Not run (automation)
HAPI Bot
There was a problem hiding this comment.
Findings
-
[Major] Imported Codex history is replayed again after switching to local — the new import path writes the whole selected transcript into the fresh HAPI session before
loop()starts, but the same run still setsreplayTranscriptHistoryOnStartfor any newresumeSessionId. If that remote session is later handed off to local,codexLocalLauncherattaches the transcript with replay enabled and sends the same historical user/agent messages again, duplicating imported history in the chat. Evidence:cli/src/codex/runCodex.ts:97, with replay consumed bycli/src/codex/codexLocalLauncher.ts:84.
Suggested fix:const replayTranscriptHistoryOnStart = Boolean( opts.resumeSessionId && !opts.existingSessionId && !opts.importHistory );
-
[Major] Hidden stale Codex selection can resume the wrong thread — after selecting an old Codex session and then toggling
showOldCodexSessionsoff, the query result no longer contains that option, so the<select>visually falls back to “Start new Codex session”. The state still keepsselectedCodexSessionId, andhandleCreate()sends it asresumeSessionIdeven thoughselectedCodexSessionisnull, so the user can unknowingly resume/import the hidden old thread using the directory text box. Evidence:web/src/components/NewSession/index.tsx:602.
Suggested fix:useEffect(() => { if (!selectedCodexSessionId) return if (codexSessionsState.isLoading) return if (!codexSessionsState.sessions.some((session) => session.id === selectedCodexSessionId)) { setSelectedCodexSessionId('') } }, [selectedCodexSessionId, codexSessionsState.isLoading, codexSessionsState.sessions])
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found two Major regressions in the current diff. The previous bot finding about metadata-only SSE updates appears fixed in this head.
Testing
- Not run (automation)
HAPI Bot
There was a problem hiding this comment.
Findings
- [Minor] Codex session picker only loads the first cursor page — the machine endpoint returns
nextCursor, and the CLI scanner can expose more sessions than one page, but this hook discardsnextCursorafter the first request. BecauselistCodexSessions()capslimitto 100, any matching Codex thread after the first page is unreachable from the resume/import selector. Evidence:web/src/hooks/queries/useCodexSessions.ts:26
Suggested fix:const sessions: CodexSessionSummary[] = [] let cursor: string | undefined do { const page = await api.getMachineCodexSessions(machineId, { includeOld, olderThanDays: 180, limit: 100, cursor }) if (page.success === false) return page sessions.push(...(page.sessions ?? [])) cursor = page.nextCursor ?? undefined } while (cursor) return { success: true, sessions, nextCursor: null }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Minor pagination gap in the new Codex session selector path. The prior Major findings from the last bot review appear fixed in this head.
Testing
- Not run (automation)
HAPI Bot
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full current diff, including the follow-up pagination fix, Codex resume/import propagation, workspace-scoped Codex session listing, usage normalization, metadata/SSE updates, and the budget indicator adapter. The prior pagination finding appears fixed. Residual risk is mostly integration coverage around live Codex premium-account payload variants.
Testing
- Not run (automation)
HAPI Bot
…ession The SpawnHappySession RPC handler received importHistory from the hub (hub/src/sync/rpcGateway.ts forwards it) but dropped it before the spawnSession call - so remote web resumes with 'Import history' checked executed `hapi codex resume <thread>` without --hapi-import-history and silently skipped the history import. Fix: destructure importHistory from params and pass it to spawnSession. Chain is now complete: web -> hub RPC -> machine handler -> spawnSession -> buildCliArgs (--hapi-import-history flag). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
…pace-root machines The listCodexSessions RPC handler returned every transcript under CODEX_HOME without checking workspace roots. On machines started with --workspace-root, this let the web Codex session picker enumerate sessions from projects outside the allowed roots - a privacy/scoping leak analogous to the existing guards on spawn and directory browsing. Fix: add an optional pathAllowed callback to registerCodexSessionHandlers and listCodexSessions. apiMachine.ts wires in an isWithinWorkspaceRoots check when normalizedWorkspaceRoots is set; sessions with a null path are also excluded when the filter is active. When no workspace roots are configured, pathAllowed is undefined and the full list is returned unchanged (same behavior as before for single-machine installs). Fixes bot-review Major finding on tiann#847 (follow-up review). Co-authored-by: Cursor <cursoragent@cursor.com>
…esume When the user picks a Codex session from the history picker the spawn call still sent the form's directory input as `directory`, so resuming a session from /repo-a while the input showed /repo-b launched Codex in the wrong workspace with the wrong files. Fix: resolve `selectedCodexSession.path` from the sessions list and use that as `spawnDirectory` when a Codex session is selected. Falls back to `trimmedDirectory` for plain new sessions. Also: relax `canCreate` guard to allow spawn when a Codex session is selected even if the directory input is empty (the path comes from the session, not the form). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
be0d991 to
5104afa
Compare
There was a problem hiding this comment.
Findings
- [Minor] Pathless Codex resume can enable a no-op Create button — the selector can return Codex transcripts with
path: null(CodexSessionSummary.pathallows null andlistCodexSessionskeeps them), butcanCreatenow treats any selected Codex session as enough even when the directory input is empty. In that state the button is enabled, thenhandleCreatecomputesspawnDirectoryasselectedCodexSession?.path ?? trimmedDirectoryand silently returns because both are empty, so the user gets no error and no session starts. Evidenceweb/src/components/NewSession/index.tsx:632
Suggested fix:const selectedCodexSession = agent === 'codex' && selectedCodexSessionId ? (codexSessionsState.sessions.find((s) => s.id === selectedCodexSessionId) ?? null) : null const hasSpawnDirectory = Boolean(selectedCodexSession?.path ?? trimmedDirectory) const canCreate = Boolean(machineId && hasSpawnDirectory && !isFormDisabled && !missingWorktreeDirectory)
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full current diff. Found one UI regression in the Codex resume flow for transcripts without a recorded workspace path. Residual risk remains around live Codex premium-account payload variants, but the added adapter/normalizer tests cover the documented cases.
Testing
- Not run (automation)
HAPI Bot
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full current diff, including the follow-up change that gates Codex resume creation on the resolved spawn directory. No new blocker/major/minor findings met the confidence threshold. Residual risk remains around live Codex payload variants and UI behavior for very large local transcript sets, but the added normalizer, adapter, import, and route tests cover the documented cases.
Testing
- Not run (automation); checked
git diff --checkonly.
HAPI Bot
Soup-local fixup for the integration of feat/codex-usage-indicator-rebased
(this branch's parent, adds toSummaryMetadata reading patch.metadata.name
directly) with feat/sse-patch-extend-session-state (wraps SessionPatch.metadata
in a {version, value} envelope). After both merge into driver/integration the
codex helper still references the unwrapped shape, but the sse-patch path at
line ~390 (toSessionSummaryMetadata(patch.metadata.value)) already supersedes
it. The codex assignment is dead but breaks typecheck because the unwrapped
properties no longer exist on the envelope type.
Standalone fix: removes the helper and its single call site. Both metadata
paths in patchSessionSummary collapse to the sse-patch envelope-aware code.
Layer applied AFTER sse-patch in soup so the dead code exists at merge time.
Not appropriate upstream as-is: each parent branch (codex tiann#847, sse tiann#897)
works fine standalone. Merge-only fixup belongs in the soup until one of
those PRs lands and the dependency tree gets re-baselined.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ession The SpawnHappySession RPC handler received importHistory from the hub (hub/src/sync/rpcGateway.ts forwards it) but dropped it before the spawnSession call - so remote web resumes with 'Import history' checked executed `hapi codex resume <thread>` without --hapi-import-history and silently skipped the history import. Fix: destructure importHistory from params and pass it to spawnSession. Chain is now complete: web -> hub RPC -> machine handler -> spawnSession -> buildCliArgs (--hapi-import-history flag). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
…pace-root machines The listCodexSessions RPC handler returned every transcript under CODEX_HOME without checking workspace roots. On machines started with --workspace-root, this let the web Codex session picker enumerate sessions from projects outside the allowed roots - a privacy/scoping leak analogous to the existing guards on spawn and directory browsing. Fix: add an optional pathAllowed callback to registerCodexSessionHandlers and listCodexSessions. apiMachine.ts wires in an isWithinWorkspaceRoots check when normalizedWorkspaceRoots is set; sessions with a null path are also excluded when the filter is active. When no workspace roots are configured, pathAllowed is undefined and the full list is returned unchanged (same behavior as before for single-machine installs). Fixes bot-review Major finding on tiann#847 (follow-up review). Co-authored-by: Cursor <cursoragent@cursor.com>
99135fd to
321e3df
Compare
…esume When the user picks a Codex session from the history picker the spawn call still sent the form's directory input as `directory`, so resuming a session from /repo-a while the input showed /repo-b launched Codex in the wrong workspace with the wrong files. Fix: resolve `selectedCodexSession.path` from the sessions list and use that as `spawnDirectory` when a Codex session is selected. Falls back to `trimmedDirectory` for plain new sessions. Also: relax `canCreate` guard to allow spawn when a Codex session is selected even if the directory input is empty (the path comes from the session, not the form). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Revalidate imported Codex history against workspace roots —
--workspace-rootnow scopes the session picker and the spawn directory, but the actual import still trusts anyresumeSessionId. A client can POST/api/machines/:id/spawnwithdirectoryinside an allowed root,agent: "codex",importHistory: true, and a Codex thread id whose transcript belongs to another project;runCodexthen imports that transcript throughimportCodexSessionHistorywithout checking the transcript cwd/path, leaking out-of-scope history into the new HAPI session. Evidence:cli/src/codex/runCodex.ts:108, with the unchecked lookup atcli/src/codex/importHistory.ts:42.
Suggested fix:and haveconst importedHistory = await importCodexSessionHistory({ session, codexSessionId: opts.resumeSessionId, expectedDirectory: workingDirectory })
importCodexSessionHistoryparse the transcriptsession_meta.cwd/pathbefore replaying, rejecting when it does not resolve to the requested spawn directory or an allowed workspace root.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Major issue in the latest full diff: Codex history import can bypass the workspace-root boundary even though the picker/spawn path are scoped.
Testing
- Not run (automation); checked
git diff --check.
HAPI Bot
heavygee
left a comment
There was a problem hiding this comment.
Fixed in 4fdb65e. importCodexSessionHistory now accepts expectedDirectory; it reads the first session_meta event from the transcript, extracts cwd/path, and rejects (warning + {imported:0,filePath:null}) if the session path does not start with the expected directory. runCodex passes workingDirectory as expectedDirectory at the call site.
There was a problem hiding this comment.
Findings
- [Major] Use path-aware validation for imported Codex history — the follow-up guard still treats sibling paths as inside the selected workspace because it checks
sessionPath.startsWith(expectedDirectory). A transcript from/tmp/workspace-otherpasses when the requested spawn directory is/tmp/workspace, so a direct/api/machines/:id/spawnrequest can still import an out-of-directory Codex thread into a scoped runner session. Evidence:cli/src/codex/importHistory.ts:72.
Suggested fix:import { isAbsolute, relative, resolve } from 'node:path' function isSameOrChild(parent: string, child: string): boolean { const rel = relative(resolve(parent), resolve(child)) return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) } if (args.expectedDirectory) { const sessionPath = extractSessionPath(content.split('\n')) if (!sessionPath || !isSameOrChild(args.expectedDirectory, sessionPath)) { logger.warn( `[codex-history-import] Rejecting import: session path "${sessionPath ?? '(missing)'}" is outside expected directory "${args.expectedDirectory}"` ) return { imported: 0, filePath: null } } }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one Major issue in the latest full diff: the Codex history import boundary check added after the previous review is still bypassable for sibling path prefixes.
Testing
- Not run (automation); checked
git diff --check refs/remotes/origin/main...refs/remotes/origin/pr-847.
HAPI Bot
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The prior Major finding on path-prefix validation is addressed in
cli/src/codex/importHistory.tswith path-awarerelative(resolve(...))containment and missing-path rejection. - Reviewed the latest full diff, including Codex history import/listing, workspace-root scoping, spawn/resume parameter flow, usage normalization, budget adapter behavior, and SSE/session metadata cache updates. Residual risk: large PR and review-only pass; no local test execution in this run.
Testing
- Not run (automation)
HAPI Bot
Ethan's indicator (tiann#537) was designed for time-window plans (plus / pro 5h+weekly). On Codex Pro accounts that exhaust the subscription windows AND any topped-up credits, the app-server emits rate_limits.primary=null + secondary=null + credits.has_credits=false + balance="0", and the indicator silently fell back to context-window- only - reading "80% context, plenty of room" while the account was actually blocked. Extend the data path end-to-end: shared/schemas - add CodexUsageCreditsSchema (hasCredits / unlimited / balance) and optional rateLimitReachedType / planType / limitId on CodexUsageSchema. JSON-only, no SCHEMA_VERSION bump. cli/codexUsage - normalize credits + reached_type + plan_type + limit_id from the rate_limits root regardless of whether primary / secondary are present. web/codexUsageDisplay - add isCodexUsageBlocked() helper; force ring to 100% and color red when blocked; render a critical-severity "Credits" row with $balance + 'subscription / top-up exhausted' detail; render a critical-severity "Limit Reached" header when codex sets rate_limit_reached_type. Unlimited credit accounts read "Unlimited" and stay green. Covered by 4 new cli tests (premium-credits shape from a real Codex Pro rollout, plus reached-type + unlimited-credits cases), 3 new web tests (blocked-state ring forcing, Limit Reached header, unlimited non-blocking), and 1 new shared schema test. Co-authored-by: Cursor <cursoragent@cursor.com>
Codex sends 'balance' as a precision-preserving string ('250.0000000000',
'0', '0.0000000000') with no declared unit. The previous render asserted
USD with a $ prefix and dumped the string verbatim, producing the
visually awful '$250.0000000000'.
Credits are an internal billing token per the OpenAI Codex rate card
(https://help.openai.com/en/articles/20001106-codex-rate-card): GPT-5.5
consumes 125 credits per 1M input tokens / 750 per 1M output, and a $5
top-up grants 125 credits (~$0.04/credit, not the $1/credit the prior
comment fabricated). Chatgpt.com's own UI even renders credits and any
USD conversion separately ('246 credits, ~10-62 cloud messages'), so
prefixing $ on the balance is a flat-out type error.
- formatCreditsBalance(): Number-parse + toLocaleString with 2dp cap
for values >= 1 (4dp for sub-unit balances), trailing zeros trimmed.
'250.0000000000' -> '250', '12.345' -> '12.35', '0.0000000000' -> '0'.
- Drop the $ prefix; the row label 'Credits' carries the unit.
- isCodexUsageBlocked / exhausted-detail check both now parse balance
numerically instead of literal-matching '0' / '0.00', so future
trailing-zero variants ('0.0000000000') cannot slip past.
Adds a 4-case parametrized test covering the real string shapes observed
in the wild plus a decimal-rounding case.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ethan's ring (PR tiann#537) preferred contextWindow.percent and only fell back to rate-limits when context was absent. Real consequence: weekly=100% but ctx=80% rendered as a green '80' ring, hiding a HARD subscription cap behind a SOFT context fill. Then when both windows AND credits exhausted, the blocked override jumped the ring to red 100 - so the same circle silently switched semantics from 'context fill' to 'usage exhaustion' mid-session. Operator caught it: 'the circle that WAS showing you context now shows you red 100 meaning no more usage'. Make the ring mean one thing across all states: 'percent of the most-pressing limit you're about to hit'. - New getCodexUsageRing(): max across context + 5h + weekly (blocked still forces 100). Returns { percent, axis } so callers can show which constraint is in front. - getCodexUsageRingPercent() kept as a thin wrapper for any future callers that only need the number. - getCodexUsageRingTitle(): axis-aware aria-label + title so hovering the ring tells you 'Weekly subscription window 100% used' instead of 'Codex usage' regardless of state. - getCodexUsageRows() marks the dominant row (dominant: true). Popover paints a left-accent bar + bolds the matching label so opening it immediately answers 'why is the ring at 100?'. - Ring colour gains an amber intermediate (60-85%) instead of jumping straight from blue to red at 85. Replaces the 'prefers context' + 'falls back to rate-limits' tests with three new ones covering the bug shape (ctx 80 vs weekly 100), the inverse (context dominates), and dominant-row marking. Co-authored-by: Cursor <cursoragent@cursor.com>
…= effective state Introduces a flavor-agnostic AgentBudgetState shape under shared/ and refactors the Codex indicator to consume it via a Codex-specific adapter. This is the seed of the cross-flavor agent budget gauge umbrella (tiann#846); Claude / Cursor / Gemini adapters can drop in without touching the renderer. Why Co-authored-by: Cursor <cursoragent@cursor.com> --- The previous ring conflated two questions in one number: 1. 'How much room for THIS task?' (context fill) 2. 'Am I about to be blocked?' (rate-limit / credits state) The number's semantics silently flipped between them based on account state - context% in the normal case, 100 in the blocked case - so the same circle meant different things at different times. Worse, a Pro account with subscription windows at 100% but credits available read red 100 (technically true: weekly is capped) when the operationally correct signal was amber (credits cover the overage, user is not actually blocked). Design ------ - AgentBudgetState.operationalAxisId picks the always-visible centre number (defaults to context for LLM agents). Stays consistent across all states; the number no longer changes meaning. - AgentBudgetState.effective is the per-flavor verdict (green / amber / red / blocked) computed by the adapter using its specific blocking rules. The renderer paints the ring colour from this; user gets one honest 'are you about to be blocked' signal alongside the operational centre number. - Adapter encodes the Codex-Pro covering rule: subscription window at cap AND credits > 0 -> amber, not red. The popover marks the credits axis as 'covering' with a blue accent so the user can see why. - Popover renders pressure axes top-to-bottom with the dominant one carrying a colour-coded left accent + bold label, then a divider, then the informational metadata rows (token breakdown etc). Generalisation seed ------------------- - shared/src/agentBudget.ts: flavor-agnostic types (AgentBudgetState / Axis / EffectiveState / MetadataRow). - web/src/components/AssistantChat/AgentBudgetIndicator.tsx: renderer that consumes AgentBudgetState. Knows nothing about codex credits or claude rate-limit headers - drop in a new adapter and the indicator works for that flavor. - web/src/components/AssistantChat/codexBudgetAdapter.ts: toCodexBudgetState() - the only Codex-flavor module under web/. All 5h / weekly / credits / plan_type semantics live here. Removes the previous getCodexUsageRing / getCodexUsageRows / getCodexUsageRingTitle / CodexUsageRing / CodexUsageRow helpers (replaced by the adapter) plus their test file. 11 new tests in codexBudgetAdapter.test.ts cover the operator-caught scenarios: weekly at cap + credits covering -> amber, blocked, unlimited credits, fresh session before context arrives, etc.
…ession The SpawnHappySession RPC handler received importHistory from the hub (hub/src/sync/rpcGateway.ts forwards it) but dropped it before the spawnSession call - so remote web resumes with 'Import history' checked executed `hapi codex resume <thread>` without --hapi-import-history and silently skipped the history import. Fix: destructure importHistory from params and pass it to spawnSession. Chain is now complete: web -> hub RPC -> machine handler -> spawnSession -> buildCliArgs (--hapi-import-history flag). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
…r UX
Blocker: remove replayExistingEvents from local usage scanner.
importCodexSessionHistory() already sends all user/agent messages to
HAPI; setting replayExistingEvents:true on the local scanner caused
every imported message to be sent twice (once via importHistory, once
via the scanner's onEvent -> sendUserMessage/sendAgentMessage path).
The scanner's role in the local launcher is live-tail only.
Minor: AgentBudgetAxisId union - use (string & {}) instead of string
so IDE still completes the well-known ids ('context', 'fiveHour', etc.)
while accepting arbitrary flavor-specific strings at compile time.
Plain string collapsed the union and lost completions.
Minor: AgentBudgetIndicator popover - add pointerdown outside-click and
Escape keyboard handlers so the popover closes without requiring a
second button click.
Nit: formatCodexUsageReset - use explicit `<= 0` guard instead of
falsy `!resetAt` to make the epoch-exclusion intent clear.
Co-authored-by: Cursor <cursoragent@cursor.com>
…pace-root machines The listCodexSessions RPC handler returned every transcript under CODEX_HOME without checking workspace roots. On machines started with --workspace-root, this let the web Codex session picker enumerate sessions from projects outside the allowed roots - a privacy/scoping leak analogous to the existing guards on spawn and directory browsing. Fix: add an optional pathAllowed callback to registerCodexSessionHandlers and listCodexSessions. apiMachine.ts wires in an isWithinWorkspaceRoots check when normalizedWorkspaceRoots is set; sessions with a null path are also excluded when the filter is active. When no workspace roots are configured, pathAllowed is undefined and the full list is returned unchanged (same behavior as before for single-machine installs). Fixes bot-review Major finding on tiann#847 (follow-up review). Co-authored-by: Cursor <cursoragent@cursor.com>
…esume When the user picks a Codex session from the history picker the spawn call still sent the form's directory input as `directory`, so resuming a session from /repo-a while the input showed /repo-b launched Codex in the wrong workspace with the wrong files. Fix: resolve `selectedCodexSession.path` from the sessions list and use that as `spawnDirectory` when a Codex session is selected. Falls back to `trimmedDirectory` for plain new sessions. Also: relax `canCreate` guard to allow spawn when a Codex session is selected even if the directory input is empty (the path comes from the session, not the form). Fixes bot-review Major finding on tiann#847. Co-authored-by: Cursor <cursoragent@cursor.com>
When a session-updated SSE event carries a metadata patch (e.g. a Codex history import setting codexSessionId or title), patchSessionSummary now maps it through toSummaryMetadata so the sessions list cache reflects name/path/summary/agentSessionId changes immediately. Previously, patchSessionSummary returned `true` (patch applied) while silently ignoring the metadata payload, suppressing the queueSessionListInvalidation() fallback and leaving stale titles/paths in the list until the next full refetch. Co-authored-by: Cursor <cursoragent@cursor.com>
Two bot-flagged Majors: 1. replayTranscriptHistoryOnStart: add !opts.importHistory guard so that when history is imported via importCodexSessionHistory() the transcript is not replayed a second time if the session is later handed off to local mode (codexLocalLauncher). 2. Stale Codex session selection: add useEffect in NewSession that clears selectedCodexSessionId whenever the selected id is no longer present in the filtered sessions list (e.g. after toggling showOldCodexSessions off), preventing handleCreate() from silently resuming a hidden thread. Co-authored-by: Cursor <cursoragent@cursor.com>
useCodexSessions was discarding nextCursor after the first request, capping the resume/import selector at one page (100 sessions). Replace the single fetch with a do-while pagination loop so all matching threads are surfaced regardless of how many the CLI scanner has indexed. Co-authored-by: Cursor <cursoragent@cursor.com>
… Codex sessions canCreate previously treated any selected Codex session as sufficient to enable the button, even when that session had path: null. handleCreate would then fall through with an empty spawnDirectory and silently no-op. Now derives hasSpawnDirectory from selectedCodexSession?.path ?? trimmedDirectory so the button stays disabled until there is a real directory to spawn in. Co-authored-by: Cursor <cursoragent@cursor.com>
…before importing history
importCodexSessionHistory now accepts an optional expectedDirectory.
If provided, the first session_meta event in the transcript is checked;
if its cwd/path does not start with expectedDirectory the import is
rejected with a warning and returns {imported:0, filePath:null}.
runCodex passes workingDirectory as expectedDirectory so a crafted
web request cannot replay a session from another workspace by pairing
an allowed directory with a foreign Codex session id.
Co-authored-by: Cursor <cursoragent@cursor.com>
…tory validation
startsWith("/tmp/workspace") incorrectly matches "/tmp/workspace-other".
Replace with isSameOrChild() using relative(resolve(parent), resolve(child))
so only genuine subdirectories and the directory itself pass the guard.
Also treat a missing session_meta path as a rejection (import: 0) rather
than silently allowing it through.
Co-authored-by: Cursor <cursoragent@cursor.com>
dc01f15 to
88a740b
Compare
There was a problem hiding this comment.
Findings
- [Major] Codex resume can bypass workspace-root scoping —
cli/src/api/apiMachine.ts:286
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Found one workspace-root enforcement gap in the new Codex resume/import flow. Residual risk: review-only pass; local tests not run here.
Testing
- Not run (automation). Suggested: add an
ApiMachineClientspawn RPC test wheredirectoryis inside an allowed workspace root butresumeSessionIdresolves to a Codex transcript whosesession_meta.cwdis outside, and assert spawn is rejected beforespawnSessionis called.
HAPI Bot
importCodexSessionHistory returns {imported:0} on path mismatch but
runCodex() still invokes 'codex resume <id>' natively, so a crafted
spawn request with an allowed directory + foreign resumeSessionId could
load an out-of-workspace Codex session.
Fix: in SpawnHappySession handler, when agent='codex' and workspace roots
are configured, resolve the session's path via findCodexSessionPath() and
reject with an error if it falls outside the roots - before spawnSession()
is called.
Also exports findCodexSessionPath() from codexSessions.ts for reuse.
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- No issues found in the latest diff. The prior workspace-root bypass finding is addressed in this head. Residual risk: review-only pass; full CI was not run here.
Testing
- Not run (automation).
HAPI Bot
What
Rebases @dsus4wang's stalled #537 onto current
main(179 commits caught up since April), then extends the indicator to handle premium-credits accounts, fix unit-rendering bugs, and lay a flavor-agnostic shape for the cross-flavor budget gauge umbrella (#846).Original PR by @dsus4wang. The first 3 commits on this branch preserve his authorship intact:
8f5f164eAdd Codex session list/resume flow to web new-session UI3f4f33dcrestore Codex session history2f32998badd Codex usage indicatorThe remaining 4 commits are follow-up work on top:
753b51b8feat: surface credits + rate_limit_reached_type for premium accountsc448e537fix: drop $ prefix + format credit balance (rate-card-correct rendering)b0fad40efix: ring shows worst constraint, not just context97dc5414refactor: split ring meaning - centre = context, colour = effective stateWhy the extension
Operator-tested @dsus4wang's indicator against a live Codex Pro account on 2026-06-09. Three issues surfaced that the original PR couldn't have caught on a Plus-tier test account:
Premium-credits accounts have no time-window rate limits. When subscription is exhausted and billing falls back to credits, codex emits
rate_limits.primary=null+secondary=null+credits.has_credits=false. The original indicator was silent on this entire state - showed only the context-window % even when the account was hard-blocked.Ring semantics drifted between states. The same circle meant 'context fill' in the normal case but 'usage exhaustion' when blocked. Operator caught it: 'the circle that WAS showing you context now shows you red 100 meaning no more usage'.
A Pro account that exhausts the subscription window but tops up credits is not blocked - codex falls back to credit billing transparently. The original would have read this as 'weekly 100%, red, you're done' even though the user can keep sending. False alarm vs false safety in the same widget.
What changed
Premium-credits support
CodexUsageSchema(shared) gainscredits(hasCredits/unlimited/balance) plus optionalrateLimitReachedType/planType/limitId. JSON-only, noSCHEMA_VERSIONbump.normalizeCodexUsage(cli) extracts those fields from thetoken_countpayload'srate_limitsroot.Ring metaphor: centre = task room, colour = account constraint
AgentBudgetStateshape undershared/src/agentBudget.ts. Flavor-agnostic - declares onlyaxes(each withpressure 0-100), anoperationalAxisId(which axis the centre number shows), and aneffectivestate (green/amber/red/blocked) computed per-flavor.AgentBudgetIndicator(web) is a flavor-agnostic renderer that consumes the state. The Codex adapter (codexBudgetAdapter.ts) is the only Codex-specific module on the web side.covering. The popover renders a left-accent on the covering axis so the user sees why the gauge isn't red.Cross-flavor seed (umbrella #846)
AgentBudgetStateis intentionally not Codex-specific. AtoClaudeBudgetStateadapter (or Cursor / Gemini equivalents) can drop in without touching the indicator. The Codex adapter is the reference implementation; Claude is queued next.Visual change
80blue80blue (unchanged)80blue (silently hides the weekly cap)80amber; popover shows1 Week Usage 100%(dominant, accented) andCredits 246 covering exhausted subscription window(blue accent)80blue (silently misrepresents block)80red; tooltip 'Blocked: subscription window and credits both exhausted'; popover showsCredits 0 subscription / top-up exhausted(critical, dominant)Testing
bun typecheck+bun run test: 114 test files, 958 tests, all pass on this branchcodexBudgetAdapter.test.tscovering the operator-caught scenarios (premium-credits shape, covering / blocked / red transitions, transition-window edge where both subscription axes are present at 100 + credits 0, unlimited credits exemption, etc); plus refreshedcodexUsageSchema.test.tsandcodexUsage.test.tsfor the cli normalizerKnown follow-ups (out of scope for this PR)
t()in a follow-up so zh-CN locale gets coverage too.account/rateLimits/updatedstandalone events:appServerEventConverter.tscurrently only surfaces rate limits when piggy-backed onthread/tokenUsage/updated. Standalone rate-limit-update events from the codex app-server are dropped. Worth surfacing in a follow-up so rate-limit transitions reach the UI without waiting for the next turn.AgentBudgetStateshape is the cross-flavor foundation. Claude adapter is queued next (depends on confirming Anthropic ratelimit headers reach HAPI's hook stream); Cursor is data-blocked until they expose telemetry; Gemini follows.Related
Made with Cursor