diff --git a/CREDITS.md b/CREDITS.md index b9340055f6..8cff47f965 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -95,3 +95,34 @@ unnecessary. If you find a landing that belongs on this page, open an issue. Being missed is the defect this file documents, not a claim you have to argue for. + +### A gap the gate does not close + +The gate checks that a trailer is **present**. It cannot check that the trailer +resolves to the account it names. + +A 2026-09-04 backlog review found carry PR +[#3374](https://github.com/lidge-jun/opencodex/pull/3374), carrying +[#3333](https://github.com/lidge-jun/opencodex/pull/3333) by +[@blackjune67](https://github.com/blackjune67), with: + +``` +Co-authored-by: hajune +``` + +(The address is masked here — `privacy:scan` blocks real contributor emails in the +tree. What matters is its shape: a personal work address, not a GitHub-linked one.) + +That is the git identity on the contributor's own commits, so it looks correct +in every review. But GitHub attributes co-authors by **account-linked** email, +and that address is linked to no account — so the trailer would have credited +nobody, and the contributor would have been invisible on their own patch. The +gate passed it, because a trailer was there. + +It was corrected before the merge to the contributor's account-linked +`users.noreply.github.com` address, which is why there is no table row for it above. + +The lesson generalizes: when carrying work, take the trailer address from the +author's GitHub account (the numeric-id `users.noreply.github.com` form is always +safe), not from the commit metadata on their branch. A contributor who commits +under a work email is the normal case, not an edge case. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md new file mode 100644 index 0000000000..f7f030084a --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md @@ -0,0 +1,87 @@ +# 260904 — Repository hygiene campaign + +Unit for the branch/PR/issue drawdown requested on 2026-09-04: delete landed and +abandoned refs locally and on `origin`, close superseded and partially-landed +pull requests and issues, consolidate surviving scope into new issues, and credit +every contributor whose work is carried. + +## Inventory at entry (2026-09-04, origin/dev = b5777aa2d) + +| Surface | Count | +|---|---| +| Local branches | 230 | +| Remote branches on `origin` | 56 | +| Open pull requests | 53 | +| Open issues | 45 | +| Worktrees | 67 | + +## Classification of local branches + +Every branch was scored on four independent axes rather than by name: + +1. `git merge-base --is-ancestor
origin/dev` — plain ancestry. +2. `git cherry origin/dev
` — patch-equivalence, which catches rebases. +3. Content landing — the files the branch touches + (`git diff --name-only origin/dev...
`) are compared two-dot against + `origin/dev` restricted to exactly those paths. Zero remaining difference + means the branch's content is already on `dev` even though a squash merge + destroyed its commit identity. +4. Exact reference matching against live GitHub state: open-PR head refs, + worktree-backing refs, and the PR number a scratch branch was cut for. + +Resulting buckets: + +| Bucket | Count | Disposition | +|---|---|---| +| PROTECTED (`dev`, `main`, `preview`) | 3 | never touched | +| OPEN_PR_HEAD | 7 | never touched | +| WORKTREE-backed | 44 | never touched | +| SAFE_DELETE (ancestor or zero unique commits) | 13 | delete | +| Scratch branches for MERGED/CLOSED PRs | 85 | delete | +| Content already landed on `dev` | 6 | delete | +| UNIQUE_WORK still unlanded | 39 | keep | + +## Prior-run failure this unit must not repeat + +A cleanup run on 2026-09-02 guessed PR numbers from branch names, treated the +guesses as merge evidence, and deleted the head refs of open pull requests: only +4 of 33 open PR heads survived it. Two rules follow. Open-PR head refs are read +from `gh` and matched by exact string immediately before each deletion batch, +never inferred. And a branch is deleted only when at least one of the four tests +above passes on the branch itself. + +## A shell hazard that produced a false positive + +The content-landing test was first written in shell. The login shell here is +zsh, which does not word-split an unquoted variable, so a 57-path file list +collapsed into a single nonexistent pathspec and `git diff` returned empty — +reporting `feat/macos-app`, a branch with 57 genuinely unlanded files including +an entire `app/` tree absent from `dev`, as fully landed. Acting on that would +have destroyed the macOS app work. + +The test was rebuilt in Python passing a real argument list, and validated +against controls in both directions before any deletion: an open PR head must +score UNLANDED, and a branch whose content is known to be on `dev` must score +LANDED. The rewritten test moved `feat/macos-app` to UNLANDED and reduced the +"landed" set from a bogus 41 to a verified 6. + +Rule for this unit: any bulk classifier gets a negative control before its +output authorizes a destructive action. + +## Work phases + +| Phase | Doc | Scope | +|---|---|---| +| wp0 | this file + 010 | roadmap and inventory | +| wp1 | 020 | local branch deletion | +| wp2 | 030 | `origin` remote branch deletion | +| wp3 | 040 | maintainer-authored PR drawdown | +| wp4 | 050 | contributor PR drawdown with credit | +| wp5 | 060 | issue drawdown and consolidation | +| wp6 | 070 | credit ledger and closeout | + +## Out of scope + +Merging any pull request, pushing to `dev`/`main`/`preview`, releases, +force-push, history rewriting, worktree removal, and behavior changes under +`src/`. The local test suite is forbidden for this unit by explicit instruction. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md new file mode 100644 index 0000000000..773b6db649 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md @@ -0,0 +1,98 @@ +# 010 — Classification method and its controls + +## The four tests + +A local branch is deletable when at least one holds, and no guard fires. + +``` +T1 ancestry git merge-base --is-ancestor
origin/dev +T2 patch-equiv git cherry origin/dev
-> no '+' lines +T3 content paths = git diff --name-only origin/dev...
+ git diff --name-only origin/dev
-- -> empty +T4 scratch branch name encodes a PR number whose state is MERGED or CLOSED + AND the name matches the scratch prefix set + AND the number is a WHOLE numeric token of the branch name + AND the branch is provably a duplicate of that PR's head: + identical SHA, an ancestor of it, or content-identical to it +``` + +T3 is the one that matters for this repository, because `dev` takes squash +merges: after a squash the branch shares no commit with `dev`, so T1 and T2 both +report "unmerged" for work that is fully shipped. T3 asks the only question that +is actually load-bearing — is there any difference left in the files this branch +claims to change. + +T4 is deliberately narrow. It fires only for throwaway prefixes +(`pr*`, `rb-`, `jrb-`, `mtp/`, `big-`, `cf-`, `ocx-`, `wip/`, `backup/`, +`candidate`, `cursor-`, `midstream`) created by earlier review and rebase runs, +and only when the referenced PR is already MERGED or CLOSED. A `codex/*` branch +is never deleted on T4 alone. + +**T4 alone is not sufficient, and the first version of it was wrong.** PR state +says nothing about whether *this branch* still holds unique work, so T4 now +requires a positive duplication proof against the PR head itself: the branch is +the same commit, an ancestor of it, or content-identical to it. If the PR head +cannot be fetched or the branch matches none of those, the branch falls through +to the content test against `dev`, and if that also fails it is preserved. + +The number must also be a whole numeric token of the branch name. The naive +regex extracted `2608` from the date suffix in +`cursor-call-prerebase-260818` and matched it to an unrelated merged PR — the +exact name-guessing that destroyed open-PR heads on 2026-09-02, reproduced +inside the very unit written to prevent it. That branch holds two unique Cursor +stream-EOF and cancel fixes and 31 otherwise-unreachable commits. + +This was caught by an independent auditor, not by the author of the rule. + +## The guards + +Deletion is refused, regardless of test result, for: + +- `dev`, `main`, `preview` +- any ref appearing as `headRefName` of an open pull request, read from `gh` + immediately before the batch and matched as an exact string +- any ref backing a live worktree, from `git worktree list --porcelain` +- the currently checked-out branch + +## Controls run before deletion was authorized + +The content test is a destructive-action authority, so it was falsified first. + +**Negative control.** `origin/codex/responses-usage-passthrough`, head of open +PR #3364, must not score LANDED. It differs from `dev` in 38 files and scored +UNLANDED. Passed. + +**Positive control.** `codex/remote-hub-restack-roadmap-archive` carries 39 +unique commits but every file it touches is already identical on `dev`; a +commit-based test calls it unmerged, the content test calls it LANDED. Passed. + +**Failure the controls caught.** The first shell implementation reported 41 +branches LANDED including `feat/macos-app`, which adds an entire `app/` tree +that does not exist on `dev`. Cause: zsh does not word-split unquoted +variables, so `git diff ... -- $paths` passed one 57-line pathspec that matched +nothing and produced empty output, which the test read as "no difference." Any +branch would have scored LANDED. Rebuilt in Python with a real argv list; the +landed set fell from 41 to 6 and `feat/macos-app` correctly moved to UNLANDED. + +## Result + +Candidate set 104, of which 33 failed the hardened tests and are preserved. + +| Verdict | Count | Proof | +|---|---|---| +| Delete | 50 | identical SHA to its PR head | +| Delete | 2 | ancestor of its PR head | +| Delete | 13 | ancestor of `dev` or zero unique commits | +| Delete | 6 | content already on `dev` (squash-hidden) | +| **Total deletion set** | **71** | every entry carries a named proof | +| Preserved: failed the duplication proof | 32 | | +| Preserved: number not a whole token | 1 | `cursor-call-prerebase-260818` | +| Keep: unlanded unique work | 39 | | +| Keep: open-PR head, worktree-backed, protected | 54 | | + +Every entry in the final set names its own proof, so no deletion rests on the +absence of evidence. Ledgers: `.tmp/hygiene/DELETE_FINAL.json` and +`.tmp/hygiene/REJECTED_FINAL.json`. + +Ledger of the deletion set with per-branch reason: +`.tmp/hygiene/delete-local.json` (scratch space, not tracked). diff --git a/devlog/_fin/260904_repo_hygiene_campaign/015_audit_record.md b/devlog/_fin/260904_repo_hygiene_campaign/015_audit_record.md new file mode 100644 index 0000000000..3b5321594d --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/015_audit_record.md @@ -0,0 +1,78 @@ +# 015 — Audit record for the deletion ledger + +The branch-deletion ledger was reviewed by an independent auditor before any +branch was touched. It failed three times. Each failure is recorded here because +each one would have destroyed work. + +## Round 1 — FAIL + +> `cursor-call-prerebase-260818` was matched to unrelated PR #2608 by parsing a +> date-like branch suffix and still contains unique unmerged patches + +The scratch-branch rule extracted the first 3–4 digit run from a branch name and +treated the matching PR's state as merge evidence. The branch is dated +2026-08-18, so `260818` yielded `2608`, which is a real merged PR about a +completely different subject. The branch carries two unique Cursor fixes — an +unlabeled stream EOF failure and a cancel surface — and 31 commits reachable +from nothing else. + +This is the same class of error that deleted open-PR head refs on 2026-09-02, +reproduced inside the unit written to prevent it. Writing the rule down did not +prevent it; an auditor running the numbers did. + +Fix: the PR number must be a whole numeric token of the branch name, and PR +state alone no longer authorizes anything — the branch must be proven a +duplicate of that PR's head (same SHA, ancestor, or content-identical). +Candidate set 104 → approved 71. + +## Round 2 — FAIL + +> `final.py` can authorize deletion from a stale PR-head ref or failed `git +> diff` because both command failures are ignored + +The generator ignored return codes. A failed `fetch` left a stale +`refs/prhead/` that would be compared as if current, and a failed `git diff` +produced empty stdout that read as "no difference" — the same shape as the zsh +bug in `010_method.md`, where absence of output was mistaken for absence of +change. Twice in one unit, so it is a pattern and not an accident: **empty +output is not evidence unless the command is known to have succeeded.** + +Fix: fail-closed. Git failures raise, PR heads are force-fetched with a checked +return code, and any error rejects the branch. Regenerating produced exactly the +same 71 branches, which is itself the evidence that the earlier approvals were +sound rather than lucky. + +## Round 3 — FAIL + +> cached T1/T2 and T3 proofs are not recomputed or SHA-bound, so a branch that +> moves after classification can lose new work + +Proofs were inherited from JSON snapshots taken earlier in the session and the +ledger stored no SHAs, so a branch that gained a commit between classification +and deletion would still be deleted on the strength of a stale verdict. + +Fix: snapshots now supply only the candidate list. Every proof is recomputed +live, and each approval records the branch tip, the proof, and the `origin/dev` +SHA it was proven against. Execution re-reads each tip immediately before +deletion and refuses on any mismatch. + +## Round 4 — PASS + +- 71/71 recorded tips equal current branch tips +- 71/71 proofs still hold at the recorded SHA +- 33/33 rejected branches still present, including `cursor-call-prerebase-260818` +- guards empty against live state: no open-PR head, no worktree ref, nothing protected +- `origin/dev` moved during the audit (`b5777aa2d` → `664d80c76`) and invalidates + no proof; no rejected branch became landed as a result + +Non-safety note from the auditor: `rb-2122-ELZMyj` and `rb-2734` are +tree-identical to their PR heads but stay preserved because the comparison uses +three-dot form. Over-preservation, so it stands. + +## What this cost and why it was worth it + +Four rounds against one auditor, no branch deleted until the fourth passed. The +first round alone justifies the whole exercise: the plan document explicitly +warned against branch-name guessing on line 46, and the implementation did it +anyway on line 12 of the very next file. A rule you wrote does not audit the +code you wrote. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/020_wp1_local_branches.md b/devlog/_fin/260904_repo_hygiene_campaign/020_wp1_local_branches.md new file mode 100644 index 0000000000..4c76c491d7 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/020_wp1_local_branches.md @@ -0,0 +1,54 @@ +# 020 — wp1: local branch deletion + +Delete the 71 branches in the verified deletion set, in batches, re-reading the +guard sets before each batch. Each entry carries a named proof; a branch with no +proof is preserved rather than deleted. + +## Procedure + +1. Snapshot every local ref to scratch: `git for-each-ref refs/heads` with SHAs, + so any deletion is recoverable by SHA for as long as the objects survive gc. +2. Re-read open-PR head refs from `gh` and worktree refs from + `git worktree list --porcelain`. Intersect with the deletion set; a non-empty + intersection aborts the phase. +3. Delete with `git branch -D` in batches of ~20, capturing the reported SHA for + each deletion. +4. Verify: the local branch count drops by exactly 71, and every + protected / open-PR / worktree ref still resolves. Counts are measured live + at execution rather than asserted here — the branch total moves as other + sessions work in this repository, and a stale expected number is a false + alarm, not a safety property. + +`-D` rather than `-d` is required because squash-landed branches are not +ancestors of `dev` and `-d` refuses them; that is exactly the case T3 exists to +decide, and the decision has already been made with evidence. + +## Outcome (executed 2026-09-04) + +71 branches deleted, each after re-reading its tip and comparing it to the SHA +recorded at classification. Zero failures, zero tip mismatches. + +| Measure | Before | After | +|---|---|---| +| Local branches | 241 | 170 | + +Post-deletion verification, run against live state rather than the plan: + +| Check | Result | +|---|---| +| Open-PR head refs present locally that were lost | 0 of 13 | +| Worktree-backing refs lost | 0 of 47 | +| Preserved (rejected) branches wrongly deleted | 0 of 33 | +| `dev` / `main` / `preview` intact | yes | + +That first row is the whole point of this unit. The 2026-09-02 run left only 4 +of 33 open-PR heads alive; this one lost none. + +## Exit criteria + +- Exactly the 71 approved refs are gone; nothing else was removed. +- Every open-PR head ref present locally still resolves. +- Every worktree-backing ref still resolves. +- `dev`, `main`, `preview` resolve to their pre-phase SHAs. +- `cursor-call-prerebase-260818` and the other 32 preserved branches still + resolve. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/030_wp2_remote_branches.md b/devlog/_fin/260904_repo_hygiene_campaign/030_wp2_remote_branches.md new file mode 100644 index 0000000000..de23ab8d5d --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/030_wp2_remote_branches.md @@ -0,0 +1,65 @@ +# 030 — wp2: origin remote branch deletion + +`origin` carries 56 branches. The deletable set is the intersection of: + +- not `dev`, `main`, `preview` +- not the head ref of an open pull request whose head repository is + `lidge-jun/opencodex` (fork-hosted heads are not ours to delete and are not + reachable as `origin` refs anyway) +- content already on `dev` by the T3 test applied to `origin/`, or the + branch is a spent dispatch/promotion artifact + +Two families dominate the remote list and need separate judgment: + +- `origin/codex/win-dispatch-*` (9 refs) — CI dispatch artifacts pinned to a + commit SHA. Spent once their run finished. +- `origin/assets/*` and `origin/media/*` — evidence assets referenced from PR + and issue bodies by raw URL. Deleting these breaks images in published + descriptions, so they are retained unless the referencing item is closed and + the image is no longer rendered. Default is keep. + +Deletion uses `git push --no-verify origin --delete `, one ref per +command with a bounded timeout. `--no-verify` is required because the pre-push +hook runs a local suite, which is forbidden for this unit; the safety that hook +would provide is already supplied by the T1–T4 evidence and the guard sets, and +a deletion pushes no code. + +## Outcome + +Of 62 non-protected remote refs, only 2 were provably spent: + +| Branch | Proof | Result | +|---|---|---| +| `codex/regaudit-ci-main-af6113a03` | empty vs `dev` | deleted | +| `codex/260904-logs-cost-effort-polish` | content already on `dev` (PR #3367 merged) | already gone; pruned locally | + +38 hold unique unlanded work, 14 are open-PR heads, 5 are orphans, 3 protected. +The remote was already close to minimal — the sprawl was local. + +## A third fail-open, caught here + +The first remote pass marked all five `assets/*` branches deletable as +"content_landed". They are **orphan branches with no merge base**, so +`git diff origin/dev...origin/assets/*` exits 128 with +`fatal: ... no merge base` and prints nothing. Reading that empty stdout as +"no difference" would have deleted five evidence branches holding 36 image files +that exist nowhere else. + +This is the same mistake as the zsh word-split in `010_method.md` and the +ignored return codes in `015_audit_record.md`: **empty output treated as +evidence of absence, when it was actually evidence of a failed command.** Three +occurrences in one campaign, each in code written after the previous one was +documented. + +The remote classifier is now fail-closed the same way: a missing merge base +disqualifies every `dev`-relative test and the branch is preserved outright. +The five orphan asset branches are retained under `orphan_no_merge_base`. + +The general lesson, now stated once for the whole unit: a test whose "safe" +answer is produced by silence must verify that the command spoke. + +## Exit criteria + +- `git ls-remote --heads origin` no longer lists any deleted ref. +- Every open PR's head ref still resolves on its own repository. +- Asset branches still referenced by open items remain. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/040_wp3_maintainer_prs.md b/devlog/_fin/260904_repo_hygiene_campaign/040_wp3_maintainer_prs.md new file mode 100644 index 0000000000..846f225265 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/040_wp3_maintainer_prs.md @@ -0,0 +1,21 @@ +# 040 — wp3: maintainer-authored PR drawdown + +Ten of the 53 open pull requests are authored by `lidge-jun`. These carry no +contributor-credit obligation, so they are classified first and act as a +rehearsal for the evidence format used on contributor PRs. + +Classification per PR: + +- **SUPERSEDED** — every file the PR touches is already identical on `dev` + (T3 applied to the PR head). Close with a comment naming the landing commit. +- **PARTIAL** — some paths landed, some did not. Close and carry the remainder + into a consolidated follow-up issue. +- **LIVE** — keep open. + +Evidence recorded per PR: head SHA, files touched, files still differing from +`dev`, and the commit or PR that landed the overlap. + +## Exit criteria + +Every maintainer PR has a verdict with captured evidence, and each SUPERSEDED or +PARTIAL one is closed with a comment a reader can independently check. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/050_wp4_contributor_prs.md b/devlog/_fin/260904_repo_hygiene_campaign/050_wp4_contributor_prs.md new file mode 100644 index 0000000000..b34897c322 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/050_wp4_contributor_prs.md @@ -0,0 +1,32 @@ +# 050 — wp4: contributor PR drawdown with credit + +43 of the 53 open pull requests come from outside contributors. Closing someone's +pull request is the moment their work either gets recorded or disappears, so this +phase is bound by the attribution policy in `AGENTS.md` and the existing +`CREDITS.md` ledger. + +## Rules + +1. No contributor PR is closed without a comment that names the author, states + what happened to their work, and links the evidence. +2. If the work landed on `dev` by another route — reimplementation, carry, or + rebase — that is a carry, and it requires a `Co-authored-by` trailer on the + landing commit. For work already landed without one, the repair path is + `CREDITS.md`, because `dev`, `main`, and `preview` are force-push protected + and the affected commits are inside published tags. History is not rewritten. +3. PARTIAL contributions are closed only alongside a follow-up issue that names + the contributor and states which part of their proposal survives. +4. A PR that is merely stale, unrebased, or awaiting review is LIVE. Age is not + evidence of supersession. + +## Draft-state contributors + +Many contributor PRs sit in draft behind the four-box readiness gate, and several +carry `intake: hygiene-blocked`. Draft state means the gate has not passed, not +that the work is unwanted — these are classified on content like any other. + +## Exit criteria + +Every contributor PR has a verdict with evidence; every closure has a credited +comment URL captured; every carried contribution appears in `CREDITS.md` or +already carries its trailer. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/060_wp5_issues.md b/devlog/_fin/260904_repo_hygiene_campaign/060_wp5_issues.md new file mode 100644 index 0000000000..a597a55161 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/060_wp5_issues.md @@ -0,0 +1,25 @@ +# 060 — wp5: issue drawdown and consolidation + +45 open issues. Classification mirrors the PR phase, with one addition: an issue +can be superseded by a *shipped feature* rather than by a specific PR, so the +evidence is a released capability plus the commit that introduced it. + +## Consolidation + +PARTIAL issues are the reason this phase exists. Where several issues describe +facets of one surviving need — account-pool routing, quota-window handling, +provider catalog capability gaps — they are closed individually and absorbed +into one consolidated issue per cluster. Each consolidated issue must: + +- state the remaining scope in its own words, not by reference only; +- link every absorbed issue by number; +- name every original reporter so credit follows the scope; +- use the repository issue template. + +A consolidated issue that merely lists links is not acceptable — the point is +that the surviving requirement stays legible after the sources are closed. + +## Exit criteria + +Every open issue has a verdict; consolidated issues exist for each PARTIAL +cluster; no issue is closed without its reporter being named. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/070_wp6_credit_and_closeout.md b/devlog/_fin/260904_repo_hygiene_campaign/070_wp6_credit_and_closeout.md new file mode 100644 index 0000000000..43447e5666 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/070_wp6_credit_and_closeout.md @@ -0,0 +1,20 @@ +# 070 — wp6: credit ledger and closeout + +Final phase. Reconcile every closure made in wp3–wp5 against the attribution +policy, extend `CREDITS.md` where a contribution was carried without a trailer, +and record the campaign result. + +## Checks + +1. Each closed contributor item has a comment naming its author. Verified by + re-reading the comments through `gh`, not from memory of having posted them. +2. Each carried contribution is either covered by a `Co-authored-by` trailer on + its landing commit or listed in `CREDITS.md`. +3. `missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs` + remains the forward guard; this phase must not grow the historical list + without recording why. + +## Closeout + +Final counts for branches, remote refs, open PRs, and open issues, each measured +live rather than derived from the plan. The unit then moves to `devlog/_fin/`. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/080_drawdown_ledger.md b/devlog/_fin/260904_repo_hygiene_campaign/080_drawdown_ledger.md new file mode 100644 index 0000000000..866ff8f582 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/080_drawdown_ledger.md @@ -0,0 +1,101 @@ +# 080 — PR and issue drawdown ledger + +## What the evidence actually showed + +The campaign assumed a backlog full of superseded work. It was not. Running the +content-landing test against all 53 open PR heads found exactly one fully landed +(#3367, which merged during the campaign) and one mostly landed (#2877, a devlog +PR). Re-running it across 35 contributor PRs returned **0% landed for every +single one** — 68 files unlanded on #1645, 95 on #2462, 65 on #2113, and so on. + +That is the finding, not a failure to find one: this backlog is not stale, it is +unreviewed. Closing those PRs as "superseded" would have destroyed real work and +told 30-odd contributors their submissions were duplicates when they were not. + +## Overlap that looked like duplication and was not + +A file-overlap pass surfaced 35 PR pairs sharing ≥30% of their files. Nearly all +were false positives of two kinds: + +- **Intentional stacks.** #3340 → #3349 → #3350 (@Flowershangfromthebranches) is + a declared 3-PR stack; each says so and names the commit that is uniquely its + own. #3365 and #3370 target their parent's head branch, which is the + documented stacked-PR workflow, not a duplicate. +- **Shared surface.** Ten GUI PRs touch `gui/src/pages/Models.tsx` and the i18n + bundles because that is where GUI work lives. Co-editing a file is not + supersession. + +One real supersession existed: #3312 and #3348, same author, same 30 source +files, v2 versus v4 of one failover audit. #3348 fixes a cooldown key that v2 +derives from a positional pool id — a correctness bug, not a style change — so +the older PR was closed toward the newer one with that diff quoted. + +## Issues + +| Verdict | Count | +|---|---| +| SUPERSEDED — closed, implementation cited | 3 | +| PARTIAL — closed into a consolidated issue | 11 | +| LIVE — left open | 24 | +| STALE-NOINFO — left open, specific request posted | 7 | + +Closed as implemented: #1572 (policy fallback, cd7ea8a88 + 457c33675), #2288 +(remote hub, 91a4f6c40), #3158 (four P2 follow-ups, eceb02d9d + 0d8147c20). + +### Consolidated issues + +| New | Absorbs | Theme | +|---|---|---| +| #3375 | #695, #1062, #1977, #2275 | OAuth account-pool lifecycle | +| #3376 | #2344, #2874, #2969 | quota history and reset windows | +| #3377 | #3268, #3271, #3281 | per-model capability declarations | +| #3378 | #3344, #3362 | OpenCode Go wire contract | +| #3379 | #2399, #2748, #3017 | dashboard management gaps | + +Each consolidated issue states the surviving requirement in its own words, cites +the code that proves what already shipped, links every absorbed issue, and names +every original reporter. Each closure comment credits its reporter, says what +landed and what did not, and invites correction on the new issue. Where an +absorbed issue has an open PR against it (#2973, #3282), the closure says +explicitly that the PR is not superseded. + +The seven STALE-NOINFO issues were not closed. Each got a comment saying where +the code stands and naming the one artifact that would unblock it. Closing a +report because the reporter has not answered yet is how a project stops +receiving reports. + +## An attribution defect found in the carry PRs + +Four maintainer PRs (#3371–#3374) carry contributor work. Three name their +author in a linked `Co-authored-by` trailer. #3374 carried @blackjune67's #3333 +with: + +``` +Co-authored-by: hajune +``` + +That is the git identity on the contributor's commits, but GitHub matches +co-authors by **account-linked** email, so this trailer credits nobody — +@blackjune67 would not appear on the contributor graph for their own patch. The +description now carries +`blackjune67 <46661504+blackjune67@users.noreply.github.com>`, and the PR has a +comment telling whoever squashes it to keep that exact trailer. + +This is the failure mode `CREDITS.md` exists to repair, caught before the merge +rather than after. `missing_coauthor_credit` in +`.github/scripts/pr-carry-attribution.cjs` verifies a trailer is *present*; it +cannot tell that a present trailer points at an unlinked identity. Worth +tightening, and recorded here rather than fixed silently. + +## Final counts + +| Surface | Before | After | +|---|---|---| +| Local branches | 241 | 171 | +| Remote branches | 61 | 59 | +| Open PRs | 53 | 56 | +| Open issues | 45 | 32 | + +Open PRs rose because four carry PRs and two stacked PRs were opened by other +work during the campaign; one PR (#3312) was closed by it. Issues fell by 13 +with 5 consolidated issues created — net 14 closed. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/090_closeout.md b/devlog/_fin/260904_repo_hygiene_campaign/090_closeout.md new file mode 100644 index 0000000000..74813e7962 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/090_closeout.md @@ -0,0 +1,80 @@ +# 090 — Campaign closeout + +## Result + +| Surface | Before | After | Change | +|---|---|---|---| +| Local branches | 241 | 171 | −70 | +| Remote branches | 61 | 59 | −2 | +| Open issues | 45 | 33 | −12 | +| Open PRs | 53 | 57 | +4 | + +The after-counts are live at closeout, not a subtraction from the plan. Both +"increases" are inbound traffic during the campaign, not work left undone: four +carry PRs and two stacked PRs were opened by other sessions, and new reports +arrived (for example #3384 from @Yum-wu). 14 issues were closed and 5 +consolidated issues opened, so the issue ledger nets −12 against a moving +baseline rather than −13 against a frozen one. + +Counting against live state instead of the entry snapshot is deliberate. A +repository with contributors does not hold still for a cleanup, and a closeout +that reports the number it predicted rather than the number that exists is +reporting on its own plan. + +Local branch deletion: 71 refs, each with a recorded proof and a tip SHA +re-checked immediately before removal. Zero open-PR heads lost, zero +worktree-backing refs lost, zero preserved branches removed. + +Issues: 14 closed (3 implemented, 11 consolidated), 5 consolidated issues opened +(#3375–#3379), 7 stale reports given a specific unblocking request instead of a +silent close. + +PRs: 1 closed (#3312, superseded by the same author's #3348). The open-PR count +rose because unrelated work opened carry and stacked PRs while this ran. + +## What this campaign was actually about + +The instruction was to clean up merged branches and close superseded work. The +branch half was real: 71 of 241 local refs were duplicates of PR heads or +content already on `dev`. The PR half was not — 0 of 35 contributor PRs had +landed. The backlog is unreviewed, not stale, and the correct action was to +leave it open and say so. + +## The recurring defect + +Four separate times, a check reported success because a command had failed: + +1. zsh did not word-split an unquoted path list, so `git diff` matched nothing + and `feat/macos-app` — 57 unlanded files including an entire `app/` tree — + scored "landed". +2. `git fetch` and `git diff` return codes were ignored, so a stale ref or a + failed diff could authorize a deletion. +3. Cached proofs were never rechecked, so a branch that moved after + classification would still be deleted on a stale verdict. +4. Orphan `assets/*` branches have no merge base, so the diff exited 128 and + printed nothing; five evidence branches holding 36 unique images scored + "landed". + +Every one produced *empty output*, and empty output was read as "no +difference." The rule this campaign ends with: **a test whose safe answer is +silence must first prove the command spoke.** + +Three of the four were caught by an independent auditor that failed the plan +three times before passing. The fourth was caught by re-checking a result that +looked too convenient. None were caught by the plan document, which had +explicitly warned against this class of error on its own line 46. + +## Attribution + +Carry PRs #3371–#3373 credit their authors correctly. #3374 named a git identity +not linked to any GitHub account, which credits nobody; the trailer now names +`blackjune67 <46661504+blackjune67@users.noreply.github.com>` and the PR carries +an instruction to preserve it through the squash. Every issue closure names its +reporter and states what shipped and what did not. + +## Follow-ups worth doing + +- `missing_coauthor_credit` verifies a trailer exists but not that it resolves + to a real account. An unlinked-email check would have caught #3374. +- The 24 LIVE issues and 55 unreviewed PRs are the actual backlog. That is a + review campaign, not a hygiene one. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md new file mode 100644 index 0000000000..bf8e5d701c --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md @@ -0,0 +1,63 @@ +# 100 — Per-PR verdicts + +Full classification of the 53 pull requests open when the campaign started. +Method: fetch each PR head, take the files it touches +(`git diff --name-only origin/dev...`), then compare those exact paths +two-dot against `origin/dev`. Remaining differences mean the work has not +landed. + +## Closed + +| PR | Author | Verdict | Evidence | +|---|---|---|---| +| #3312 | @RHODIZSECURITY | SUPERSEDED by #3348 | same 30 src/test files; the 9 that differ are v4 refinements, including a cooldown key moved off a positional pool id onto the key itself | + +## Landed during the campaign + +| PR | Verdict | Evidence | +|---|---|---| +| #3367 | merged | 24 files touched, 0 remaining; merged as `664d80c76` while the campaign ran | +| #2877 | 3 of 4 files landed | only `090_closeout.md` of the 260829 devlog unit still differs | + +## Not superseded — measured, not assumed + +Every remaining contributor PR was measured at **0% landed**. A sample, with +files touched and files still differing from `dev`: + +| PR | Author | Touched | Still differ | +|---|---|---|---| +| #1645 | @waw4303 | 68 | 68 | +| #2462 | @kwannz | 95 | 95 | +| #2113 | @cb8010d6 | 65 | 65 | +| #2881 | @wonny-log | 51 | 51 | +| #2562 | @roy6732856 | 46 | 46 | +| #2351 | @harryzhou2000 | 41 | 41 | +| #3025 | @randomix777 | 37 | 37 | +| #2921 | @Warexpor | 36 | 36 | +| #2956 | @Manson2438 | 34 | 34 | +| #2230 | @ppvia | 33 | 33 | +| #3349 / #3350 | @Flowershangfromthebranches | 30 / 30 | 30 / 30 | +| #3252 | @x3M3x | 24 | 24 | +| #2527 | @harryzhou2000 | 19 | 19 | +| #2213 | @louis-tepe | 18 | 18 | +| #2280 | @cristph | 17 | 17 | +| #2716 | @zigzag-007 | 17 | 17 | +| #3329 | @Veritas-7 | 17 | 17 | +| #3340 | @Flowershangfromthebranches | 17 | 17 | +| #3251 | @abhisheksharma2411 | 12 | 12 | +| #3283 | @vanch007 | 12 | 12 | + +…and the remainder identically. Full output: `.tmp/hygiene/pr-landing2.txt`. + +## Overlap pairs that are not duplicates + +35 PR pairs share ≥30% of their files. Two benign causes: + +- **Declared stacks.** #3340 → #3349 → #3350, #3364 → #3365, #3369 → #3370. Each + child states its parent and names its own unique commit. `enforce-target` + explicitly supports this workflow. +- **Shared surface.** Ten GUI PRs co-edit `gui/src/pages/Models.tsx` and the + i18n bundles because that is where GUI work lives. + +Closing either class as duplicates would have been wrong, which is why file +overlap was used only to generate candidates and never as evidence. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/110_credit_verification.md b/devlog/_fin/260904_repo_hygiene_campaign/110_credit_verification.md new file mode 100644 index 0000000000..05b13e8e43 --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/110_credit_verification.md @@ -0,0 +1,52 @@ +# 110 — Contributor credit verification + +## What was checked + +Four maintainer pull requests carry contributor work and therefore owe a +`Co-authored-by` trailer under `AGENTS.md`. The check is not "does a trailer +exist" but "does the trailer name the GitHub account that authored the original +pull request" — those are different questions, and the difference is the whole +finding. + +| Carry PR | Carries | Author | Trailer resolves | +|---|---|---|---| +| #3371 | #3357 | @huaiqing-afk | yes | +| #3372 | #3322 | @luvs01 | yes | +| #3373 | #3335 | @x3M3x | yes | +| #3374 | #3333 | @blackjune67 | **no — corrected** | + +#3374 carried `Co-authored-by: hajune `. That address is the +git identity on every commit in #3333, so it survives any review that compares +the trailer to the branch. It is not linked to a GitHub account, and GitHub +attributes co-authorship by account-linked email, so the contributor would have +received nothing for their own patch. + +Corrected to `blackjune67 <46661504+blackjune67@users.noreply.github.com>`, with +a comment on the PR instructing whoever squashes it to preserve that exact +trailer. Recorded in `CREDITS.md` under "A gap the gate does not close". + +## Why the gate missed it + +`missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs` fails a +carry PR that has no trailer. It has no way to ask GitHub whether the address in +a trailer resolves to an account, so a well-formed trailer pointing at an +unlinked work email passes. A contributor committing under a company email is +the common case, not an exotic one, which makes this a systematic hole rather +than a one-off. + +A useful hardening: resolve the trailer email through the commits API on the +referenced PR and require it to match the PR author's account, rejecting +addresses that resolve to no account. + +## Re-verification + +`.tmp/hygiene/verify_credit.py` re-reads all four carry PRs live and asserts +each trailer names the original PR's GitHub author. It is a live check against +the API rather than a re-reading of this document. + +## Closure comments + +Every issue and pull request closed in this campaign carries a comment that +names its author, states what shipped with a code citation, states what did not, +and points at the consolidated issue where the surviving scope lives. Comment +IDs are recorded in the goalplan criterion `c-5`. No item was closed silently. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/120_issue_verdicts.md b/devlog/_fin/260904_repo_hygiene_campaign/120_issue_verdicts.md new file mode 100644 index 0000000000..8865a7dd1d --- /dev/null +++ b/devlog/_fin/260904_repo_hygiene_campaign/120_issue_verdicts.md @@ -0,0 +1,65 @@ +# 120 — Per-issue verdicts + +All 45 issues open at campaign start, classified by three independent analysts +working disjoint batches. Every verdict carries a `file:line` or commit +citation; "no landing found" verdicts name the searches performed. + +## Closed as implemented + +| Issue | Reporter | Implementation | +|---|---|---| +| #1572 | @brunoflma | `src/server/responses/policy-fallback.ts`, cd7ea8a88 + 457c33675 | +| #2288 | @mobaicloud | `src/client/connect.ts`, 91a4f6c40 | +| #3158 | @lidge-jun | eceb02d9d + 0d8147c20 | + +## Closed into consolidated issues + +| Issue | Reporter | Absorbed by | What was still missing | +|---|---|---|---| +| #695 | @luwei1990 | #3375 | session affinity, 401/403 rotation, health lifecycle | +| #1062 | @agentHits | #3375 | aggregate pool health, account-attributed usage | +| #1977 | @dbc-hbin | #3375 | durable one-shot warmup scheduling | +| #2275 | @luvs01 | #3375 | caller-stable operation id on the manual endpoint | +| #2344 | @Michael-Han0608 | #3376 | quota history retention | +| #2874 | @wonny-log | #3376 | reset-window pool ordering | +| #2969 | @terrytan95 | #3376 | reset-driven window activation (PR #2973 open) | +| #3268 | @turin-dev | #3377 | text-only model declaration | +| #3271 | @GoldenLoaf24h | #3377 | video processing mode passthrough | +| #3281 | @Simon-Opopeee | #3377 | context tier selection (PR #3282 open) | +| #3344 | @colthreepv | #3378 | `x-opencode-session` header | +| #3362 | @0disoft | #3378 | `indexed_web_access` sanitization | +| #2399 | @ncepuee | #3379 | journal entry deletion | +| #2748 | @areskts | #3379 | custom date/hour usage ranges | +| #3017 | @hayabusasxs | #3379 | account selector rename API | + +Where an absorbed issue has an open implementation PR (#2973, #3282), the +closure comment says explicitly that the PR is not superseded. + +## Left open — still valid, unimplemented + +#95, #1213, #1416, #1533, #1711, #2279, #2358, #2455, #2495, #2511, #2730, +#2811, #2834, #2894, #3191, #3259, #3266, #3352, #3353, #3366 and the +needs-info set below. Each was verified against current `dev` rather than +assumed: for example #2894 (SOCKS5) is unimplemented because +`src/types/config.ts` defines only a global HTTP(S) proxy with no per-provider +override and no scheme validation. + +## Left open — blocked on the reporter + +#1527, #1782, #1811, #3245, #3255, #3279, #3320. + +These were **not** closed. Each received a comment stating where the code +stands and naming the single artifact that would unblock it — a redacted +`UserId` element, a current reproduction, a failing request URL. Closing a +report because its author has not replied yet is how a project stops receiving +reports, and several of these are plausible defects whose evidence simply has +not arrived. + +## Note on partial verdicts + +Eleven issues were PARTIAL and eleven were closed, but several other PARTIAL +findings (#95, #1213, #1533, #2358, #2455, #2511, #2811, #2834, #3191, #3353) +were left open instead. The difference is whether the remainder belongs to a +cluster: a partial whose surviving scope stands alone stays as its own issue, +because folding a single coherent request into a consolidated one loses detail +without reducing count. diff --git a/devlog/_plan/260903_muse_provider_parity/000_plan.md b/devlog/_plan/260903_muse_provider_parity/000_plan.md new file mode 100644 index 0000000000..5c95d37092 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/000_plan.md @@ -0,0 +1,132 @@ +# Meta Muse: from credential import to a first-class provider + +- Date: 2026-09-03 +- Session: `01a0670b-54e5-7d41-9f86-b7cf5983b334` +- Work class: **C4** — the request path, a persisted cache, the management API, and a + user-visible dashboard surface move together, and the cache is keyed by a credential + identity that failover can change mid-turn. +- Status: **P (wp0)**. + +## Loop spec + +- Archetype: satisfy-spec integration. The verifier defines done; there is no metric to + maximize. +- Trigger: the user opened `http://localhost:10100/#providers`, expected Muse usage to + render, and found nothing. The investigation documents existed; the code did not. +- Goal: `meta-muse` behaves like a first-class OAuth provider in the dashboard — usage + windows visible, and every other parity surface either closed or recorded as a + deliberate, evidence-backed non-goal. +- Non-goals: Meta console GraphQL, Muse Voice/Image models, translated docs locales, + `meta-model`'s key path, and any inference call issued to obtain a quota. +- Verifier: focused `bun test` on the touched suites, `bun run test:changed`, + `bun x tsc --noEmit`, `bun run privacy:scan`, `bun run lint:gui`, `cd gui && bun run build`. + **The repository-wide local suite is forbidden by standing user instruction.** + Exact-head GitHub CI is the authoritative gate. +- Stop condition: every work-phase closed and each PR green at its exact head SHA and + merged into `dev`. +- Memory artifact: this unit. +- Terminal outcomes: `DONE` for each phase; `BLOCKED` if CI or branch protection refuses + for an unrelated reason; `NEEDS_HUMAN` if a display decision needs the user. +- Escalation: each A gate dispatches an independent read-only reviewer on + `xai/grok-4.6`. Two failed correction loops on the same packet stops the phase. +- HOTL resource bounds: write scope is the IN list below; `gh` for PR and CI; subagents + are read-only reviewers plus bounded workers with disjoint write scopes. No token or + wall-clock bound was set, so `BUDGET_EXHAUSTED` is not an available outcome. + +## What the predecessor unit got right, and the three things it did not + +`260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md` designed this feature and +was never built. Its core judgment holds and is adopted wholesale: Meta publishes no +quota endpoint (`003` §E probed 17 paths, all 404), the value arrives only as an SSE +event on a streaming turn, so the seam inverts — writes come from the request path, +reads are cache-only, and refresh does not exist. `supportsPerAccountQuota` must stay +false because that predicate gates `fetchAccountQuota`, whose fallback branch at +`src/providers/quota.ts:1629` sends any non-Kiro/non-Antigravity bearer to Anthropic's +usage endpoint. + +Three of its file-change decisions are **wrong against the current tree**, and this unit +corrects them. Each was measured, not reasoned: + +| `050` said | Measured | Consequence | +|---|---|---| +| add `onSubscriptionUsage` to `SseInspectorHandlers` in `relay.ts` | `onParsedPayload` already exists (`src/server/relay.ts:834`), fires for **every** parsed frame before terminal handling (`:1020`), and is already threaded through all three passthrough construction sites | **`relay.ts` is not modified at all.** A new handler would duplicate a seam that exists | +| the GUI account row needs new rendering | `ProviderAuthPanel.tsx:517` already renders `QuotaBars` for any account carrying `quota`, and `useProviderAccountPools.ts:100` already requests `?quota=1` for every OAuth provider | wp2 shrinks to the observation-age affordance; bars appear the moment the API returns them | +| `hasPassiveAccountQuota` guards the read path | the read path also runs `fetchProviderAccountQuotas` (`quota.ts:1683`), which **probes**; a passive provider needs a different function, not the same one behind a second flag | wp1 adds a cache-only reader, not an allowlist entry | + +The general lesson, and the reason wp0 exists at all: a plan written against a tree +three commits ago names files that have since grown the seam it was going to add. + +## The decision this unit turns on + +A passive quota is **an observation, not a measurement**. Every other provider's bars +answer "what is true now"; a Muse bar answers "what was true at the last streaming +turn", which may be days old. Rendering the two identically is the one way this feature +can actively mislead — a user reading 4% and deciding to start a long job, when the +real figure moved hours ago. + +So observation age is not decoration on this feature; it is the feature's honesty +condition, and it is why wp2 is a work-phase rather than a footnote in wp1. + +## Work-phase map (dependency-ordered, PHASE-SPLIT-01) + +| Phase | Doc | Delivers | PR | +|---|---|---|---| +| wp0 | this folder + `001` | measured parity inventory, diff-level decade docs | — | +| wp1 | `010_wp1_passive_quota_core.md` | parser, observation seam, generation-fenced write, cache-only read path | PR 1, base `dev` | +| wp2 | `020_wp2_observation_age_ui.md` | the dashboard states the observation age; absent renders nothing | PR 2, base `dev` | +| wp3 | `030_wp3_parity_closeout.md` | remaining surfaces closed or recorded NOT-APPLICABLE with evidence | PR 3, base `dev` | + +wp1 → wp2 → wp3 is a real dependency chain: wp2 renders what wp1 caches, wp3's docs and +provider-note corrections are only true once both have landed. They are **independent +PRs off `dev`, not a stack** (`DEV-STACK-01`): wp1 is server-side, wp2 is +`gui/` plus one API field, wp3 is prose and small allowlists. The diffs do not overlap, +so stacking would impose a false merge order. + +## Scope + +### IN + +- `src/providers/muse-subscription-usage.ts` (NEW) — the parser +- `src/providers/quota.ts` — `hasPassiveAccountQuota`, `recordPassiveAccountQuota`, + `readPassiveProviderAccountQuotas` +- `src/server/responses/core.ts` — the observation handler on the existing + `noteInspectedPayload` seam +- `src/server/management/oauth-account-routes.ts` — cache-only enrichment for a passive + provider +- `gui/src/components/QuotaBars.tsx`, `gui/src/components/provider-workspace/ProviderAuthPanel.tsx`, + `gui/src/i18n/en.ts` (+ the other locale files' single new key) +- `src/providers/registry.ts` — the `meta-muse` note's quota sentence, in wp3 only +- `docs-site/src/content/docs/guides/providers.md` — English only +- `tests/` — focused suites beside the existing provider tests +- `devlog/_plan/260903_muse_provider_parity/` + +### OUT + +- `src/server/relay.ts` — the seam already exists; see the correction table above +- `src/generated/model-metadata.ts`, `scripts/model-metadata.source.json` — generated +- `supportsPerAccountQuota` — must stay false; `tests/meta-muse-oauth.test.ts:92` locks it +- `src/adapters/openai-responses.ts` — the translated path drops the event + (`004` Q3, ANSWERED: no). Documented gap, not a silent one +- Meta console GraphQL (`fb_dtsg` + rotating `doc_id`), Muse Voice/Image, translated + docs locales, `meta-model`'s key path +- `src/lab/` must stay off the core request path — `core.ts` is one of the three files + `tests/core-lab-boundary.test.ts` guards, and this unit edits it + +## Accept criteria + +1. `c1` (wp0) — this unit holds 000-range measured research plus one diff-level decade + doc per implementation phase; the wp0 commit contains no production code. +2. `c2` (wp1) — the parser maps both windows through `normalizePercent` / + `normalizeResetAt`, drops `tier`, returns `null` (never throws) on junk, and routes a + non-300-minute window to `customWindows` rather than the five-hour slot. +3. `c3` (wp1) — the write lands under the account that **served** the turn, is discarded + when the config generation moved, persists across restart, and + `supportsPerAccountQuota("meta-muse")` stays false. +4. `c4` (wp1) — no code path issues an inference call to refresh a Muse quota. +5. `c5` (wp2) — the account row shows the percentages with their observation age, and + renders nothing (not a zero bar) when no observation exists. +6. `c6` (wp3) — every remaining parity surface is closed or recorded NOT-APPLICABLE with + file-level evidence. +7. `c7` — `tsc` exits 0, focused suites pass, `privacy:scan` and `lint:gui` green, the + GUI builds, and the full local suite was never run. +8. `c8` — each PR targets `dev`, is green at its exact head SHA, and is merged. diff --git a/devlog/_plan/260903_muse_provider_parity/001_parity_inventory.md b/devlog/_plan/260903_muse_provider_parity/001_parity_inventory.md new file mode 100644 index 0000000000..6cee41900a --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/001_parity_inventory.md @@ -0,0 +1,200 @@ +# Measured: what `meta-muse` has, and what a first-class OAuth provider has + +Research doc (000-range). No diffs here; the implementation lives in the decade docs. + +Measured against this checkout on 2026-09-03 (`dev` at `162d11e18`) by an independent +read-only reviewer, then spot-verified by the main agent on the load-bearing rows. Every +claim carries a file:line. Reference providers: `anthropic`, `kiro`, `google-antigravity`. + +## A. The user-visible defect, traced end to end + +The dashboard shows no Muse usage because of exactly one predicate: + +```ts +// src/providers/quota.ts:1477 +export function supportsPerAccountQuota(provider: string): boolean { + return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"; +} +``` + +The chain, in order: + +1. `gui/src/hooks/useProviderAccountPools.ts:100` requests + `/api/oauth/accounts?provider=meta-muse"a=1` — for **every** OAuth provider, with + no allowlist. The GUI is already asking. +2. `src/server/management/oauth-account-routes.ts:284` computes + `wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider)`, + which is false, and returns the plain account list. +3. `gui/src/components/provider-workspace/ProviderAuthPanel.tsx:517` renders `QuotaBars` + only when `account.quota != null || account.quotaUnavailable || reserveQuotaSlots`. + None hold, so after the reserve timer expires the row shows nothing. + +**Nothing is broken.** Every layer behaves correctly for a provider that reports no +quota. The provider note says so itself (`src/providers/registry.ts:1543`): "Meta +reports subscription window usage inside streaming responses, but OpenCodex does not yet +read or display it." + +That matters for the fix: the GUI request and the bar component both already exist and +are provider-agnostic, so the server-side write is the whole of the missing machinery. + +## B. Surfaces `meta-muse` already inherits, with no code + +Recorded so wp3 does not "fix" something that works. All follow from +`authMode: "oauth"` plus absence from an exclusion set. + +| Surface | Why it already applies | Evidence | +|---|---|---| +| login / status / logout, account list, switch active, remove, alias | `isPublicOAuthProvider` is `name !== "chatgpt" && isOAuthProvider(name)` | `src/oauth/index.ts:296`; routes at `oauth-account-routes.ts:145,254,305,521,535` | +| GUI account rows, switch, reauth, add-account | the panel keys on the OAuth surface, not the provider id | `ProviderAuthPanel.tsx:224` | +| HIGH_RISK ToS modal | explicitly listed | `gui/src/oauth-tos-risk.ts:10` | +| 429 rotation across accounts | `EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"])`; everything else with `authMode: "oauth"` is in | `src/oauth/generic-account-failover.ts:52,100` | +| serving-account attribution | `stampOAuthAccountLabel(..., resolved.accountId)` runs for every OAuth provider | `src/server/responses/core.ts:3440` | +| SSE inspection on the passthrough path | `createSseInspector` has no provider allowlist | `src/server/relay.ts:886` | +| per-token cost rows | both Muse Spark 1.3 tiers already priced | `src/usage/expected-prices.ts:169-170` | +| CLI `list` / `current` / `use` / `remove` / `alias` / `login` | classified `"oauth"` generically | `src/cli/account-api.ts:85` | + +Two more **compile and run today but are inert** for want of a cached quota row: +headroom-ranked pre-dispatch selection (`generic-account-failover.ts:281` returns null +when `hasHeadroomEvidence` is false) and quota-aware cooldown. wp1 arms both as a side +effect — worth knowing, because it means wp1 changes routing behaviour for a user with +two Muse accounts, not only a display. + +**That side effect carries the unit's one Critical finding.** `headroomOf` +(`account-quota-rank.ts:36`) reads `getCachedProviderAccountQuota`, which applies no +staleness check (`quota.ts:1489` returns `entry.quota` without consulting `entry.ts`). +Every existing caller is safe by construction — a row exists only because a probe wrote +it, and `fetchAccountQuota` re-probes past `ACCOUNT_QUOTA_TTL_MS` (`quota.ts:1602`) — so +freshness is an invariant of the probe path rather than a property of the cache. + +A passive row is the first row in this system that no probe refreshes. Feeding one to a +routing decision would make the proxy confidently prefer an account whose measurement is +arbitrarily old. wp1 therefore bounds the ROUTING read at one hour +(`010` §`account-quota-rank.ts`) while leaving the DISPLAY read unbounded, because wp2 +shows the age and a human can discount it. Same number, two consumers, different +obligations. + +## C. The real gaps + +| # | Surface | Gap | Evidence | Disposition | +|---|---|---|---|---| +| 1 | per-account quota read | `supportsPerAccountQuota` excludes `meta-muse`, and it is the wrong predicate anyway — it gates a **probe** | `quota.ts:1477`, `:1683`, `:1629` | wp1: a separate cache-only reader | +| 2 | quota write | nothing ever keys `meta-muse\0` in `accountQuotaCache` | `quota.ts:1430` | wp1 | +| 3 | `?quota=1` enrichment | gated on the probe predicate | `oauth-account-routes.ts:284` | wp1 | +| 4 | observation age | `QuotaBars` renders no timestamp; `AccountQuota.updatedAt` exists but is unread | `QuotaBars.tsx:164`, `codex-quota-utils.ts:21` | wp2 | +| 5 | provider-level row | `maybeFetchProviderQuota` has no `meta-muse` branch, so the Providers overview card is empty | `quota.ts:2298-2301` | wp3 decides: derive from the cache or record NOT-APPLICABLE | +| 6 | provider note | says the quota is unread — false once wp1 lands | `registry.ts:1543` | wp3 | +| 7 | docs-site | same stale sentence | `docs-site/.../providers.md:475` | wp3 | +| 8 | `ocx account refresh` | prints "no quota report" | `src/cli/account-extended.ts:328` | wp3: must stay probe-free by design; make the message honest | +| 9 | `skills/ocx` recipes | no `meta-muse` account recipe | `skills/ocx/references/03_recipes.md:16` | wp3 | + +## D. Surfaces that are NOT gaps, and why + +Recorded now so wp3 does not spend effort proving them twice. + +- **Connection test.** `provider-routes.ts:1195` short-circuits any provider with + `liveModels === false` to `{ applicable: false, reason: "static_catalog" }` before any + network call. `meta-muse` sets `liveModels: false` deliberately (`registry.ts:1538`): + the authenticated roster carries `muse-image-1.0` and `muse-voice-transcribe-1.0`, + which a Responses-agent provider cannot drive. `kiro` is in exactly the same class. + **NOT-APPLICABLE by design, not a gap.** +- **`clear-cooldown`.** Anthropic-only (`oauth-account-routes.ts:465`) because the + generic failover health map is process-local (`generic-account-failover.ts:78`). + Provider-wide absence; out of scope for a Muse unit. +- **Account import.** `ACCOUNT_IMPORT_PROVIDER = "google-antigravity"` + (`src/oauth/account-import/types.ts:3`) — a cockpit-tools document format with no Meta + analogue. +- **401 replay.** `FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"])` + (`src/oauth/index.ts:540`). Muse holds a **static API key** — `003` §B measured the + OAuth `access_token` returning 401 while the sibling `api_key` returns 200 — so there + is nothing to force-refresh. Adding it would replay an identical credential. +- **Background refresh.** `defaultRefreshPolicy: "disabled"` (`src/oauth/index.ts:240`), + the same posture as `anthropic`, for the same reason: the vendor restricts use outside + its own client, so every exchange stays attributable to a user action. + +## E. The seam wp1 uses, measured + +`050` planned to add `onSubscriptionUsage` to `SseInspectorHandlers`. That handler is +unnecessary — the general seam already exists and is strictly better placed: + +```ts +// src/server/relay.ts:834 +onParsedPayload?: (payload: unknown) => void; +``` + +It fires for **every** parsed SSE frame, and critically it fires *before* terminal +handling (`relay.ts:1020`), inside a `try/catch` that guarantees inspection never throws +into the pump (`:1021`). All three passthrough construction sites already thread it: + +| Site | Line | How | +|---|---|---| +| eager relay | `core.ts:4811` | `onParsedPayload: noteInspectedPayload` | +| tee + terminal | `core.ts:4862` → `relay.ts:1357` | via `inspectionConsumerOptions` | +| tee metadata-only | `core.ts:4862` → `relay.ts:1411` | same options object | + +Both tee consumers read the same `inspectionConsumerOptions` literal built at +`core.ts:4857`, so extending `noteInspectedPayload` covers every passthrough shape at +once. **This is why `relay.ts` is not in wp1's file list.** + +### The serving account, in that scope + +`runResponses` holds these at the point the inspector is constructed: + +| Variable | Declared | Meaning | +|---|---|---| +| `genericFailoverAccountId` | `core.ts:3309`, set `:3444` | the account `meta-muse` actually dispatched on; rebound at each rotation site (`:5131`, `:5445`, `:6134`) | +| `resolved: OAuthAccessSnapshot` | `:3397` | carries `.accountId` and `.generation` | +| `replayOAuthCredentialSnapshot` | `:3304`, filled `:3431` | `{ accountId, generation }` | +| `anthropicPoolAccountId` | `:3305` | Anthropic only | + +`genericFailoverAccountId` is rebound by every rotation, so reading it **at event time** +— not at handler-construction time — is what makes attribution survive a mid-turn +failover. That is the difference between recording the quota of the account that served +the turn and recording it against the account that failed. + +## F. Generation fencing: the correction `050` already carried, verified + +`050` recorded an A-gate finding that capturing the generation immediately before the +write cannot see a config change that happened **earlier in the turn**. The tree +confirms the mechanism it must use: + +```ts +// src/providers/quota.ts:1470 +function mayCommitAccountQuotaKey(key: string, writerGeneration: number): boolean { + return writerGeneration >= lastReconciledGeneration || liveAccountQuotaKeys.has(key); +} +``` + +Every existing writer follows the same shape: `captureConfigGeneration()` before the +await, `mayCommitAccountQuotaKey` before the `set` (`quota.ts:1378`, `:1404`, `:1648`). +A streaming turn is a long await, so the caller must capture when it resolves the +credential and pass the number in. + +Reconciliation removes rows whose account no longer exists +(`reconcileProviderAccountQuotaRows`, registered as `provider-quota-history` in +`src/lib/state-store-registrations.ts:109`), so a logged-out account cannot leave a stale +bar behind. + +## G. Persistence, measured + +`persistAccountQuotaCache` (`quota.ts:1449`) debounces into +`schedulePersistAccountQuotas` (`src/providers/account-quota-disk.ts:59`), and +`readPersistedAccountQuotas` (`:40`) drops rows older than `DISK_MAX_AGE_MS` = 6 hours +(`:28`). + +**This bounds the honesty problem in wp2.** A passive observation can be arbitrarily old +in memory, but a restart discards anything past six hours. The in-memory TTL +(`ACCOUNT_QUOTA_TTL_MS` = 10 minutes, `quota-wire.ts:22`) is a **probe** TTL — it decides +when to re-probe, and `sweepExpiredProviderAccountQuotaRows` is exported but not +registered as a `sweepExpired` callback (`src/lib/state-store-registrations.ts:109` +registers only `reconcileGeneration`), so an unprobed row is not swept on the TTL tick. +A passive row therefore survives in memory past 10 minutes, which is correct for this +feature and is exactly why the age must be displayed. + +## H. Method note + +The parity inventory was dispatched as a read-only reviewer packet demanding a file:line +for every claim, precisely because the predecessor unit's plan had drifted from the tree +in three places. Two of its findings — the pre-existing `onParsedPayload` seam and the +already-generic GUI bar rendering — deleted planned work rather than adding it. A +roadmap written from the old plan alone would have shipped a duplicate handler and a +redundant component change. diff --git a/devlog/_plan/260903_muse_provider_parity/010_wp1_passive_quota_core.md b/devlog/_plan/260903_muse_provider_parity/010_wp1_passive_quota_core.md new file mode 100644 index 0000000000..cf40dd4099 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/010_wp1_passive_quota_core.md @@ -0,0 +1,314 @@ +# wp1 — passive Muse subscription quota: parser, seam, cache + +Own PR, base `dev`. Branch: `codex/meta-muse-passive-quota`. + +Research: `001` (this unit) and `260903_muse_spark_plan_oauth/003` §E. Implementation only. + +## Decisions taken here, so Build does not have to make them + +| Question | Decision | Why | +|---|---|---| +| Where to observe | `noteInspectedPayload` in `core.ts:3891`, on the existing `onParsedPayload` seam | `001` §E: covers all three passthrough sites at once; `relay.ts` untouched | +| Which account | `genericFailoverAccountId` read **at event time** | it is rebound at every rotation site; reading it at construction attributes the turn to the account that failed | +| Translated path | **not covered**, deliberately | `openai-responses.ts`'s switch drops unknown types (`004` Q3, answered) | +| `supportsPerAccountQuota` | **stays false** | it gates a probe whose fallback ships a Meta bearer to Anthropic (`quota.ts:1629`) | +| Read path | a new cache-only function, not the probe function behind a flag | `fetchProviderAccountQuotas` probes; a passive provider has nothing to probe | +| Refresh | does not exist | obtaining a fresh value would mean spending an inference turn | +| `tier` | dropped | an opaque numeric id, not the label the CLI prints | + +## NEW `src/providers/muse-subscription-usage.ts` + +```ts +import { normalizePercent, normalizeResetAt, asRecord } from "./quota-wire"; +import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types"; + +/** The SSE frame type Meta emits on streaming turns. */ +export const MUSE_SUBSCRIPTION_USAGE_TYPE = "response.subscription_usage"; + +export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null; +``` + +Mapping table, all mandatory: + +| Source | Target | Rule | +|---|---|---| +| `subscription.window.used_percent` | `fiveHourPercent` | `normalizePercent`; assign **only** if `window_duration_mins === 300` | +| `subscription.window.resets_at` | `fiveHourResetAt` | `normalizeResetAt` (unix seconds; `epochMillis` scales) | +| `subscription.weekly.used_percent` | `weeklyPercent` | `normalizePercent` | +| `subscription.weekly.resets_at` | `weeklyResetAt` | `normalizeResetAt` | +| `window` with any other `window_duration_mins` | `customWindows[]` | label `"${duration}m"`; never forced into the 5h slot | +| `subscription.tier` | — | dropped | +| — | `updatedAt` | `Date.now()`, never from the payload | + +Returns `null` — never throws — when the payload is not an object, carries no +`subscription`, or yields no usable window. Either window may be absent independently. +A window present but unparseable yields no slot rather than a zero. + +**Why `window_duration_mins` is checked rather than assumed:** the measured payload says +300, but a plan change could move it, and silently filing a 10-hour window in the +five-hour slot would understate usage by the ratio of the windows — a wrong number +presented with full confidence. + +## MODIFY `src/providers/quota.ts` + +Three additions, all beside the existing per-account block (after +`setCachedProviderAccountQuotaForTests`, `:1494`). + +```ts +/** + * Providers whose per-account quota is OBSERVED in-band, never probed. + * + * Deliberately separate from supportsPerAccountQuota: that predicate gates + * fetchAccountQuota, whose fallback branch sends any non-Kiro/non-Antigravity bearer to + * Anthropic's usage endpoint. Meta exposes no quota endpoint at all (17 paths probed, + * all 404), so there is nothing for that path to call. + */ +export function hasPassiveAccountQuota(provider: string): boolean { + return provider === "meta-muse"; +} + +/** + * Record a quota observed on a streaming turn. + * + * The CALLER captures writerGeneration when it resolves the serving credential, not + * here: a streaming turn is a long await, and capturing at write time cannot see a + * config change that happened earlier in the same turn. + */ +export function recordPassiveAccountQuota( + provider: string, + accountId: string, + quota: ProviderQuota, + writerGeneration: number, +): void { + if (!hasPassiveAccountQuota(provider) || !accountId) return; + const key = accountCacheKey(provider, accountId); + if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; + accountQuotaCache.set(key, { ts: Date.now(), quota }); + persistAccountQuotaCache(); +} + +/** Cache-only per-account rows for a passive provider. Never probes. */ +export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] { + if (!hasPassiveAccountQuota(provider)) return []; + hydrateAccountQuotaCache(); + const set = getAccountSet(provider); + if (!set) return []; + const rows: ProviderAccountQuota[] = []; + for (const account of set.accounts) { + const entry = accountQuotaCache.get(accountCacheKey(provider, account.id)); + if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota }); + } + return rows; +} +``` + +Three details that are not arbitrary: + +- `hydrateAccountQuotaCache()` must be called in the reader. It is idempotent + (`diskHydrated`, `quota.ts:1440`) and is otherwise only reached from probe paths that a + passive provider never enters — without it, a restart shows nothing until the next + streaming turn even though the row is on disk. +- Absent rows are **omitted**, not returned with `quota: null`. A user who has not run a + streaming turn has no observation, and `unavailable` would claim a failed probe that + never happened. +- `sweepExpiredOnWrite` is **not** called here. Existing probe writers call it because + they run on a poll; this runs on every streaming turn, and a state sweep on the request + path is exactly the hot-path work `state-store-registrations.ts:97` warns against. + +## MODIFY `src/server/responses/core.ts` + +One capture at credential resolution, one branch in the existing payload handler. + +Near `genericFailoverAccountId` (`:3309`), add: + +```ts +// Captured where the credential is resolved, not at write time: see quota.ts +// recordPassiveAccountQuota. Only meta-muse observes a quota, so this stays 0 elsewhere. +let passiveQuotaWriterGeneration = 0; +``` + +set alongside `genericFailoverAccountId = resolved.accountId` (`:3444`): + +```ts +if (hasPassiveAccountQuota(route.providerName)) passiveQuotaWriterGeneration = captureConfigGeneration(); +``` + +Extend `noteInspectedPayload` (`:3891`). The existing body opens with an early return +for the undeclared-tool guard, so the observation goes **before** it: + +```ts +const noteInspectedPayload = (payload: unknown) => { + if (passiveQuotaObserved && route.providerName === "meta-muse") { + const record = payload as { type?: unknown } | null; + if (record && typeof record === "object" && record.type === MUSE_SUBSCRIPTION_USAGE_TYPE) { + const quota = parseMuseSubscriptionUsage(payload); + // Read the account HERE, not at construction: rotation rebinds it mid-turn. + const accountId = genericFailoverAccountId; + if (quota && accountId) { + recordPassiveAccountQuota("meta-muse", accountId, quota, passiveQuotaWriterGeneration); + } + } + } + if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return; + // ... unchanged +}; +``` + +where `passiveQuotaObserved` is a `const` computed once beside the handler: + +```ts +const passiveQuotaObserved = hasPassiveAccountQuota(route.providerName) + && route.provider.authMode === "oauth"; +``` + +**Ordering is load-bearing.** The undeclared-tool guard returns early once it has fired +(`inspectionSawUndeclaredTool`), so an observation placed after it would be dropped for +the rest of any turn that tripped the guard — a turn that still legitimately reports +usage. + +**Import discipline.** `core.ts` is one of the three files `tests/core-lab-boundary.test.ts` +guards. Both new imports (`src/providers/quota`, `src/providers/muse-subscription-usage`) +are already-reachable or leaf modules: `quota.ts` is imported by `core.ts` today, and the +parser imports only `quota-wire` and `quota-types`. Neither reaches `src/lab/`. The +parser must **not** import `quota.ts` — that would be a cycle. + +## MODIFY `src/server/management/oauth-account-routes.ts` + +At `:284`, the enrichment gate becomes: + +```ts +const passiveQuota = url.searchParams.get("quota") === "1" && hasPassiveAccountQuota(provider); +const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider); +if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts()); +const rows = passiveQuota + // No probe, and ?refresh=1 is ignored: there is nothing to refresh. + ? readPassiveProviderAccountQuotas(provider) + : await fetchProviderAccountQuotas(provider, url.searchParams.get("refresh") === "1"); +``` + +The existing `byId` merge below is unchanged and already omits `quotaUnavailable` when +the row does not carry it. + +`?refresh=1` is accepted and ignored rather than rejected: the GUI sends it on a manual +refresh for every provider, and a 400 would surface an error for an action that is simply +a no-op here. + +## MODIFY `src/oauth/account-quota-rank.ts` — A-gate amendment + +**Blocker found at the audit gate, folded here.** `headroomOf` (`:36`) reads +`getCachedProviderAccountQuota` and applies **no staleness bound**: + +```ts +// src/providers/quota.ts:1489 +export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { + const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); + return entry?.quota ?? null; // no ts check +} +``` + +For every provider that exists today this is safe by construction: a row is only written by +a probe, and `fetchAccountQuota` re-probes once `ACCOUNT_QUOTA_TTL_MS` (10 minutes, +`quota-wire.ts:22`) has passed, so a row consulted for routing is at most that old. +**The passive path breaks that invariant.** Nothing re-probes, and `001` §G established +that account-quota rows are not swept on the TTL tick, so a Muse row can be hours or days +old in memory and up to six hours old after a restart. + +Left unfixed, `preferredInitialAccount` (`generic-account-failover.ts:281`) would send the +first attempt of every turn to whichever account looked best whenever it was last +observed — plausibly the one that has since been exhausted. That is worse than the +current unranked behaviour, because it is confidently wrong rather than uninformed. + +```ts +/** + * How old a PASSIVELY observed quota may be and still steer routing. + * + * A probed row is implicitly fresh: fetchAccountQuota re-probes after + * ACCOUNT_QUOTA_TTL_MS. A passive row has no such refresh, so the bound is explicit + * here. It is deliberately longer than the probe TTL — an hour-old reading of a + * five-hour window is still informative — and deliberately far shorter than the + * six-hour disk horizon, which exists to preserve a value for DISPLAY, where the age is + * shown to the user and no automatic decision rides on it. + */ +const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000; +``` + +In `headroomOf`, immediately after the null check: + +```ts + if (hasPassiveAccountQuota(provider) && Date.now() - quota.updatedAt > PASSIVE_HEADROOM_MAX_AGE_MS) return null; +``` + +Returning `null` is the correct shape, not a zero or a low rank: it reproduces "no +evidence", which `rankAccountsByHeadroom` (`:71`) and `hasHeadroomEvidence` (`:87`) +already handle by leaving the ring untouched. The stale-row case therefore degrades to +exactly today's behaviour rather than to a different wrong answer. + +The display path is deliberately **not** bounded this way. wp2 shows the age, so an old +number is labelled rather than hidden — the user can judge it, and a routing algorithm +cannot. + +Added tests in `tests/muse-passive-quota-cache.test.ts`: + +- a passive row younger than the bound produces headroom; one older produces `null` +- `hasHeadroomEvidence` is false for a roster whose only rows are stale +- an `anthropic` row of the same age is unaffected (the bound is passive-only) + +## Tests + +`tests/muse-subscription-usage.test.ts` — parser, fixture-driven: + +- the measured payload from `003` §E → both windows, correct millisecond resets +- `window_duration_mins: 600` → `customWindows`, and `fiveHourPercent` **undefined** +- weekly-only; window-only (each independently absent) +- `used_percent: 150` → clamped to 100 (`normalizePercent` clamps rather than rejects) +- `used_percent: "12"` → 12 (`toFiniteNumber` accepts numeric strings) +- missing `subscription`; non-object; `null`; array → `null`, no throw +- `tier` never appears in the output +- `updatedAt` is local, not the payload's `resets_at` + +`tests/muse-passive-quota-cache.test.ts`: + +- `recordPassiveAccountQuota` writes under the serving account key and + `getCachedProviderAccountQuota` reads it back +- a stale `writerGeneration` discards the write +- `readPassiveProviderAccountQuotas` omits accounts with no observation +- the row persists and rehydrates after a simulated restart +- `hasPassiveAccountQuota("meta-muse")` is true while + `supportsPerAccountQuota("meta-muse")` stays **false** — the exfiltration guard from + wp4 must survive this phase +- `recordPassiveAccountQuota("anthropic", ...)` is a no-op + +`tests/muse-passive-quota-observation.test.ts` — the seam, driven through +`createSseInspector` with a recorded transcript: + +- a transcript containing the event invokes the handler exactly once +- a transcript without it never does +- the payload is delivered before the terminal frame is processed +- a handler that throws does not break the pump (guaranteed by `relay.ts:1021`; asserted + so a future refactor cannot silently remove the guarantee) + +No live call, no real Keychain, no network in any test. + +## Verification + +```bash +bun test tests/muse-subscription-usage.test.ts tests/muse-passive-quota-cache.test.ts \ + tests/muse-passive-quota-observation.test.ts tests/meta-muse-oauth.test.ts \ + tests/provider-account-quota.test.ts tests/oauth-accounts-api.test.ts \ + tests/core-lab-boundary.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +``` + +`tests/core-lab-boundary.test.ts` is listed explicitly because this phase edits +`core.ts`, one of the three files it guards, and `test:changed` follows the import graph +from changed modules — it would select that test only if the boundary test itself +imports `core.ts`, which is not something to assume. + +## Terminal outcome + +`DONE` when a streaming `meta-muse` turn populates the serving account's five-hour and +weekly percentages, `/api/oauth/accounts?provider=meta-muse"a=1` returns them, a +restart preserves the observation, and no code path issues an inference call to refresh a +quota. diff --git a/devlog/_plan/260903_muse_provider_parity/020_wp2_observation_age_ui.md b/devlog/_plan/260903_muse_provider_parity/020_wp2_observation_age_ui.md new file mode 100644 index 0000000000..b9140c8c72 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/020_wp2_observation_age_ui.md @@ -0,0 +1,134 @@ +# wp2 — the dashboard states how old the observation is + +Own PR, base `dev`, after wp1 lands. Branch: `codex/muse-quota-observation-age`. + +## Why this is a work-phase and not a line in wp1 + +Every other quota bar in this dashboard answers *what is true now*: Anthropic's is at +most `ACCOUNT_QUOTA_TTL_MS` (10 minutes) old, and a stale probe is marked +`quotaUnavailable` (`oauth-account-routes.ts:298`). A Muse bar answers *what was true at +the last streaming turn*, which can be hours old — bounded only by the six-hour disk +horizon (`account-quota-disk.ts:28`), and unbounded in memory because the account-quota +TTL sweep is not registered as a `sweepExpired` callback (`001` §G). + +Rendering the two identically is the one way this feature can actively mislead. So the +age is the honesty condition, not decoration. + +## Scope boundary + +`QuotaBars` is shared by the Codex account pool, the provider overview, the combo +workspace, and every OAuth account row. **The change must be additive and opt-in**: a +component that starts rendering a timestamp for every caller would put an age on +Anthropic's bars, where it is noise. + +## MODIFY `gui/src/components/QuotaBars.tsx` + +One optional prop, rendered only when passed: + +```ts + /** + * Render "observed ago" beside the bars. Set ONLY for a passively observed + * quota (meta-muse), where the value can be arbitrarily old. A probed provider + * refreshes on its own TTL and must not carry this. + */ + observedAt?: number; +``` + +Rendered above the rows in both layouts, from the existing `quota.updatedAt`: + +```tsx +{observedAt !== undefined && ( +

{t("quota.observedAgo").replace("{age}", formatObservedAge(observedAt, t, locale))}

+)} +``` + +`formatObservedAge` is a new exported helper in the same file (co-located with +`buildQuotaRows`, which is already exported for the same reason). Buckets, chosen so the +string never implies more precision than an observation has: + +| Elapsed | Output | +|---|---| +| < 60s | `quota.observedJustNow` | +| < 60m | `${n}m` | +| < 24h | `${n}h` | +| otherwise | `${n}d` | + +A negative elapsed (clock skew between the write and the browser) renders as just-now +rather than a negative number. + +## MODIFY `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +At the `QuotaBars` call (`:522`), pass the prop **only** for the passive provider: + +```tsx + +``` + +The provider id is compared here rather than a capability being plumbed through the +account payload: the GUI has no other consumer for such a flag, and one string in one +render site is easier to audit than a new field on every account row. If a second passive +provider appears, this becomes a server-sent boolean — recorded as the migration, not +done speculatively. + +**The absent case needs no change.** `ProviderAuthPanel.tsx:517` already renders the +quota block only when `account.quota != null || account.quotaUnavailable || +reserveQuotaSlots`, and `QuotaBars` returns `null` when `buildQuotaRows` is empty and +`pending` is false (`QuotaBars.tsx:193`). An account with no observation renders nothing, +which is already correct — c5's "not a zero bar" half is asserted, not implemented. + +## MODIFY the locale files + +Three keys in `gui/src/i18n/en.ts`, near the existing `quota.*` block: + +```ts + "quota.observedAgo": "Observed {age} ago", + "quota.observedJustNow": "Observed just now", + "quota.observedHint": "Meta reports usage only during a streaming response, so this is the last value seen — not a live reading.", +``` + +`quota.observedHint` is the `title` on the age line. Without it the user has no way to +know why this one provider's number lags. + +Every other locale file (`ko`, `ja`, `zh`, `zh-TW`, `fr`, `de`, `ru`, `tr`) gets the +same three keys. Translate `ko` and leave the rest on the English string if no confident +translation exists — a missing key breaks the typed `TFn` lookup, which is the failure +mode to avoid. + +## Tests + +`gui/tests/quota-observed-age.test.tsx`: + +- `formatObservedAge` bucket boundaries: 59s, 60s, 59m, 60m, 23h, 24h, and a negative +- `QuotaBars` without `observedAt` renders no age line (the regression that protects + every other caller) +- with `observedAt` renders it in both `compact` and `stacked` layouts +- a `meta-muse` account row with a quota shows the age; an account without a quota + renders no bars and no age + +## Verification + +```bash +bun test gui/tests/quota-observed-age.test.tsx gui/tests/oauth-tos-warning-gate.test.tsx +bun x tsc --noEmit +bun run lint:gui +cd gui && bun run build +``` + +Plus render grounding (C-RENDER-GROUNDING-01): this phase changes a rendered surface, so +C loads `http://localhost:10100/#providers` against a proxy carrying a real observation, +screenshots the Muse account row, and reads the screenshot back. A built-but-unviewed +bundle is not evidence. + +## Terminal outcome + +`DONE` when the Muse account row shows both windows with a truthful age, the same +component renders no age for Anthropic, and an account with no observation renders +nothing at all. diff --git a/devlog/_plan/260903_muse_provider_parity/030_wp3_parity_closeout.md b/devlog/_plan/260903_muse_provider_parity/030_wp3_parity_closeout.md new file mode 100644 index 0000000000..d56b9a2816 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/030_wp3_parity_closeout.md @@ -0,0 +1,140 @@ +# wp3 — close or record every remaining parity surface + +Own PR, base `dev`, after wp1 and wp2 land. Branch: `codex/meta-muse-parity-closeout`. + +This phase exists because "make Meta first-class" is only verifiable against an +enumerated list. `001` §C is that list; this doc dispositions every row. + +## The rule this phase applies + +A surface is closed when `meta-muse` behaves like a first-class provider, or recorded +NOT-APPLICABLE when the difference follows from a **measured property of Meta's API** — +never from "we did not get to it". Every NOT-APPLICABLE carries a file:line and a reason +that would survive a reviewer asking "why not just add it to the allowlist?". + +## 1. Provider note — CLOSE (`src/providers/registry.ts:1543`) + +The note currently says: + +> Meta reports subscription window usage inside streaming responses, but OpenCodex does +> not yet read or display it, and there is no endpoint to query it on demand. + +False after wp1. Replace that sentence with: + +> OpenCodex reads Meta's subscription windows from streaming responses and shows the +> last observed value with its age; there is no endpoint to query them on demand, so a +> fresh reading requires a streaming turn and translated (non-passthrough) turns do not +> report one. + +Both clauses after the semicolon are load-bearing: the first explains why the number can +be stale, the second is the documented gap from `004` Q3 rather than a silent one. + +`tests/meta-model-api-provider.test.ts` asserts note substrings — check before editing. + +## 2. docs-site — CLOSE (`docs-site/src/content/docs/guides/providers.md:475`) + +Same correction, English only. The surrounding paragraphs about the ToS boundary and +per-team rate limits are unchanged and remain accurate. + +## 3. `ocx account refresh meta-muse` — CLOSE the message, keep the behaviour + +`src/cli/account-extended.ts:328` prints "no quota report" because +`maybeFetchProviderQuota` has no `meta-muse` branch. **The behaviour is correct and must +not change** (c4: no path may issue an inference call to refresh a quota). The message is +what misleads — it reads like a failure. + +Emit, for a provider where `hasPassiveAccountQuota` is true: + +> meta-muse reports usage only during a streaming response; there is nothing to refresh. +> Run a request through this provider to update it. + +A CLI that explains an intentional absence is the difference between a documented design +and an apparent bug. + +## 4. Provider-level overview card — DECIDE, then close + +`quota.ts:2298-2301` gives `/api/provider-quotas` a row for anthropic, antigravity and +kiro; `meta-muse` has none, so `ProviderCapacityQuota.tsx:47` renders "No quota data". + +Two honest options, decided in wp3's P against the tree at that time: + +- **(a)** derive the provider row from the cached active account's observation — no + probe, consistent with wp1's seam, and it fills a visibly empty card. +- **(b)** record NOT-APPLICABLE: the provider card means "the provider's capacity", and + Meta's documented limits are **per team, not per key** (`001`, `003` §E), so a + per-account subscription window is the wrong quantity to promote to provider level. + +**Current lean: (b), with the empty card given an explanatory string** rather than a +number that means something different from every other provider's provider-level bar. +wp3's audit gate decides; whichever is chosen, the reason is recorded here. + +## 5. `skills/ocx` — CLOSE (`skills/ocx/references/03_recipes.md`) + +Add a `meta-muse` account recipe covering import login, `ocx account list meta-muse`, +and the passive-quota caveat. `bun run skill:surface:check` must stay green; the surface +map is generated from `src/cli/capabilities.ts`, and `tests/skill-ocx.test.ts` fails if a +hand-written page names a command the registry does not have. + +`src/cli/capabilities.ts:250` also carries a stale line — "`anthropic` is the only OAuth +pool with this setting; other OAuth providers are refused without a round-trip" — which +`001` shows is wrong: generic OAuth providers do reach the pool endpoint, and their +settings persist inertly (`pool-settings-capability.ts:40`). Correct it while here. + +## 6. Recorded NOT-APPLICABLE (no code) + +Each with the measured reason, written into this doc's closing section at D: + +| Surface | Reason | Evidence | +|---|---|---| +| Connection test | `liveModels: false` short-circuits to `static_catalog` before any network call; the authenticated roster carries image and voice models a Responses-agent provider cannot drive. `kiro` is the same class | `provider-routes.ts:1195`; `registry.ts:1538`; `003` §C | +| 401 replay / `FORCE_REFRESH_PROVIDERS` | the credential is a **static API key**; the OAuth `access_token` 401s while the `api_key` returns 200, so there is nothing to force-refresh | `src/oauth/index.ts:540`; `003` §B | +| Background refresh | `defaultRefreshPolicy: "disabled"`, same posture as `anthropic`: the vendor restricts use outside its own client, so every exchange stays attributable to a user action | `src/oauth/index.ts:240` | +| Account import | `ACCOUNT_IMPORT_PROVIDER` is a cockpit-tools document format with no Meta analogue | `src/oauth/account-import/types.ts:3` | +| `clear-cooldown` | anthropic-only because the generic failover health map is process-local — a provider-wide gap, not a Muse gap | `oauth-account-routes.ts:465`; `generic-account-failover.ts:78` | +| GUI generic pool card | no dashboard editor exists for **any** generic OAuth provider | `ProviderAuthPanel.tsx:353` | +| Translated-path quota | `openai-responses.ts` dispatches on `payload.type` through a switch with no case for the event | `004` Q3 | + +The last two are the honest ones to resist closing: both are real absences a user could +hit, and both are provider-wide rather than Meta-specific. Fixing either inside a Muse +unit would be scope creep that lands untested for its other providers. + +## 7. Side effect worth stating: routing changes, not just display + +`001` §B measured that headroom-ranked pre-dispatch selection +(`generic-account-failover.ts:281`) and quota-aware cooldown are already wired for any +generic failover provider but inert while `hasHeadroomEvidence` is false. wp1's cache +**arms both** for a user with two or more Muse accounts. + +That is desirable — it is what "first-class" means here — but it must be stated in the +PR description, because a reviewer reading a quota-display PR would not expect account +selection order to change. `001` §B and `003` §F establish the soundness: the RPM/TPM +limits are per team, but subscription windows are per subscription, so two Muse accounts +carry genuinely different headroom. + +It is desirable **only within the staleness bound wp1 adds**. Unbounded, it is the +opposite: a routing preference computed from a days-old observation is worse than no +preference, because the unranked ring at least rotates. wp1 caps the routing read at one +hour and returns "no evidence" beyond it, which degrades to today's behaviour rather than +to a different wrong answer (`010`, `001` §B). + +wp3's PR description must therefore describe the routing change **and** its bound. A +reviewer told only "quota now steers account selection" would reasonably object; the +bound is what makes the claim defensible. + +## Verification + +```bash +bun test tests/meta-muse-oauth.test.ts tests/meta-model-api-provider.test.ts \ + tests/skill-ocx.test.ts tests/cli-account.test.ts tests/provider-registry-parity.test.ts +bun run skill:surface:check +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +## Terminal outcome + +`DONE` when every row in `001` §C is either closed with a diff or recorded here as +NOT-APPLICABLE with its measured reason, and no user-facing text claims OpenCodex cannot +read a value it now reads. diff --git a/devlog/_plan/260903_muse_provider_parity/031_wp3_disposition_record.md b/devlog/_plan/260903_muse_provider_parity/031_wp3_disposition_record.md new file mode 100644 index 0000000000..86f6c30990 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/031_wp3_disposition_record.md @@ -0,0 +1,54 @@ +# wp3 closeout: every parity surface, closed or recorded + +Terminal record for the unit. `001` §C listed the gaps; this dispositions each one against the +tree as it stands after wp1 and wp2. + +## Closed with a diff + +| # | Surface | What changed | Where | +|---|---|---|---| +| 1-3 | per-account quota read, write, and `?quota=1` enrichment | passive parser, cache, observation seam, cache-only API read | wp1 (#3358) | +| 4 | observation age | `QuotaBars` renders it for passive providers only | wp2 (#3359) | +| 6 | provider note | now states that the windows ARE read, that they can be stale, and where they are absent | `src/providers/registry.ts` | +| 7 | docs-site | same correction, English source | `docs-site/src/content/docs/guides/providers.md` | +| 8 | `ocx account refresh meta-muse` | said "no quota report available", which reads as a failed probe; now explains that nothing is probed and how to update the value. **Behaviour unchanged** — no command may spend an inference turn to refresh a quota (c4) | `src/cli/account-extended.ts` | +| 9 | `skills/ocx` | new recipe 9 covering the read, the staleness, both expected absences, and the connection-test answer | `skills/ocx/references/03_recipes.md` | +| — | `ocx account strategy` help text | claimed "`anthropic` is the only OAuth pool with this setting; other OAuth providers are refused without a round-trip". `001` showed that is wrong: generic providers reach the endpoint and their settings persist inertly | `src/cli/capabilities.ts` | + +## Recorded NOT-APPLICABLE, with the measured reason + +Each of these differs from a first-class provider because of a **measured property of Meta's API**, +not because the work was skipped. + +| Surface | Reason | Evidence | +|---|---|---| +| Connection test | `liveModels === false` short-circuits to `{ applicable: false, reason: "static_catalog" }` before any network call. The flag is deliberate: the authenticated roster carries `muse-image-1.0` and `muse-voice-transcribe-1.0`, which a Responses-agent provider cannot drive. `kiro` is the same class | `provider-routes.ts:1195`; `registry.ts:1538`; `003` §C | +| Provider-level overview card | **Option (b) was taken here and OVERRULED by the owner on the live dashboard** — see `040_wp4_provider_level_quota.md`. The rebuttal was already in the tree: `fetchAnthropicQuota` and `fetchKiroQuota` answer the provider row with the ACTIVE account's usage, so provider level in this dashboard means "the account in use", and a per-subscription window is exactly the right quantity. Implemented as option (a): the row is the active account's last observation, cache-only | `040`; `quota.ts` `fetchPassiveProviderQuota` | +| 401 replay (`FORCE_REFRESH_PROVIDERS`) | the credential is a static API key — the OAuth `access_token` 401s while the sibling `api_key` returns 200 — so a replay would resend an identical credential | `src/oauth/index.ts:540`; `003` §B | +| Background refresh | `defaultRefreshPolicy: "disabled"`, the same posture as `anthropic`: the vendor restricts use outside its own client, so every exchange stays attributable to a user action | `src/oauth/index.ts:240` | +| Account import | `ACCOUNT_IMPORT_PROVIDER` is a cockpit-tools document format with no Meta analogue | `src/oauth/account-import/types.ts:3` | +| `clear-cooldown` | anthropic-only because the generic failover health map is process-local. A provider-WIDE gap, not a Muse gap; fixing it here would land untested for its other providers | `oauth-account-routes.ts:465`; `generic-account-failover.ts:78` | +| GUI generic pool card | no dashboard editor exists for ANY generic OAuth provider | `ProviderAuthPanel.tsx:353` | +| Translated-path quota | `openai-responses.ts` dispatches on `payload.type` through a switch with no case for the event, so a translated turn drops it. Now stated in the provider note and the docs rather than left silent | `004` Q3 | +| Quota-aware cooldown | `030` §7 and `001` §B originally said wp1 would arm this. **That was wrong** and is corrected here: `exhaustedCooldownMs` returns null unless the provider is `kiro`, so a Muse 429 still gets Retry-After or the 60s default. Only pre-dispatch RANKING arms | `account-quota-rank.ts:102` | + +The last row is the one worth reading twice. It was an over-claim in this unit's own roadmap, +caught at review, and it would have shipped in a PR description as a capability that does not +exist. + +## The routing change, and why it is bounded + +wp1's cache arms headroom-ranked pre-dispatch selection for a user with two or more Muse accounts. +That is desirable — it is part of what "first-class" means — but only inside the two guards wp1 +added, both of which closed review blockers: + +- **Staleness:** passive rows older than an hour return "no evidence", so a stale roster degrades + to today's unranked ring rather than to a confidently wrong preference. +- **Partial rosters:** a probe fills every account at once; an observation fills one at a time. + Since `RANK_UNKNOWN` sorts after `RANK_HEALTHY`, one observed account at 100% would otherwise + outrank N unmeasured ones — the exact inversion ranking exists to prevent. + +## Terminal outcome + +`DONE`. Every row in `001` §C is closed with a diff or recorded above with its measured reason, +and no user-facing text claims OpenCodex cannot read a value it now reads. diff --git a/devlog/_plan/260903_muse_provider_parity/040_wp4_provider_level_quota.md b/devlog/_plan/260903_muse_provider_parity/040_wp4_provider_level_quota.md new file mode 100644 index 0000000000..f356505f70 --- /dev/null +++ b/devlog/_plan/260903_muse_provider_parity/040_wp4_provider_level_quota.md @@ -0,0 +1,116 @@ +# wp4 — provider-level Muse quota row (owner-overruled 031) + +Own PR, base `dev`. Branch: `codex/meta-muse-provider-quota`. + +## What changed since 031 + +`031_wp3_disposition_record.md` recorded the provider-level card as NOT-APPLICABLE +(option b from `030` §4): "Meta's documented limits are per team, while the observed +window is per subscription, so promoting it to provider level would relabel a different +quantity." + +The owner then opened the live dashboard (v2.42.0, Usage tab → 요청 한도) and rejected +that: "지금 업데이트 된 최신 버전인데도 안돼 … 이거 해결해서 뜰때까지". The +rebuttal to (b) was already in the codebase: `fetchAnthropicQuota` and +`fetchKiroQuota` both answer the provider-level row with **the active account's** +usage (`quota.ts:1387` — "Provider-level Kiro row: the active account's usage, shown on +the Providers page"). Provider level in this dashboard has always meant "the account in +use", and per-subscription is exactly the right quantity for that. + +## The change (one branch) + +`src/providers/quota.ts`, in `maybeFetchProviderQuota` after the kiro branch: + +```ts +// Passive providers report no probe: the row is the ACTIVE account's last observed +// subscription windows, the same shape fetchAnthropicQuota/fetchKiroQuota return. +// Cache-only — a dashboard load or ocx account refresh must never spend an inference +// turn; refresh=1 is a no-op on this path. +if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name); +``` + +New helper beside `fetchKiroQuota`: + +```ts +async function fetchPassiveProviderQuota(provider: string): Promise { + const activeId = getAccountSet(provider)?.activeAccountId; + if (!activeId) return null; + hydrateAccountQuotaCache(); + const entry = accountQuotaCache.get(accountCacheKey(provider, activeId)); + if (!entry?.quota) return null; + return report(provider, `${provider}:subscription-observation`, entry.quota); +} +``` + +Rules: + +- **Active account only.** Inactive accounts' observations never promote. Matches the + anthropic/kiro provider-row semantics. +- **No observation → null.** The card's empty state is then correct pre-first-turn. +- **`report.updatedAt` = the observation time.** `report()` already copies + `quota.updatedAt`, and both consumer surfaces render relative time from it: + `ProviderUsage.tsx:151` (`pws.stats.quotaUpdated`) and + `ProviderOverviewDashboard.tsx:166` (`pws.dashboard.checkedAgo`). **No GUI change.** +- **No new imports.** Everything stays in `quota.ts`; the lab boundary is untouched. + +## Consumer audit (the one cross-cutting risk) + +`fetchProviderQuotaReports` also feeds `replaceCachedProviderQuotas` +(`quota.ts:2525`), which `combos/resolve.ts:130,157` reads for +exhausted-provider skipping. That cache is safe by construction: +`getCachedProviderQuota` has a 30-minute age bound +(`quota-routing-cache.ts:21-26`) — stale passive rows are ignored by routing, and a +fresh "100%" observation correctly parks the provider for up to 30 minutes. This is the +desired behaviour, not a hazard. + +## CLI consequence (kept, now reachable in both directions) + +`ocx account refresh meta-muse` hits `/api/provider-quotas?refresh=1`. Before this +branch: report null → the wp3 "nothing to refresh" message. After: with an observation +cached, it prints the cached windows (still zero network calls upstream). Both outputs +are correct for their state; the wp3 test (`cli-account` 19b, no seeded cache) stays +green, and a new test pins the seeded-observation case. + +## Tests + +In `tests/muse-passive-quota-observation.test.ts` (extend): + +- active account with a cached observation → provider report carries the windows, + source `meta-muse:subscription-observation`, `updatedAt` equal to the observation + time +- no observation → no report row for meta-muse +- **no network**: `fetchImpl` spy (or the absence of any fetch in the module path) + proves a dashboard refresh issues zero upstream calls for meta-muse — pin by + stubbing `globalThis.fetch` to throw if reached +- an inactive account holding the only observation → no provider row +- `refresh=1` returns the same cached row + +In `tests/cli-account.test.ts` (extend 19-series): + +- with a seeded observation, `ocx account refresh meta-muse` prints the cached + windows rather than "nothing to refresh" (and never contacts upstream) + +## Docs + +`031_wp3_disposition_record.md` provider-card row amended to record the override and +point here. The `providers.md` sentence shipped in wp3 ("there is no endpoint to query +them on demand") stays true and unchanged. + +## Verification + +```bash +bun test tests/muse-passive-quota-observation.test.ts tests/cli-account.test.ts \ + tests/provider-quota.test.ts tests/core-lab-boundary.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +cd gui && bun run build +``` + +Repository-wide local suite forbidden. Exact-head CI is the gate. + +## Terminal outcome + +`DONE` when `/api/provider-quotas` carries `meta-muse` once the active account has +an observation, the Usage tab 요청 한도 and the Providers overview RATE LIMITS render +it with its observation time, and the PR is green at its exact head SHA and merged. diff --git a/devlog/_plan/260903_muse_provider_parity/assets/021_observed_age_render.png b/devlog/_plan/260903_muse_provider_parity/assets/021_observed_age_render.png new file mode 100644 index 0000000000..ed75e6cfd7 Binary files /dev/null and b/devlog/_plan/260903_muse_provider_parity/assets/021_observed_age_render.png differ diff --git a/devlog/_plan/260903_responses_passthrough/000_research.md b/devlog/_plan/260903_responses_passthrough/000_research.md new file mode 100644 index 0000000000..87764e2edd --- /dev/null +++ b/devlog/_plan/260903_responses_passthrough/000_research.md @@ -0,0 +1,99 @@ +# 260903_responses_passthrough — 000 research + +## Trigger + +User report: check upstream openai/codex for response-stream movements and anything meant to make +passthrough explicit; improve opencodex accordingly as stacked PRs. + +## Upstream movements (verified 2026-09-03, clone ~/Developer/codex/121_openai-codex @ 728cb12fe) + +- e017e93ac #41980 "Preserve raw response usage metadata" (2026-09-01): the complete upstream + `response.usage` object (including fields codex-rs does not type) is preserved into + `ResponseUsageMetadata.metadata` and exposed via `rawResponse/completed` notifications on SSE, + Responses WebSocket, turn, and compaction completion paths. codex-api/src/sse/responses.rs:478-490 + extracts `resp_val.get("usage")` as raw JSON before typed deserialize. +- 2c4a95736 #41087 "Expose response usage metadata in completion events" (2026-08-27): app-server + `rawResponse/completed` carries `{ threadId, turnId, responseId, usage }`; `usage` null when the + upstream event omits it. +- 5f79a92e3 #41912 (2026-08-31): cumulative token usage persisted in rollout; `thread/resume` + re-emits `thread/tokenUsage/updated`. +- e0c727de0 #40931 (2026-08-26): rate-limit failure inside an HTTP-200 stream is the existing + `response.failed` event classified retryable. +- Issues: #37138 — a proxy stripping `usage` from `response.completed` is silently accepted with + `token_usage=None` and bypasses session totals/budget accounting; #37141 — a malformed/partial + usage block fails SSE deserialize, classified retryable, causing full-request retry storms. +- opencodex dev: bea573abe #3358 reads Muse subscription usage from `response.completed.usage` + in-band (src/server/responses/core.ts noteInspectedPayload + src/providers/muse-subscription-usage.ts). + +Net: the wire source of truth is the raw `response.completed.response.usage` object. Both upstream +codex and opencodex's own passive-quota feature depend on unknown usage fields surviving the relay. + +## opencodex passthrough shape (current tree, post-3361) + +- Happy-path streaming forward: byte-verbatim unless a block rewrite fires + (core.ts `clientBlockRewrite`; relaySseEagerBounded / relaySseWithBlockRewrite). +- Every block rewrite is identity-preserving when unchanged (`rewritten === event` → original + block bytes; responses-field-backfill.ts:316, sse-payload-rewrite.ts compose). +- Parse-modify-reserialize rewrites keep unknown fields (spread on the parsed object). +- Known rebuild-from-whitelist points (gap candidates): + 1. `src/bridge.ts` responsesUsage() — rebuilds usage from typed OcxUsage for the + translated-provider bridge AND buildResponseJSON non-streaming rebuild; unknown usage fields + (subscription metadata) are dropped. + 2. Non-streaming rebuild in core.ts (`buildResponseJSON(terminalEvents...)`). + 3. Compact path (responses/compact.ts) — native ChatGPT/OpenAI: upstream body verbatim. + 4. ws-bridge.ts — Responses WebSocket transport frame handling. +- Internal typed extractors (request-log.ts, openai-responses.ts usageFromResponsesPayload) + feed the proxy's own accounting only — not client-visible. OK by design. + +## Audit result (Sol reviewer Euclid, 01a0679f-8f04-7673-a23c-33df980d7c4c) + +Full re-serialization inventory over relay.ts / relay-eager.ts / repair chain / bridge / ws transports: + +- Happy-path SSE relay, terminal-bounded relay, trackSseForRequestLog, createSseInspector, + snapshot repair (JSON fields), terminal repair (real terminals), item-id repair, model rewrite, + field backfill, image/namespace/custom-tool rewrites, non-streaming JSON passthrough, + upstream-WS→SSE normalization, SSE→client-WS reframing: SAFE for unknown `response.usage` keys + (spread-preserved or byte-verbatim). Intentional field drops exist (namespace scrub deletes + `namespace`; custom-tool repair drops `arguments`; undeclared-tool guard is fail-closed) — by design. +- Synthetic terminal events (missing/failed upstream terminal) cannot carry unseen upstream + fields — inherent, acceptable. +- B1 (High, verified): the AdapterEvent bridge drops unknown usage fields irrecoverably — + `usageFromResponsesPayload` (src/adapters/openai-responses.ts) narrows to typed OcxUsage and + returns undefined when input+output are both 0 (metadata-only usage disappears entirely); + `responsesUsage()` (src/bridge.ts) rebuilds only input/output/total + cache/reasoning + details. Hits `buildResponseJSON` (non-streaming/buffered) and `bridgeToResponsesSSE` + (translated providers). +- B2 (Medium, verified): no test pins "unknown keys inside response.completed.response.usage + survive client passthrough" on any path. +- #3358 Muse observer is safe (observer-only; the raw `response.subscription_usage` frame survives + passthrough; subscription.tier omission is dashboard-cache only). + +## Narrow audit confirmation (Ampere, 01a067a9-1a22-7650-b739-434533aac908) + +- Canonical openai forward (pool/direct) never reaches bridgeToResponsesSSE/buildResponseJSON — + including stream:false (bounded JSON, spread-preserving transforms) and compaction (upstream body + copied byte-verbatim; only headers reduced). +- openai-responses adapter is ALWAYS the passthrough adapter (adapters/registry.ts), so B1's + blast radius is: translated adapters parsing Responses-shaped upstreams (future providers, Lab + conformance executor) and any buffered rebuild. Fix stands as #41980 parity + future-proofing. +- WS→SSE drops non-`response.*` sideband frames (codex.rate_limits, websocket_timing) — SSE clients + have no semantic for them; recorded as residual, not a gap. + +## Fix plan + +- wp2 (010): `OcxUsage` gains the raw upstream usage object; the openai-responses adapter attaches + it; `responsesUsage()` merges unknown keys (normalized known keys win, extras pass through, + zero-count metadata-only usage is no longer dropped). Unit tests in the adapter + bridge suites. +- wp3 (020): regression coverage pinning unknown usage keys through (a) forward SSE passthrough, + (b) non-streaming JSON passthrough, (c) bridge rebuild, (d) WS normalization. Stacked on wp2. +- wp4 (030): push stacked PRs against origin/dev — independent of PR #3361. + +## Review findings folded (PR #3364) + +- Codex connector P2: empty-completion retry mergeUsage dropped rawUsage → the content attempt's + raw usage now wins (empty-completion-guard.ts). +- Codex connector P2: the retained raw usage clone was not charged to the translator budget → + parseStream reserves/releases its serialized size like the adjacent retained collectors. +- CodeRabbit minor: unknown-shaped `cache_write_tokens` must not leak through the raw spread → + excluded from raw input details; only the validated normalized value is emitted. +- CodeRabbit minor (MD041): document headings rebuilt. diff --git a/devlog/_plan/260903_responses_passthrough/010_wp2_raw_usage_bridge.md b/devlog/_plan/260903_responses_passthrough/010_wp2_raw_usage_bridge.md new file mode 100644 index 0000000000..8451609f11 --- /dev/null +++ b/devlog/_plan/260903_responses_passthrough/010_wp2_raw_usage_bridge.md @@ -0,0 +1,32 @@ +# 010 — wp2: carry raw upstream usage through the AdapterEvent bridge + +## Goal + +openai/codex#41980 preserves the complete raw `response.usage` object. opencodex's translated/ +buffered path (bridgeToResponsesSSE + buildResponseJSON) must do the same instead of rebuilding +usage from the closed OcxUsage shape. + +## Files + +- `src/types/request.ts` (OcxUsage): `rawUsage?: Record` — the raw upstream usage + object; wire data only, accounting keeps reading the canonical fields. +- `src/adapters/openai-responses.ts` `usageFromResponsesPayload`: capture the raw usage object when + unknown keys exist (top-level or nested details); stop dropping metadata-only usage; charge the + retained clone to the translator budget. +- `src/bridge.ts` `responsesUsage()`: merge extras under normalized known keys; nested detail extras + preserved; `cache_write_tokens` never copied raw (validated normalized value only). +- `src/server/responses/empty-completion-guard.ts` `mergeUsage`: the content attempt's rawUsage wins. + +## Tests + +tests/responses-usage-passthrough.test.ts: stream/non-stream adapter extras, metadata-only usage +kept, canonical-only narrow, rebuild merge + strict defaults, unknown-shaped known key excluded, +retry merge. + +## Close-out (D) + +- Commit 1f0d820aa: OcxUsage.rawUsage + adapter extras capture (incl. zero-count metadata-only usage) + + responsesUsage merge; tests in tests/responses-usage-passthrough.test.ts. +- Review: 5 subagent dispatches failed pool-wide (401/capacity/transport); direct independent audit PASS. + Nuance accepted: zero-count-with-extras usage shows 0 tokens in display. +- Residual: unknown response.* event types dropped on translated paths (B3) — separate unit. diff --git a/devlog/_plan/260903_responses_passthrough/020_wp3_coverage.md b/devlog/_plan/260903_responses_passthrough/020_wp3_coverage.md new file mode 100644 index 0000000000..4f8450d9e3 --- /dev/null +++ b/devlog/_plan/260903_responses_passthrough/020_wp3_coverage.md @@ -0,0 +1,23 @@ +# 020 — wp3: passthrough regression coverage (unknown usage keys survive) + +## Goal + +Pin the passthrough contract so a future whitelist rebuild cannot silently drop usage extras. + +## Tests + +- Forward SSE with usage extras reaches the client (terminal block intact when no rewrite fires). +- Non-streaming forward JSON: extras survive. +- WS normalization (ws-upstream): response.done with usage extras → client frame keeps them. +- A usage-less response.completed stays accepted (#37138 adjacency). +- Bridge rebuild keeps extras (wp2 suite). + +## Stack + +Branch wp3 (codex/responses-usage-coverage) on top of wp2's branch head; PR targets wp2's branch +per DEV-STACK; retarget to dev after the parent merges. + +## Close-out (D) + +- ab0a19f9e: 4 tests pin unknown usage keys on forward SSE, non-streaming JSON, WS response.done + normalization; usage-less completed stays accepted. diff --git a/devlog/_plan/260903_responses_passthrough/030_wp4_prs.md b/devlog/_plan/260903_responses_passthrough/030_wp4_prs.md new file mode 100644 index 0000000000..831bf1edff --- /dev/null +++ b/devlog/_plan/260903_responses_passthrough/030_wp4_prs.md @@ -0,0 +1,7 @@ +# 030 — wp4: push + PRs + +- PR-A #3364 from codex/responses-usage-passthrough against dev: bridge fix + unit tests. +- PR-B #3365 from codex/responses-usage-coverage stacked on PR-A head: passthrough coverage. +- Template sections filled; --no-verify push; gh pr checks on exact heads. +- docs: no user-visible behavior change on the passthrough path; the bridged-path change is + internal translation fidelity. No docs-site change needed. diff --git a/devlog/_plan/260903_voice_sideband_regression/000_research.md b/devlog/_plan/260903_voice_sideband_regression/000_research.md new file mode 100644 index 0000000000..486c41ce91 --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/000_research.md @@ -0,0 +1,90 @@ +# 260903_voice_sideband_regression — 000 research + +## Symptom (2026-09-03, live evidence) + +- ChatGPT.app (bundled codex-cli 0.153.0-alpha.5) voice session: + `Realtime voice session failed ... message="unexpected status 404 Not Found: realtime websocket handshake failed"` + — app log `~/Library/Logs/com.openai.codex/2026/09/03/codex-desktop-7bd4860e-...-t0-i1-000150-0.log` + lines 12425-12491 (two attempts, 11:29:04Z and 11:29:18Z). + Transport line: `Starting realtime voice transport clientOwnsCall=false ... model=gpt-live-1-codex ... version=v3`, + sideband line: `Starting realtime voice app-server sideband ... transport=webrtc`. +- Proxy usage log (`~/.opencodex/usage.jsonl` 627511/627513): two `gpt-live` requests, `status:201`, + provider `openai-p3b640f` (a POOL account, not the app's own login). No `gpt-live` `status:101` + (sideband upgrade) since 2026-07-29 (line 294981). +- `~/.codex/config.toml` line 10: `openai_base_url = "http://127.0.0.1:10100/v1"` (marker-owned, Design B). + No `experimental_realtime_ws_base_url` present. +- App auth (`~/.codex/auth.json`) account hash `c602fb19` != every pool account hash in + `~/.opencodex/codex-accounts.json` (the four active: d1f8d4d6 / c6a3378e / 6eff99ad / f462cf1e). + +## Upstream contract (openai/codex main 728cb12fe, pulled 2026-09-03 into ~/Developer/codex/121_openai-codex) + +1. `codex-rs/core/src/realtime_conversation.rs:1189-1206` — for `Webrtc` transport the sideband base is + `config.experimental_realtime_ws_base_url` only; when unset the `RealtimeWebsocketClient` default applies. +2. `codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs:60,784` — + `OPENAI_REALTIME_API_BASE_URL = "https://api.openai.com/v1"` is that default (since 438c9e98d / PR #35830, + 2026-07-28: "Use https://api.openai.com/v1 for WebRTC sideband websocket joins instead of deriving the URL + from the model provider"). `normalize_realtime_path` (L1163-1172) maps FramelessBidi to `/v1/live/{callId}`. +3. `codex-rs/core/src/client.rs:726-754` — call-create goes through the model provider (= `openai_base_url` + = the proxy) and the sideband reuses `sideband_websocket_auth_headers(client_setup.api_auth)`, i.e. the + APP'S OWN token, sent straight to api.openai.com. +4. `codex-rs/codex-api/src/endpoint/realtime_call.rs:66-79` — API shape (non backend-api base) posts + `{base}/live` for FramelessBidi; `decode_call_id_from_location` (L259) reads the `Location` header. +5. `codex-rs/config/src/config_toml.rs:404-408` — `experimental_realtime_ws_base_url` and + `experimental_realtime_webrtc_call_base_url` are root keys; `config/src/loader/mod.rs:85-86` denies them + only for PROJECT-LOCAL layers; user `~/.codex/config.toml` is honored. +6. `codex-rs/app-server/src/request_processors/turn_processor.rs:1232-1240` — desktop `Webrtc` transport + never sets a per-call `sideband_base_url` (that override, PR #41923 34c4f7e72, exists only for + `ExistingCall`). + +Net: call-create is answered by pool account X (proxy choice); the sideband join goes to +api.openai.com with the app's own account Y. The call does not exist for Y → 404. Upstream's own tracker +has the same shape: openai/codex#35094 ("Realtime V3 WebRTC call succeeds, sideband WebSocket returns +404 call_id_not_found", 2026-07-24). Independent proxies (Aether WebSocket-Mode.md) document the same +rule: call-create and sideband must share origin + credential. + +## Upstream commits since 94cbbddaf (local clone was at 2026-08-30) touching voice + +- 34c4f7e72 #41923 per-call sideband endpoint for ExistingCall (no effect on desktop Webrtc path) +- 64c9cde45 #41924 realtime history in Core (new RealtimeEvent::History* variants; transparent relay unaffected) +- e1d0ef995 #42377 app-server realtime always available (feature flag removed) +- deb147116 / dc0dc4f15 / eb10d91e4 / 8d01cd42f / 8813bd4b0 / 13bc770ea / d60560f14 / 65237aeca / fc7d34ad6 / 379d50be3 + — third_party/voice helper runtime (local STT/TTS host), not a wire-contract change for the proxy. + +## Why the previous fixes did not cover this + +- 260724_gpt_live_hotfix (PR #379) added `/v1/live/{callId}` sideband relay on the proxy — correct, but the + client stopped sending the sideband to the provider base four days later (438c9e98d). +- 260812_realtime_standalone_ws fixed the STANDALONE WebSocket transport (`GET /v1/realtime?intent=...`). + The desktop now uses WebRTC v3 again (`transport=webrtc`), which is the sideband path. + +## Fix options + +A. (chosen) `ocx start` injects `experimental_realtime_ws_base_url` (marker-owned, same value as + `openai_base_url`) so the sideband upgrade comes back to the proxy. The proxy already relays + `GET /v1/live/{callId}` → `wss://api.openai.com/v1/live/{callId}` with pool auth + (`src/server/live.ts:238-247, 348-368`). Both legs then run under the proxy-selected account, and + `codexPoolAffinityKey` (`src/codex/auth-context.ts:84-98`, keyed on `session-id` + `thread-id` which + codex-rs attaches via `build_session_headers`) keeps them on the same pool account. +B. Also inject `experimental_realtime_webrtc_call_base_url` — unnecessary: call-create already follows + `openai_base_url`. Not injected (keeps the footprint to one key). +C. Proxy-side only (no config change) — impossible: the client never contacts the proxy for the sideband. + +## Risks / residuals + +- Upstream key is named `experimental_*`; if renamed the injected line becomes a no-op (fails back to the + current broken state, not worse). Test pins the exact key. +- Users on a hand-written `openai_base_url` (user-owned) are already not injected; the new key follows the + same ownership rule (never overwrite a user-owned value). +- The proxy's `loopbackRouteAllowed` (`src/server/index.ts:819`) allows WS upgrades on `/v1/realtime` and + `/v1/live` only, NOT `/v1/live/{callId}`; a directly spawned app-server on the unauthenticated loopback + listener would still 404 the sideband. Add the keyed paths for WS upgrades (020). + +## Audit notes (Sol reviewer, PASS) + +- `experimental_realtime_ws_base_url` redirects the sideband AND the standalone realtime WebSocket; it does + NOT redirect WebRTC call-create (that follows `openai_base_url`; `experimental_realtime_webrtc_call_base_url` + is the separate call-create override and stays un-injected). +- codex-rs snapshots sideband auth headers before call-create; both legs carry the same app identity. +- The app-server public Webrtc transport carries only `sdp` (`app-server-protocol/src/protocol/v2/realtime.rs:275`); + no client-side field can redirect the sideband, so the config key is the only lever. +- With the override, the exact sideband URL is `ws://127.0.0.1:/v1/live/{callId}` (methods.rs:1084/1129/1166). diff --git a/devlog/_plan/260903_voice_sideband_regression/010_wp2_inject_realtime_ws_override.md b/devlog/_plan/260903_voice_sideband_regression/010_wp2_inject_realtime_ws_override.md new file mode 100644 index 0000000000..1481099f55 --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/010_wp2_inject_realtime_ws_override.md @@ -0,0 +1,38 @@ +# 010 — wp2: inject `experimental_realtime_ws_base_url` with the loopback override + +## Goal +When `ocx start` installs Design B loopback routing (`openai_base_url = "http://127.0.0.1:/v1"`), +also install a marker-owned root `experimental_realtime_ws_base_url` with the SAME value, so codex-rs +(`core/src/realtime_conversation.rs:1194-1206`) sends the WebRTC sideband join back through the proxy. + +## Files +- `src/codex/injected-marker.ts` + - add `REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url"`, `isRootRealtimeWsBaseUrlLine(line)`. + - `stripJournaledOpenaiBaseUrl(content, injectedUrl)`: also drop a root `experimental_realtime_ws_base_url` + line whose value === injectedUrl (plus its marker line). Value evidence survives app reserialization (#1798). + - `hasInjectedOpenaiBaseUrl` unchanged (openai_base_url stays the ownership signal). +- `src/codex/inject.ts` + - `buildRealtimeWsBaseUrlLine(target)` → `experimental_realtime_ws_base_url = `. + - `stripInjectedOpenaiBaseUrl(content)`: drop marker-owned `experimental_realtime_ws_base_url` lines too + (same marker-adjacency rule). Must run before `removeOcxSection` (it keys on the marker line). + - new `setRootRealtimeWsBaseUrlForTarget(content, target)`: mirror of `setRootOpenaiBaseUrlForTarget`; + a user-owned (unmarked) key is kept, returns `keptUserRealtimeWsBaseUrl`. + - Design B branch (L972-978): after `setRootOpenaiBaseUrlForTarget`, if `!keptUserBaseUrl` call + `setRootRealtimeWsBaseUrlForTarget`. When the user owns `openai_base_url` we inject nothing (existing rule). + - Legacy provider-table mode: NOT injected (the public ingress needs the opencodex API key, which the + sideband auth headers cannot carry) — documented residual. + - `stripOpencodexConfigResult` (L1390-1401): `stripInjectedOpenaiBaseUrl` + journaled strip already cover it + after the helper changes; add a regression assertion. + - Summary message: mention "voice sideband override" in the Design B success line (L1304). + +## Tests (tests/codex-inject.test.ts, tests/codex-injected-marker.test.ts) +1. loopback inject writes both keys, each preceded by the marker; second run is idempotent (byte-equal). +2. user-owned `experimental_realtime_ws_base_url = "https://my.gateway/v1"` (no marker) survives injection + and restore. +3. restore/strip removes both marker-owned keys; app-reserialized (comments dropped) config is restored via + journal value match. +4. user-owned `openai_base_url` → neither key injected (keptUserBaseUrl path). +5. legacy target (non-loopback) → no realtime key written. + +## Checks +`bun run typecheck`; `bun test tests/codex-inject.test.ts tests/codex-injected-marker.test.ts tests/codex-inject-integration.test.ts`. diff --git a/devlog/_plan/260903_voice_sideband_regression/020_wp3_proxy_affinity_probe.md b/devlog/_plan/260903_voice_sideband_regression/020_wp3_proxy_affinity_probe.md new file mode 100644 index 0000000000..78283bd7e6 --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/020_wp3_proxy_affinity_probe.md @@ -0,0 +1,36 @@ +# 020 — wp3: proxy-side sideband parity + same-account affinity + probe + +## Goal +With the sideband now arriving at the proxy as `GET /v1/live/{callId}` (Upgrade: websocket, headers from +codex-rs `build_session_headers`: `session-id`, `thread-id`, plus the app's Authorization), prove both legs +select the same pool account and that the unauthenticated loopback listener admits the keyed join paths. + +## Files +- `src/server/index.ts` `loopbackRouteAllowed` (L807-822): allow WebSocket upgrades on + `/v1/live/{callId}` and `/v1/realtime/calls/{callId}` (regex `^/v1/(live|realtime/calls)/[^/]+/?$`) and + `/v1/realtime?call_id=`; plain HTTP on those paths stays 404. Same trust model as the existing + `/v1/realtime` / `/v1/live` upgrade allowance (260812 A1). +- `src/server/live.ts`: no URL change needed (`buildLiveSidebandUpstreamWsUrl` already targets + `wss://api.openai.com/v1/live/{callId}`). Add a comment block tying the design to 438c9e98d and to the + injected override. Keep `LIVE_CLIENT_PROTOCOL_HEADERS` (session-id/thread-id are relayed verbatim). +- Affinity: `resolveLiveRelay` → `resolveFirstUsableOpenAiSidecar` → `codexPoolAffinityKey(headers)` + (`src/codex/auth-context.ts:84-98`). The key is derived from `session-id` + `thread-id`; both legs carry + the same pair, so the binding created on call-create is reused on the sideband. Regression test only. + +## Tests +- `tests/server-live.test.ts` (or new `tests/live-sideband-affinity.test.ts`): two pool accounts + configured; POST `/v1/live` with headers {session-id: S, thread-id: T} → record upstream account A; + then WS upgrade `GET /v1/live/rtc_x` with the same headers → assert upstream auth is account A. + Negative: different thread-id may pick a different account (no assertion on which). +- `tests/loopback-listener-admission.test.ts`: WS upgrade on `/v1/live/rtc_x` admitted (not 404); + `GET /v1/live/rtc_x` without Upgrade → 404. + +## Probe (isolated, never port 10100) +`OPENCODEX_HOME=$(mktemp -d) bun run src/cli/index.ts start --port ` with a copied pool credential +is NOT allowed (auth files out of scope). Instead run the in-process server test harness with a fake +upstream WebSocket (`experimentalRealtimeWsBaseUrl` pointing at a local ws server, as +`tests/native-profile-drain-server.test.ts:211` does) and assert the relayed upgrade URL is +`/v1/live/rtc_x` and the request reached the fake with pool auth. Record transcript in 021. + +## Checks +`bun run typecheck`; `bun test tests/server-live.test.ts tests/loopback-listener-admission.test.ts tests/live-sideband-affinity.test.ts`. diff --git a/devlog/_plan/260903_voice_sideband_regression/021_wp3_probe_transcript.md b/devlog/_plan/260903_voice_sideband_regression/021_wp3_probe_transcript.md new file mode 100644 index 0000000000..36e18ae406 --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/021_wp3_probe_transcript.md @@ -0,0 +1,43 @@ +# 021 — wp3 probe transcript (isolated home, ephemeral port, fake upstream) + +Command: `bun .tmp/voice-probe.ts` (scratch script, not committed; OPENCODEX_HOME + CODEX_HOME = mktemp, +proxy on port 0, fake ChatGPT backend + fake sideband WS server, pool of two accounts under round-robin, +`experimentalRealtimeWsBaseUrl` pointed at the fake so the relay's upstream sideband dial is observable). +Port 10100 untouched. + +Exit code: 0 + +``` +call-create status 201 location /v1/live/rtc_probe +sideband relay reply echo:ping +[ + { + "leg": "call-create", + "path": "/realtime/calls?intent=quicksilver&architecture=avas", + "acct": "acct-a", + "sid": "sess_probe", + "tid": "thread_probe" + }, + { + "leg": "call-create", + "path": "/realtime/calls?intent=quicksilver&architecture=avas", + "acct": "acct-b", + "sid": "s2", + "tid": "t2" + }, + { + "leg": "sideband", + "path": "/v1/live/rtc_probe", + "acct": "acct-a", + "sid": "sess_probe", + "tid": "thread_probe" + } +] +SAME_ACCOUNT_BOTH_LEGS true | other-thread account acct-b +``` + +Reading: call-create for (sess_probe, thread_probe) went out under `acct-a`; an unrelated thread advanced +round-robin to `acct-b`; the keyed sideband join `GET /v1/live/rtc_probe` with the same session/thread +headers was relayed to `/v1/live/rtc_probe` under `acct-a` again and echoed a frame back. This is the +exact request shape codex-rs produces with `experimental_realtime_ws_base_url = http://127.0.0.1:/v1` +(realtime_websocket/methods.rs:1084/1129/1166). diff --git a/devlog/_plan/260903_voice_sideband_regression/030_wp4_docs_pr.md b/devlog/_plan/260903_voice_sideband_regression/030_wp4_docs_pr.md new file mode 100644 index 0000000000..45448c9fd6 --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/030_wp4_docs_pr.md @@ -0,0 +1,16 @@ +# 030 — wp4: docs + push + PR + +## Files +- `docs-site/src/content/docs/troubleshooting/voice*.md` (or the page that documents GPT-Live / realtime): + add "sideband 404 after Codex 0.146+ (2026-07-28)" section — cause, that `ocx start` now writes + `experimental_realtime_ws_base_url`, how to verify (`grep experimental_realtime_ws_base_url ~/.codex/config.toml`, + a `gpt-live` `101` row in usage), and the manual line for hand-written configs. +- Korean/other locales: only if the English page has a translated twin; keep them from contradicting. +- `devlog/_plan/260903_voice_sideband_regression/040_d_record.md` with the terminal outcome. + +## Git +- branch `codex/voice-sideband-override` off current HEAD (162d11e18 == origin/dev). +- commits per B step; push `--no-verify` (authorized), PR against `dev` using + `.github/PULL_REQUEST_TEMPLATE.md` (Summary / Verification / Checklist), no `gui` mention. +- After push: `gh pr checks ` / workflow runs for the EXACT head SHA; report status. +- Stacked PR only if wp2 and wp3 need separate review; default single PR since wp3 is small. diff --git a/devlog/_plan/260903_voice_sideband_regression/040_d_record.md b/devlog/_plan/260903_voice_sideband_regression/040_d_record.md new file mode 100644 index 0000000000..f3b89501ca --- /dev/null +++ b/devlog/_plan/260903_voice_sideband_regression/040_d_record.md @@ -0,0 +1,24 @@ +# 040 — D record + +Terminal outcome: DONE (pending exact-head CI on the final push). + +- Branch `codex/voice-sideband-override`, PR https://github.com/lidge-jun/opencodex/pull/3361 against `dev`. +- Commits: 1d5ffdf36 e6a73759f (roadmap), 5f351210c fff79258d (wp2 inject), bb3000dc8 5817505bb 2c296e1e8 (wp3 proxy), + 17cfccf8f f36ff15d3 (wp4 docs). +- First head 2c296e1e8: all CI checks green (test 1-4/4, gates, macos, keyring x3, npm-global x3, hygiene, + enforce-target, label, react-doctor, storage policy, api usage); Windows shard skipped by the runner + selector. Second head f36ff15d3 (docs only, CodeRabbit follow-ups): re-run in progress at close time. +- Reviews: Sol auditors Dalton (root cause PASS), Carver (plan FAIL -> blockers folded), Zeno (wp2 FAIL -> + PASS round 2), Leibniz (wp3 FAIL -> PASS round 2); grok-bot maintainer review recommends merge after CI; + CodeRabbit 2 minor doc findings folded. +- Gates run: typecheck, focused inject/live/loopback suites, test:changed (10550 pass), privacy:scan, + docs build. Full local suite deliberately not run (user instruction); CI covers it. + +## Residuals (follow-up material, not blockers) + +- User-owned root `openai_base_url` (hand-written proxy config): no realtime key injected; those users + need the manual line. Documented in the guide. +- Provider-table forms (non-loopback admission, authless Desktop): desktop v3 voice stays broken because + the sideband cannot carry the admission token. Documented as residual. +- Upstream key is `experimental_*`; a rename makes the injected line a no-op (back to today's failure). +- Merge into `dev` is a maintainer action, not taken here. diff --git a/devlog/_plan/260904_astra_release_alignment/000_research.md b/devlog/_plan/260904_astra_release_alignment/000_research.md new file mode 100644 index 0000000000..ad75311a7d --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/000_research.md @@ -0,0 +1,141 @@ +# 000 — Research: GPT-6-Astra shipped, and the adapter_eof report + +Two questions, deliberately in one unit because the user hit them in the same breath +and the second one turned out NOT to be caused by the first. + +## Q1: Astra shipped. What does upstream actually say? + +Source of truth: `~/Developer/codex/121_openai-codex`, `origin/main`. Two commits landed +it on 2026-09-03: + +- `ed391d4dd` — "Add GPT-6-Astra to the bundled model catalog (#42607)" +- `1f7b99922` — "Add GPT-6-Astra to Amazon Bedrock catalogs (#42619)" + +Read with `git show origin/main:codex-rs/models-manager/models.json`. The real row: + +| field | upstream value | +|---|---| +| `slug` | `gpt-6-astra` | +| `display_name` | `GPT-6-Astra` | +| `description` | `Our most capable model for complex, demanding work.` | +| `context_window` | `272000` | +| `max_context_window` | `872000` | +| `comp_hash` | `3000` | +| `visibility` | `hide` | +| `priority` | `1` | +| `minimal_client_version` | `0.153.0` | +| `shell_type` | `unified_exec` | +| `tool_mode` | `code_mode_only` | +| `default_reasoning_level` | `low` | +| `supported_reasoning_levels` | low, medium, high, xhigh, max, ultra | +| `multi_agent_version` | `v2` | +| `multi_agent_reasoning_effort` | `xhigh` | +| `prefer_websockets` | `true` | +| `use_responses_lite` | `true` | +| `support_verbosity` / `default_verbosity` | `true` / `low` | +| `supports_image_detail_original` | `true` | +| `node_repl_auto_review_required` | `true` | +| `available_in_plans` | 23 plans incl. `free`, `go`, `plus`, `pro`, `team`, `enterprise` | + +It also carries its OWN `base_instructions` / `model_messages` — a GPT-6 agent prompt, +not Sol's. + +Bedrock side (`#42619`): `openai.gpt-6-astra`, with `global.` and `us.` runtime prefixes. +Out of scope here; opencodex does not route Bedrock. + +## Q1a: What does opencodex currently claim? + +Earlier in this same session Astra was registered SPECULATIVELY from a leaked slug +(PR #3410, on `dev` as `db2e2eb47`). That guess is now measurably wrong. Live catalog +row read from `~/.codex/opencodex-catalog.json`: + +| field | opencodex now | upstream | verdict | +|---|---|---|---| +| `display_name` | `GPT-6 Astra` | `GPT-6-Astra` | WRONG (space vs hyphen) | +| `description` | "…leaked API identifier; presentation provisional" | "Our most capable model for complex, demanding work." | WRONG | +| `context_window` (resolved) | `272000` | `272000` | OK — see correction below | +| long window / `max_context_window` | `922000` | `872000` | WRONG (over-advertises by 50k) | +| `priority` | `105` | `1` | WRONG | +| `visibility` | `list` | `hide` | deliberate divergence, argued in 010 | +| `comp_hash`, `shell_type`, `tool_mode`, ladder | match | match | OK (inherited from Sol, coincidentally right) — but the ladder is FRAGILE, see 015/C3 | + +**Correction (audit round 1).** An earlier draft of this table listed `context_window: 922000` +as drift. Measured: `nativeOpenAiContextWindow("gpt-6-astra")` is already **272,000**, +because `NATIVE_GPT56_CONTEXT_WINDOW` is 272,000. The 922,000 that appears in +`~/.codex/opencodex-catalog.json` is the materialized **long window** (the 1M-opt-in +ceiling), so the drift is real but sits on `max_context_window`, not the default window. + +`minimal_client_version` was also listed as MISSING. It is out of reach by design: +`upstreamNativeEntry` deletes that key from every result it returns, so no change inside +this unit's mechanism can populate it. Dropped from the drift list rather than left as a +criterion the plan cannot meet. + +Root cause of the drift: `src/codex/data/upstream-models.json` has 8 rows and Astra is +not one of them (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, +`gpt-5.4-mini`, `gpt-5.2`, `codex-auto-review`). So `NATIVE_OPENAI_CAPABILITY_SOURCES` +in [native-models.ts](../../../src/codex/catalog/native-models.ts) borrows Sol's pinned +snapshot, and `NATIVE_OPENAI_ALIAS_PRESENTATION` overlays a hand-written label. Both were +correct answers to "no upstream row exists"; neither is correct now that one does. + +Note the local snapshot's Sol row reads `context_window: 372000`, while upstream main now +reads `272000` for Sol too — the pin is stale beyond Astra. Out of scope for this unit; +recorded so the next reader does not mistake it for a new defect. + +## Q2: "Stream disconnected before completion … reason: adapter_eof" + +The user reported this live, alongside a "Reconnecting… 5/5" indicator, in the same +message as the Astra request. The instinct is that ungating Astra caused it. The evidence +says otherwise. + +### What adapter_eof means in this codebase + +It is opencodex's OWN synthesized terminal, not an upstream error string. Three emitters: + +- [bridge.ts](../../../src/bridge.ts) streaming path — when the adapter generator returns + without a done/error event, the bridge closes open items and emits + `response.incomplete` with `incomplete_details.reason = "adapter_eof"` so codex-rs never + hits its parser's "stream closed before response.completed". +- [bridge.ts](../../../src/bridge.ts) buffered path — same reason string for the non-stream + surface, so one condition produces one signal on both surfaces. +- [relay.ts](../../../src/server/relay.ts) — the relay surface's equivalent. + +Consumed at [combo-stream-preflight.ts](../../../src/server/responses/combo-stream-preflight.ts). + +So `adapter_eof` = "the upstream stream ended mid-turn without a terminal event". It is a +symptom label, and its cause is always upstream or transport, never the catalog. + +### Evidence from the local request history + +`~/.opencodex/routing-history.sqlite`, table `requests`: + +- `close_reason = 'adapter_eof'`: **0 rows for all time** (not just 24h). Read this as a + caution about the instrument rather than as exoneration — 25,493 rows carry a NULL + `close_reason`, so the table may never record this condition. The positive evidence in + 021 is what actually settles the question. Query note: the time column is epoch-ms + `timestamp`; there is no `created_at`. +- Astra requests exist and all failed BEFORE this unit's window, at 2026-09-03 20:26 on + `openai-p3b640f`, as `502 / upstream_server_error` — the pre-release probes + (`gpt-6-astra`, `astra`, `gpt-6`, `gpt-5.7-astra`, `mewfour`, `gpt-5.6-cyber`), each + ~1s. That is the slug 404/502ing before launch, which is exactly what the prereg unit + predicted. None of them is an `adapter_eof`. +- The session actually producing the user's error is `anthropic / claude-fable-5-1`, and + its `total_tokens` climbs to **852,994** by 04:12:56 local. The long tail includes a + 45,900 ms turn with `first_output_ms = 45,877` — i.e. 46 seconds before the first byte. + +That is the shape of a very large context on a long-lived stream, and it is the provider +the user's own turn was running on. The "Reconnecting… 5/5" indicator is the client +retrying that dropped stream, not the proxy rejecting a model. + +### Working hypothesis (to be proved or refuted in wp3) + +`adapter_eof` here is a genuine mid-stream disconnect, surfaced faithfully by the bridge. +If that holds, the correct outcome is NOT a bridge patch — the bridge is doing the one +right thing by refusing to call a truncated turn "completed". + +**Resolved in [021](021_wp3_evidence.md):** the disconnect was a local `ocx service` +restart during this session's Astra work, which tore down in-flight streams. The +request table has a five-hour recording gap ending exactly at the current proxy's process +start time. Not an upstream fault, not a code defect, and not Astra. + +Explicitly ruled out already: Astra's ungating (no Astra row in any adapter_eof), and the +catalog change from PR #3410 (catalog code emits no terminal events). diff --git a/devlog/_plan/260904_astra_release_alignment/010_wp2_catalog_alignment.md b/devlog/_plan/260904_astra_release_alignment/010_wp2_catalog_alignment.md new file mode 100644 index 0000000000..48b5e95471 --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/010_wp2_catalog_alignment.md @@ -0,0 +1,176 @@ +# 010 — wp2: replace the Astra guess with the shipped upstream row + +Consumes 000; **amended by 015 after the round-1 audit returned FAIL**. Goal: every field +opencodex projects for `gpt-6-astra` comes from the shipped upstream definition instead of +Sol's snapshot plus a hand-written label. + +## What the audit changed about this phase + +The original headline ("fix the 922k context window") was wrong. Measured on current +`dev`: `nativeOpenAiContextWindow("gpt-6-astra")` is **already 272,000**, because +`NATIVE_GPT56_CONTEXT_WINDOW` is 272,000. The real drift is three things — the +presentation, the **long-window** ceiling (922,000 vs the shipped 872,000), and a ladder +that the naive fix would have BROKEN. See 015 for the full disposition. + +## Approach: pin the real row, drop the alias scaffolding + +Astra is no longer an alias with no upstream identity — it has its own row. The whole +capability-source + alias-presentation path exists to answer "what do we show for a slug +upstream has never described", and that question is now answered. So the change is +subtractive where possible. + +### File change map + +**1. `src/codex/data/upstream-models.json`** + +Add the real `gpt-6-astra` row, copied from +`~/Developer/codex/121_openai-codex`, `git show origin/main:codex-rs/models-manager/models.json`. +Copy it whole, including `base_instructions` and `model_messages` — Astra's own GPT-6 +prompt, not Sol's. Do NOT hand-edit values; the point is that this file is a pin. + +Keep the existing 8 rows untouched. Sol's stale `372000` window is a separate defect +(000 Q1a) and is explicitly OUT of this unit. + +**2. `src/codex/catalog/native-models.ts`** + +- Remove `NATIVE_GPT6_ASTRA_MODEL` from `NATIVE_OPENAI_CAPABILITY_SOURCES`. With a real + pinned row, `nativeOpenAiCapabilitySourceSlug("gpt-6-astra")` must return the slug + itself so `PINNED_UPSTREAM_MODELS` resolves Astra's own entry. +- Remove its `NATIVE_OPENAI_ALIAS_PRESENTATION` entry. `display_name` and `description` + now come from the pinned row (`GPT-6-Astra`, "Our most capable model for complex, + demanding work."). Leaving the overlay in place would keep overwriting the real values + with the provisional ones. +- Keep Daybreak in both maps: it still has no upstream row. +- Rewrite the `NATIVE_GPT6_ASTRA_MODEL` doc comment: it currently describes a leak and a + 404 probe. Replace with the shipped facts (commits `ed391d4dd` #42607 / `1f7b99922` + #42619, `minimal_client_version 0.153.0`, `available_in_plans` incl. free/go/plus/pro). +- Gating: keep it OUT of `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`. Rationale is now + STRONGER, not weaker — `available_in_plans` lists 23 plans including `free`, so this is + a broadly-available model, and gating it behind a roster that has not refreshed yet + would hide a model the user is entitled to. Record this as a decision, not an omission. + +**2b. `src/codex/catalog/effort.ts` — added by 015/C3, the blocker that mattered** + +`isGpt56NativeSlug` is `nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-")`. +Measured: it returns **true** for `gpt-6-astra` today, only because the capability source +is Sol. Change 2 flips it false, and `applyReasoningLevels(entry, isGpt56NativeSlug(slug) +? undefined : ["low","medium","high","xhigh"])` in `sync.ts` then truncates Astra's ladder +to xhigh, dropping the shipped `max` and `ultra`. That is the opposite of this unit's goal. + +The predicate is misnamed for its actual meaning — "native slug entitled to the full +5.6-era ladder". Keep Astra inside it: extend the check so a self-described native with a +max/ultra ladder also qualifies, or name Astra explicitly. Its five other call sites in +`sync.ts` (`ensureUltraReasoningLevel`, `ensureGpt56ReasoningLevels`, the preserved-row +path) must keep taking the same branch they take today. + +**3. `src/codex/catalog/metadata.ts`** + +- `NATIVE_GPT56_FAMILY`: remove `NATIVE_GPT6_ASTRA_MODEL`. It is not a 5.6-family member + and must not ride the measured 922,000 GPT-5.6 clamp. +- `NATIVE_OPENAI_CONTEXT_OVERRIDES`: set the Astra entry to the shipped numbers — + `contextWindow: 272_000`, `maxContextWindow: 872_000`, `maxInputTokens: 872_000`. + Note what each does: the default window is unchanged in value (272,000 either way), the + **long window drops 922,000 → 872,000**, and `maxInputTokens` is clamped to the active + window by `nativeOpenAiMaxInputTokens`'s `Math.min(narrowed, window)` — so it reads + 272,000 under the default window and 872,000 only under the long-window opt-in. Do NOT + touch that clamp; advertising input above the window is the defect it prevents. +- `upstreamNativeEntryForSlug`: the guard `if (!sourceSlug.startsWith("gpt-5.6-")) return + undefined;` currently lets Astra through only because its capability source WAS Sol. + After change 2 that guard rejects Astra and `UPSTREAM_NATIVE_ENTRIES` loses the row — + which would regress `shouldUpgradeToUpstreamEntry` and the sync backfill. Admit Astra + through an explicit **self-described allowlist** holding exactly + `NATIVE_GPT6_ASTRA_MODEL`. A structural predicate such as `PINNED_UPSTREAM_MODELS.has(slug)` + is REJECTED (015/C2): it would also admit `gpt-5.5`, `gpt-5.4` and `gpt-5.4-mini` into a + map that authorizes replacing their persisted rows during sync, which the invariant + comment above that map forbids. +- Record the knock-on effects the first draft omitted (015/M6): `nativeOpenAiContextTier` + reports `longWindow` 872,000 instead of 922,000; the auto-compact soft budget follows the + resolved window; and a `providerContextCaps.openai` lever at 922,000 no longer sits above + Astra's long window, so it stops being a no-op for this slug. +- `DOCUMENTED_NATIVE_OPENAI_ADDITIONS`: keep Astra. Installs with a live codex-rs catalog + older than 0.153.0 still need the row to exist. Update the comment to say the slug is + shipped-but-newer rather than unlisted. + +**3b. Verified-unaffected consumers (015/H1), named so the next reader need not re-derive** + +- `src/codex/catalog/provider-fetch.ts` — gates on `isNativeOpenAiCapabilityAliasModel` and + resolves `nativeOpenAiAliasPresentation(...)?.displayName ?? cm.modelId` for CUSTOM model + rows. After removal an explicit custom Astra row labels itself `gpt-6-astra`. Acceptable: + a custom row is user-declared, and the native row carries the real label. Confirm, do not + change. +- `src/codex/catalog/parsing.ts` — uses the same predicate to classify a routed + `openai/gpt-6-astra` row as ChatGPT-native. Covered by the existing ChatGPT-forward Astra + test in `tests/codex-catalog.test.ts`; that test is now on the affected list and must stay + green. + +**4. Tests** + +- `tests/codex-catalog.test.ts`: rewrite "gpt-6-astra is registered ungated with Sol + capabilities…". It currently asserts `nativeOpenAiCapabilitySourceSlug === "gpt-5.6-sol"` + and a "leaked API identifier" description, both of which this unit deliberately breaks. + Replace with assertions on the projected identity (`GPT-6-Astra`, the shipped + description) and keep the two that still hold: membership in `NATIVE_OPENAI_MODELS`, and + `codexAccountGatedCanonicalWireModel` returning undefined (the slug IS the wire id). + Comparing `upstreamNativeEntry` against `upstream-models.json` is REJECTED as the primary + oracle (015/H2): once Astra self-describes, that compares the code to its own input, so a + mis-transcribed pin would pass. Independent oracle instead: when + `~/Developer/codex/121_openai-codex` is present, read the upstream `models.json` and + compare; when absent, skip with a recorded reason rather than silently degrade. +- Keep the existing ChatGPT-forward custom Astra test green (015/H1). +- `tests/native-model-toggle.test.ts`: keep "gpt-6-astra lists without any roster so the + request reaches upstream" as-is — that contract is unchanged and is what makes the row + visible. Add the LONG-WINDOW assertion, which is the one that actually goes red without + the patch: `nativeOpenAiContextTier("gpt-6-astra")` must be + `{ defaultWindow: 272000, longWindow: 872000 }` (measured today: `longWindow: 922000`). +- Add a post-sync ladder assertion (015/C3): after catalog sync, Astra's + `supported_reasoning_levels` still contain `max` and `ultra`. + +## Scope boundary + +IN: the files above (`upstream-models.json`, `native-models.ts`, `effort.ts`, +`metadata.ts`, the tests). OUT: Sol's stale pin, Bedrock routing, GUI, any change to +Daybreak's alias treatment, and the `Math.min` input clamp. + +**`visibility` (015/M3).** Upstream ships `hide`; opencodex projects `list` and keeps doing +so. That divergence is deliberate and belongs here rather than being left unargued: +upstream hides a row the ChatGPT client reveals through its own entitlement UI, whereas an +opencodex user picks models by hand from the proxy's list. Hiding it would reproduce the +original complaint — the model exists but cannot be selected. `disabledModels` remains the +user's lever. + +## Accept criteria + +1. `upstreamNativeEntry("gpt-6-astra").display_name === "GPT-6-Astra"` and its description + is the shipped sentence — cross-checked against the upstream checkout when available. +2. `nativeOpenAiContextTier("gpt-6-astra")` is `{ defaultWindow: 272000, longWindow: 872000 }`. + Activation: today it measures `longWindow: 922000` via `NATIVE_GPT56_FAMILY` membership, + so this assertion is red before the patch and green after — unlike the default window, + which is already 272,000 and proves nothing. +3. After catalog sync, Astra's `supported_reasoning_levels` still include `max` and + `ultra`. Activation: without the `effort.ts` amendment the sync else-branch truncates the + ladder at `xhigh`, so this assertion fails on the naive patch. +4. `UPSTREAM_NATIVE_ENTRIES` gains `gpt-6-astra` and NOT `gpt-5.5`, `gpt-5.4`, + `gpt-5.4-mini`. Activation: the rejected structural predicate would admit all three. +5. Astra stays absent from `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` and present in + `nativeModelRows` with no entitlement roster (existing test still green). +6. `bun test tests/codex-catalog.test.ts tests/native-model-toggle.test.ts` — 0 fail, plus + `bun run test:changed` for the widened touch set. +7. `bun run typecheck` — exit 0. +8. Live: restarted `ocx service`, `/v1/models` shows `gpt-6-astra`, and + `~/.codex/opencodex-catalog.json` shows `display_name: "GPT-6-Astra"` with a ladder that + still contains `max` and `ultra`. + +### Verifier reality check (PLAN-VERIFIER-REAL-01) + +- `bun test tests/codex-catalog.test.ts tests/native-model-toggle.test.ts` — RUN this + session, exit 0, 303 pass. Reads the change target: both files import from + `src/codex/catalog`, which is where every edit lands. YES. +- `bun run typecheck` — RUN this session, exit 0. Reads the target: project-wide + `tsc --noEmit`. YES. +- `jq` against `~/.codex/opencodex-catalog.json` — RUN this session; it is the file the + Codex client actually reads, written by sync. Observes the target end-to-end. YES. +- `bun run test:changed` — the import-graph selector AGENTS.md names for a touch set wider + than one file. Reads the target: it walks Bun's module graph from the changed files, which + now include `effort.ts` and `metadata.ts`. YES, with the documented limit that it cannot + see subprocess or golden-file dependencies — which is why the post-sync ladder assertion + is written as an explicit test rather than assumed covered. diff --git a/devlog/_plan/260904_astra_release_alignment/015_audit_synthesis.md b/devlog/_plan/260904_astra_release_alignment/015_audit_synthesis.md new file mode 100644 index 0000000000..7deb18432a --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/015_audit_synthesis.md @@ -0,0 +1,109 @@ +# 015 — Audit round 1 synthesis (REVIEW-SYNTHESIS-01) + +Reviewer verdict: **FAIL**, 3 Critical + 5 High. Every blocker was re-derived locally before +being accepted or rebutted; nothing here is taken on the reviewer's word. + +## Measurement that settles three blockers at once + +``` +bun .tmp/astra-probe.ts # scratch, gitignored +{ "window": 272000, "maxInput": 272000, + "tier": { "defaultWindow": 272000, "longWindow": 922000 }, + "isGpt56_astra": true, "solWindow": 272000, "solMaxInput": 272000 } +``` + +## Dispositions + +### C1 — ACCEPTED. Accept criterion 2 was half vacuous and half impossible. + +Measured: `nativeOpenAiContextWindow("gpt-6-astra")` is **already** 272,000 on current +`dev`, because `NATIVE_GPT56_CONTEXT_WINDOW` is itself 272,000 +([metadata.ts](../../../src/codex/catalog/metadata.ts)). So 010's "set it to 272,000" +was a no-op dressed as a change, and its test would have passed without the patch. + +Measured: `nativeOpenAiMaxInputTokens` returns 272,000, not 872,000, because +`nativeOpenAiMaxInputTokens` ends in `Math.min(narrowed, window)` — the input ceiling can +never exceed the advertised window. Asserting 872,000 was arithmetically unreachable. + +**Amendment.** The real drift is `maxContextWindow` 922,000 → 872,000, which is the LONG +window (the 1M-opt-in ceiling), not the input ceiling. Restate: + +- `nativeOpenAiContextTier("gpt-6-astra")` must become `{ defaultWindow: 272000, + longWindow: 872000 }` (measured today: `longWindow: 922000`). This is the assertion that + actually goes red without the patch. +- `nativeOpenAiMaxInputTokens("gpt-6-astra")` stays 272,000 under the default window; the + 872,000 only becomes reachable when the user opts into the long window. Do NOT change + the `Math.min` clamp — over-advertising input above the window is the exact defect that + clamp exists to prevent. + +### C2 — ACCEPTED. The structural guard leaks three unrelated slugs. + +`PINNED_UPSTREAM_MODELS` holds 8 rows; `slug === sourceSlug && PINNED_UPSTREAM_MODELS.has(slug)` +would newly admit `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini` into `UPSTREAM_NATIVE_ENTRIES`, +which authorizes on-disk row replacement during sync — an invariant the code comment states +in so many words. + +**Amendment.** Replace the predicate with an explicit allowlist of self-described slugs +containing exactly `NATIVE_GPT6_ASTRA_MODEL`, so the widening cannot reach any other row. + +### C3 — ACCEPTED, and it is the most consequential find. + +Measured: `isGpt56NativeSlug("gpt-6-astra")` is **true** today, purely because the +capability source is Sol. Removing the alias entry flips it false, and +[sync.ts](../../../src/codex/catalog/sync.ts) then takes the else branch of +`applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low","medium","high","xhigh"])` +— truncating Astra's ladder to xhigh and dropping the shipped `max` and `ultra` rungs. +That would have broken the exact thing this unit exists to fix, and no criterion tested it. + +**Amendment.** Add `src/codex/catalog/effort.ts` to the file-change map. `isGpt56NativeSlug` +is misnamed for what it now gates: it means "native slug whose ladder is the full 5.6-era +ladder". Widen it to also return true for a self-described native carrying a max/ultra +ladder, or add Astra explicitly. Add a post-sync ladder assertion to the accept criteria. + +### H1 — ACCEPTED. Add `provider-fetch.ts` and `parsing.ts` to the map, plus the existing +ChatGPT-forward Astra test at `tests/codex-catalog.test.ts` to the affected-test list. + +### H2 — ACCEPTED. The pin test was a tautology: once Astra self-describes, +`upstreamNativeEntry` returns the very JSON the test reads. Re-anchor on the upstream +checkout — compare against `~/Developer/codex/121_openai-codex`'s `models.json` when +present, and skip with a recorded reason when it is not, so the oracle is independent. + +### H3 — ACCEPTED as a documentation fix, MOOT as a diagnosis. +The stall watchdog in [bridge.ts](../../../src/bridge.ts) is indeed a better fourth +candidate than `outbound.ts`, which is a downstream translator. 021 supersedes this: the +cause is now positively identified (service restart), not merely narrowed by elimination. +020's candidate list is corrected for the record. + +### H4 — PARTIALLY ACCEPTED, and 021 resolves it. +The reviewer is right that "0 rows for all time" makes the instrument suspect, and that +absence alone could not have carried a NOOP. That objection is why 021 does not rest on +absence: it rests on a POSITIVE signal — a five-hour recording gap that ends exactly at the +proxy's process start time, with `service.log` shutdown/start pairs in the window. The +verdict is NOOP because the cause is known, not because the table was empty. + +### H5 — ACCEPTED. The merge gate was below policy. +AGENTS.md requires `bun run typecheck` AND `bun run test` before a non-trivial PR is +review-ready, and this change now reaches `sync.ts`, `effort.ts`, `parsing.ts`, +`provider-fetch.ts`. 030 must name the gate explicitly: run `bun run test:changed` plus the +named focused files locally, and require exact-head hosted CI green before +`gh pr merge --admin`. + +### M1-M6, L1-L2 — ACCEPTED as corrections + +M1 three emitters not two. M2 `030_outcome.md` collides with `030_wp4_merge.md`; the +outcome lives in `021_wp3_evidence.md`, already written. M3 the `visibility: list` +divergence from upstream's `hide` is argued nowhere — it must be argued in 010 (opencodex +deliberately lists what upstream hides, because the proxy's users select models by hand). +M4 the history verifier must use epoch-ms `timestamp`, not `created_at`. M5 +`minimal_client_version` is deleted by `upstreamNativeEntry`, so the plan's mechanism +cannot fix that drift — drop it from the drift table as out of reach. M6 record the +`nativeOpenAiContextTier` / auto-compact / provider-cap effects. L1 the 922,000 in the +on-disk catalog is the materialized long window, not `context_window` drift. L2 wp1 is the +docs cycle itself. + +## Net effect on scope + +The unit grows by two files (`effort.ts`, and the map now names `parsing.ts` / +`provider-fetch.ts` as verified-unaffected or amended), and the headline claim changes: +the meaningful catalog drift is **presentation + long-window ceiling + ladder preservation**, +not the default context window, which was already correct. diff --git a/devlog/_plan/260904_astra_release_alignment/020_wp3_adapter_eof.md b/devlog/_plan/260904_astra_release_alignment/020_wp3_adapter_eof.md new file mode 100644 index 0000000000..f57bef9268 --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/020_wp3_adapter_eof.md @@ -0,0 +1,104 @@ +# 020 — wp3: adapter_eof, diagnosed before it is patched + +Consumes 000 Q2. This phase is a DIAGNOSIS phase whose deliverable may legitimately be +"no code change". Writing it as an implementation phase up front would presuppose a defect +the evidence does not yet support. + +**Status: closed. The outcome is recorded in `021_wp3_evidence.md` — NOOP, cause +positively identified as a local `ocx service` restart that dropped in-flight streams.** +This document is kept as the investigation contract it was; the corrections below are the +round-1 audit's, folded back per REVIEW-SYNTHESIS-01. + +## What is already established (000) + +- `adapter_eof` is opencodex's own synthesized terminal, meaning "the adapter generator + ended without a done/error event". **Three** emitters, not two (015/M1): + [bridge.ts](../../../src/bridge.ts) streaming path, [bridge.ts](../../../src/bridge.ts) + buffered path, and [relay.ts](../../../src/server/relay.ts); with a consumer at + [combo-stream-preflight.ts](../../../src/server/responses/combo-stream-preflight.ts). +- `close_reason = 'adapter_eof'` has **0 rows for all time** in `routing-history.sqlite` — + not merely 24h (015/H4). That is a warning about the instrument, not a clean bill of + health: 25,493 rows carry a NULL `close_reason`, so the table may simply never record + this condition. Absence alone therefore proves nothing, and 021 does not rest on it. +- Every `gpt-6-astra` row in history is a `502 upstream_server_error` from the 20:26 + pre-release probes, none of them an `adapter_eof`. +- The user's live session runs `anthropic / claude-fable-5-1` at ~853k total tokens, with + a 45,900 ms turn whose first byte arrived at 45,877 ms. + +Astra is therefore excluded as a cause. That is a finding, not an assumption. + +## The question this phase must answer + +Does opencodex DROP a stream it could have kept, or does it faithfully report an upstream +cut? Those have opposite correct responses, and the bridge comment already argues for the +second: synthesizing `response.incomplete` instead of `response.completed` is the whole +point of that code path, because reporting a truncated turn as clean is the failure mode it +exists to prevent. + +## Investigation steps (ordered, each with its stop condition) + +1. **Confirm the surface.** Determine whether the failing turn ran over SSE or the + websocket sideband. `prefer_websockets` is true for the 5.6 family and Astra, and + `experimental_realtime_ws_base_url` in `~/.codex/config.toml` points at the proxy, so + the ws path is live. Stop when the transport is named with evidence. +2. **Find the drop point.** Candidates, corrected by the audit (015/H3): + - **The stall watchdog in [bridge.ts](../../../src/bridge.ts)** (`stallTicks >= + maxStallTicks`, `resolveStallTimeoutSec`). This is the leading local suspect and the + first draft wrongly omitted it by casting `bridge.ts` as only the reporter. A byte-idle + timeout is exactly the shape that ends a generator without a terminal, and 000 records + a 45,877 ms time-to-first-byte on this very session. + - The empty-completion guard in + [empty-completion-guard.ts](../../../src/server/responses/empty-completion-guard.ts). + - SSE record handling in [sse-decoder.ts](../../../src/lib/sse-decoder.ts), whose own + comment warns that dropping a record turns a success into an adapter_eof. + - [outbound.ts](../../../src/chat/outbound.ts) is **reclassified**: it translates an + already-synthesized incomplete for chat-completions clients. A downstream consumer, + not a drop point, and not on the path for a Responses client at all. + Stop when a reachable local drop is identified, or all are excluded. +3. **Correlate with context size** — only if step 1-2 leaves the cause open, and only after + establishing that the history table can record the condition at all (015/H4). If the + drop appears only at very large contexts, that is an upstream/transport limit, and the + honest outcome is NOOP with evidence rather than a retry loop that hides truncation. + +## Decision rule (written before the evidence, on purpose) + +- **Local defect found** (opencodex discards a stream it holds a terminal for, or + mis-parses a record): fix it, with a regression test that goes red without the fix. + Verify by mutation. +- **Upstream/transport cut confirmed**: outcome is **NOOP**. Record the evidence in + `030_outcome.md`. Do NOT add a silent retry or downgrade the incomplete to completed — + that would trade a visible truncation for an invisible one, which is exactly what the + bridge comment forbids. +- **Inconclusive**: outcome is **BLOCKED**, naming what evidence was unavailable. Note that + a blind instrument (H4) pushes toward BLOCKED, not NOOP — NOOP requires a positive + finding, which is what 021 supplies. + +## Scope boundary + +IN: read-only diagnosis across the bridge/adapter/transport path, plus a narrowly scoped +fix ONLY if step 2 finds a local defect. OUT: retry-policy redesign, reconnection UX, +any change to how `adapter_eof` is reported to the client, and anything touching the +Astra catalog work in 010. + +## Accept criteria + +1. The transport of the failing turn is named with evidence. +2. Each of the three candidate drop points is either implicated or excluded, each with a + `file:line` citation. +3. A terminal outcome is recorded in `021_wp3_evidence.md` — the decade slot `030` belongs + to the merge phase (015/M2). Either a fix plus a red-without-it test, or NOOP/BLOCKED + with the evidence that supports it. +4. If a fix lands: `bun run typecheck` exit 0 and the touched suites 0 fail. + +### Verifier reality check (PLAN-VERIFIER-REAL-01) + +- `sqlite3 ~/.opencodex/routing-history.sqlite` queries — RUN this session; returns the + rows quoted in 000. The schema's time column is epoch-ms `timestamp`, not `created_at` + (015/M4). Observes the target (the actual failing traffic). YES. +- `rg -n 'adapter_eof' ~/.opencodex/service.log` — RUN this session; zero matches. This + command does NOT establish that the emitter logs to that file, so its emptiness is not + evidence on its own (015/H4). Retained only as a negative check alongside 021's positive + timeline evidence. +- `bun test` on a responses/bridge suite — deferred: naming a specific file before step 2 + identifies the code path would be inventing a gate. Recorded as unresolved rather than + claimed. diff --git a/devlog/_plan/260904_astra_release_alignment/021_wp3_evidence.md b/devlog/_plan/260904_astra_release_alignment/021_wp3_evidence.md new file mode 100644 index 0000000000..396ba4705f --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/021_wp3_evidence.md @@ -0,0 +1,97 @@ +# 021 — wp3 evidence: upstream refuses gpt-6-astra on a ChatGPT account + +Sub-document of 020. **This document was rewritten after audit round 2.** Its first version +concluded the failure was a self-inflicted `ocx service` restart. That conclusion was +wrong, the reviewer caught it, and the corrected finding is materially more useful. + +## How the first conclusion failed, and what it cost + +Round 1 of the audit (015/H4) warned that `close_reason = 'adapter_eof'` returning zero +rows made the instrument suspect. Round 2 pressed harder: the reviewer issued a live +request and showed that `routing-history.sqlite` gained **no new rows at all**, so the +"recording gap ends exactly at process start" claim was false — the gap included the +present moment, under a demonstrably live proxy. I reproduced that exactly: a successful +`xai/grok-4.6` completion returned `pong` and the table count stayed at 632,372. + +The reviewer inferred a stalled history writer. That was also wrong, and the real cause is +the reason both of us went astray: + +**`routing-history.sqlite` is a derived INDEX, not the log.** `ocx logs index-status` +reports 633,039 indexed rows against a 524,909,345-byte source — 667 more than the SQL +query returned, because the file on disk is a snapshot that lags the live writer. Querying +it directly, as both audit rounds did, reads a stale projection. The authoritative reader +is `ocx observe logs`. + +The lesson is worth stating plainly: **two rounds of confident reasoning were built on a +tool that was not reading the live data.** Neither the restart theory nor the stalled-writer +theory survived contact with the correct instrument. + +## The actual cause + +`ocx observe logs` shows the failing turns immediately. Nine `gpt-6-astra` requests, all +status **502**, all carrying the same upstream message: + +``` +The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account. +``` + +Five of them land in a ~5-second burst (`1788480646653` … `1788480649531`), on one +`conversationId`. That burst IS the user's "Reconnecting… 5/5": the client retried five +times, each retry was refused by upstream, and the turn ended without a terminal event — +which [bridge.ts](../../../src/bridge.ts) faithfully reports as +`incomplete_details.reason = "adapter_eof"`. + +The route decision confirms it reached upstream rather than being filtered locally: +`routeKind: "native"`, one candidate, `eligible: true`, `reason: "native-family"`, +`terminalSource: "synthetic"`, `errorCode: "upstream_server_error"`. + +A tenth, earlier row (`1788480208361`) failed differently — `503 "Codex credential refresh +did not complete; retry this request"` — which is the error observed live earlier in the +session and a separate transient. + +## What this proves + +1. **The user's `adapter_eof` is an Astra entitlement refusal, not a transport fault.** + The proxy dispatched correctly; the ChatGPT backend refused the slug. +2. **The refusal message is verbatim the Daybreak Blue pattern.** + [metadata.ts](../../../src/codex/catalog/metadata.ts) already records the identical + sentence for `gpt-daybreak-blue-latest`: "not supported when using Codex with a ChatGPT + account". Astra is in exactly that state for this account today. +3. **Shipping upstream is not the same as being reachable.** Upstream's `available_in_plans` + lists 23 plans including `free`, and `models.json` ships the row — yet this Pro account's + Codex surface rejects it. Catalog availability and account entitlement are different + facts, and only the second one decides whether a request succeeds. +4. **The bridge behaved correctly** by refusing to call a refused turn "completed". + +## Verdict for wp3: NOOP for the transport layer, with a finding that lands in wp2 + +No bridge/transport change. Do NOT add a retry (the client already retried five times), and +do NOT downgrade `adapter_eof` to `completed`. + +But this is not a null result. It changes the gating question 010 answered: + +- 010 argued Astra should stay OUT of `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` because + `available_in_plans` is broad. That reasoning is now contradicted by a live 502 from the + account actually in use. +- The user's explicit instruction for this session was "전체 노출되도록 해놔 요청도 보내고 + 오류가 나도록" — list it everywhere, let the request go out, let the error surface. The + current behavior does exactly that, and the error it surfaces is the true one. +- So the row stays listed and ungated **by user instruction**, and the honest improvement is + not to hide the model but to make the refusal legible instead of appearing as a generic + `adapter_eof` after five silent retries. + +That improvement is deliberately NOT folded into this unit. It is a user-visible error +surface change with its own blast radius, and 020's scope boundary excludes changing how +`adapter_eof` is reported. Recorded here as the next unit's candidate. + +## Reproduction (corrected) + +``` +ocx observe logs --limit 2000 --jsonl \ + | jq -r 'select(.model=="gpt-6-astra") | [(.timestamp|tostring), (.status|tostring), (.upstreamError // "-")] | @tsv' +``` + +Do NOT query `routing-history.sqlite` directly for live traffic; it is an index snapshot +that lags the writer, which is what produced two wrong conclusions above. Verify liveness +with `ocx logs index-status` (compare `indexed rows` against a direct `select count(*)`) +before treating any absence in that table as evidence. diff --git a/devlog/_plan/260904_astra_release_alignment/030_wp4_merge.md b/devlog/_plan/260904_astra_release_alignment/030_wp4_merge.md new file mode 100644 index 0000000000..613dc525c1 --- /dev/null +++ b/devlog/_plan/260904_astra_release_alignment/030_wp4_merge.md @@ -0,0 +1,65 @@ +# 030 — wp4: land on dev + +Consumes 010 and 020/021. Nothing here starts until both have closed with their own +evidence. Amended by 015/H5: the original gate was below repository policy. + +## Preconditions + +- 010's accept criteria met (catalog projection matches the pin, focused tests + typecheck + green, live `/v1/models` shows the shipped window). +- wp3 has a recorded terminal outcome in `021_wp3_evidence.md` — a landed fix, or a + NOOP/BLOCKED verdict with evidence. A NOOP still counts as closed; it just contributes + documentation rather than a code diff. + +## Pre-merge gate (015/H5) + +AGENTS.md requires `bun run typecheck` AND `bun run test` before a non-trivial PR is +review-ready. This change reaches `metadata.ts`, `effort.ts`, and the sync path, which the +two focused test files do not cover, so "focused tests only" is not a defensible gate here. + +The user's standing constraint for this session is that the full local suite is not run. +The substitute is named explicitly rather than left implicit: + +1. `bun run typecheck` — exit 0, locally. +2. `bun test` on the focused files, plus `bun run test:changed` for the import-connected + set — 0 fail, locally. +3. **Exact-head hosted CI**: after pushing, confirm the CI run whose head SHA equals the PR + head is green before `gh pr merge --admin`. `gh pr checks` returning an empty required + set is NOT green evidence — read the actual run conclusion for that SHA. + +If exact-head CI cannot be confirmed green, the honest options are to wait or to record the +merge as admin-forced with the gap named in the PR description. Do not silently downgrade +the gate. + +## Steps + +1. Branch `codex/260904-astra-release-alignment` from current `dev`. +2. Commit in units: the upstream pin, the catalog/metadata realignment, the tests, and the + devlog unit (DEV-GIT-COMMIT-01 — each logically complete step gets its own commit). +3. `git push --no-verify` — pre-authorized by the user for this session. +4. Open the PR with the repository template (Summary / Verification / Checklist), filled + from real command output, not restated intent. No GUI change, so no screenshot gate. +5. Confirm the pre-merge gate above at the exact PR head SHA. +6. `gh pr merge --admin --merge` — pre-authorized. Merge commit, not squash, so the local + `dev` can fast-forward onto it. +7. `git checkout dev && git pull --ff-only origin dev`. +8. Re-run `ocx service` and re-verify the live surface at the merged HEAD. Note that this + restart drops in-flight turns (021) — expected, and the reason the user saw + `adapter_eof` earlier. + +## Accept criteria + +1. PR number and merge commit sha recorded. +2. `git rev-parse --short HEAD` equals `git rev-parse --short origin/dev`, worktree clean. +3. `bun run typecheck` exit 0 at the merged HEAD. +4. Live at merged HEAD: `/healthz` ok, `/v1/models` contains `gpt-6-astra`, its + `context_length` is 272,000, and its effort ladder still advertises `max` and `ultra` + (the regression 015/C3 identified). +5. Exact-head CI conclusion recorded, or the gap named explicitly in the PR description. + +### Verifier reality check (PLAN-VERIFIER-REAL-01) + +- `git rev-parse` / `git status` — RUN repeatedly this session. Observes the target. YES. +- `curl /healthz` and `/v1/models` — RUN this session against port 10100. Observes the + live projection, which is the thing the user actually sees. YES. +- `gh pr view --json state,mergeCommit` — RUN this session on #3410. YES. diff --git a/devlog/_plan/260904_bug_stack_train/000_research.md b/devlog/_plan/260904_bug_stack_train/000_research.md new file mode 100644 index 0000000000..ac4c1cba78 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/000_research.md @@ -0,0 +1,79 @@ +# 000 — Live manifest and disposition research + +Snapshot taken 2026-09-04, base `origin/dev` = `b5777aa2d642`. +Worktree: `/Users/jun/.codex/worktrees/9d5b/opencodex`. + +All findings below were produced by four parallel read-only research lanes and +re-checked against the current tree. Every disposition names its evidence. + +## Open bug-labelled PRs (10) + +| PR | Author | Verdict | Basis | +|----|--------|---------|-------| +| #3335 | x3M3x | LAND_AS_IS | GUI hardcodes 2 of 5 strategies at `gui/src/components/combo-workspace-controls.tsx:24-50`; canonical set already has 5 at `gui/src/combo-workspace-data.ts:11-30`. Test is RED without the fix. | +| #3333 | blackjune67 | LAND_AS_IS | Models panels are persistent and toggle `hidden` (`gui/src/pages/Models.tsx:2228-2312`); scoping to the visible panel id stops width leakage. Test asserts selectors absent on dev. | +| #3322 | luvs01 | LAND_AS_IS | Head already implements the exact requested message at `src/cli/observe.ts:75-77`. The `CHANGES_REQUESTED` review is stale against the corrected head. | +| #3357 | huaiqing-afk | LAND_AS_IS (draft) | One global previous-text slot at `src/adapters/cursor/protobuf-request.ts:303-381` lets every tool result reset narration detection. PR tracks roles independently. Strong RED regression. | +| #3325 | luvs01 | BLOCKED_ON_POLICY | Code correct, but `.github/workflows/` is a restricted surface (`.github/scripts/pr-sponsored-surface.cjs:24-27`); hygiene fails `unsponsored_surface` without the `maintainer-sponsored` label. The second "failure" is a cancelled `enforce-target` run, not a real failure. | +| #3364 | lidge-jun | LAND_WITH_FIX | Exact-head CI green. Missing a direct `parseResponse()` non-stream regression even though production parse calls the same extractor (`src/adapters/openai-responses.ts:2450-2481`). | +| #3361 | lidge-jun | LAND_AS_IS | Exact-head CI green; marker/journal ownership preserved per key; `startServer` stays synchronous. Touches unauthenticated loopback admission, so it needs explicit maintainer security sign-off. | +| #3332 | full999 | LAND_WITH_FIX | Writes an OUTPUT limit into an INPUT field: `ModelMetadata.maxTokens` is output (`src/generated/model-metadata.ts:4-12`) but lands in `maxInputTokens`. Would shrink Claude 1M input models to 64K/128K. | +| #3348 | RHODIZSECURITY | DEFER | 2,248 lines / 34 files across failover, credentials, persistence, shutdown, and the core response path. Confirmed blocker: generic HTTP 410/413 become retryable hops, so an oversized or invalid request is replayed to the next provider. | +| #3312 | RHODIZSECURITY | DEFER (superseded) | Functionally the same work as #3348 with the same 410/413 blocker; currently CONFLICTING/DIRTY. Not an ancestry successor, but #3348 supersedes it. | + +## Open bug-labelled issues (6) + +None are safely fixable from the evidence currently attached. Detail: + +- **#3352** (GPT-5.6 401) — NEEDS_REPORTER_EVIDENCE. Mechanism is established end to end: + gating at `src/codex/catalog/native-models.ts:5`, roster fetch at + `src/codex/model-entitlements.ts:185`, unconfirmed-evidence fallback at `:548`, + granted-only projection at `:958`/`:1024`, and the exact 401 at + `src/codex/auth-context.ts:435`. The reported `0.142.2` floor theory is already + ruled out — the code enforces `0.144.0` at `:75`. Letting `unknown` through would + be a security-policy change, not a bug fix. +- **#3320** (Windows non-ASCII scheduler) — NEEDS_REPORTER_EVIDENCE. Production XML + writes a locale-independent SID (`src/service.ts:1841,1912`); exact `` + matching is deliberate (`:2117`) because folding two non-ASCII identities to `???` + could adopt another account's task. Needs redacted live XML before any patch. +- **#3279** (GUI 401) — NEEDS_REPORTER_EVIDENCE. Each page load mints a session from + its own Host-derived origin (`src/server/gui-session.ts:166`); exact origin checks + are the admission boundary (`:417`); expiry is deterministic at 5 minutes (`:62`). + Canonicalizing localhost/IPv4/IPv6 would weaken auth without proving cause. +- **#3255** (capability vs speed) — PRODUCT_DECISION. The two dimensions are already + independent (`src/reasoning-effort.ts:5` vs `src/codex/catalog/effort.ts:160`), and + there is no Ultra-fast wire tier to pass through. +- **#3245** (stream disconnect) — NEEDS_REPORTER_EVIDENCE. 426 is intentional + (`src/server/index.ts:1107`) and the 426-then-POST path is already covered + (`tests/server-auth.test.ts:1384`). The reporter saw no subsequent POST, which puts + the failure before the Responses bridge. +- **#1527** (Cursor large context) — NEEDS_REPORTER_EVIDENCE. Every known defect in + this path is already fixed; a matched current-dev trace is required. + +## Issue #3366 — deviceauth (the implementation target) + +Key correction to the issue's premise: `chatgpt` is deliberately excluded from the +generic OAuth surface (`src/oauth/index.ts:284-297`, `tests/oauth-public-surface.test.ts:77-111`) +and `openai|codex|chatgpt` route through the separate Codex-auth API. Returning +`deviceCode` from `src/oauth/` alone therefore does NOT light up the existing UI — +the Codex-auth layer discards it today at `src/codex/auth-api.ts:2199-2209`. + +Upstream wire flow, confirmed against `codex-rs/login/src/device_code_auth.rs`: +15-minute poll window, only 403/404 mean pending, server-issued `code_verifier`, +and `redirect_uri=https://auth.openai.com/deviceauth/callback`. + +Non-fabrication note: the issue claims a `codex_cli_rs` User-Agent is required. +Upstream actually builds a raw auth client with no Codex default headers +(`device_code_auth.rs:165-171`), and its real UA is dynamic. We do not hard-code +client impersonation; we send no custom UA and let the platform default stand. + +## Stack plan + +Dependency-ordered, bottom-up (DEV-STACK-01): + +1. `codex/deviceauth-core` — the grant itself in `src/oauth/` (010) +2. `codex/deviceauth-surface` — Codex-auth API + CLI + docs (020) +3. `codex/bug-carry` — carried contributor fixes with attribution (030) + +Deferred out of the stack with recorded reasons: #3348, #3312, #3325, and all six +bug issues. Documented in 040. diff --git a/devlog/_plan/260904_bug_stack_train/010_wp2_deviceauth_core.md b/devlog/_plan/260904_bug_stack_train/010_wp2_deviceauth_core.md new file mode 100644 index 0000000000..eb3de69d31 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/010_wp2_deviceauth_core.md @@ -0,0 +1,72 @@ +# 010 — wp2: deviceauth grant core (stack layer 1) + +Branch: `codex/deviceauth-core`, based on `origin/dev` `b5777aa2d642`. +Thesis: implement the OpenAI deviceauth grant as a self-contained module and let +`loginChatGPT` select it. Nothing outside `src/oauth/` changes in this layer. + +## Files + +- ADD `src/oauth/chatgpt-device.ts` — the grant. +- MODIFY `src/oauth/chatgpt.ts` — export `credsFromToken` for reuse; add the + `flow` option to `loginChatGPT`. +- MODIFY `src/oauth/index.ts` — thread `flow` through the `chatgpt` registry entry. +- ADD `tests/chatgpt-device-auth.test.ts`. +- MODIFY `tests/oauth-device-code-contract.test.ts` — extend the shared contract to chatgpt. + +## Wire protocol (from codex-rs device_code_auth.rs) + +1. `POST https://auth.openai.com/api/accounts/deviceauth/usercode` + JSON `{ client_id }` -> `{ device_auth_id, user_code, interval? }` +2. `POST https://auth.openai.com/api/accounts/deviceauth/token` + JSON `{ device_auth_id, user_code }`; 403/404 = pending; 200 = + `{ authorization_code, code_verifier }` +3. `POST https://auth.openai.com/oauth/token` form-encoded + `grant_type=authorization_code`, `client_id`, `code`, `code_verifier`, + `redirect_uri=https://auth.openai.com/deviceauth/callback` + +Poll window 15 minutes; default interval 5s; the interval field may arrive as a +string, so coerce numerically and floor at 1s. + +## Signatures + +```ts +export type ChatGPTLoginFlow = "browser" | "device"; +export async function loginChatGPTDevice(ctrl: OAuthController): Promise; +export async function loginChatGPT( + ctrl: OAuthController, + opts?: { forceLogin?: boolean; flow?: ChatGPTLoginFlow }, +): Promise; +``` + +`onAuth` publishes `{ url: "https://auth.openai.com/codex/device", deviceCode: user_code, +instructions }` — matching the kimi/nous/copilot contract where `deviceCode` carries the +HUMAN code, never the opaque polling handle. + +## Credential boundary + +- Never log `device_auth_id`, `authorization_code`, `code_verifier`, or any token. +- Do NOT reuse `safeErrorDescription` from the callback flow: it reflects upstream + body text. Device errors carry status only. +- Bound the success payload; reject non-string `authorization_code`/`code_verifier`. + +## Tests (red-then-green) + +`tests/chatgpt-device-auth.test.ts`, stubbing `globalThis.fetch` by URL in the +established style of `tests/oauth-device-code-contract.test.ts:16-63`: + +1. requests a user code and surfaces the fixed verification URL + human code +2. treats only 403/404 as pending and honors the returned interval +3. exchanges the server-issued `authorization_code`/`code_verifier` at the device callback URI +4. rejects a malformed success payload without reflecting the body +5. aborts promptly on signal +6. surfaces `accountId`/`email` from a realistic device-token `id_token`, because Codex + pool admission rejects a credential with no account id (`src/codex/auth-api.ts:2221`). + Wire success alone is not proof the credential is usable. + +Focused command: `bun test tests/chatgpt-device-auth.test.ts tests/oauth-device-code-contract.test.ts tests/chatgpt-oauth.test.ts` + +## Security review gate + +`src/oauth/` is a restricted authentication surface +(`.github/scripts/pr-sponsored-surface.cjs:24`); `MAINTAINERS.md:60` requires explicit +security review. The PR description states this; it does not merge as routine work. diff --git a/devlog/_plan/260904_bug_stack_train/020_wp3_deviceauth_surface.md b/devlog/_plan/260904_bug_stack_train/020_wp3_deviceauth_surface.md new file mode 100644 index 0000000000..ba954946cd --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/020_wp3_deviceauth_surface.md @@ -0,0 +1,67 @@ +# 020 — wp3: deviceauth surface (stack layer 2) + +Branch: `codex/deviceauth-surface`, based on `codex/deviceauth-core`. +Thesis: make the grant reachable. Without this layer the core is unreachable from any +user-facing path, because `openai|codex|chatgpt` go through the Codex-auth API, which +drops `deviceCode` from the start DTO at `src/codex/auth-api.ts:2440` and opens the +authorization URL at `:2206` (conditional on `shouldOpenBrowserForLogin`, which already +honors an explicit/configured false at `src/oauth/open-browser-choice.ts:20` — the gap is +that a device flow is not itself a reason to skip the open). + +## Files + +- MODIFY `src/codex/auth-api.ts` — accept `device?: boolean` on login start, pass + `flow: "device"` into the chatgpt login, return `deviceCode` in the start DTO, and + suppress the server-side browser open when `deviceCode` is present (mirroring + `src/server/management/oauth-account-routes.ts:185`). +- MODIFY `src/cli/account-auth.ts` — add `--device`; include it in the login body; + print `Device code: ` in the Codex pre-poll block; preserve it under + `--no-wait --json`. +- MODIFY `src/cli/capabilities.ts` — declare the flag. +- MODIFY `gui/src/components/use-add-codex-account-oauth.ts` — keep `deviceCode` and + `instructions` on the start DTO (dropped today at `:148`) and request device mode. +- MODIFY `gui/src/components/add-codex-account-reducer.ts` — carry both fields in state. +- MODIFY `gui/src/components/add-codex-account-waiting-step.tsx` — pass them to + `LoginHint` (today it passes only `url` at `:38`). The shared renderer at + `gui/src/components/login-url-block.tsx:42-47,73-107` is already device-capable, so no + new UI component is needed. +- REGENERATE `skills/ocx/references/01_management_surface.md` via `bun run skill:surface` + (gated by `tests/skill-ocx.test.ts`). +- MODIFY `docs-site/` provider/account docs (English source; do not let locales contradict). + +## Poll budget (audit blocker 2) + +The device grant lives 15 minutes, but both existing poll budgets stop at five: +Codex-auth polls 150 x 2s and then records an error (`src/codex/auth-api.ts:2214,2414`), +and the CLI independently stops at the same 150 x 2s (`src/cli/account-auth.ts:123`). +Shipping the grant without widening these would advertise a 15-minute window that +dies at minute five — exactly the headless case this feature exists for, where the +operator walks to another device to enter the code. + +Both budgets are raised for the device flow, and a test proves a login completing +after minute five still succeeds (fake timers; no real waiting). + +## Security review gate (audit finding 4) + +`src/oauth/`, `src/codex/auth-api.ts`, and `src/cli/account-auth.ts` are restricted +authentication surfaces (`.github/scripts/pr-sponsored-surface.cjs:24`) and require +explicit security review per `MAINTAINERS.md:60`. Both deviceauth PRs carry that +requirement in their description; neither is merged as routine. + +Explicitly NOT done: repurposing `--code` as device user-code input. The device user +code is entered at `auth.openai.com`, while `account code` submits callback +authorization material to a different endpoint (`src/codex/auth-api.ts:2457-2472`). +Conflating them would silently break the existing paste fallback. + +## Tests + +- `tests/codex-auth-api.test.ts`: device login returns `deviceCode` and does not open a URL. +- `tests/cli-account.test.ts`: `--device` prints URL + device code + flow id; `--no-wait --json` preserves it. +- `tests/codex-auth-api.test.ts`: a device login that completes after minute five still succeeds. +- `tests/cli-account.test.ts`: with fake timers, a normal (polling) `--device` login completes + after minute five. The `--no-wait` case bypasses polling and does not cover this. +- `gui/tests/add-codex-account-device.test.tsx`: the start request carries `device: true`, + and the waiting step renders the device code and verification URL. + +Focused: `bun test tests/codex-auth-api.test.ts tests/cli-account.test.ts tests/skill-ocx.test.ts` +plus the single focused GUI test file. No repository-wide suite. diff --git a/devlog/_plan/260904_bug_stack_train/030_wp4_bug_carry.md b/devlog/_plan/260904_bug_stack_train/030_wp4_bug_carry.md new file mode 100644 index 0000000000..3e4294bd48 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/030_wp4_bug_carry.md @@ -0,0 +1,39 @@ +# 030 — wp4: carried contributor fixes (PARALLEL, not stacked) + +Corrected after plan audit. These were originally drafted as a third stack layer above +deviceauth. That was wrong: none of the four consumes deviceauth and none consumes +another, so stacking them would impose a false merge order. DEV-STACK-01 says +independent parts open as parallel PRs off trunk, and DEV-STACK-03 says one thesis per +layer — four unrelated theses in one layer violates both. + +Each fix therefore gets its own branch off `dev`, merged independently: +`codex/carry-3335`, `codex/carry-3333`, `codex/carry-3322`, `codex/carry-3357`. + +Four PRs were judged root-correct with RED-without-fix regressions. Each is carried as +its own independent PR with a `Co-authored-by` trailer in its commit, so the +contributor graph records the author (AGENTS.md; `CREDITS.md` exists because 27 +landings previously lost attribution). + +| Source PR | Author trailer | Scope | +|-----------|----------------|-------| +| #3335 | `Co-authored-by: x3M3x ` | GUI combo strategy selector: render all five | +| #3333 | `Co-authored-by: hajune ` | Models tab spacing + Combos layout stability | +| #3322 | `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>` | `logs --follow` capability contract | +| #3357 | `Co-authored-by: huaiqing-afk ` | Cursor repeated-narration breaker | + +Carry method: fetch the PR head and re-apply its source/test hunks onto a fresh branch +off `dev`, one commit per source PR, each carrying its trailer. Do not pipe +`gh pr diff` straight into `git apply` for #3335 — GitHub emits binary PNG hunks +without full index data, so the whole-patch check fails on the two +`docs/pr-assets/*.png` files even though every source hunk applies cleanly. + +Focused verification, per branch — each branch runs only its own tests: + +| Branch | Command | +|--------|---------| +| `codex/carry-3335` | `cd gui && bun test tests/combo-strategy-selector.test.tsx` | +| `codex/carry-3333` | `cd gui && bun test tests/models-tab-layout.test.ts` | +| `codex/carry-3322` | `bun test tests/cli-usage-report.test.ts tests/cli-capabilities.test.ts` | +| `codex/carry-3357` | `bun test tests/cursor-repetition-breaker.test.ts` | + +No repository-wide suite (explicit user constraint). diff --git a/devlog/_plan/260904_bug_stack_train/040_deferrals.md b/devlog/_plan/260904_bug_stack_train/040_deferrals.md new file mode 100644 index 0000000000..d1e6da02b2 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/040_deferrals.md @@ -0,0 +1,30 @@ +# 040 — Recorded deferrals + +Deferring is a disposition, not an omission. Each item below stays open with a +stated reason rather than being force-landed. + +## #3348 / #3312 — combos failover hardening + +Both carry the same confirmed correctness blocker: generic HTTP 410 and 413 are +classified as retryable hops (`src/combos/failover.ts:563-617` on both heads), so an +oversized or invalid request would be replayed to the next provider. Their own tests +encode the wrong expectation. #3348 functionally supersedes #3312 (30 shared files, +near-identical source diffs; #3312 is additionally CONFLICTING/DIRTY). + +At 2,248 lines across 34 files spanning failover, credential rotation, durable +cooldown persistence, shutdown, and the core response path, this is not reviewable +inside a mixed campaign. It needs its own split stack. + +## #3325 — dev bump guard fork filter + +The code is correct, but `.github/workflows/` is a restricted surface +(`.github/scripts/pr-sponsored-surface.cjs:24-27`) and the hygiene gate fails +`unsponsored_surface` without a maintainer sponsorship decision. That is a policy +action for a human, not a patch. Note the second red check is a cancelled +`enforce-target` run that `gh pr checks` renders as a failure. + +## All six bug issues + +See 000. Every one needs reporter evidence or a product decision. Three of them +(#3352, #3320, #3279) would require weakening an auth or identity boundary to +"fix" without a reproduction, which is the wrong trade. diff --git a/devlog/_plan/260904_bug_stack_train/050_outcome.md b/devlog/_plan/260904_bug_stack_train/050_outcome.md new file mode 100644 index 0000000000..300bf73f6b --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/050_outcome.md @@ -0,0 +1,51 @@ +# 050 — Outcome record + +## Shipped + +| PR | Branch | Base | Content | +|----|--------|------|---------| +| #3369 | `codex/deviceauth-core` | `dev` | The deviceauth grant (#3366 layer 1) | +| #3370 | `codex/deviceauth-surface` | `codex/deviceauth-core` | API/CLI/GUI surface + poll budgets (layer 2) | +| #3371 | `codex/carry-3357` | `dev` | Cursor repeated-narration breaker, carried from #3357 | +| #3372 | `codex/carry-3322` | `dev` | `logs --follow` contract, carried from #3322 | +| #3373 | `codex/carry-3335` | `dev` | Combo strategy selector, carried from #3335 | +| #3374 | `codex/carry-3333` | `dev` | Models tab width stability, carried from #3333 | + +#3369 and #3370 are a real stack (layer 2 consumes layer 1). The four carries are +parallel branches off `dev`: none consumes another, so stacking them would have +imposed a false merge order. The plan audit caught that before anything was pushed. + +## What review changed + +The audits were not a formality. Across eight reviewer rounds they found, with +reproductions: + +- A finite-but-absurd poll interval overflowed the 32-bit timer and fired + immediately — 34 token requests in ~50ms against an auth endpoint. +- The 15-minute deadline was not enforced during an in-flight poll, so a grant + arriving after expiry was accepted. +- `credsFromToken` cast `access_token` instead of validating it, so a 200 with no + token resolved a login as successful with an undefined credential. +- The GUI never actually requested device mode, and the test covering it was + false-green: its mock returned a device payload regardless of the request. +- The modal's 5-minute cancel timer would have aborted a device login ten minutes + before its grant expired. +- Both poll-budget tests permitted the exact regression they existed to catch. +- Reauth could not reach the device flow at all — it skips the pick step. + +The first attempt at the GUI trigger reused the "Don't open a browser on the proxy +machine" preference. That was wrong twice: the toggle is not rendered in the Codex +modal, and the preference means "use a different browser", not "change protocol". +It became an explicit device-login row instead. + +## Not shipped, and why + +Recorded in 040. #3348 and #3312 both classify generic HTTP 410/413 as retryable +hops, which would replay an oversized or invalid request to the next provider; +at ~2,000 lines each across the failover, credential, and core response paths they +need their own review cycle. #3325 is correct but touches a restricted workflow +surface and needs a maintainer sponsorship decision, not a patch. + +All six open bug issues need reporter evidence or a product decision. Three of them +(#3352, #3320, #3279) would require weakening an auth or identity boundary to +"fix" without a reproduction. diff --git a/devlog/_plan/260904_bug_stack_train/060_closeout.md b/devlog/_plan/260904_bug_stack_train/060_closeout.md new file mode 100644 index 0000000000..07a45b79c7 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/060_closeout.md @@ -0,0 +1,56 @@ +# 060 — Closeout + +Six pull requests merged into `dev`, each proven an ancestor of the branch head: + +| PR | Merge commit | Content | +|----|--------------|---------| +| #3369 | `f825858da` | OpenAI deviceauth grant (#3366 layer 1) | +| #3385 | `d060f53ab` | deviceauth surface: API, CLI, GUI, poll budgets (layer 2) | +| #3371 | `53a2adfc4` | Cursor repeated-narration breaker (from #3357) | +| #3372 | `8a0c10865` | `logs --follow` capability contract (from #3322) | +| #3373 | `d753fa53b` | Combo strategy selector (from #3335) | +| #3386 | `a33381182` | Models tab width stability (from #3333) | + +Proof form for each: `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD`. + +## The mistake worth remembering + +Two PRs had to be rebuilt mid-train for the same reason, and CI caught both: + +- `codex/carry-3333` copied `gui/src/styles.css` wholesale from #3333's head. That PR + predates #3367 and #3382, so the copy silently reverted the Logs table clipping fix and + the sidebar footer rework. `tests/logs-table-overflow.test.ts` failed on a declaration + nothing had intentionally touched. +- `codex/deviceauth-surface-v2` copied the nine i18n catalogs the same way, reverting every + key `dev` had added since — `sidebar.preferences` among them — which broke the GUI build's + `TKey` union. + +**Carrying another author's work means applying their diff, not taking their files.** A file +carries its own history with it. Both rebuilds used +`git diff -- ` and applied that. + +A third defect surfaced from the same area: `tests/dashboard-tabs.test.ts` located its +target with `indexOf(".page-tabs {")`, which matches any rule whose selector merely *ends* +in that string. Adding a scoped `.main-inner--combos > .page-tabs` rule above the base one +made the guard read the wrong block. It is now anchored to a line-start rule, and removing +`flex-wrap` from the real base rule still fails it. + +## Review value + +Eight reviewer rounds across the two deviceauth PRs produced, each with a reproduction: +a 32-bit timer overflow that turned a hostile `interval` into 34 auth requests in ~50ms; an +unenforced deadline that accepted a grant arriving after expiry; a cast `access_token` that +let a 200 with no token resolve a login as successful; a GUI that never actually requested +device mode, covered by a test that was false-green because its mock answered with a device +payload regardless of the request; a five-minute modal timer against a fifteen-minute grant; +budget tests that permitted the exact regression they existed to catch; and a reauth path +that could not reach the device flow at all. + +None of those were visible from the diff alone. + +## Still open, deliberately + +See 040. #3348 and #3312 (generic 410/413 classified as retryable hops, ~2,000 lines each), +#3325 (correct, but needs a maintainer sponsorship decision for a restricted workflow +surface), and all six bug issues (reporter evidence or a product decision; three would +require weakening an auth or identity boundary to "fix" without a reproduction). diff --git a/devlog/_plan/260904_bug_stack_train/070_issue_dispositions.md b/devlog/_plan/260904_bug_stack_train/070_issue_dispositions.md new file mode 100644 index 0000000000..3561c95003 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/070_issue_dispositions.md @@ -0,0 +1,24 @@ +# 070 — Bug-issue dispositions + +Six open bug-labelled issues, each root-caused against the current tree. None is +safely fixable from the evidence attached today. Deferring is the disposition, not +an absence of one. + +| Issue | Disposition | Why | +|-------|-------------|-----| +| #3352 | NEEDS_REPORTER_EVIDENCE | Mechanism is fully traced, cause is not. Letting `unknown` entitlement through would be a security-policy change, not a bug fix. | +| #3320 | NEEDS_REPORTER_EVIDENCE | Production XML writes a locale-independent SID; exact `` matching is deliberate. Needs redacted live XML. | +| #3279 | NEEDS_REPORTER_EVIDENCE | Each page load mints a session from its own Host-derived origin; the exact origin check IS the admission boundary. | +| #3255 | PRODUCT_DECISION | Reasoning and speed are already independent dimensions; there is no Ultra-fast wire tier to pass through. | +| #3245 | NEEDS_REPORTER_EVIDENCE | The reporter saw no POST after the 426, which puts the failure before the Responses bridge. | +| #1527 | NEEDS_REPORTER_EVIDENCE | Every known defect in this path is already fixed; needs a matched current-dev trace. | + +## The pattern worth naming + +Three of these (#3352, #3320, #3279) have an obvious-looking fix that is the wrong +trade: allow the unconfirmed entitlement, fold non-ASCII identities together, treat +localhost/IPv4/IPv6 as one origin. Each would make the symptom go away by widening a +trust boundary, without a reproduction proving that boundary is what failed. A bug +report is not evidence that the check causing the symptom is the wrong check. + +Full mechanism traces with file:line are in `000_research.md`. diff --git a/devlog/_plan/260904_bug_stack_train/080_merge_ledger.md b/devlog/_plan/260904_bug_stack_train/080_merge_ledger.md new file mode 100644 index 0000000000..30196e8419 --- /dev/null +++ b/devlog/_plan/260904_bug_stack_train/080_merge_ledger.md @@ -0,0 +1,35 @@ +# 080 — Merge ledger + +Every merge, with the proof form used for each: + +``` +git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD +``` + +| Order | PR | Merge commit | Ancestor of dev | +|-------|----|--------------|-----------------| +| 1 | #3369 | `f825858da5b2e8dc5c949cc9f17b5111bf07bda4` | ok | +| 2 | #3372 | `8a0c1086539b82648984e0a1c3546d9d493d5fd9` | ok | +| 3 | #3371 | `53a2adfc45ed18a980355abe353ec02f06f3f39e` | ok | +| 4 | #3373 | `d753fa53bec651c90e538602a56d1a1cddf56589` | ok | +| 5 | #3385 | `d060f53abe255b28f8c36330ddd0c4e39fd9b6a2` | ok | +| 6 | #3386 | `a33381182b144bfccb61269f4dfbc73057eacae2` | ok | + +Each merge was gated on the check-run rollup for that PR's exact `headRefOid`, not on +`gh pr checks` output alone — a cancelled superseded run renders as a failure there, and +an empty required-check list is not evidence of green. + +## Two rebuilds, and one flake that was not one + +#3370 could not be rebased after its parent #3369 squash-merged: the branch still carried +the core commits, and the rebase conflicted against content that had already landed in +squashed form. Rebuilt as #3385 from the surface file set on current `dev`. + +#3374 was rebuilt as #3386 after CI exposed the `styles.css` revert. + +One genuine flake: `test 4/4` failed on +`update stops the running proxy before replacing files > npm launcher restarts the stopped +runtime after a staged update failure` — a 91-second timing-sensitive test in +`tests/update-stop-first.test.ts`, which reads nothing from `gui/` while that PR changed +only stylesheets. It passed locally (15 pass / 0 fail) and passed on re-run. Distinguishing +that from a real failure required reading the shard log, not assuming. diff --git a/devlog/_plan/260904_dashboard_minimal/000_inventory.md b/devlog/_plan/260904_dashboard_minimal/000_inventory.md new file mode 100644 index 0000000000..2487c1afae --- /dev/null +++ b/devlog/_plan/260904_dashboard_minimal/000_inventory.md @@ -0,0 +1,89 @@ +# 000 — Dashboard inventory (as shipped, v2.42.0, dev @ 664d80c76) + +Evidence: `assets/_1440.png` (full page, ko, 1440 px headless Chrome against the live +proxy), `assets/_text.txt` (visible text), `assets/_interactive.txt` (interactive +controls with refs, `agbrowse snapshot --interactive`). Storage was captured mid-scan (its skeleton +is the honest first paint on a 1.6 GB CODEX_HOME) and is inventoried from source. + +Counts are from the captures: interactive = controls in the snapshot, words = visible text words. + +| Route | Source | Interactive | Words | Screenshot | +|---|---|---|---|---| +| Sidebar + top bar | gui/src/App.tsx, components/sidebar-github-row.tsx, styles.css | 22 | — | every capture, left rail | +| #dashboard (overview) | pages/Dashboard.tsx, dashboard-overview-sections.tsx (669 L), dashboard-dialogs.tsx | 34 | 199 | dashboard_1440.png | +| #dashboard/providers | same | 18 | — | dashboard_providers_1440.png | +| #dashboard/models | same | 28 | — | dashboard_models_1440.png | +| #startup | pages/Startup.tsx (403 L), startup-sections.tsx | 22 | 167 | startup_1440.png | +| #providers | pages/Providers.tsx, components/provider-workspace/* | 27 | 310 | providers_1440.png | +| #models | pages/Models.tsx (2329 L) | 135 | 460 | models_1440.png | +| #models/combos | pages/Combos.tsx, components/combo-workspace-* | 59 | — | models_combos_1440.png | +| #models/routing | pages/RoutingProfiles.tsx (1139 L) | 28 | — | models_routing_1440.png | +| #models/compatibility | pages/CompatibilityMatrix.tsx | 27 | — | models_compatibility_1440.png | +| #subagents | pages/Subagents.tsx, components/subagents-workspace/* | 60 | 232 | subagents_1440.png | +| #logs | pages/Logs.tsx (1147 L) | 50 | 346 | logs_1440.png | +| #logs/debug | pages/Debug.tsx, debug-log-viewer.tsx | 24 | — | logs_debug_1440.png | +| #usage | pages/Usage.tsx (889 L) | 27 | 654 | usage_1440.png | +| #storage | pages/Storage.tsx (1469 L), components/storage-workspace/* | 16 (skeleton) | — | storage_1440.png | +| #codex-set | pages/codex-set-multiauth.tsx, codex-set-prompt.tsx, components/codex-set/*, CodexAccountPool.tsx | 51 | 361 | codex-set_1440.png | +| #integrations | pages/Integrations.tsx, ApiKeys.tsx, Claude*.tsx, Grok.tsx | 86 | 233 | integrations_1440.png | + +## Element-level notes from the captures (main agent's own pass) + +Sidebar / top bar +- Brand + version chip, 9 nav rows, language combobox, theme button ("시스템"), a "프록시" label + row with stop + reload-models icon buttons, GitHub row with star + download(update) icons. +- The "프록시" row is a label with two icon buttons and no state; "시스템" (theme) is a full-width row + for a rarely-used control. + +Dashboard overview +- Six stat cards: subagent mode segmented (v1/base/v2 — a control inside a stat card), status, + version, uptime, provider count, tokens(30d)+coverage. +- A green "재부팅 후에도 opencodex가 자동으로 준비됩니다" notice band (duplicated on #startup). +- Card "서브에이전트 위임" with a value chip and "설정 열기" (duplicates #subagents). +- Card "모델 동기화" with "지금 동기화" (duplicates the top-bar reload-models icon). +- Card "Codex 실행 시 opencodex 시작" toggle + two sentences (duplicates #startup shim row). +- Cards "웹 검색 사이드카", "비전 사이드카" with model comboboxes, a streaming toggle, "고급 설정". +- Tabs "활성 프로바이더", "사용 가능한 모델" duplicate #providers and #models content. + +Startup +- Orange sync banner (Codex version drift) with copy button; green hero card; three stat cards + restating the hero; "보호 상태 상세" list; "복구 방법" with copyable commands; "대시보드로 + 돌아가기" + "새로고침" buttons. + +Providers +- Left list (status dot, model count), right overview: 3 stat cards (ready / needs setup / + inactive), "사용량 제한" per-provider quota bars with reset times, "최근 사용" list, "JSON 편집", + "+ 프로바이더 추가", filter icon. + +Models +- Top notice "Codex가 이 카탈로그보다 오래된 모델 목록을 보여주고 있습니다" + "Codex 모델 목록 + 새로고침" button; 4 tabs; a 4-line explanatory paragraph; provider list; global toggles row + (새 모델을 비활성화 상태로 추가, 섀도우 호출 가로채기 with model picker, 기본 창/상한 stepper + + toggle) each with a helper sentence; "우선 순서" explanation block; "모두 접기 / 모두 펼치기"; + per-provider group header with 6 controls (edit, 기본 별칭 사용, 커스텀 모델 추가, 모두 켜기, + 모두 끄기, 기본 창/상한 + 사용자 지정 창) repeated per group. + +Subagents +- 3 tabs; "추천" list with per-row up/down/remove; "저장"; "모델" search + checklist. Helper + sentence with inline code. + +Logs +- Title + sentence; auto-refresh checkbox; tabs; surface segmented; "가로챈 헬퍼만"; two filter + inputs with labels; 10-column table; per-row "상세보기" link under the status. + +Usage +- Range segmented (전체/Codex/Claude/Grok) + period segmented; 4 tabs with counts; 6 stat cards; + cost banner sentence; heatmap with legend; model search + table; provider table; coverage. + +Codex 설정 +- Tabs 다중 인증 / 프롬프트; header controls (Spark 할당량 toggle, 한도 도달 계정 일시 중지, + 할당량 새로고침); "OpenAI 계정 모드" card; main account card with 5 badges/buttons; per-account + cards with plan badge, count badge, 4 buttons, priority select, quota bars, ✕. + +Integrations +- 18 tabs (one per client) in two rows; 4-number summary + "모두 해제"; "API 키" row; explanatory + paragraph; card grid: name, status badge, one-line, toggle, "설정". + +Storage +- Title + sentence, "다시 스캔", card list (source: per-category size cards, cleanup presets, + log guard section, protection toggles). diff --git a/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md b/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md new file mode 100644 index 0000000000..d4ccdedcd9 --- /dev/null +++ b/devlog/_plan/260904_dashboard_minimal/001_subagent_opinions.md @@ -0,0 +1,881 @@ +# 001 — Subagent opinions (independent, read-only, dev @ 664d80c76) + +Three reviewers were dispatched in parallel with the same packet (evidence pack in `assets/`, +full source access, no edits, no suites, no proxy mutation). Model requested → model that +answered (as self-reported in the REVIEWER line): + +| # | Requested | Answered as | Agent | Status | +|---|---|---|---|---| +| R1 | gpt-5.6-sol / medium | claude-fable-5-1 (proxy routed) | 01a06823-44b2 "Mendel" | complete | +| R2 | anthropic/claude-opus-5 / medium | claude-opus-5 | 01a06823-4549 "Epicurus" | complete | +| R3 | xai/grok-4.6 / high | grok-4.6 | 01a06823-45e1 "Averroes" | complete (≈21 min) | + +Evidence caveat both reviewers raised: the first capture pass had four misrouted text files +(logs, logs_debug, models_compatibility, and subagents==storage). They were recaptured before +002 was written; the reviewers' verdicts for those routes were source-grounded and were +re-checked against the corrected captures in 002. + +## Where R1 and R2 agree (high confidence) + +- Dashboard "활성 프로바이더" and "사용 가능한 모델" tabs duplicate Providers/Models → remove. +- Dashboard duplicates settings that have a home elsewhere: subagent v1/base/v2 switch, + "서브에이전트 위임" card, shadow-call intercept, "Codex 실행 시 opencodex 시작" → demote to + their owning page (Subagents / Models / Startup). +- Integrations 18-tab strip is redundant with the card grid → collapse; zero-valued summary + cards and uninstalled-client cards → hide when zero / behind "add client". +- Sidebar: GitHub star orb removed from chrome; GitHub row demoted; language + theme + collapsed into a compact footer control; "프록시" label removed (orbs keep aria-labels). +- Models: 4-line catalog subtitle + picker-order paragraph → tooltip/help; per-provider header + control wall (6 controls × N providers) → per-provider action menu. +- Codex 설정: per-account priority explanation ×6 → one shared ⓘ; 별칭 편집 / ✕ → overflow + menu; truncated account ID → tooltip (copyable). +- Usage: 활동일 card removed; coverage shown once; cost estimate keeps its disclaimer; heatmap + collapsed/follows range. +- Startup: three stat cards restating the hero → collapse; "대시보드로 돌아가기" removed. +- Providers: 3 summary cards restate the rail → collapse; "최근 사용" demoted to Usage; + quota bars KEEP (both call them the highest-value element on the page). +- Never touch: stop/restart orbs, reboot-protection health bar, quota bars, storage + destructive-action ceremony + quarantine, JSON 편집 escape hatch, conditional warning + banners, cost/lab disclaimers, model visibility toggles. + +## Where they disagree + +| Topic | R1 | R2 | Note for 002 | +|---|---|---|---| +| Version chip in sidebar | demote to tooltip | keep (most-asked support fact) | R2 wins: one chip, zero cost, high support value. | +| Sidebar nav rows | demote Codex 설정 / 서브에이전트 / 저장소 under other pages | keep all 9 | R2 wins for this loop: route changes are a scope expansion; nav stays. | +| Logs 10-column table | collapse 5 columns behind a column picker | (no call) | Defer — Logs was just reworked (#3367); revisit after the rest lands. | +| Memory 관찰 card | collapse behind runtime details | #1 highest noise: collapse body, keep pressure bar | Agree on collapse; R2's shape (keep pressure/in-flight/restart) is the one to build. | +| Providers summary cards | keep | collapse (restate the rail) | R2 wins: the rail group headers already carry ready/needs-setup/inactive counts. | +| Integrations "모두 해제" | demote to bulk menu | keep | R2 wins: bulk rollback of a config-writing feature is safety, not noise. | +| Storage subtitle | keep | keep | agree. | + +## R3 — headline (full text in §R3 below) + +R3 converges with R1/R2 on: dashboard clone tabs, triple v1/base/v2 (owner: Subagents), dual shadow-call (owner: Models), Models essay/control wall, Integrations 18 tabs, Usage heatmap + sticker price, sidebar star/GitHub/update chrome, Combos empty-state expert form, Routing dry-run on empty tab, per-account 선택 순서 ×N, page subtitles. R3-only calls: remove the third Codex-restart orb on the Models page head (Models.tsx:2207); demote "재시도" on Routing to error-only; DEMOTE 활동일; keep Providers 3 summary cards (disagrees with R2). R3 keeps Startup three stat cards (R1 collapses them) and keeps "모두 해제". + +## R1 — full review + +Review basis: commit `664d80c76`, current source, visible-text/control captures, and all 16 PNGs. No files were changed and no tests or proxy operations were run. + +The intended product posture should be: show current health, exceptions, and the next useful action; disclose implementation detail, raw identifiers, historical data, and rare configuration only on demand. + +Evidence warning: three supplied captures are misrouted: + +- `logs_1440.png` / `logs_text.txt` show Codex authentication. +- `logs_debug_1440.png` / `logs_debug_text.txt` show Integrations. +- `models_compatibility_1440.png` / its text show Usage. + +Those routes can be reviewed structurally from source, but not visually validated from this evidence pack. + +## Sidebar + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `opencodex` | `gui/src/App.tsx:249` | KEEP | Stable product identity anchors every route. | Removing it makes the shell anonymous. | +| `v2.42.0` | `gui/src/App.tsx:250` | DEMOTE-to-System/status tooltip | Version matters during diagnosis, not during every navigation decision. | Operators may take one extra action when comparing versions. | +| `대시보드`, `프로바이더`, `모델`, `로그&디버그`, `사용량`, `연동` | `gui/src/App.tsx:62` | KEEP | These are distinct, frequent operator jobs. | Combining them would obscure major workflows. | +| `Codex 설정` | `gui/src/App.tsx:64` | DEMOTE-to-Codex subsection under Providers or Models | It is product-specific configuration inside a universal proxy and currently competes with primary operations. | Codex-heavy users lose one-click access. | +| `서브에이전트` | `gui/src/App.tsx:67` | DEMOTE-to-Models/Advanced | It configures model selection behavior rather than a standalone runtime resource. | Multi-agent users need one extra click. | +| `저장소` | `gui/src/App.tsx:70` | DEMOTE-to-System/maintenance | Storage cleanup is periodic maintenance, not a primary daily destination. | Disk-pressure investigation is less immediately discoverable. | +| `한국어` | `gui/src/App.tsx:323` | COLLAPSE-behind-settings-popover | Locale is a rare preference after initial selection. | Language switching becomes one click deeper. | +| `시스템` theme control | `gui/src/App.tsx:335` | COLLAPSE-behind-settings-popover | Theme has no proxy-operational decision value. | Theme switching becomes less immediate. | +| `프록시` plus stop/restart icons | `gui/src/App.tsx:339` | KEEP | Stop and restart are consequential runtime controls. | Hiding them would delay recovery. | +| `GitHub` | `gui/src/components/sidebar-github-row.tsx:131` | REMOVE | Repository promotion is unrelated to operating the local proxy. | Users lose a convenience link; the repository remains reachable elsewhere. | +| star control | `gui/src/components/sidebar-github-row.tsx:136` | REMOVE | Spending user identity/reputation has zero operator value in persistent navigation. | Users cannot star from the dashboard. | +| update icon | `gui/src/components/sidebar-github-row.tsx:147` | DEMOTE-to-System/version-status | Updating is operationally relevant only when an update exists. | Manual update checks become less prominent. | + +## Topbar + +The desktop evidence has no independent topbar; this is the mobile shell. + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| menu button | `gui/src/App.tsx:258` | KEEP | It is the only narrow-screen navigation entry. | Removing it blocks mobile navigation. | +| `opencodex` brand | `gui/src/App.tsx:263` | KEEP | It provides compact route context. | Minimal risk, but removing it weakens orientation. | +| session logout icon | `gui/src/App.tsx:265` | COLLAPSE-behind-account/menu | Logout is infrequent and visually indistinguishable among three adjacent icon-only actions. | Connected-runtime logout takes one extra step. | +| proxy stop icon | `gui/src/App.tsx:271` | KEEP | Emergency shutdown is high-value and confirmation-gated. | None if label and confirmation remain. | +| Codex restart icon | `gui/src/App.tsx:275` | DEMOTE-to-model-stale-banner-or-menu | Restart is usually relevant only after stale-state detection. | Manual restart is one click deeper outside stale conditions. | + +## Dashboard — Overview + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| subtitle explaining “local proxy, providers, models” | `gui/src/pages/Dashboard.tsx:80` | REMOVE | The sidebar and page title already establish this context. | First-time users lose a generic orientation sentence. | +| `개요 / 활성 프로바이더 / 사용 가능한 모델` tabs | `gui/src/pages/Dashboard.tsx:54` | REMOVE | The latter two duplicate dedicated Providers and Models routes. | Users lose read-only shortcuts; replace with linked counts. | +| `서브에이전트 v1/base/v2` | `gui/src/pages/dashboard-overview-head.tsx:34` | DEMOTE-to-Subagents-settings | It is a mutation embedded in what should be a status overview. | Mode switching is no longer available from the landing screen. | +| `상태 온라인` | `gui/src/pages/dashboard-overview-head.tsx:73` | KEEP | Runtime reachability is the dashboard’s primary decision signal. | None. | +| `버전` | `gui/src/pages/dashboard-overview-head.tsx:79` | DEMOTE-to-status-tooltip | It matters only for mismatch/update diagnosis. | Exact version is less glanceable. | +| `가동 시간` | `gui/src/pages/dashboard-overview-head.tsx:80` | COLLAPSE-behind-runtime-details | Uptime rarely changes an operator decision unless diagnosing restarts. | Restart-loop detection requires opening details. | +| `프로바이더 9` | `gui/src/pages/dashboard-overview-head.tsx:81` | KEEP | A linked count quickly reveals whether expected capacity exists. | Count alone does not reveal unhealthy providers. | +| `토큰 (30일) / 커버리지` | `gui/src/pages/dashboard-overview-head.tsx:82` | DEMOTE-to-Usage | It duplicates the Usage report and dominates the health row with historical volume. | Cost-conscious users lose a landing-page summary. | +| reboot-protection status bar | `gui/src/pages/dashboard-overview-head.tsx:93` | KEEP | Startup protection is a real availability decision and links to remediation. | None. | +| `서브에이전트 위임 / 설정 열기` | `gui/src/pages/dashboard-overview-sections.tsx:127` | DEMOTE-to-Subagents | It duplicates the dedicated configuration surface. | One-click access from dashboard is lost. | +| `모델 동기화 / 지금 동기화` | `gui/src/pages/dashboard-overview-sections.tsx:206` | KEEP | Catalog drift requires an explicit corrective action. | None. | +| `Codex 실행 시 opencodex 시작` | `gui/src/pages/dashboard-overview-sections.tsx:487` | DEMOTE-to-Startup-safety | It is startup policy, not live health. | Users may overlook launcher behavior unless following startup status. | +| `웹 검색 사이드카` | `gui/src/pages/dashboard-overview-sections.tsx:509` | DEMOTE-to-Models/Advanced | This is model-routing configuration, not dashboard status. | Web-search operators need one additional navigation step. | +| `응답 실시간 스트리밍` | `gui/src/pages/dashboard-overview-sections.tsx:531` | COLLAPSE-behind-web-search-details | It is a secondary tuning flag. | Streaming behavior is less discoverable. | +| `비전 사이드카` | `gui/src/pages/dashboard-overview-sections.tsx:549` | DEMOTE-to-Models/Advanced | It is another routing configuration block occupying the primary overview. | Image-routing configuration becomes less immediate. | +| `쉐도우 호출 가로채기` | `gui/src/pages/dashboard-overview-sections.tsx:626` | DEMOTE-to-Models/Advanced | It is a specialized Codex compatibility feature. | Helper-call routing becomes harder to discover. | +| `메모리 관찰` summary | `gui/src/components/MemoryObservabilityCard.tsx:470` | COLLAPSE-behind-System/runtime-details | Memory is useful primarily when abnormal; normal RSS/JSC figures are monitoring noise. | Slow leaks may be noticed later unless warning thresholds remain visible. | + +## Dashboard — Active Providers + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| entire `활성 프로바이더` tab | `gui/src/pages/Dashboard.tsx:56` | REMOVE | It is a less actionable duplicate of the Providers workspace. | Operators lose a compact read-only inventory. | +| provider count | `gui/src/pages/dashboard-providers-section.tsx:16` | DEMOTE-to-linked-dashboard-stat | The number is useful, but not a standalone page. | None if linked to Providers. | +| `이름` | `gui/src/pages/dashboard-providers-section.tsx:22` | DEMOTE-to-Providers-list | Names belong in the actionable provider workspace. | None. | +| `어댑터` | `gui/src/pages/dashboard-providers-section.tsx:27` | COLLAPSE-behind-provider-details | Adapter type is implementation detail for troubleshooting. | Advanced users need to open details. | +| `Base URL` | `gui/src/pages/dashboard-providers-section.tsx:28` | COLLAPSE-behind-provider-details | Raw endpoints have no routine decision value and visually dominate the table. | Endpoint mistakes become one click less visible. | +| default `모델` | `gui/src/pages/dashboard-providers-section.tsx:29` | DEMOTE-to-provider-details | It is actionable only in the provider editor. | Users lose at-a-glance default-model comparison. | + +## Dashboard — Available Models + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| entire `사용 가능한 모델` tab | `gui/src/pages/Dashboard.tsx:57` | REMOVE | It duplicates the Models catalog without offering catalog actions. | Operators lose a fast read-only model lookup. | +| total model count | `gui/src/pages/dashboard-models-section.tsx:29` | DEMOTE-to-linked-dashboard-stat | The count is useful as health context, not as a separate page. | None if linked. | +| `모델 검색…` | `gui/src/pages/dashboard-models-section.tsx:37` | DEMOTE-to-Models | Search belongs where results can be enabled, disabled, or configured. | Dashboard-only lookup disappears. | +| provider accordion rows | `gui/src/pages/dashboard-models-section.tsx:51` | REMOVE | They repeat the same provider/model hierarchy already presented in Models. | Read-only browsing requires entering Models. | +| raw model-ID chips | `gui/src/pages/dashboard-models-section.tsx:68` | COLLAPSE-behind-provider-model-details | Raw IDs matter when configuring or copying, not in a health dashboard. | Copying an ID takes one extra action. | + +## Startup Safety + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| explanatory subtitle | `gui/src/pages/Startup.tsx:322` | COLLAPSE-behind-help-tooltip | The page’s protected/at-risk result explains its purpose more directly. | New users lose conceptual context. | +| `대시보드로 돌아가기` | `gui/src/pages/Startup.tsx:325` | REMOVE | Global navigation already provides this route. | Keyboard users lose a redundant shortcut. | +| `새로고침` | `gui/src/pages/Startup.tsx:328` | KEEP | Rechecking after remediation is a direct operator action. | None. | +| runtime compatibility warning and `ocx sync` | `gui/src/pages/Startup.tsx:360` | KEEP | It identifies actionable version/config drift. | None. | +| protected/at-risk hero | `gui/src/pages/startup-sections.tsx:44` | KEEP | This is the page’s decisive answer. | None. | +| three cards: routing, protection, preference | `gui/src/pages/startup-sections.tsx:59` | COLLAPSE-behind-protection-details | They restate the hero in implementation terms during healthy operation. | Exact mechanism is less glanceable. | +| `보호 상태 상세` with platform | `gui/src/pages/startup-sections.tsx:99` | COLLAPSE-behind-hero-disclosure | Detailed service/shim state is needed mainly when risk exists. | Healthy users need one click to inspect mechanisms. | +| install/repair action for unhealthy service or shim | `gui/src/pages/startup-sections.tsx:112` | KEEP | It is the direct remediation path. | None. | +| `복구 방법` command list | `gui/src/pages/startup-sections.tsx:237` | COLLAPSE-behind-manual-recovery | Manual commands are fallback capability after one-click remediation. | CLI-oriented users need to expand it. | + +## Providers + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `프로바이더 추가` | `gui/src/pages/Providers.tsx:322` | KEEP | Adding capacity is a core provider task. | None. | +| left provider rail and ready/disabled status | `gui/src/pages/Providers.tsx:328` | KEEP | It is the primary inventory and selection mechanism. | None. | +| `프로바이더 개요` explanatory sentence | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:98` | REMOVE | The workspace structure already communicates that it manages providers. | Minimal onboarding loss. | +| `JSON 편집` | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:103` | COLLAPSE-behind-Advanced | Raw config editing is high-risk and rarely the first action. | Power users need one extra action; advanced access must remain obvious. | +| ready/setup/disabled summary cards | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:110` | KEEP | They summarize actionable provider health. | None. | +| attention list | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:120` | KEEP | Exceptions should remain more prominent than normal providers. | None. | +| full `사용량 제한` bars for every provider | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:146` | COLLAPSE-behind-usage-limits | Normal low-utilization quota rows consume most of the screen; surface only nearing-limit rows initially. | Operators lose passive comparison of all quotas. | +| `최근 사용` ranking | `gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:196` | DEMOTE-to-Usage/providers | Historical ranking duplicates Usage and does not help configure a provider. | A quick “most used” glance disappears from Providers. | + +## Models — Catalog + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| stale-Codex banner and restart action | `gui/src/pages/Models.tsx:2215` | KEEP | It detects a real mismatch and gives the corrective action. | None. | +| `모델 / 콤보 / 라우팅 / 호환성` tabs | `gui/src/pages/models-tab-strip.tsx:19` | KEEP | They represent distinct model-management capabilities. | Removing them would bury major features. | +| long catalog subtitle | `gui/src/pages/Models.tsx:2226` | COLLAPSE-behind-help-tooltip | It explains nuanced cache/visibility semantics but pushes controls below the fold. | Users may misunderstand direct-ID behavior without opening help. | +| provider rail | `gui/src/pages/Models.tsx:2132` | KEEP | It is the simplest way to scope a large catalog. | None. | +| top-level `새 모델을 비활성화 상태로 추가` | `gui/src/pages/Models.tsx:2173` | COLLAPSE-behind-catalog-policy | This is a rare future-model policy, not a routine model-selection action. | Newly discovered models may surprise users who never inspect policy. | +| `별칭` global control | `gui/src/pages/Models.tsx:2175` | COLLAPSE-behind-Advanced | Alias management is specialized and already has per-provider controls. | Users need an extra action to audit all aliases. | +| shadow-call controls | `gui/src/pages/Models.tsx:2173` | COLLAPSE-behind-Codex-advanced | They are product-specific compatibility controls. | Helper-call overrides are less discoverable. | +| subagent mode `v1/base/v2` | `gui/src/pages/Models.tsx:2173` | DEMOTE-to-Subagents-settings | It belongs with delegation configuration. | Cross-surface users lose immediate mode visibility. | +| global `기본 창 / 상한` | `gui/src/pages/Models.tsx:2173` | COLLAPSE-behind-context-settings | Context limits are advanced tuning and dangerous to change casually. | Operators diagnosing truncation need one extra click. | +| picker-order explanatory paragraph | `gui/src/pages/Models.tsx:1752` | COLLAPSE-behind-info-tooltip | It is reference documentation, not a decision control. | Ordering behavior is less immediately explicit. | +| `모두 접기 / 모두 펼치기` | `gui/src/pages/Models.tsx:1759` | KEEP | It directly manages information density in a large catalog. | None. | +| provider header actions: aliases, custom model, all on/off, context | `gui/src/pages/Models.tsx:2192` | COLLAPSE-behind-provider-action-menu | Repeating six controls on every provider creates the screen’s largest control wall. | Bulk actions require opening a per-provider menu. | +| individual model rows/toggles | `gui/src/pages/Models.tsx:2192` | KEEP | Visibility selection is the catalog’s primary capability. | None. | + +## Models — Combos + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| combos tab | `gui/src/pages/models-tab-strip.tsx:21` | KEEP | Failover/load-distribution is a distinct operator capability. | None. | +| tab subtitle | `gui/src/pages/Models.tsx:2226` | COLLAPSE-behind-help-tooltip | Existing combos explain themselves; onboarding text is primarily needed for an empty state. | First-time comprehension depends more on the empty state. | +| duplicate `콤보 추가` in rail and `콤보 만들기` in editor | `gui/src/components/ComboWorkspace.tsx:108` | REMOVE | The empty workspace presents multiple labels for the same creation action. | Ensure one retained CTA focuses or opens the complete form. | +| combo search with zero combos | `gui/src/components/ComboWorkspace.tsx:112` | REMOVE | Search has no decision value until at least one combo exists. | None; show it conditionally once combos exist. | +| `콤보 ID` | `gui/src/components/ComboWorkspace.tsx:197` | KEEP | Stable identity is required to create and address a combo. | None. | +| public model name and native OpenAI alias | `gui/src/components/ComboWorkspace.tsx:197` | COLLAPSE-behind-identity-advanced | Most users can accept `combo/` and do not need namespace/alias mechanics initially. | Advanced naming is less discoverable. | +| display name | `gui/src/components/ComboWorkspace.tsx:197` | COLLAPSE-behind-identity-advanced | It is conditional on alias behavior rather than core failover setup. | Native-alias users need to expand the section. | +| strategy and ordered targets | `gui/src/components/ComboWorkspace.tsx:197` | KEEP | These define combo behavior and are the primary decisions. | None. | +| default reasoning level | `gui/src/components/ComboWorkspace.tsx:197` | COLLAPSE-behind-behavior-advanced | Target defaults are usually sufficient. | Users may miss a useful normalization override. | +| multimodal/adaptive reasoning toggles | `gui/src/components/ComboWorkspace.tsx:197` | COLLAPSE-behind-capabilities | These are compatibility constraints, not minimum combo creation inputs. | Misconfigured heterogeneous targets may need more deliberate inspection. | + +## Models — Routing + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `라우팅 (beta)` | `gui/src/pages/models-tab-strip.tsx:22` | KEEP | Explicit beta labeling correctly bounds expectations. | None. | +| `프로필 만들기` | `gui/src/pages/RoutingProfiles.tsx:624` | KEEP | Creating a policy is the primary task. | None. | +| profile cards with model and revision | `gui/src/pages/RoutingProfiles.tsx:624` | KEEP | Operators need to choose the policy under inspection. | None. | +| revision badge | `gui/src/pages/RoutingProfiles.tsx:638` | COLLAPSE-behind-profile-details | Revision is audit metadata, not a selection criterion for most operators. | Concurrent-edit diagnosis is less immediate. | +| `드라이런 평가` shown before any profile exists | `gui/src/pages/RoutingProfiles.tsx:1015` | COLLAPSE-behind-selected-profile | The disabled form is dead visual weight until a profile is selected. | Users may not discover dry-run until selecting a profile. | +| context/tools/image/structured inputs | `gui/src/pages/RoutingProfiles.tsx:1017` | KEEP | These are the minimum meaningful routing simulation inputs. | None. | +| `라우팅 분석` empty panel | `gui/src/pages/RoutingProfiles.tsx:1097` | REMOVE | “No analysis yet” contributes no decision value before a profile has traffic. | Users lose advance awareness that analytics exists; reveal after first data or via details. | +| p50/p95/p99/cooldown/confidence badge wall | `gui/src/pages/RoutingProfiles.tsx:1101` | COLLAPSE-behind-analytics-details | Default view should show success/fallback and anomalies; latency distribution is diagnostic depth. | Performance tuning requires expansion. | + +## Models — Compatibility + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| compatibility tab | `gui/src/pages/models-tab-strip.tsx:23` | KEEP | Compatibility evidence prevents unsafe model assumptions. | None. | +| refresh button | `gui/src/pages/CompatibilityMatrix.tsx:460` | KEEP | Evidence freshness is operationally meaningful. | None. | +| community-evidence panel | `gui/src/pages/CompatibilityMatrix.tsx:473` | COLLAPSE-behind-community-evidence | Community information is secondary to local/production evidence. | Users may overlook useful external evidence. | +| status cards | `gui/src/pages/CompatibilityMatrix.tsx:478` | KEEP | They summarize whether compatibility evidence is usable. | None. | +| layer/verdict/subject filters | `gui/src/pages/CompatibilityMatrix.tsx:480` | KEEP | Filtering is necessary for a large evidence matrix. | None. | +| compatibility matrix | `gui/src/pages/CompatibilityMatrix.tsx:520` | KEEP | It is the route’s primary decision surface. | None. | +| second full `verdicts` table | `gui/src/pages/CompatibilityMatrix.tsx:553` | COLLAPSE-behind-list-view | It repeats matrix contents in another representation and doubles page length. | Table-oriented users need to switch views. | +| selected-verdict detail pane | `gui/src/pages/CompatibilityMatrix.tsx:613` | KEEP | Evidence details preserve explainability without crowding every row. | None. | + +## Subagents + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `추천 / 모델 / 설정` sticky section tabs | `gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:78` | KEEP | They organize three related jobs in one long page. | None. | +| instructional sentence mentioning `spawn_agent` | `gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:97` | COLLAPSE-behind-info-tooltip | It is durable documentation repeated above a self-explanatory ranked list. | First-time users may not understand dual picker/delegation effects. | +| selected 1–5 ranked list | `gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:105` | KEEP | The order directly changes model preference. | None. | +| separate `저장` button | `gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:145` | KEEP | It makes a multi-row reorder transaction explicit. | Auto-save would make accidental reorder harder to undo. | +| full available-model list | `gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:152` | COLLAPSE-behind-모델-chooser | It should not occupy the first viewport once five recommendations are complete. | Adding/removing candidates takes one disclosure action. | +| `먼저 부를 모델` | `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:66` | KEEP | It is a clear primary delegation decision. | None. | +| `Codex 설정에도 기본값으로 저장` | `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:99` | COLLAPSE-behind-Advanced | Persistence scope is an expert setting. | Users may assume dashboard state applies to new sessions. | +| `일 나누는 방법 알려주기` | `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:116` | COLLAPSE-behind-Advanced | Prompt-injection behavior is implementation-level tuning. | Delegation behavior may be harder to explain. | +| `울트라 모드` and custom text editor | `gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:133` | COLLAPSE-behind-Advanced-policy | It changes broad delegation policy and exposes raw policy text. | Power users need to expand it; active status should remain visible. | + +## Logs + +Visual evidence is invalid for this route; verdicts below come from source. + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `로그 / 디버그` tabs | `gui/src/pages/Logs.tsx:550` | KEEP | Historical request inspection and live debug capture are distinct jobs. | None. | +| `자동 새로고침` | `gui/src/pages/Logs.tsx:543` | KEEP | Freshness materially changes incident diagnosis. | None. | +| subtitle | `gui/src/pages/Logs.tsx:596` | REMOVE | The table and filters already make the request-log purpose obvious. | Minimal onboarding loss. | +| surface segmented filter | `gui/src/pages/Logs.tsx:598` | KEEP | It is the fastest way to isolate client-specific failures. | None. | +| intercepted-only checkbox | `gui/src/pages/Logs.tsx:621` | COLLAPSE-behind-more-filters | It is a specialized diagnostic predicate. | Shadow-call debugging needs one extra click. | +| conversation and model filters | `gui/src/pages/Logs.tsx:629` | KEEP | They directly narrow incidents and sessions. | None. | +| default table columns: time, model, provider, status, duration | `gui/src/pages/Logs.tsx:732` | KEEP | These answer what ran, where, whether it worked, and how long it took. | None. | +| tokens, tok/s, estimated cost, effort, request ID all visible | `gui/src/pages/Logs.tsx:735` | COLLAPSE-behind-column-picker | Ten default columns exceed routine scan needs; preserve them as optional columns/detail fields. | Performance/cost comparison requires enabling columns. | +| per-row `상세` | `gui/src/pages/Logs.tsx:833` | KEEP | It is the correct disclosure point for route, attempt, usage, and raw data. | None. | +| raw JSON | `gui/src/pages/Logs.tsx:1140` | KEEP | It is already correctly collapsed behind `
`. | None. | + +## Logs — Debug + +Visual evidence is invalid for this route; verdicts below come from source. + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| debug subtitle | `gui/src/pages/debug-settings-panel.tsx:119` | COLLAPSE-behind-help-tooltip | Debug users generally know why they opened the route. | First-time users lose guidance. | +| refresh | `gui/src/pages/debug-settings-panel.tsx:105` | KEEP | Manual re-read is essential when follow is disabled. | None. | +| follow checkbox | `gui/src/pages/debug-settings-panel.tsx:113` | KEEP | It controls live-tail behavior directly. | None. | +| four capture switches | `gui/src/pages/debug-settings-panel.tsx:28` | KEEP | Operators must explicitly choose potentially sensitive or expensive debug streams. | None. | +| reset button | `gui/src/pages/debug-settings-panel.tsx:43` | KEEP | It quickly returns debugging to a safe baseline. | None. | +| second stream selector row | `gui/src/pages/debug-settings-panel.tsx:48` | COLLAPSE-behind-active-stream-dropdown | It duplicates the enabled-stream concepts in another horizontal control group. | Switching streams is one compact selector instead of direct buttons. | +| empty debug explanation | `gui/src/pages/debug-log-viewer.tsx:25` | KEEP | It explains why no log viewer is shown and what must be enabled. | None. | +| live raw log viewer | `gui/src/pages/debug-log-viewer.tsx:43` | KEEP | It is the route’s core diagnostic output. | None. | + +## Usage + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| surface and date-range filters | `gui/src/pages/Usage.tsx:811` | KEEP | They define the report being inspected. | None. | +| subtitle | `gui/src/pages/Usage.tsx:815` | COLLAPSE-behind-info-tooltip | The missing-data caveat matters, but not as permanent header copy. | Users may initially assume missing usage is zero. | +| section tabs with counts | `gui/src/pages/Usage.tsx:722` | KEEP | They provide navigation through a long report. | None. | +| requests + measured cards | `gui/src/pages/Usage.tsx:287` | COLLAPSE-behind-coverage-summary | The pair is meaningful mainly for coverage diagnosis, not as two primary KPIs. | Data-quality gaps become less immediately visible. | +| total tokens | `gui/src/pages/Usage.tsx:289` | KEEP | It is the core consumption measure. | None. | +| cache-hit and cache-write cards | `gui/src/pages/Usage.tsx:290` | COLLAPSE-behind-token-breakdown | Cache accounting is optimization detail. | Cache-efficiency analysis takes one extra step. | +| coverage | `gui/src/pages/Usage.tsx:299` | KEEP | It qualifies every aggregate on the page. | None. | +| active days | `gui/src/pages/Usage.tsx:300` | REMOVE | The selected 7/30-day range and heatmap already communicate activity continuity. | Users lose a compact count. | +| API list-price estimate and disclaimer | `gui/src/pages/Usage.tsx:302` | COLLAPSE-behind-cost-estimate | It is explicitly not billing and can dwarf more reliable usage signals. | Cost comparison is less prominent. | +| annual heatmap | `gui/src/pages/Usage.tsx:400` | COLLAPSE-behind-activity-history | It consumes substantial vertical space while rarely affecting proxy operation. | Long-term usage patterns need expansion. | +| models and providers tables | `gui/src/pages/Usage.tsx:691` | KEEP | They answer where consumption occurred. | None. | +| detailed coverage panel | `gui/src/pages/Usage.tsx:707` | COLLAPSE-behind-coverage | Keep the percentage primary; disclose reported/estimated/unreported composition. | Data provenance takes one extra action. | + +## Storage + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `다시 스캔` | `gui/src/pages/Storage.tsx:1414` | KEEP | Storage state can change after cleanup and needs explicit refresh. | None. | +| subtitle | `gui/src/pages/Storage.tsx:1419` | KEEP | The promise not to disturb active sessions is an important safety contract. | None. | +| `CODEX_HOME` path and last-scan timestamp | `gui/src/pages/Storage.tsx:1421` | COLLAPSE-behind-scan-details | These are diagnostic metadata rather than cleanup decisions. | Multi-home users must open details to confirm target. | +| bucket rail with size/count | `gui/src/components/storage-workspace/StorageWorkspace.tsx:535` | KEEP | It identifies where disk usage is concentrated. | None. | +| total bytes and files | `gui/src/components/storage-workspace/StorageWorkspace.tsx:614` | KEEP | They establish cleanup scale. | None. | +| repeated home-path summary card | `gui/src/components/storage-workspace/StorageWorkspace.tsx:623` | REMOVE | The same path is already available in page scan details and does not merit a KPI card. | Target path is less visible if scan details are also collapsed. | +| ten largest files | `gui/src/components/storage-workspace/StorageWorkspace.tsx:643` | COLLAPSE-behind-largest-files | File-level paths are diagnostic depth after bucket-level triage. | Manual forensic cleanup needs expansion. | +| bucket oldest/newest timestamps | `gui/src/components/storage-workspace/StorageWorkspace.tsx:575` | COLLAPSE-behind-bucket-details | They are useful for investigation, not the initial storage decision. | Age-based cleanup decisions take one extra action. | +| cleanup policy/quarantine tabs | `gui/src/pages/Storage.tsx:1278` | KEEP | Policy and recoverable deletion are safety-critical capabilities. | None. | +| manual archived-session cleanup | `gui/src/pages/Storage.tsx:1310` | COLLAPSE-behind-manual-cleanup | Automatic policy should be primary; manual cleanup is fallback. | Immediate archive cleanup is one click deeper. | + +## Codex Settings + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| `다중 인증 / 프롬프트` tabs | `gui/src/pages/CodexSet.tsx:43` | KEEP | They are unrelated capabilities and should remain separated. | None. | +| `Codex Spark 할당량` | `gui/src/components/codex-account-pool-main-card.tsx:218` | COLLAPSE-behind-account-display-options | It controls visibility of a special quota rather than account operation. | Spark users may overlook the hidden quota. | +| `한도 도달 계정 일시 중지` | `gui/src/components/codex-account-pool-main-card.tsx:234` | KEEP | It is a high-value bulk recovery action. | None. | +| `할당량 새로고침` | `gui/src/components/codex-account-pool-main-card.tsx:242` | KEEP | Quota freshness directly affects routing decisions. | None. | +| account email, plan, next/current, quota bars | `gui/src/components/codex-account-pool-cards.tsx:79` | KEEP | These are the minimum facts needed to manage account rotation. | None. | +| repeated email + plan + truncated account ID line | `gui/src/components/codex-account-pool-cards.tsx:146` | COLLAPSE-behind-account-details | It duplicates the visible identity; raw ID is troubleshooting detail. | Copying an account ID takes one extra action. | +| `별칭 편집` on every row | `gui/src/components/codex-account-pool-cards.tsx:133` | COLLAPSE-behind-row-overflow-menu | It is infrequent and repeats as a prominent button across the pool. | Alias editing takes one extra click. | +| delete `×` | `gui/src/components/codex-account-pool-cards.tsx:136` | COLLAPSE-behind-row-overflow-menu | Destructive account removal should not sit as an unlabeled visual peer to routing controls. | Removal is less immediate but safer. | +| selection-priority control | `gui/src/components/codex-account-pool-cards.tsx:148` | COLLAPSE-behind-routing-details | Most users use defaults; priority is advanced pool tuning. | Priority conflicts may be harder to inspect. | +| explanatory paragraph repeated for each priority selector | `gui/src/components/codex-account-pool-cards.tsx:148` | REMOVE | One shared tooltip/help disclosure is sufficient. | No capability loss if the explanation remains centrally accessible. | + +## Integrations + +| element | source file:line | verdict | reason | risk | +|---|---|---|---|---| +| page subtitle | `gui/src/pages/Integrations.tsx:133` | COLLAPSE-behind-help-tooltip | The route and client states already communicate the job. | New users lose a short orientation sentence. | +| 18-tab strip | `gui/src/pages/Integrations.tsx:142` | COLLAPSE-behind-client-picker | Showing every supported client before relevance is known is the page’s largest noise source. | Direct one-click navigation to rare clients is lost; hashes must remain supported. | +| overview tab | `gui/src/pages/integrations/integration-tabs.ts:31` | KEEP | It is the appropriate default summary. | None. | +| detected/configured/update counts | `gui/src/pages/integrations/IntegrationsOverview.tsx:517` | KEEP | They summarize actionable integration state. | None. | +| `마지막 변경` | `gui/src/pages/integrations/IntegrationsOverview.tsx:541` | COLLAPSE-behind-history | A timestamp alone rarely changes the next action. | Recent unexpected changes are less glanceable. | +| `모두 해제…` | `gui/src/pages/integrations/IntegrationsOverview.tsx:545` | DEMOTE-to-bulk-actions-menu | A broad destructive mutation should not be a permanent summary-row peer. | Emergency bulk disable takes one extra action. | +| API key row | `gui/src/pages/integrations/IntegrationsOverview.tsx:568` | KEEP | Credentials are a distinct integration prerequisite. | None. | +| onboarding paragraph about backups/provider blocks | `gui/src/pages/integrations/IntegrationsOverview.tsx:571` | COLLAPSE-behind-how-it-works | It is important reference copy, but not a repeated operational decision. | Users may not understand backup behavior before first apply; show it in confirmation. | +| cards for applied or update-needed clients | `gui/src/pages/integrations/IntegrationsOverview.tsx:596` | KEEP | These states require monitoring or action. | None. | +| cards for every uninstalled client | `gui/src/pages/integrations/IntegrationsOverview.tsx:596` | COLLAPSE-behind-add-client | Unsupported/uninstalled clients should be discoverable without dominating routine operation. | Users may not notice a supported integration until opening “Add client.” | +| config filesystem paths on overview cards | `gui/src/pages/integrations/IntegrationsOverview.tsx:596` | COLLAPSE-behind-client-details | Paths are implementation detail useful during troubleshooting. | Manual file verification takes one extra action. | +| rollback history | `gui/src/pages/integrations/IntegrationsOverview.tsx:619` | COLLAPSE-behind-recent-changes | Keep a visible warning/recent reversible operation, but hide normal chronology. | Cross-client audit history becomes less prominent. | + +## Top 15 highest-noise removals + +1. Remove the Dashboard `Active providers` tab; it duplicates Providers (`gui/src/pages/Dashboard.tsx:56`). +2. Remove the Dashboard `Available models` tab; it duplicates Models (`gui/src/pages/Dashboard.tsx:57`). +3. Replace Integrations’ 18 always-visible tabs with a relevant-client picker (`gui/src/pages/Integrations.tsx:142`). +4. Hide all uninstalled integration cards behind `Add client` (`gui/src/pages/integrations/IntegrationsOverview.tsx:596`). +5. Move repeated per-provider Models controls into a provider action menu (`gui/src/pages/Models.tsx:2192`). +6. Remove GitHub star from persistent navigation (`gui/src/components/sidebar-github-row.tsx:136`). +7. Remove the persistent GitHub repository row (`gui/src/components/sidebar-github-row.tsx:131`). +8. Remove the second full compatibility verdict table from default flow (`gui/src/pages/CompatibilityMatrix.tsx:553`). +9. Remove the empty Routing Analytics panel until data exists (`gui/src/pages/RoutingProfiles.tsx:1097`). +10. Remove `Active days`; the range and heatmap already express it (`gui/src/pages/Usage.tsx:300`). +11. Remove the duplicate Storage home-path KPI card (`gui/src/components/storage-workspace/StorageWorkspace.tsx:623`). +12. Remove the duplicate Combos creation CTA in the zero-combo state (`gui/src/components/ComboWorkspace.tsx:108`). +13. Remove combo search while the combo count is zero (`gui/src/components/ComboWorkspace.tsx:112`). +14. Remove repeated account-priority explanatory copy from every Codex account row (`gui/src/components/codex-account-pool-cards.tsx:148`). +15. Remove generic page subtitles where the title and primary surface already communicate the job, beginning with Dashboard and Logs (`gui/src/pages/Dashboard.tsx:80`, `gui/src/pages/Logs.tsx:596`). + +## Explicitly do not touch + +- Runtime offline/error states and retry actions: they carry immediate recovery value. +- Startup protected/at-risk status and remediation: this prevents reboot-time routing failures. +- Provider attention/reauthentication indicators: exceptions are exactly what the minimal UI should emphasize. +- Model visibility toggles and stale-catalog warning: these directly determine what Codex can select. +- Combo target ordering and strategy: collapsing these would hide the combo’s actual behavior. +- Routing dry-run inputs and result evidence after a profile is selected. +- Log status, model, provider, time, duration, and row detail disclosure. +- Usage coverage percentage: aggregates without provenance are misleading. +- Storage quarantine and confirmation flows: recoverability and destructive-action safety outrank visual minimalism. +- Codex account quota, current/next account, pause, and reauthentication controls. +- Integration applied/update-needed/conflict states and confirmation dialogs. +- Accessibility semantics already present in tabs, disclosures, dialogs, focus restoration, and icon labels. + +REVIEWER: claude-fable-5-1 + + + +## R2 — full review + +I have everything I need. Writing up the full review now. + +# Dashboard minimalism review — opencodex GUI @ 664d80c76 + +**Evidence caveat first.** Four of the sixteen text/interactive captures are byte-identical duplicates of other routes, so those pages were never actually captured. `models_compatibility_text.txt` == `usage_text.txt` (md5 `9c6df39d…`), `subagents_text.txt` == `storage_text.txt` (`2052bcf5…`), `logs_text.txt` is the Codex-Set multi-auth page, and `logs_debug_text.txt` == `integrations_text.txt` (`afd25efb…`). The `_interactive.txt` dumps for those four *are* correct, and I verified the mismatch against the PNGs. Verdicts for Compatibility, Subagents, Logs, and Debug are therefore grounded in source + interactive refs, not screenshots — worth recapturing before anyone acts on them. + +My operator model throughout: someone running a local proxy who needs to answer *is it up, is my traffic going where I think, am I about to hit a limit, and how do I fix it*. Everything else is reference material and should be one click away, not on the page. + +## Sidebar and topbar + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `opencodex` + `v2.42.0` brand | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:246) | KEEP | Version is the single most-asked support question and it is live from `/healthz`. | none | +| 9 nav rows (대시보드…연동) | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:62) | KEEP | One row per page, already deduplicated once. | none | +| 언어 `Select` in footer | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:325) | DEMOTE-to-icon-menu | A locale is set once per install and then occupies a full-width footer row forever. | Discoverability drops for first-run users; keep it in the same footer cluster as theme. | +| 시스템 / theme toggle | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:335) | COLLAPSE-behind-icon-only | Same argument, and the `mode` word adds nothing the icon does not. | Screen-reader label already exists on the button; keep it. | +| `프록시` label + stop/restart orbs | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:339) | KEEP orbs, REMOVE label | The two orbs are the only destructive controls in the shell and must stay reachable; the word "프록시" above them is decoration. | Orbs already carry `aria-label` + `title`, so nothing is lost. | +| GitHub link row | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:132) | DEMOTE-to-footer-icon | A repo link is not an operating control; it currently gets equal weight to the proxy kill switch. | None — the same URL is the star button's fallback. | +| ★ star orb | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:136) | REMOVE from chrome | This is a promotion ask polling `gh` every 5 min on every page; it carries zero operator value. Note `AGENTS.md` treats starring as a user-consent action, which reinforces that it should not be ambient UI. | Maintainer loses a star funnel. Keep the action inside the update dialog if it must live somewhere. | +| ⬇ update orb + dot | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:147) | KEEP | "Am I current?" is a real operator question and the dot is the only ambient signal for it. | none | +| Mobile topbar duplicate orbs | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:264) | KEEP | Sidebar is off-canvas at that width; these are not duplicates in practice. | none | + +## Dashboard — 개요 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| "대시보드" h2 + subtitle | [Dashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Dashboard.tsx:78) | REMOVE subtitle | The sidebar row is already highlighted; the sentence restates the product description. | Nothing; the h2 stays. | +| 개요 / 활성 프로바이더 / 사용 가능한 모델 tabs | [Dashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Dashboard.tsx:54) | COLLAPSE-behind-Providers-and-Models-pages | Both tabs are strictly-poorer copies of full pages that already exist in the sidebar (see the two sections below). | Loses a same-page glance; the counts stay in the stat row. | +| 서브에이전트 `v1 / base / v2` radio group | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:52) | DEMOTE-to-Subagents-page | A three-way mode switch is the highest-consequence control on the page and it is sitting in a stat cell shaped like a read-only metric. The identical control already exists on Models. | Users who learned it here must relearn; mitigate by leaving the resolved mode as text. | +| ⓘ next to 서브에이전트 | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:37) | KEEP | The modes are genuinely non-obvious; this is disclosure done right. | none | +| 상태 / 온라인 | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:73) | KEEP | The reason the page exists. | none | +| 버전 `2.42.0` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:79) | REMOVE | Byte-identical to the sidebar brand version 200px away, from the same `/healthz`. | none | +| 가동 시간 `1시간 42분` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:80) | DEMOTE-to-tooltip-on-상태 | Uptime only matters when it is *short* (did it crash?); as a standing number it is trivia. | A restart-detector loses a glance; the tooltip keeps it. | +| 프로바이더 `9` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:81) | KEEP | Cheap, and a drop to 0 is diagnostic. | none | +| 토큰 (30일) + 커버리지 99% | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:82) | KEEP value, DEMOTE coverage | 51.5B tokens is a real signal; "커버리지 99%" is a measurement-quality caveat that belongs on Usage where it is already explained in full. | Users misreading totals as exact; keep coverage as a tooltip. | +| 재부팅 보호 health bar | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:95) | KEEP | Highest-value row on the page: one line, actionable, deep-links to Startup. | none | +| 프로젝트 설정 경고 block | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:116) | KEEP | Conditional and only renders when broken. | none | +| 서브에이전트 위임 / 없음 / 설정 열기 | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:120) | DEMOTE-to-Subagents-page | A whole panel whose steady state is "없음" plus a link to the page that owns it. | The link is the only affordance lost; the sidebar row replaces it. | +| 모델 동기화 + hint + 지금 동기화 | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:206) | KEEP button, REMOVE hint | Sync is a genuine recurring action; the two-line explanation is read once. | Move the hint to the button's `title`. | +| Codex 실행 시 opencodex 시작 toggle + 2-line hint | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:487) | DEMOTE-to-Startup-page | Its own hint tells you to go verify on Startup — that is the page that owns launch behaviour, and it already renders the shim row. | Two places to change one setting becomes one; bookmark holders lose nothing. | +| 웹 검색 사이드카 card | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:509) | COLLAPSE-behind-고급 disclosure | Set once at install; occupies a permanent half-width card thereafter. | Rarely-changed setting gets one extra click. | +| 응답 실시간 스트리밍 toggle | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:533) | COLLAPSE-behind-same | Sub-setting of a set-once setting. | none beyond the above | +| 비전 사이드카 card + effort | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:549) | COLLAPSE-behind-same | Same lifecycle as web search; pair them in one "사이드카" section. | none | +| 고급 설정 popover (max/timeout) | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:588) | KEEP | Already correctly collapsed. | none | +| 쉐도우 호출 가로채기 panel | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:626) | REMOVE (duplicate) | The identical toggle + model select + ⓘ + `⚠ 5.6-luna` badge is rendered on Models at [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1594). Two live editors for one setting is a consistency bug waiting to happen. | Dashboard-only users lose the control; Models is the honest home since it is about model rewriting. | +| 추론 상한 panel | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:37) | COLLAPSE-behind-고급 | Conditional on v2 already, but still a full panel for two rarely-touched selects. | none | +| 메모리 관찰 card (whole) | [dashboard-overview-panels.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-panels.tsx:21) | COLLAPSE-behind-single-pressure-row | This is developer telemetry on the operator's home screen. It polls every 5s and renders RSS, JS heap, JSC heap, arena, and a growth rate. | Leak-hunting gets slower; keep the pressure bar + 상세 정보 so every number stays reachable. | +| 진행 중 요청 `3` | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:425) | DEMOTE-to-overview-stat-row | This is the one genuinely operator-facing number in the card — it belongs next to 상태, not inside a memory panel. | none if relocated | +| 작업 완료 후 재시작 button | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:431) | KEEP | Drain-and-restart is materially different from the sidebar stop orb and is confirm-gated. | none | +| rss / 임계값의 28% pressure bar | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:443) | KEEP | The one memory fact with a threshold attached, so the only one that is actionable. | none | +| 상주 메모리 / JS 힙 / JSC 힙 / 시간당 변화 | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:451) | COLLAPSE-behind-상세-정보 | Four monospace byte counts nobody acts on; the growth tone already escalates into the pressure bar. | Move them into the existing `
` at line 470 — zero capability lost. | +| 상세 정보 `
` | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:470) | KEEP | Model example for the rest of the page. | none | + +## Dashboard — 활성 프로바이더 tab + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| Whole tab (9-row table) | [dashboard-providers-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-providers-section.tsx:20) | REMOVE | The Providers page shows the same nine providers *plus* quota bars, attention list, and per-provider actions. This tab is a read-only subset with no path to act on anything in it. | Loses a compact table; add an "adapter/baseURL" column toggle to the Providers rail if anyone misses it. | +| `Base URL` column | [dashboard-providers-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-providers-section.tsx:28) | DEMOTE-to-provider-detail | Raw endpoint URLs are configuration trivia except when debugging a specific provider — which is exactly when you are in its detail view. | Local-endpoint users (`http://100.100.125.116:8081/v1`) lose an at-a-glance check. | +| `어댑터` chip column | [dashboard-providers-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-providers-section.tsx:27) | DEMOTE-to-provider-detail | `openai-responses` vs `openai-chat` matters at setup time only. | same | + +## Dashboard — 사용 가능한 모델 tab + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| Whole tab | [dashboard-models-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-models-section.tsx:27) | REMOVE | Shows 96 models grouped by provider with a search box — the Models page shows the same grouping with visibility toggles, aliases, caps, and per-model detail. | Loses a read-only browser; the `96` count survives in the stat row. | +| 모델 검색 input | [dashboard-models-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-models-section.tsx:39) | REMOVE with tab | Duplicate of the Models rail. | none | + +## 시작 안전성 (startup) + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 시작 안전성 h2 + subtitle | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:321) | KEEP | Genuinely non-obvious page; the subtitle earns its line here. | none | +| 대시보드로 돌아가기 | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:325) | REMOVE | The sidebar is permanently visible and has a 대시보드 row. This is a back button in an app with no back problem. | Deep-linked arrivals lose one click; browser Back still works. | +| 새로고침 | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:328) | KEEP | State changes out-of-band after `ocx service repair`. | none | +| Codex runtime clamp notice | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:366) | KEEP | Conditional, explains a live capability loss, ships its own fix command. | none | +| 재부팅 보호됨 hero + h3 + detail | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:44) | KEEP badge, COLLAPSE prose | Badge + heading + paragraph say the same thing three times when green. | Keep the paragraph for `at-risk`/`error` where it carries the diagnosis. | +| Codex 라우팅 / 재부팅 보호 / 필요 시 자동 시작 grid | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:59) | COLLAPSE-behind-보호-상태-상세 | Three stats that restate the hero when protected. | Nothing if folded into the details panel below them. | +| 보호 상태 상세 + `darwin` | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:99) | KEEP | Per-mechanism status with install/repair buttons — the actionable core. | none | +| 백그라운드 서비스 / shim hint lines | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:105) | DEMOTE-to-tooltip | One-line explanations under labels whose badges already say 사용 가능 / 설치되지 않음. | Novices lose inline context; `title` retains it. | +| 복구 방법 section (3 copy blocks) | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:237) | COLLAPSE-behind-`
` | Manual fallback for the one-click buttons directly above; its own intro paragraph says so. | Users on locked-down shells still get it, one click in. | +| `ocx restore` row | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:264) | KEEP inside that details | The escape hatch out of the proxy entirely — must never become hard to find. | Keep it last, not hidden behind a second layer. | +| Windows tray section | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:159) | KEEP | Already platform-gated to `win32`. | none | + +## 프로바이더 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 프로바이더 h2 + 프로바이더 추가 | [Providers.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Providers.tsx:319) | KEEP | Primary action, correctly placed. | none | +| Rail search + filter popover | [ProviderWorkspaceShell.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx:382) | KEEP search, COLLAPSE filter | Search earns its place at 9+ providers; the filter is already behind a popover. | none | +| Sort: 5 modes (az/za/free-paid/paid-free/accounts-first) | [ProviderWorkspaceShell.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx:50) | REMOVE 3 of 5 | Five sort orders for a nine-item list. `za` and `paid-free` are pure inversions nobody asks for. | Keep az + accounts-first; loses ordering nobody exercises. | +| Type filter (cloud/local/selfHosted/login) | [ProviderWorkspaceShell.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx:432) | REMOVE | Four-way taxonomy over nine rows that the user can already see. | Large installs lose a facet; status + pricing filters remain. | +| 프로바이더 개요 title + "모든 모델 프로바이더를 한곳에서 관리합니다" | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:100) | REMOVE both | A second page title inside a page that already has "프로바이더" as its h2, plus a tagline. | none | +| JSON 편집 | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:104) | KEEP | Escape hatch for anything the UI cannot express. | none | +| 준비됨 8 / 설정 필요 0 / 비활성 1 cards | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:110) | REMOVE | The rail immediately left shows `준비됨 8`, `비활성 1` as group headers with the same counts. Three large cards restating adjacent headers. | The zero-state "설정 필요 0" disappears — which is the point, since zero needs no card. | +| 사용량 제한 quota rows | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:147) | KEEP | The single highest-value block in the entire dashboard for a multi-account operator. | none | +| `방금 전 전 확인` meta | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:166) | DEMOTE-to-tooltip | Per-row freshness stamp on every provider; also note the visible ko double-particle bug ("전 전"). | Stale-quota detection moves to hover. | +| OpenAI 보정/커버리지 caveat lines | [ProviderCapacityQuota.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx) | COLLAPSE-behind-ⓘ | Two full sentences of estimation methodology under one provider's bars. | Users may over-trust the pooled estimate; the ⓘ must stay adjacent to the number. | +| 최근 사용 (4 rows) | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:196) | DEMOTE-to-Usage | Request counts are a usage question, and Usage shows all 19 providers instead of the top 4. | Loses a shortcut into a provider from a usage ranking. | + +## 모델 — 카탈로그 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 모델 h2 + restart orb | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2204) | KEEP | Catalog changes need a Codex re-read; the orb is the fix. | none | +| Stale-catalog banner + 새로고침 | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2215) | KEEP | Conditional and directly actionable. | none | +| Tab strip 모델/콤보/라우팅(beta)/호환성 | [models-tab-strip.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/models-tab-strip.tsx:65) | KEEP strip, DEMOTE 호환성 | Compatibility Lab is opt-in by architecture (`AGENTS.md`) yet takes a permanent quarter of the strip. | Lab users need one more click; put it behind an overflow or the Lab activation. | +| 5-line catalog subtitle | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2226) | COLLAPSE-behind-ⓘ | Explains toggling, hiding, direct-id calls, and cache invalidation — a paragraph of documentation above the controls. | Genuinely useful once; keep every word in the popover. | +| 새 모델을 비활성 상태로 추가 | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1586) | KEEP | Real policy decision with security-ish consequences. | none | +| 별칭 button + table | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1592) | KEEP | Already collapsed behind a toggle. | none | +| 쉐도우 호출 가로채기 row | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1594) | KEEP (canonical) | This is where it should live; delete the Dashboard twin instead. | none | +| 서브에이전트 v1/base/v2 row | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1603) | DEMOTE-to-Subagents | Third rendering of one mode switch (Dashboard, Models, Subagents). Pick one owner. | Two entry points collapse to one; state is server-side so nothing diverges. | +| 기본 창 / 상한 + 5-line hint | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1706) | KEEP control, COLLAPSE hint | The 350k default genuinely governs behaviour; the paragraph explaining relay `context_length` is reference. | Misconfiguration risk if the hint is fully removed — use ⓘ, not deletion. | +| 커스텀 2개 chip | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1744) | REMOVE | A count of custom models with no link and no action. | none | +| 피커 순서 hint (ⓘ + 3-line) | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1752) | COLLAPSE-behind-ⓘ | Explains sort precedence that the list already demonstrates. | none | +| 모두 접기 / 모두 펼치기 | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1759) | KEEP | Earns its place at 8 provider groups. | none | +| Per-provider 기본 별칭 사용 switch (×8) | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1591) | COLLAPSE-into-provider-card-overflow | Eight repetitions of a set-once toggle in the densest header row in the app. | Bulk alias changes get slower; the global switch stays visible. | +| Per-provider 모두 켜기/끄기 | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1342) | KEEP | The fastest way to go from 40 Cursor models to 6. | none | +| Per-provider 기본 창/상한 + 사용자 지정 창 | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1350) | KEEP switch+select, COLLAPSE 사용자 지정 창 | Per-model overrides are a modal-worthy minority case; note the source comment already argues for the occupied slot, so keep the switch/select pair. | Per-model context tuning gets one click deeper. | +| `1,048,576` raw values | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1367) | KEEP but format | Four providers show `1,048,576` while others show `1M` / `350k` for the same kind of number. | Formatting only — no capability. | +| 새 모델 정책 끔/켬 + full model id list | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1420) | COLLAPSE-behind-provider-expand | On kimi and meta-muse this dumps nine fully-qualified ids into the header area. | The ids stay in the expanded body where they belong. | + +## 모델 — 콤보 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 콤보 `0` + 콤보 추가 (×3 buttons) | [combo-workspace-overview-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-overview-panel.tsx:44) | REMOVE 2 of 3 | The empty state renders "콤보 추가", "콤보 추가", "콤보 만들기" — three buttons for one action. | none | +| 4 count pills (total/failover/roundRobin/other) | [combo-workspace-overview-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-overview-panel.tsx:49) | COLLAPSE-when-zero | Four pills all reading 0 on a fresh install. | none when non-empty — keep them then. | +| 콤보 소개 blurb + 사용법 section | [combo-workspace-overview-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-overview-panel.tsx:47) | COLLAPSE-behind-ⓘ | Two separate explanatory blocks (`overviewBlurb`, `howBody`) for one feature. | Keep one in the empty state only. | +| Per-field helper text (콤보 ID, 공개 모델 이름, 표시 이름, 전략, 기본 추론 수준) | [combo-workspace-controls.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-controls.tsx:79) | COLLAPSE-to-placeholder-and-tooltip | Every single field carries a sentence; the create form is more prose than form. | Novice error rate may rise; keep the two non-obvious ones (전략, 적응형 추론) inline. | +| 적응형 추론 단계 2-sentence hint | [combo-workspace-controls.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-controls.tsx:143) | KEEP | Genuinely unguessable behaviour. | none | +| 할당량 알 수 없음 placeholder | [combo-workspace-controls.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-controls.tsx:294) | REMOVE-until-known | Renders before a provider is even picked. | none | + +## 모델 — 라우팅 (beta) + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `+ 프로필 만들기` / 재시도 pair | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:616) | REMOVE 재시도 | An unconditional retry button next to the create action, with no error present. | Error-state retry must remain; make it conditional on `loadError`. | +| 드라이런 평가 form (4 fields) | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:585) | COLLAPSE-behind-`
` | A simulator rendered at full size on a page with zero profiles. | Profile authors click once more. | +| 라우팅 분석 empty state | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:621) | KEEP | Correct empty-state copy, one line. | none | +| 6 fieldsets (candidates/require/optimize/limits/unknownEvidence/compatibility) | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:681) | COLLAPSE 4 of 6 | Candidates + require are the profile; optimize, limits, unknown-evidence, and compatibility-gating are expert tuning. | Advanced authors get a disclosure; nothing is removed. | +| `revision` badges (×2) | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:638) | DEMOTE-to-detail-only | Shown on both the list row and the detail header. | none | + +## 모델 — 호환성 (Lab) + +Reviewed from source and refs; screenshot missing. + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| Whole tab | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2322) | DEMOTE-behind-Lab-activation | `AGENTS.md` states Lab is opt-in and must not touch the core path; the UI contradicts that by advertising it to every user. | Lab users lose a top-level tab; gate it on the same activation flag the runtime uses. | +| 4 status cards (subject/verdict/observation/event counts) | [CompatibilityMatrix.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CompatibilityMatrix.tsx:135) | REMOVE | Internal projection cardinality — meaningless to an operator. | Lab developers lose a health readout; keep it in the detail pane. | +| 3 `전체` filter selects | [CompatibilityMatrix.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CompatibilityMatrix.tsx:436) | KEEP | The matrix is unusable unfiltered. | none | +| 프로덕션 관측 block + "검증이 아닙니다" | [CompatibilityMatrix.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CompatibilityMatrix.tsx:209) | KEEP | The disclaimer is load-bearing; without it these numbers read as verdicts. | none | + +## 서브에이전트 + +Reviewed from `subagents_interactive.txt` + source; screenshot missing. + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 추천 5/5, 모델 21, 설정 tabs | [Subagents.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Subagents.tsx:210) | KEEP | Three genuinely different jobs. | none | +| Per-row 위로/아래로/삭제 (×5 = 15 buttons) | ref `e61`–`e93`, [SubagentsWorkspace.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx) | COLLAPSE-to-drag-plus-hover | Fifteen always-visible buttons to order five items. | Keyboard users must keep the arrows — reveal on focus, not hover alone. | +| 저장 button | ref `e95` | KEEP | Explicit commit for a reorder. | none | +| 21 추천 추가/제거 buttons | ref `e101`–`e161` | KEEP | This is the tab's whole purpose. | none | +| 서브에이전트 위임 select + 일 나누는 방법 알려주기 + 울트라 모드 | ref `e166`–`e174` | KEEP | Canonical home for delegation once the Dashboard and Models copies are demoted here. | none | +| `Codex 설정에도 기본값으로 저장` | ref `e170` | KEEP | Cross-writes real Codex config; must stay explicit. | none | + +## 로그&디버그 + +Reviewed from source + refs; screenshot missing (capture shows Codex Set). + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 로그 / 디버그 tabs | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:550) | KEEP | Two distinct surfaces. | none | +| 자동 새로고침 checkbox | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:544) | KEEP | 2s polling must be defeatable while reading. | none | +| Page subtitle | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:596) | REMOVE | A table of requests needs no caption. | none | +| Surface filter all/claude/codex/grok | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:600) | KEEP | Primary triage axis. | none | +| 가로챈 헬퍼만 checkbox | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:621) | COLLAPSE-behind-filter-popover | Narrow debugging facet occupying permanent toolbar width. | Shadow-call debugging gets one click deeper. | +| 대화 + 모델 filter inputs | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:629) | KEEP | Two text filters is the right number for a log table. | none | +| Detail modal: 8 sections incl. 원본 JSON | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:933) | KEEP | On-demand by definition, and raw JSON is already in `
`. | none | +| 비용 section disclaimer, repeated per row | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:1028) | DEMOTE-to-section-tooltip | Same disclaimer appears on Usage and in every log detail. | Legal/accuracy framing weakens slightly; keep it on Usage in full. | + +## 사용량 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 전체/Codex/Claude/Grok + 30일/7일 filters | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:223) | KEEP | The two axes of the report. | none | +| Page subtitle | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:815) | KEEP | The "누락된 사용량은 0으로 표시하지 않습니다" clause changes how you read every number below. | none | +| SectionTabs 개요/모델/프로바이더/커버리지 | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:722) | KEEP | Scroll-to anchors, not panel swaps. | none | +| 요청 / 측정됨 side-by-side | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:287) | COLLAPSE-to-one | `231928` and `229025` differ by 1.2%; two cards to express one number and its caveat. | Show `231928` with measured-count on hover. | +| 커버리지 99% card | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:299) | REMOVE | Third restatement of the same measurement-quality fact (card, tab meta, and a whole 커버리지 상세 section). | The dedicated section keeps every number. | +| 캐시 히트 토큰 + 캐시 생성 sub | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:290) | KEEP | Cache ratio is the main cost lever on this workload. | none | +| 활동일 `30` | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:300) | REMOVE | On a 30-day range this reads `30` for any regular user, and the heatmap below shows activity per day. | none | +| API 정가 환산치 + 2 disclaimers | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:302) | KEEP | ~$39k is the most attention-grabbing figure in the app; the disclaimers are mandatory next to it. | none | +| 일별 활동 heatmap (13-month) | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:406) | DEMOTE-to-7d/30d-window | A year-wide grid where the screenshot shows ~4 populated columns and twelve months of empty dots. | Long-history users lose the annual view; make the range control drive the heatmap span. | +| 모델 table, 66 rows | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:696) | COLLAPSE-to-top-15-plus-더보기 | The tail is `770`, `227`, `59`, `41`, `38`, `19`, `1` tokens — and `no-such-model` / `unpriced-model` test rows. | Nothing if 더보기 reveals the rest; search already exists. | +| Rows with 0 measured / 0 tokens | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:792) | COLLAPSE-behind-"측정 안 됨" toggle | ~15 rows contributing nothing to any total. | Probe-failure debugging needs them; keep them one toggle away. | +| 프로바이더 table (19) | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:704) | KEEP | Short enough to read whole. | none | +| 커버리지 상세 5 cards + note | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:622) | KEEP | Once the duplicates above are gone, this is the single canonical home. | none | +| 미지원 `0` card | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:627) | COLLAPSE-when-zero | A card whose only value is zero. | none + +Now the final three route tables and the ranked lists. + +## 저장소 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 저장소 h2 + 다시 스캔 | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1408) | KEEP | Disk state changes outside the app. | none | +| Page subtitle | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1419) | KEEP | "정리는 활성 세션을 건드리지 않습니다" is a safety promise before a destructive action. | Removing it would make cleanup scarier, not cleaner. | +| `codexHome` path + 마지막 스캔 timestamp | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1421) | DEMOTE-to-tooltip | Two facts on a meta line; the path already has a `title` attribute. | Multi-home operators lose a glance — keep the path, drop the timestamp. | +| Cleanup percent slider + 미리보기 | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:246) | KEEP | Preview-before-delete is the correct shape for a destructive control. | none | +| 정리 도움말 paragraph | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:241) | COLLAPSE-behind-ⓘ | Third explanatory block on a page that already has a subtitle and a confirm dialog. | The confirm dialog retains the consequential wording. | +| 영구 삭제 toggle + warning | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:314) | KEEP | Irreversible-vs-quarantine is the single most important choice on the page. | none | +| Quarantine/restore panel | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1461) | KEEP | The undo path for the above. | none | + +## Codex 설정 (codex-set) + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 다중 인증 / 프롬프트 tabs | [CodexSet.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CodexSet.tsx:43) | KEEP | Unrelated surfaces, lazily mounted. | none | +| `OpenAI 계정 모드` banner (renders empty) | [codex-set-multiauth.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/codex-set-multiauth.tsx:28) | REMOVE-when-empty | In the capture this is a titled card with no body and no badge — pure vertical space. | When pool/direct badges exist it is meaningful; render only then. | +| 한도 도달 계정 일시 중지 / 할당량 새로고침 | [codex-account-pool-main-card.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/codex-account-pool-main-card.tsx:240) | KEEP | Two bulk actions over six accounts. | none | +| `선택 순서 · 기본 (0)` + 3-line hint, per account (×6) | [AccountPriorityControl.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/AccountPriorityControl.tsx:35) | COLLAPSE-to-control-plus-one-tooltip | The identical three-sentence explanation is repeated under every account card. Six copies of one paragraph. | None — one ⓘ at the pool header covers all rows. | +| `ID: account-…8327` | [codex-account-pool-main-card.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/codex-account-pool-main-card.tsx:138) | DEMOTE-to-tooltip | A truncated opaque id that cannot be copied or acted on. | Support debugging — make it copyable in the tooltip instead. | +| `리셋 크레딧 1개` badges | ref `e62`, `e81`, `e98` | KEEP | Real consumable state. | none | +| 이 계정을 다음에 사용 / 일시 중지 / 별칭 편집 / 삭제 (×5 accounts) | ref `e66`–`e141` | COLLAPSE-to-overflow-menu | ~20 always-visible buttons; only "다음에 사용" is routinely clicked. | Keep 다음에 사용 inline, move 별칭/삭제 into a ⋯ menu — nothing removed. | +| Per-account quota bars | ref `e72`ff | KEEP | The actual decision input for which account to pin. | none | +| 로테이션 전략 + 3 explanation lines | [AccountPoolStrategyControls.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/AccountPoolStrategyControls.tsx:71) | KEEP select, COLLAPSE 2 of 3 lines | `strategyDesc` is needed; `unboundDefinition` and the quota-rebinding caveat are reference. | Subtle rebinding behaviour becomes less discoverable — keep it in the ⓘ. | +| 고급 설정 | ref `e151` | KEEP | Correct disclosure. | none | + +## 연동 (integrations) + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| 연동 h2 + subtitle | [Integrations.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Integrations.tsx:131) | KEEP h2, REMOVE subtitle | The tab strip and cards below make the purpose self-evident. | none | +| 18-tab strip (개요…Aside) | [Integrations.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Integrations.tsx:142) | DEMOTE-to-detail-from-card | Eighteen tabs across one row for clients where the detected count is 0. The card grid below already lists all 17 with 설정 buttons — the strip is a second, redundant navigation for the same set. | Direct-hash bookmarks must keep working; route card clicks to the same panels. | +| Client marks on tabs | [Integrations.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Integrations.tsx:160) | KEEP | If the strip survives, the logos are what makes 18 labels scannable. | none | +| 감지된 0 / 설정된 0 / 업데이트 필요 0 / 확인 중 17 / 마지막 변경 알 수 없음 | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:519) | COLLAPSE-to-2-cards | Five summary cards of which three read 0 and one reads 알 수 없음. Keep 감지됨 and 설정됨. | Stale-count visibility drops; surface it as a badge only when non-zero. | +| `확인 중` × 17 rows | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:537) | KEEP as skeleton | Transient probe state, not permanent copy. | none — but it should look like a skeleton, not a value. | +| 모두 해제… | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:551) | KEEP | Bulk rollback for a config-writing feature. | none | +| 키 관리 explanation paragraph | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:568) | KEEP | Describes backup/restore semantics before the app edits user config files. | Removing it would hide a real consequence. | +| 온보딩 line | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:571) | COLLAPSE-to-empty-state-only | Redundant once any client is configured. | none | +| 복원 센터 heading rendered twice | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:619) | REMOVE one | The capture shows "복원 센터 / 복원 센터" — the section title and its skeleton label both render. Looks like a bug. | none | + +## Top 15 highest-noise removals, ranked + +1. **메모리 관찰 card body** — [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:451). Four byte counts + growth rate polling every 5s on the home screen. Collapse into the existing `
`; keep the pressure bar, in-flight count, and restart. +2. **Dashboard 활성 프로바이더 tab** — [dashboard-providers-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-providers-section.tsx:20). A read-only subset of the Providers page with no way to act. +3. **Dashboard 사용 가능한 모델 tab** — [dashboard-models-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-models-section.tsx:27). Same, for Models. +4. **Duplicate 쉐도우 호출 가로채기 panel** — [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:626). Two live editors for one server setting; Models is the honest home. +5. **Third copy of the v1/base/v2 mode switch** — [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:52) and [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1603). Consolidate on Subagents. +6. **Integrations 18-tab strip** — [Integrations.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Integrations.tsx:142). Duplicate navigation for a card grid that is already complete. +7. **Providers 3 summary cards** — [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:110). Restates the rail group headers 200px to the left. +8. **Per-account 선택 순서 hint ×6** — [AccountPriorityControl.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/AccountPriorityControl.tsx:35). One paragraph printed six times. +9. **Usage 커버리지 99% card + 활동일** — [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:299). Coverage appears four times on one page; 활동일 is tautological on a 30d range. +10. **Usage model-table tail (~50 rows)** — [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:696). Rows down to 19 tokens, including obvious test fixtures. +11. **13-month heatmap** — [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:406). Mostly empty; should follow the range control. +12. **Models 5-line catalog subtitle + 피커 순서 hint + 커스텀 chip** — [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2226). Documentation stacked above the controls. +13. **Startup 3-stat grid + 대시보드로 돌아가기** — [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:59). Restates the hero; back button in a permanent-sidebar app. +14. **Sidebar star orb + GitHub row weight** — [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:136). Promotion ask polling `gh` every 5 minutes, at parity with the kill switch. +15. **Integrations 3 zero-valued summary cards + duplicated 복원 센터 heading** — [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:519). + +## What I would not touch + +- **The 재부팅 보호 health bar** ([dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:95)) — one line, one dot, deep-links to the fix. This is the best-designed element in the app. +- **Provider quota bars** ([ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:147)) and per-account quota bars — for a nine-provider, six-account operator these answer the only question that changes behaviour today. +- **`ocx restore`** ([startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx:264)) — the exit from the proxy. May go inside a disclosure, never behind two. +- **Cost disclaimers next to the ~$39k figure** ([Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:302)) and the Lab "검증이 아닙니다" note ([CompatibilityMatrix.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CompatibilityMatrix.tsx:210)) — a number that will be misread without its caveat is worse than no number. +- **Storage 영구 삭제 toggle, preview, and quarantine panel** ([Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:314)) — destructive-action ceremony is not noise. +- **The sidebar stop/restart orbs** ([App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:349)) — the only always-available controls for a runaway proxy. +- **JSON 편집** ([ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:104)) — every hidden control needs an escape hatch, and this is it. +- **Conditional notices** — project-config warnings, catalog clamp, `historyTruncated`, stale-catalog banner. They cost nothing when things are fine and are the whole product when they are not. + +Two structural notes beyond the tables. First, the recurring pattern is *duplication across pages*, not verbosity within a page: shadow-call, the multi-agent mode switch, coverage, model lists, and provider lists each render in two or three places, and each pair is a state-divergence bug waiting to be filed. Deduplicating those alone removes more surface than any amount of tightening. Second, the empty states are the worst offenders — Combos with three "add" buttons and four zero-pills, Integrations with three zero-cards, Routing with a full dry-run simulator and no profiles. A zero-valued card is the highest-noise element type in this GUI, and a single "hide when zero" convention would clear a lot of it. + +I made no edits and ran no tests, per the read-only scope. No skill influenced these verdicts; the `AGENTS.md` Lab-is-opt-in invariant and the user-consent rule on starring are cited above because they support two specific calls (호환성 tab, star orb). + +REVIEWER: claude-opus-5 + +## R3 — full review + + +Read-only review at `664d80c76`. Evidence pack caveat: `logs_1440` / `logs_*.txt` captured Codex 설정, and `models_compatibility_1440` / its text files captured 사용량. Those two routes are scored from source plus the other screenshots. Sidecars/memory that the dashboard PNG cropped still appear in the full-page text dump. + +Operator test used throughout: does this help decide “is the proxy up, which provider/model is live, and what do I change if it isn’t?” If not, it should leave the first viewport. + +## Sidebar + topbar + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `opencodex` + `v2.42.0` | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:246) | KEEP | Instant “am I on the running proxy?” | Losing version makes support/upgrade harder | +| `대시보드` / `Codex 설정` / `프로바이더` / `모델` / `서브에이전트` / `로그&디버그` / `사용량` / `저장소` / `연동` | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:62) | KEEP | This is the product IA | Collapsing nav hides whole workspaces | +| `한국어` | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:323) | DEMOTE-to-settings-popover | Locale is set-once, not an ops decision | Harder first-run locale switch | +| `시스템` theme | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:335) | DEMOTE-to-same-popover | Theme is preference, not proxy state | Extra click for light/dark | +| `프록시` stop | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:349) | KEEP | Only global kill switch | Hiding it delays emergency stop | +| `Codex 모델 목록 새로고침` orb | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:355) | KEEP | Needed when Codex is stale; keep one global copy | Operators on Models lose a fallback if both page copies go | +| `GitHub` link | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:132) | DEMOTE-to-overflow/`…` | Repo browsing is not a proxy decision | Slightly slower issue/PR hop | +| `GitHub 스타 완료` | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:136) | COLLAPSE-behind-GitHub-menu | Consent/marketing chrome on every page | One extra click to star | +| `업데이트 확인` | [sidebar-github-row.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/sidebar-github-row.tsx:147) | DEMOTE-to-badge-only-when-available | Idle “check update” is noise; a pending-version dot is the decision | Missed updates if badge poll is stale | +| Mobile hamburger + duplicated orbs | [App.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/App.tsx:257) | KEEP | Narrow-screen chrome, not 1440 noise | Breaks phone/drawer use | + +## Dashboard / overview + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| subtitle `로컬 opencodex 프록시와…` | [Dashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/Dashboard.tsx:80) | REMOVE | Restates the nav label; no decision | New users lose a one-line explainer | +| tabs `개요` / `활성 프로바이더` / `사용 가능한 모델` | [Dashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/Dashboard.tsx:54) | REMOVE | Read-only clones of Providers/Models | Operators who never leave Dashboard lose a glance list | +| `서브에이전트` `v1`/`base`/`v2` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:36) | DEMOTE-to-Subagents | Mode is a settings decision, not a health stat | Extra click when flipping v1/v2 from home | +| `상태 온라인` / `가동 시간` / `프로바이더 9` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:73) | KEEP | Core health | Blind ops if removed | +| `버전 2.42.0` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:79) | DEMOTE-to-sidebar-brand-tooltip | Already in the brand chip | Duplicate version hunting | +| `토큰 (30일)` + `커버리지 99%` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:82) | DEMOTE-to-Usage-link | 515억 tokens is trivia on home; Usage already owns it | Home no longer previews spend | +| `재부팅 후에도…준비됩니다` | [dashboard-overview-head.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-head.tsx:94) | KEEP | Only when at-risk/error; green bar can shrink to a dot | Operators miss reboot risk if fully hidden | +| `서브에이전트 위임` + `설정 열기` | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:128) | DEMOTE-to-Subagents | Duplicate editor; home should show current model as a chip/link | Can’t change default spawn from home | +| `모델 동기화` + `지금 동기화` | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:205) | KEEP | Catalog rewrite is a real home action | Sync buried in Models | +| `Codex 실행 시 opencodex 시작` + long hint | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:490) | COLLAPSE-behind-Startup-row | Set-once; Startup already owns the real protection state | Toggle harder to find | +| `웹 검색 사이드카` + streaming | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:509) | COLLAPSE-behind-`고급 설정` | Rare path vs “is proxy up?” | Extra click for web-search model | +| `비전 사이드카` + `low` + `고급 설정` | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:549) | COLLAPSE-behind-same-disclosure | Same: image routing is exception handling | Vision timeout/max buried | +| `쉐도우 호출 가로채기` | [dashboard-overview-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-overview-sections.tsx:626) | DEMOTE-to-Models-catalog | Already a first-class Models control | Can’t intercept helpers from home | +| `메모리 관찰` RSS/heap/growth + restart | [MemoryObservabilityCard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/MemoryObservabilityCard.tsx:415) | COLLAPSE-behind-`상세 정보` unless warn | Debug telemetry; in-flight+restart can stay as one compact row | Leak diagnosis takes a click | + +## Dashboard / 활성 프로바이더 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| table `이름/어댑터/Base URL/모델` | [dashboard-providers-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-providers-section.tsx:16) | REMOVE | Providers workspace is the editable source of truth | Glance-only users lose adapter/URL without opening Providers | +| `어댑터` + `Base URL` columns | same | If kept at all: COLLAPSE-behind-row-detail | Operators decide on name + ready/quota, not `openai-chat` vs URL | Debugging a bad base URL needs Providers anyway | + +## Dashboard / 사용 가능한 모델 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| accordion `Anthropic Claude 13` … | [dashboard-models-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-models-section.tsx:30) | REMOVE | Catalog toggling lives on Models; this is a 96-id browser | Can’t inventory IDs from home | +| `모델 검색…` | [dashboard-models-section.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/dashboard-models-section.tsx:39) | REMOVE-with-the-tab | Search on a read-only clone is extra chrome | Same as above | + +## Startup + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `시작 안전성` + `대시보드로 돌아가기` / `새로고침` | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:319) | KEEP | This page is the recovery surface | No way back / no re-probe | +| subtitle about reboot reconnect | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:322) | COLLAPSE-behind-info-icon | Hero already says the outcome | Weaker first-visit teaching | +| `ocx sync` copy banner | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:355) | KEEP | Actionable runtime mismatch | Hidden effort-option breakage | +| `재부팅 보호됨` hero | [startup-sections.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/startup-sections.tsx) via [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:387) | KEEP | The decision this page exists for | False calm if removed | +| `Codex 라우팅` / `재부팅 보호` / `필요 시 자동 시작` cards | same | KEEP | Three-state summary is the scan | Operators must open details for every check | +| `보호 상태 상세` + shim install | same | KEEP | Install/repair is the action | Shim stays missing | +| `복구 방법` + three `ocx …` copy blocks | [Startup.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Startup.tsx:411) | COLLAPSE-behind-`복구 방법` (already a section; default-collapse when protected) | CLI copies are fallback, not daily UI | Manual repair slower when GUI install fails | + +## Providers + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| list + search + `프로바이더 추가` | [Providers.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Providers.tsx) / workspace shell | KEEP | Primary ops surface | Can’t add/select providers | +| `프로바이더 개요` subtitle | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:101) | REMOVE | “한곳에서 관리” is empty calories | None | +| `JSON 편집` | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:105) | DEMOTE-to-provider-detail/`…` | Power-user escape hatch, not overview | JSON path one click deeper | +| `8 준비됨 / 0 설정 필요 / 1 비활성` | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:111) | KEEP | Status counts earn the overview | Have to scan the list | +| `사용량 제한` bars | [ProviderOverviewDashboard.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx:150) | KEEP | Quota is the daily decision | Surprise 429s | +| `최근 사용` request counts | same file, recent-usage column | DEMOTE-to-Usage-or-provider-detail | Counts don’t change routing; quotas do | Lose “who is hot” glance | +| `방금 전 전 확인` copy | quota meta | KEEP-but-fix-copy | Timestamp is useful; doubled 전 is noise | None if only copy-fixed | +| OpenAI pool caveats (`일부만`, uncalibrated weight) | quota cards | KEEP | They change whether you trust the bar | Silent undercount | + +## Models / catalog + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `Codex가 이 카탈로그보다 오래된…` + refresh | [codex-stale-banner.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/codex-stale-banner.tsx:24) | KEEP | Conditional, actionable | Stale picker with no explanation | +| extra page-head restart orb | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2207) | REMOVE | Third copy of the same restart | Still have sidebar + banner | +| tabs `모델` / `콤보` / `라우팅 (beta)` / `호환성` | [models-tab-strip.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/models-tab-strip.tsx:65) | KEEP | Real workspaces | Lab/combo become unreachable | +| catalog subtitle (5-line essay) | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2226) | COLLAPSE-behind-`?` | Teaches cache/id rules, not a decision | New users may toggle IDs without knowing hidden IDs still work | +| left provider rail | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx) | KEEP | Filter for 96 models | Huge unfiltered list | +| `새 모델을 비활성화 상태로 추가` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1586) | COLLAPSE-behind-provider-`새 모델 정책` | Global duplicate of per-provider radios | Global default harder to set | +| `별칭` + `기본 별칭 사용` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1590) | COLLAPSE-behind-`별칭` disclosure | Alias editing is infrequent | Extra click to rename | +| `쉐도우 호출 가로채기` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1594) | KEEP | This is the right home for intercept | Helpers keep burning paid models | +| `서브에이전트 v1/base/v2` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1603) | DEMOTE-to-Subagents | Third copy of the same radios | Can’t flip mode from catalog | +| `기본 창 / 상한` + paragraph | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1706) | COLLAPSE-behind-`창` disclosure | Default 350k is set-once; per-provider caps stay | Global cap less discoverable | +| `피커 순서: Subagents에서…` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1752) | COLLAPSE-behind-tooltip | Explains a sort you cannot change here | Confusion about toggle vs order | +| `모두 접기` / `모두 펼치기` | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:1759) | KEEP | Density control on a long list | More scrolling | +| per-provider `모두 켜기/끄기`, caps, custom add | group headers in [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx) | KEEP | Actual catalog decisions | Can’t bulk-hide Cursor’s 40 | + +## Models / combos + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| tab subtitle | [Models.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Models.tsx:2226) | COLLAPSE-behind-empty-state | Repeats the empty-canvas job | Weaker first combo lesson | +| left `콤보 추가` | [ComboWorkspace.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/ComboWorkspace.tsx:108) | KEEP | List-side create | No create from the rail | +| right `콤보 만들기` + full form on empty | [combo-workspace-add-modal.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-add-modal.tsx:106) / [combo-workspace-detail-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-detail-panel.tsx:218) | DEMOTE-to-modal-on-add | Empty state already paints a 4-field expert form | Slightly slower first combo | +| `설정` / `정보` | [combo-workspace-detail-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-detail-panel.tsx:218) | KEEP `설정`; COLLAPSE-`정보` | About-tab is docs | Docs one click deeper | +| per-field hint under ID/alias/native/display/strategy | [combo-workspace-detail-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/combo-workspace-detail-panel.tsx:268) | COLLAPSE-behind-field-`?` | Four stacked essays before a target exists | Native-alias footguns less visible | +| `대상` picker + add | same | KEEP | Combo without targets is nothing | Can’t build failover | +| `이미지 / 멀티모달`, `적응형 추론 단계` | combos capabilities | COLLAPSE-behind-`기능` | Capability flags are secondary | Missed image/effort intersection | + +## Models / routing (beta) + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `+ 프로필 만들기` | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:616) | KEEP | Only create action | Can’t start a policy | +| `재시도` | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:620) | DEMOTE-to-error-only | Idle reload on an empty beta tab | Harder manual refresh | +| empty `드라이런 평가` form | [RoutingProfiles.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/RoutingProfiles.tsx:1016) | COLLAPSE-behind-profile-or-`평가` | Dry-run with zero profiles is a lab toy in the default viewport | Testing a policy needs an extra click | +| `라우팅 분석` empty | same | KEEP-as-empty-hint | Fine as a stub, not as a second card | None | + +## Models / compatibility + +Pack screenshot is Usage. From [CompatibilityMatrix.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CompatibilityMatrix.tsx:134): keep the matrix/verdicts; COLLAPSE community-evidence / status-grid counts (`subjectCount`, `observationCount`) behind `상세`. Those are Lab telemetry, not “which model can I route today?” Risk: Lab maintainers lose glance stats. + +## Subagents + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `추천` ordered 1–5 + save | [SubagentsWorkspace.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:99) | KEEP | This page’s job | Picker order uneditable | +| `spawn_agent` hint | [SubagentsWorkspace.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx:99) | COLLAPSE-behind-`?` | One-time teaching | New users miss picker vs spawn coupling | +| `모델 21` checklist | same | KEEP | Choosing the five | Can’t add candidates | +| `먼저 부를 모델` | [SubagentDelegationSection.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx) | KEEP | Default spawn is the decision | Always-empty delegation | +| `Codex 설정에도 기본값으로 저장` | [SubagentDelegationSection.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:101) | KEEP | Persistence choice | Defaults don’t stick across sessions | +| `일 나누는 방법 알려주기` + long hint | [SubagentDelegationSection.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:117) | COLLAPSE-behind-`고급` | Prompt-injection policy, not daily | Guidance toggle less obvious | +| `울트라 모드` + v2 warning | [SubagentDelegationSection.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx:135) | COLLAPSE-behind-`고급` | Expert policy; already gated | Ultra harder to enable | + +## Logs & debug + +Logs PNG in the pack is Codex 설정. From [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:540) and the real debug shot: + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `로그` / `디버그` tabs | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:550) | KEEP | Request log vs transport debug | Debug unreachable | +| logs subtitle | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:596) | REMOVE | Table is self-explanatory | None | +| surface filter Codex/Claude/Grok | [Logs.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Logs.tsx:598) | KEEP | Cuts noise in a mixed proxy | Harder isolation | +| debug subtitle + `Provider debug` / `Usage 추출` / `주입 로그` / `Claude 인바운드` | [debug-settings-panel.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/debug-settings-panel.tsx:119) | KEEP toggles; COLLAPSE-subtitle | Toggles are the page; paragraph is docs | Slightly less onboarding | +| `Follow` / `새로고침` / `런타임 재정의 해제` | logs_debug evidence | KEEP | Live tail and escape hatch | Stuck overrides | + +## Usage + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `전체/Codex/Claude/Grok` + `30일/7일` | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:811) | KEEP | Real slice controls | Can’t isolate a client | +| subtitle | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:815) | COLLAPSE-behind-`커버리지` | Methodology belongs with coverage | People may treat zeros as real | +| `요청/측정됨/총 토큰/캐시/커버리지/활동일` | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:299) | KEEP the first four; DEMOTE `활동일` | Active-days is a vanity stat here | Lose “how many days in window” | +| `API 정가 환산치 ~US$38,986` | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:303) | DEMOTE-to-`커버리지 상세` or tooltip | Fake sticker price on a subscription proxy is actively misleading | Operators who want a ceiling number must open details | +| year `일별 활동` heatmap | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:401) | COLLAPSE-behind-`활동` / replace-with-30-day-bars | GitHub-year chrome for a 30-day local log | Weaker seasonality view | +| model/provider tables | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx) | KEEP | Answers “what burned the quota?” | No breakdown | +| `커버리지 상세` | [Usage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Usage.tsx:618) | KEEP as tab | Trust-the-numbers surface | Hidden unmetered traffic | + +## Storage + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `저장소` + `다시 스캔` | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1408) | KEEP | Only action while scanning | Can’t refresh | +| subtitle | [Storage.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Storage.tsx:1419) | COLLAPSE-behind-empty/scan | Safety note belongs on destructive clean | People may fear session deletion less | +| skeleton rows | same | KEEP | Honest loading | None | + +## Codex 설정 + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| `다중 인증` / `프롬프트` | [CodexSet.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/CodexSet.tsx:43) | KEEP | Two unrelated workspaces | Prompt editor gone | +| `Codex Spark 할당량` | [codex-account-pool-main-card.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/codex-account-pool-main-card.tsx:218) | KEEP | Visibility toggle for a real quota family | Spark hidden with no switch | +| `한도 도달 계정 일시 중지` / `할당량 새로고침` / `추가` | pool header | KEEP | Daily pool ops | Can’t pause/refresh/add | +| per-account `이 계정을 다음에 사용` / `일시 중지` / `별칭` / delete / quota | cards | KEEP | Account-level decisions | Stuck on a burned account | +| `선택 순서 기본 (0)` repeated 5× | [AccountPriorityControl.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/AccountPriorityControl.tsx:35) | DEMOTE-to-nondefault-only | Default-0 on every card is wallpaper; hint is already `sr-only` | Fine-grained order less visible | +| `리셋 크레딧 N개` | cards | KEEP | Spends a real credit | Accidental hide of a billed action | +| `로테이션 전략` copy block | [CodexAccountPool.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/CodexAccountPool.tsx) | COLLAPSE-behind-select-tooltip | Three paragraphs for one dropdown | Binding/affinity less understood | +| `고급 설정` | [CodexAuthAdvancedSettings.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/components/CodexAuthAdvancedSettings.tsx:18) | KEEP | Already the right disclosure | None | + +## Integrations + +| element | source | verdict | reason | risk | +|---|---|---|---|---| +| subtitle | [Integrations.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/Integrations.tsx:133) | REMOVE | Overview cards already say it | None | +| 18-tab strip `개요…Aside` | [integration-tabs.ts](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/integration-tabs.ts:31) | DEMOTE-uninstalled-to-`더보기` | Hick’s law: 10 detected, 6 applied, 8 empty clients in the tablist | Uninstalled clients one click further | +| `감지된 10 / 설정된 6 / 업데이트 필요 2` | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:519) | KEEP | Scan-level status | No bulk picture | +| `마지막 변경 9/3/2026, 7:09:00 PM` | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:542) | DEMOTE-to-복원-센터 | Timestamp is audit, not apply/unapply | Harder “what just changed?” | +| `모두 해제…` | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx:547) | KEEP | Dangerous bulk action should stay explicit | No bulk undo-apply | +| client cards + apply/settings | [IntegrationsOverview.tsx](/Users/jun/.codex/worktrees/8938/opencodex/gui/src/pages/integrations/IntegrationsOverview.tsx) | KEEP applied/stale; COLLAPSE-`미설치` | Empty path cards (`~/.omp/...`) are inventory, not decisions | Discovering a new client needs `더보기` | +| raw home paths on cards | same | COLLAPSE-behind-`설정` | Path is for the detail pane | Copy-path slower | +| `복원 센터` | rollback history | KEEP | Undo is the safety net | No restore | + +## TOP 15 highest-noise (ranked) + +1. Sidebar `GitHub 스타` on every route — marketing/consent, zero proxy decision. +2. Dashboard `활성 프로바이더` + `사용 가능한 모델` tabs — read-only clones of two full pages. +3. Dashboard first viewport packed with sidecar/delegation/memory editors — home should be health + sync + at-risk startup. +4. Models catalog 5-line subtitle + global alias/new-policy/v2/context-cap essay before the list. +5. Triple `v1/base/v2` (Dashboard, Models, Subagents). One owner: Subagents. +6. Dual `쉐도우 호출 가로채기` (Dashboard + Models). One owner: Models. +7. Third Codex-restart orb on Models page-head (sidebar + stale banner already exist). +8. Integrations 18-tab strip including uninstalled clients. +9. Combos empty state rendering the full expert form + duplicate `콤보 추가`. +10. Routing dry-run card as the default empty beta tab. +11. Usage GitHub-year heatmap + `$38,986` sticker price on a subscription mix. +12. Almost every `page-sub` sentence (`대시보드`, `프로바이더 개요`, `연동`, `로그`, `저장소`). +13. Idle sidebar `업데이트 확인` + `GitHub` link chrome. +14. Memory RSS/heap/arena/JSC block on Overview (details already exist). +15. Codex pool `선택 순서 기본 (0)` × N cards plus rotation-strategy essay. + +## Do not touch + +- Provider ready/disabled list, add-provider, and quota bars — that is the daily “can I keep working?” surface. +- Models per-provider show/hide, stale-catalog banner, and one Codex restart path. +- Codex account pause / next / add / reset-credit — billed, irreversible-ish ops. +- Logs table (when actually on Logs) and debug transport toggles. +- Startup hero + shim/service install when unprotected. +- Integrations apply/unapply/restore for clients that are actually present. +- Stop-proxy control. +- Destructive confirms (disable-all, delete account, storage clean). Collapsing those into magic defaults is the inverse failure. + +Minimal dashboard, for this operator: **one home (health + sync + startup risk), one catalog, one account pool, one log, one usage table.** Settings that are not those five belong behind a disclosure or on their existing dedicated page — not deleted. + +REVIEWER: grok-4.6 diff --git a/devlog/_plan/260904_dashboard_minimal/002_decisions.md b/devlog/_plan/260904_dashboard_minimal/002_decisions.md new file mode 100644 index 0000000000..255851c92c --- /dev/null +++ b/devlog/_plan/260904_dashboard_minimal/002_decisions.md @@ -0,0 +1,101 @@ +# 002 — Decisions: keep / remove / collapse per element + +Merged from 001 (R1 claude-fable via sol slot, R2 claude-opus-5, R3 grok-4.6) plus the main +agent's own pass over the corrected captures. Rule applied throughout: nothing loses capability; +a hidden control moves behind a disclosure, tooltip, detail view, or to its owning page. + +Ranking is by noise removed (screen area × pages affected × duplication). Each item names the +work-phase that lands it. Votes: number of reviewers proposing remove/collapse/demote. + +## Verdicts + +| # | Element | Votes | Verdict | Owner phase | +|---|---|---|---|---| +| 1 | Dashboard tabs 활성 프로바이더 / 사용 가능한 모델 (Dashboard.tsx:54-57, dashboard-providers-section.tsx, dashboard-models-section.tsx) | 3/3 | REMOVE tabs; `#dashboard/providers` → `#providers`, `#dashboard/models` → `#models` redirect | 020 | +| 2 | Dashboard: subagent v1/base/v2 in the stat row (dashboard-overview-head.tsx:36-64) | 3/3 | REMOVE from dashboard; owner = Subagents (already has it via SubagentDelegationSection? — verify at P; if not, Models' copy moves there) | 020 | +| 3 | Dashboard: 서브에이전트 위임 card (dashboard-overview-sections.tsx:127) | 3/3 | REMOVE; owner = Subagents | 020 | +| 4 | Dashboard: 쉐도우 호출 가로채기 panel (dashboard-overview-sections.tsx:626) | 3/3 | REMOVE; owner = Models controls row | 020 | +| 5 | Dashboard: 웹 검색 / 비전 사이드카 cards (dashboard-overview-sections.tsx:509,549) | 3/3 | COLLAPSE both into one `
` "사이드카" (closed by default) on the dashboard | 020 | +| 6 | Dashboard: Codex 실행 시 opencodex 시작 card (dashboard-overview-sections.tsx:487) | 3/3 | MOVE to Startup 보호 상태 상세 panel atomically (hook `useCodexAutostart`) | 020 | +| 7 | Dashboard: 메모리 관찰 4-stat block (MemoryObservabilityCard.tsx:451) | 3/3 | COLLAPSE the stat row into the existing `
`; keep pressure bar, in-flight, restart | 020 | +| 8 | Dashboard: 버전 / 가동 시간 / 토큰(30일)+커버리지 stat cards | 2/3 | KEEP status + 프로바이더 + 토큰(30일); DROP the 버전 and 가동 시간 cards; both render as a visible sub-line on the status card | 020 | +| 9 | Dashboard subtitle (Dashboard.tsx:80) | 3/3 | REMOVE | 020 | +| 10 | Sidebar GitHub star orb (sidebar-github-row.tsx:136) | 3/3 | REMOVE from chrome; the star action stays reachable in the update dialog (DashboardDialogs) | 010 | +| 11 | Sidebar GitHub link row + update orb (sidebar-github-row.tsx:131,147) | 3/3 | COLLAPSE into one footer icon row: GitHub link icon + update icon (dot only when available); no text label | 010 | +| 12 | Sidebar language + theme rows (App.tsx:323,335) | 3/3 | COLLAPSE into the same footer icon row: globe icon opens the existing Select (beside placement), theme icon cycles; text labels removed, aria-labels kept | 010 | +| 13 | Sidebar "프록시" action label (App.tsx:339) | 2/3 | REMOVE label; orbs keep aria-label/title | 010 | +| 14 | Sidebar version chip | 1/3 | KEEP (R2/R3) | — | +| 15 | Sidebar nav rows | 1/3 | KEEP all 9 (R2/R3; route moves are out of scope) | — | +| 16 | Models page-head Codex-restart orb (Models.tsx:2207) | 1/3 (R3) | REMOVE (sidebar orb + stale banner remain) | 030 | +| 17 | Models catalog subtitle (Models.tsx:2226 SUBTITLE_TKEY.catalog) | 3/3 | COLLAPSE: subtitle → focusable `Tooltip` trigger (ⓘ button) next to the tab strip; combos/routing subtitles → keep only in empty state | 030 | +| 18 | Models global controls: 새 모델 정책 / 별칭 / 쉐도우 / v1-base-v2 / 기본 창-상한 + paragraph (Models.tsx:1580-1750) | 3/3 | COLLAPSE into one `
` "고급" (closed by default); the v1/base/v2 row moves to Subagents (see #2) | 030 | +| 19 | Models 피커 순서 paragraph (Models.tsx:1752) | 3/3 | COLLAPSE → focusable `Tooltip` trigger (ⓘ button) after 모두 펼치기 | 030 | +| 20 | Models per-provider header control wall (6 controls × N) | 2/3 | COLLAPSE 기본 별칭 사용 / 커스텀 모델 추가 / 기본 창-상한 / 사용자 지정 창 into a per-provider "⋯" labelled disclosure (inline reveal, not a menu); keep edit + 모두 켜기/끄기 inline | 030 | +| 21 | Integrations 18-tab strip (Integrations.tsx:142) | 3/3 | COLLAPSE: strip shows 개요 + API 키 + detected/applied clients; uninstalled clients under a "더보기 ▾" overflow; hashes keep working | 040 | +| 22 | Integrations subtitle (Integrations.tsx:133) | 3/3 | REMOVE | 040 | +| 23 | Integrations cards for uninstalled clients | 3/3 | COLLAPSE below a "설치되지 않음 (N)" disclosure; applied/stale/conflict cards stay | 040 | +| 24 | Integrations 마지막 변경 cell | 2/3 | REMOVE from summary (복원 센터 shows chronology) | 040 | +| 25 | Integrations 모두 해제 | 1/3 | KEEP (R2/R3: bulk rollback is safety) | — | +| 26 | Codex 설정: 선택 순서 select ×N (AccountPriorityControl.tsx) | 3/3 | COLLAPSE: render the select only when value ≠ default OR the card is expanded; hint already sr-only | 050 | +| 27 | Codex 설정: 별칭 편집 + ✕ per card | 2/3 | COLLAPSE into a per-card "⋯" labelled disclosure; 이 계정을 다음에 사용 / 일시 중지 stay inline | 050 | +| 28 | Codex 설정: truncated account ID line | 2/3 | MOVE into the ⋯ disclosure as a visible mono line + "ID 복사" button | 050 | +| 29 | Codex 설정: 로테이션 전략 three desc lines (AccountPoolStrategyControls.tsx:71) | 2/3 | KEEP (deviation at wp5 B: six existing tests pin both lines as a visible safety property — the affinity/rebinding answer — and the component comment records that as deliberate; a 2/3 vote does not outrank a tested product decision) | — | +| 30 | Codex 설정: empty OpenAI 계정 모드 card | 1/3 (R2) | REMOVE when it has no badges/body | 050 | +| 31 | Usage 활동일 card (Usage.tsx:300) | 3/3 | REMOVE | 060 | +| 32 | Usage 요청/측정됨 pair | 1/3 | KEEP (coverage story needs both) | — | +| 33 | Usage cost row (Usage.tsx:302) | 2/3 | KEEP the number + disclaimer (R2: a number without its caveat is worse); DEMOTE font to text-control | 060 | +| 34 | Usage heatmap (Usage.tsx:400) | 3/3 | COLLAPSE into `
` "일별 활동" (closed by default); 7d bars unchanged | 060 | +| 35 | Usage subtitle | 2/3 | COLLAPSE → focusable `Tooltip` ⓘ button beside the 커버리지 card label | 060 | +| 36 | Startup 3 stat cards (startup-sections.tsx:59) | 2/3 | COLLAPSE into a single line under the hero ("로컬 프록시 · 백그라운드 서비스 · 자동 시작 켜짐") | 070 | +| 37 | Startup 대시보드로 돌아가기 (Startup.tsx:325) | 2/3 | REMOVE | 070 | +| 38 | Startup 복구 방법 (Startup.tsx:411) | 3/3 | COLLAPSE into `
`, open when not protected | 070 | +| 39 | Startup subtitle | 2/3 | MOVE into the hero card as a visible `.muted` line | 070 | +| 40 | Providers 프로바이더 개요 subtitle (ProviderOverviewDashboard.tsx:98) | 3/3 | REMOVE | 080 | +| 41 | Providers 3 summary cards | 1/3 | KEEP (R1/R3) | — | +| 42 | Providers 최근 사용 list | 2/3 | COLLAPSE into `
` (closed) | 080 | +| 43 | Providers "방금 전 전 확인" copy bug | R3 | FIX the ko string (double 전) | 080 | +| 44 | Logs subtitle (Logs.tsx:596) | 3/3 | REMOVE | 080 | +| 45 | Logs 10 columns → column picker | 1/3 | DEFER (Logs just reworked in #3367) | — | +| 46 | Subagents spawn_agent hint (SubagentsWorkspace.tsx:97) | 3/3 | COLLAPSE → focusable `Tooltip` ⓘ button on the 5/5 counter | 080 | +| 47 | Subagents 일 나누는 방법 / 울트라 모드 (SubagentDelegationSection.tsx:116,133) | 2/3 | COLLAPSE into `
` "고급" | 080 | +| 48 | Combos duplicate create CTA + search on zero combos | 2/3 | REMOVE search when count 0. The inline first-combo editor STAYS (deviation at wp8 B: four existing tests pin it as a deliberate flow — draft survives a tab switch, Create gates on exhausted targets, confirmation — same rule as #29) | 080 | +| 49 | Routing dry-run card with zero profiles | 3/3 | Render only when a profile is selected | 080 | +| 50 | Storage subtitle | 2/3 keep | KEEP (safety promise) | — | +| 51 | Compatibility second verdicts table | 1/3 | DEFER (Lab surface; opt-in) | — | + +## Ask items (contested + workflow-changing) — recorded, not blocking + +- #2 owner of v1/base/v2: all three say Subagents; the Subagents page currently has no such + switch. Decision: Models' copy moves to Subagents in 030; dashboard's copy is removed in + 020. If the user wants it back on the dashboard, it is one line to re-add. +- #33 cost row: R3 wants it hidden as misleading; R2 wants it kept with the caveat. Decision: + keep with caveat (visible caveat is the safety property). + +## Phase map (dependency order, one decade doc = one work-phase = one PR) + +| Phase | Doc | Scope | Depends on | +|---|---|---|---| +| wp1 | 010_sidebar_footer.md | Sidebar footer icon row (lang/theme/GitHub/update), remove star orb + action label | — | +| wp2 | 020_dashboard_home.md | Dashboard: remove clone tabs + redirects, remove duplicated settings (autostart rehomed to Startup, effort cap rehomed to Subagents, both in this phase), collapse sidecars + memory, stat row trim | — | +| wp3 | 030_models_catalog.md | Models: remove head orb, subtitle→tooltip, advanced disclosure, per-provider ⋯ disclosure, move v2 switch to Subagents | 020 | +| wp4 | 040_integrations.md | Integrations: tab overflow, uninstalled disclosure, summary trim, subtitle | — | +| wp5 | 050_codex_set.md | Codex 설정 account cards: ⋯ disclosure, priority-on-demand, ID line in disclosure, strategy ⓘ Tooltip, empty card | — | +| wp6 | 060_usage.md | Usage: 활동일, heatmap details, subtitle tooltip, cost row weight | — | +| wp7 | 070_startup.md | Startup: hero line, remove back button, recovery details, subtitle line | 020 (autostart row already rehomed there) | +| wp8 | 080_page_polish.md | Providers / Logs / Subagents / Combos / Routing small items (#40-49) | 030 (Subagents disclosure) | +| wp9 | 090_i18n_prune_docs.md | Remove orphaned i18n keys across 9 locales, docs-site dashboard pages sync | 010-080 | + +Each phase's C runs: typecheck, lint:gui, lint:i18n (when copy changes), focused gui tests + +`cd gui && bun test tests`, `cd gui && bun run build`, privacy:scan, and a ko 1440 px +before/after screenshot pair with a DOM count of visible interactive controls and text nodes. + +## Gate note (audit blocker 8) and a11y rule + +- PR-ready gate: AGENTS.md L207-209 requires `bun run typecheck` + `bun run test` before a + non-trivial PR is review-ready. The user forbade the repository-wide local suite for this + task; hosted CI `gates` + `test N/4` shards on the exact head are the equivalent. Each + phase's D records the CI rollup at merge and never claims a local full-suite run. This is a + recorded, user-authorized deviation. +- Accessibility rule for every phase: information never moves to a `title` attribute alone; + it becomes a visible sub-line, a disclosure body, or a focusable `Tooltip` trigger. + Disclosures are labelled disclosures (aria-expanded), never called menus. diff --git a/devlog/_plan/260904_dashboard_minimal/003_audit_record.md b/devlog/_plan/260904_dashboard_minimal/003_audit_record.md new file mode 100644 index 0000000000..a301edacf7 --- /dev/null +++ b/devlog/_plan/260904_dashboard_minimal/003_audit_record.md @@ -0,0 +1,19 @@ +# 003 — Roadmap audit record (wp0) + +Reviewer: gpt-5.6-sol (agent 01a06835-bac8, medium effort), read-only, same reviewer for every +round (AUDIT-LOOP-01). Docs-only cycle; no gui/src or src/ change in this work-phase. + +| Round | Verdict | Blockers | Folded in commit | +|---|---|---|---| +| 1 | fail | 8 — star capability loss, autostart phase gap, Build-time guesses in 010/020/040/080/090, wrong /api/codex/v2 endpoint, title-only a11y, details-as-menu, i18n verifier not real, PR-ready gate | 2492b80bc | +| 2 | fail | 9 — 002 contradictions, effort-cap /api/effort-caps rehome, Models must keep v2 state, 040 collapse rule, 060 title + pinRight scope, 070 protected expression, 080 handleAdd short-circuit, 090 locale paths, 010 star mount/tests | 8939f66e1 | +| 3 | fail | 3 — dialog always mounted (explicit conditional), UltraModeState/Patch contract per phase, Tooltip nests a button | ce461f9f7 | +| 4 | fail | 3 — d.apiBase, multiAgentMode constructor sites, Tooltip accessible name | 6c7fcd904 | +| 5 | near-pass | none; residual 090 orphan list | ca4315fa3 | + +What the loop bought: every decade doc now names the exact endpoint, the exact constructor +sites of a widened type, the exact conditional mount, and which phase owns each contract +change, so the implementation cycles can fail only on execution, not on plan ambiguity. + +Verifiers run by the reviewer during the rounds: sidebar-rows 5/5, integrations-surfaces +34/34, locale-parity 5/5, multi-agent-guidance 4/4, gui lint:i18n exit 0. diff --git a/devlog/_plan/260904_dashboard_minimal/010_sidebar_footer.md b/devlog/_plan/260904_dashboard_minimal/010_sidebar_footer.md new file mode 100644 index 0000000000..fb9c999837 --- /dev/null +++ b/devlog/_plan/260904_dashboard_minimal/010_sidebar_footer.md @@ -0,0 +1,138 @@ +# 010 — WP1: sidebar footer collapses to one icon row + +Depends on: nothing. Lands as PR 1 of the stack. + +## Goal + +The sidebar footer currently spends five rows on preference/promo chrome (language select, +theme button, "프록시" label + 2 orbs, GitHub link + star + update). After: one icon row +(globe · theme · GitHub · update) and one orb row (session-logout? · stop · restart). No text +labels; every control keeps `aria-label` + `title`. The star orb leaves the chrome entirely. + +## File change map + +### MODIFY gui/src/App.tsx (L322-375) + +Before (structure): +```tsx +
+
({ value: l.code, label: localeDisplayName(l.code) }))} + onChange={v => setLocale(v as Locale)} + label={t("lang.label")} + placement="right" + portal={false} + trigger={} + /> +
+ + +
+
+ {logout orb (unchanged)}{stop orb (unchanged)}{restart orb (unchanged)} +
+ +``` + +DECISION (audit blocker 3): `Select` (gui/src/ui.tsx:96-112) has no `trigger` prop and gets +none. Keep the existing `