Skip to content

Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847

Open
heavygee wants to merge 18 commits into
tiann:mainfrom
heavygee:feat/codex-usage-indicator-rebased
Open

Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847
heavygee wants to merge 18 commits into
tiann:mainfrom
heavygee:feat/codex-usage-indicator-rebased

Conversation

@heavygee

@heavygee heavygee commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 8f5f164e Add Codex session list/resume flow to web new-session UI
  • 3f4f33dc restore Codex session history
  • 2f32998b add Codex usage indicator

The remaining 4 commits are follow-up work on top:

  • 753b51b8 feat: surface credits + rate_limit_reached_type for premium accounts
  • c448e537 fix: drop $ prefix + format credit balance (rate-card-correct rendering)
  • b0fad40e fix: ring shows worst constraint, not just context
  • 97dc5414 refactor: split ring meaning - centre = context, colour = effective state

Why 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:

  1. 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.

  2. 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'.

  3. 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) gains credits (hasCredits / unlimited / balance) plus optional rateLimitReachedType / planType / limitId. JSON-only, no SCHEMA_VERSION bump.
  • normalizeCodexUsage (cli) extracts those fields from the token_count payload's rate_limits root.
  • Credits rendered as a bare count, no $ prefix. Codex's rate card (reference) makes credits a token-mix-dependent billing unit (~$0.04/credit at Pro tier, not 1:1 USD). String-to-number parser handles the precision-preserving balance shapes codex sends ("250.0000000000", "0", "0.0000000000").

Ring metaphor: centre = task room, colour = account constraint

  • New AgentBudgetState shape under shared/src/agentBudget.ts. Flavor-agnostic - declares only axes (each with pressure 0-100), an operationalAxisId (which axis the centre number shows), and an effective state (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.
  • Adapter encodes the Codex-Pro covering rule: subscription window at cap AND credits > 0 → amber, not red, with the credits axis flagged covering. The popover renders a left-accent on the covering axis so the user sees why the gauge isn't red.
  • Centre number is always the operational axis (context for LLM agents). Ring colour is always the effective state. The two no longer fight; the centre number never silently changes meaning.

Cross-flavor seed (umbrella #846)

  • AgentBudgetState is intentionally not Codex-specific. A toClaudeBudgetState adapter (or Cursor / Gemini equivalents) can drop in without touching the indicator. The Codex adapter is the reference implementation; Claude is queued next.

Visual change

State Before After
Context 80%, no rate-limit data Ring 80 blue Ring 80 blue (unchanged)
Context 80%, weekly 100%, credits 246 Ring 80 blue (silently hides the weekly cap) Ring 80 amber; popover shows 1 Week Usage 100% (dominant, accented) and Credits 246 covering exhausted subscription window (blue accent)
Context 80%, no windows, credits 0 (blocked) Ring 80 blue (silently misrepresents block) Ring 80 red; tooltip 'Blocked: subscription window and credits both exhausted'; popover shows Credits 0 subscription / top-up exhausted (critical, dominant)

Testing

  • bun typecheck + bun run test: 114 test files, 958 tests, all pass on this branch
  • Manual: validated against a live Codex Pro account on the operator's session - weekly at 100%, 246 credits, indicator correctly reads amber with covering accent
  • New test coverage: 13 tests in codexBudgetAdapter.test.ts covering 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 refreshed codexUsageSchema.test.ts and codexUsage.test.ts for the cli normalizer

Known follow-ups (out of scope for this PR)

  • i18n: the indicator strings ('Context Window', '5h Usage', 'Credits', 'subscription / top-up exhausted', etc) are English-only. The original PR was English-only as well; the cross-flavor refactor preserves that. Should route through t() in a follow-up so zh-CN locale gets coverage too.
  • account/rateLimits/updated standalone events: appServerEventConverter.ts currently only surfaces rate limits when piggy-backed on thread/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.
  • Claude / Cursor / Gemini adapters: the AgentBudgetState shape 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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Codex history import flag is dropped before runner launch — the new web resume flow sends importHistory: true and the hub includes it in the machine RPC payload, but the machine-side SpawnHappySession handler never reads or forwards that field into spawnSession. As a result, remote web resumes execute hapi 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 at cli/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 importHistory reaches spawnSession.

Testing

  • Not run (automation)

HAPI Bot

Comment thread hub/src/sync/rpcGateway.ts Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 10, 2026
…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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-root currently 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 at cli/src/modules/common/codexSessions.ts:407 and cli/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 importHistory forwarding issue appears fixed in the latest head.

Testing

  • Not run (automation)

HAPI Bot

Comment thread cli/src/modules/common/registerCommonHandlers.ts Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 10, 2026
…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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 as directory. 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-a while the form still contains the recent/default /repo-b resumes 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 at web/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

Comment thread web/src/components/NewSession/index.tsx
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 10, 2026
…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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Metadata-only session updates stale the session list — adding metadata to SessionPatchSchema makes the new hub event payload parse as a patch, so useSSE takes the patch path instead of invalidating. patchSessionDetail merges the metadata for an open session, but patchSessionSummary only copies active/thinking/model/effort fields and still returns true; that suppresses queueSessionListInvalidation(). A Codex history import/title update can therefore update the chat header while /sessions keeps the old title/path/agentSessionId until a full refetch. Evidence: shared/src/schemas.ts:279, with the summary patch path at web/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

Comment thread shared/src/schemas.ts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sets replayTranscriptHistoryOnStart for any new resumeSessionId. If that remote session is later handed off to local, codexLocalLauncher attaches 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 by cli/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 showOldCodexSessions off, the query result no longer contains that option, so the <select> visually falls back to “Start new Codex session”. The state still keeps selectedCodexSessionId, and handleCreate() sends it as resumeSessionId even though selectedCodexSession is null, 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

Comment thread cli/src/codex/runCodex.ts
Comment thread web/src/components/NewSession/index.tsx

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 discards nextCursor after the first request. Because listCodexSessions() caps limit to 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

Comment thread web/src/hooks/queries/useCodexSessions.ts Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

heavygee added a commit to heavygee/hapi that referenced this pull request Jun 13, 2026
…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>
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 13, 2026
…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>
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 13, 2026
…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>
@heavygee heavygee force-pushed the feat/codex-usage-indicator-rebased branch from be0d991 to 5104afa Compare June 13, 2026 17:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Pathless Codex resume can enable a no-op Create button — the selector can return Codex transcripts with path: null (CodexSessionSummary.path allows null and listCodexSessions keeps them), but canCreate now treats any selected Codex session as enough even when the directory input is empty. In that state the button is enabled, then handleCreate computes spawnDirectory as selectedCodexSession?.path ?? trimmedDirectory and silently returns because both are empty, so the user gets no error and no session starts. Evidence web/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

Comment thread web/src/components/NewSession/index.tsx Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --check only.

HAPI Bot

heavygee added a commit to heavygee/hapi that referenced this pull request Jun 16, 2026
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>
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 17, 2026
…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>
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 17, 2026
…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>
@heavygee heavygee force-pushed the feat/codex-usage-indicator-rebased branch from 99135fd to 321e3df Compare June 17, 2026 14:42
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 17, 2026
…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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Revalidate imported Codex history against workspace roots — --workspace-root now scopes the session picker and the spawn directory, but the actual import still trusts any resumeSessionId. A client can POST /api/machines/:id/spawn with directory inside an allowed root, agent: "codex", importHistory: true, and a Codex thread id whose transcript belongs to another project; runCodex then imports that transcript through importCodexSessionHistory without 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 at cli/src/codex/importHistory.ts:42.
    Suggested fix:
    const importedHistory = await importCodexSessionHistory({
        session,
        codexSessionId: opts.resumeSessionId,
        expectedDirectory: workingDirectory
    })
    and have importCodexSessionHistory parse the transcript session_meta.cwd/path before 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

Comment thread cli/src/codex/runCodex.ts

@heavygee heavygee left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-other passes when the requested spawn directory is /tmp/workspace, so a direct /api/machines/:id/spawn request 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

Comment thread cli/src/codex/importHistory.ts Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts with path-aware relative(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

dsus4wang and others added 16 commits June 18, 2026 10:44
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>
@heavygee heavygee force-pushed the feat/codex-usage-indicator-rebased branch from dc01f15 to 88a740b Compare June 18, 2026 16:33

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ApiMachineClient spawn RPC test where directory is inside an allowed workspace root but resumeSessionId resolves to a Codex transcript whose session_meta.cwd is outside, and assert spawn is rejected before spawnSession is called.

HAPI Bot

Comment thread cli/src/api/apiMachine.ts
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants