diff --git a/devlog/_plan/260727_login_url_copy_parity/000_inventory.md b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md
new file mode 100644
index 0000000000..e5587b4b39
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md
@@ -0,0 +1,87 @@
+# 000 — 로그인 URL 복사 어포던스 인벤토리 (조사)
+
+세 개의 OAuth 로그인 대기 표면이 서로 다른 수준의 어포던스를 들고 있다.
+5919779d가 둘을 고쳤고 하나는 그대로 남았다. 아래는 조사 시점(dev @ f327db1e)의
+실측이다.
+
+## 표면 A — Provider Workspace 설정 패널
+
+`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:150-172`
+
+- URL 전문: `{hintForThis.url}` — 있음
+- 복사 버튼: 3-상태(`idle` / `copied` / `unavailable`), `copyTextToClipboard` 사용
+- 타이머: `linkCopyTimer` ref + 언마운트 `clearTimeout`
+- 외부 링크: `prov.didntOpen` 있음
+- a11y: 라벨 span에 `aria-live="polite"`
+- 기기 코드 복사(`prov.copyCode`)는 `navigator.clipboard.writeText` 직접 호출 —
+ 같은 파일 안에서 규약이 갈린다(복사 실패 시 라벨 변화 없음).
+
+## 표면 B — 프로바이더 추가 모달
+
+`gui/src/components/add-provider-oauth-pane.tsx:38-93`
+
+표면 A와 동일한 블록을 **복제**해서 들고 있다. 상태 이름(`linkCopyState`),
+타이머 ref, 렌더 트리, 클래스명까지 같다. 즉 이미 2벌 중복이다.
+
+## 표면 C — Codex/ChatGPT 계정 추가·재인증 모달 (구멍)
+
+`gui/src/components/add-codex-account-waiting-step.tsx:41-43`
+`gui/src/components/use-add-codex-account-oauth.ts:253-273`
+
+- URL 전문: **없음**. `ui.authUrl`은 상태에 있지만 화면에 렌더되지 않는다.
+ 복사 버튼이 듣지 않는 환경(비보안 컨텍스트)에서 URL을 얻을 방법이 0이다.
+- 복사 피드백: 2-상태(`copied` boolean)뿐. 실패는
+ `dispatch({type:"set-error"})`로 **에러 notice**에 섞인다
+ (`codexAuth.loginLinkCopyFailed`). 복사 실패와 로그인 실패가 같은 자리에
+ 같은 톤으로 뜬다.
+- 복사 구현: `navigator.clipboard?.writeText` → 실패 시 `document.execCommand("copy")`
+ 수동 textarea 폴백. 저장소 공용 래퍼(`oauth-health-display.ts:126`
+ `copyTextToClipboard`)를 쓰지 않는 유일한 경로다.
+- 타이머: `setTimeout` 2.5초, ref 없음. 연속 클릭 시 앞 타이머가 뒤 라벨을
+ 지운다 — 5919779d 감사에서 이미 잡혔던 회귀가 이 파일에만 남았다.
+- 외부 링크(`prov.didntOpen`): **없음**.
+
+`AddCodexAccountModal`은 계정 추가와 재인증 두 진입점을 모두 이 대기 화면으로
+보낸다(`AddCodexAccountModal.tsx:66-84`, `CodexAccountPool.tsx` `openReauth`).
+즉 ChatGPT 계정을 추가하는 모든 사용자가 구식 표면을 만난다.
+
+## 데이터 경로
+
+| 표면 | URL 출처 | 상태 보관 |
+|------|----------|-----------|
+| A | `POST /api/oauth/login` → `loginInfo.url` (`use-providers-oauth.ts:84`) | `Providers.tsx` useState |
+| B | 같은 엔드포인트 → `oauthUrl` + `oauthUrlProvider` | `add-provider-modal-reducer.ts` |
+| C | `POST /api/codex-auth/login` → `data.url` (`use-add-codex-account-oauth.ts:157`) | `add-codex-account-reducer.ts` `authUrl` |
+
+서버는 세 경로 모두 URL을 이미 내려주고, 브라우저 열기까지 서버가 이미 시도한다
+(`src/server/management/oauth-account-routes.ts:100-106`,
+`src/codex/auth-api.ts:759-761`). 서버 변경은 필요 없다. 이 사실이 블록의
+성격을 결정한다 — 이 블록은 기본 경로가 아니라 **자동 열기 실패 뒤의 복구 경로**다.
+
+## i18n 현황
+
+`prov.copyLink` / `prov.linkCopied` / `prov.linkCopyUnavailable` /
+`prov.didntOpen`은 6개 로케일(en/ko/ja/zh/de/ru)에 모두 있다.
+`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` /
+`codexAuth.loginLinkCopyFailed`도 6개 로케일에 있으나 표면 C 전용이다.
+
+## CSS 현황
+
+`gui/src/styles/provider-workspace-settings.css:36-39`에
+`.pwi-auth-open-link` / `.pwi-auth-url-wrap` / `.pwi-auth-url` /
+`.pwi-auth-url-actions`가 이미 정의돼 있고 표면 A·B가 공유한다.
+접두사 `pwi-`(provider workspace item)가 모달에서도 쓰이는 상태라
+이름과 소속이 어긋난다.
+
+## 문제 정의
+
+1. 표면 C에 URL 전문·외부 링크가 없어 "브라우저가 안 열림" 상황에서 막힌다.
+2. 표면 C의 복사 실패가 에러로 오분류되고 3-상태 피드백이 없다.
+3. 표면 C의 타이머가 ref로 보호되지 않는다.
+4. 표면 A·B가 같은 블록을 2벌 복제해 다음 회귀가 다시 갈라질 준비를 하고 있다.
+
+## 결론 — 작업 분해
+
+- wp1: 공용 컴포넌트 신설(3벌 중복의 단일 소유자) + 단위 테스트.
+- wp2: 표면 C 채택 + 복사 규약 통일 + 타이머 하드닝 + 회귀 테스트.
+- wp3: 표면 A·B 이관, i18n 정합, 전체 검증.
diff --git a/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md
new file mode 100644
index 0000000000..c5c099800d
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md
@@ -0,0 +1,136 @@
+# 010 — 공용 LoginUrlBlock 컴포넌트 (wp1)
+
+## 목표
+
+로그인 URL 노출 + 복사 + 외부 열기를 한 파일이 소유한다. 현재 표면 A·B가
+같은 JSX를 2벌 들고 있고 표면 C는 아예 없다. 소유자가 하나여야 다음 회귀가
+세 갈래로 갈리지 않는다.
+
+## Design Read (mini DESIGN.md)
+
+```yaml
+name: login-url-block
+role: recovery affordance inside an OAuth waiting state
+```
+
+읽기: 로컬 프록시 관리 도구(D5~D6 밀도)의 **에러 복구 보조 표면**이다.
+사용자는 이미 "브라우저가 안 열렸다"는 실패 상태에 있고, 목표는 URL을 다른
+기기/브라우저로 옮기는 것 하나뿐이다.
+
+- DESIGN_VARIANCE: 2
+- MOTION_INTENSITY: 1
+- 밀도 프로필: D5 (개발자·운영 도구)
+- 근거: 실패 복구 경로에 시각적 변주를 넣는 것은 domain-wrong이다. 이 블록의
+ 성공 기준은 "URL을 얻어간다" 하나이며, 기존 대기 패널의 시각 언어를 벗어나면
+ 오히려 사용자가 새 UI를 읽는 비용을 문다.
+
+Do: 기존 `.pwi-auth-url*` 시각 언어를 그대로 승계한다. 복사 결과를 말로 알린다.
+Don't: 새 색/모션/아이콘 체계 도입 금지. 토스트 도입 금지(대기 패널 안에서 끝낸다).
+
+### Lazy-User Gate (UX-LAZY-01)
+
+이 블록의 결정 지점은 "복사" 하나다. 나머지는 결정이 아니라 정보다.
+
+- do nothing: 서버가 이미 브라우저를 열어봤다(`openUrl`). 실패했을 때만 이 블록이 의미를 갖는다 → 렌더 조건은 `url`이 있을 때로 유지.
+- delete: "URL 전문 표시"를 지울 수 있나? 못 지운다. 클립보드가 막힌 컨텍스트에서 유일한 탈출구다(`user-select: all`).
+- absorb: 복사 실패를 시스템이 흡수한다 → 사용자에게 "실패했으니 알아서 하라"가 아니라 URL 전문을 이미 눈앞에 두어 수동 선택이 가능한 상태로 만든다.
+- demote: 없음. 이 블록 자체가 이미 실패 상태에서만 의미를 갖는 종속 정보다.
+
+### UX-STATE-01 — 에러/복구 상태 계약
+
+복사 결과는 3-상태다. 실패를 조용히 삼키지 않고, 로그인 에러와 섞지도 않는다.
+
+| 상태 | 라벨 키 | 의미 |
+|------|---------|------|
+| idle | `prov.copyLink` | 아직 누르지 않음 |
+| copied | `prov.linkCopied` | 클립보드에 들어감 |
+| unavailable | `prov.linkCopyUnavailable` | 클립보드 API 부재/거부 — URL 전문을 수동 선택하라는 신호 |
+
+`unavailable`은 dead-end가 아니다. 바로 위에 URL 전문이 `user-select: all`로
+있으므로 복구 경로가 화면에 남아 있다.
+
+## 신규 파일 — `gui/src/components/login-url-block.tsx`
+
+```tsx
+import { useEffect, useRef, useState } from "react";
+import { IconExternal, IconLink } from "../icons";
+import { useT } from "../i18n/shared";
+import { copyTextToClipboard } from "../oauth-health-display";
+
+export type LoginUrlCopyState = "idle" | "copied" | "unavailable";
+
+export function LoginUrlBlock({ url, className }: { url: string; className?: string }) { ... }
+```
+
+- props는 `url` 하나(+ 선택적 `className`). 상태·타이머·i18n을 컴포넌트가 소유한다.
+ 호출부가 복사 상태를 들고 있을 이유가 없다 — 표면 A·B의 `linkCopyState`,
+ 표면 C의 `ui.copied`가 전부 사라진다.
+- `url`이 빈 문자열이면 `null`을 반환한다. 호출부 조건문을 단순화한다.
+- 타이머는 `useRef | null>`. 클릭할 때마다
+ `clearTimeout` 후 재설정, 언마운트 시 정리. 이유: 같은 outcome을 연속으로
+ 누르면 functional-update 가드로는 앞 타이머가 뒤 라벨을 지운다(5919779d 감사 결론).
+- **`url`이 바뀌면 복사 상태를 `idle`로 되돌리고 대기 중인 타이머를 취소한다**
+ (`useEffect(..., [url])`). A 감사 blocker #1: 표면 A는
+ `Providers.tsx:210`의 `key={item.name}`로 리마운트되어 우연히 안전하지만,
+ 표면 B(`AddProviderModal.tsx:253`)에는 그런 key가 없다. 지금은 복사 상태가
+ pane 지역 상태라 무해하지만, 블록이 상태를 소유하는 순간
+ "Claude 복사 → Back → Gemini 로그인" 경로에서 **Gemini URL 위에 '복사됨'이
+ 남는다** — 클립보드에는 Claude URL이 든 채로. 거짓 성공이다.
+ 호출부 `key={url}`이 아니라 컴포넌트 내부 effect로 해결한다. 상태를 소유한
+ 쪽이 그 상태의 무효화도 소유해야 한다(이 문서의 단일 소유자 논지 그대로).
+- 라벨 span에 `aria-live="polite"`. 상태 전환을 스크린리더에 고지한다.
+- 외부 링크 `prov.didntOpen`은 블록 내부에 포함한다. 표면 A·B가 지금 이 링크를
+ 블록 밖에 두고 있으나, "URL을 얻는다"라는 하나의 사용자 의도에 속하므로
+ 블록이 함께 소유하는 것이 옳다.
+
+렌더 구조(기존 마크업/클래스 승계):
+
+```tsx
+
+```
+
+클래스명은 이번 단계에서 바꾸지 않는다. `pwi-` 접두사가 모달에서도 쓰이는
+어긋남은 사실이나, 리네임은 CSS·테스트·세 표면을 동시에 건드리므로 이 루프의
+목표(기능 동등성)와 섞으면 회귀 원인을 분리할 수 없다. 별도 항목으로 남긴다.
+
+## 신규 테스트 — `gui/tests/login-url-block.test.tsx`
+
+`gui/tests/provider-auth-login-copy-link.test.tsx`의 happy-dom + act 하네스를 재사용.
+
+1. `url`이 주어지면 URL 전문이 DOM에 렌더된다.
+2. 복사 클릭 시 `navigator.clipboard.writeText`가 그 URL로 호출되고 라벨이
+ `prov.linkCopied`로 바뀐다.
+3. 클립보드 API 부재 시 라벨이 `prov.linkCopyUnavailable`로 바뀐다(조용한 실패 금지).
+4. 2.5초 창 안에서 같은 outcome으로 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다
+ (타이머 ref 회귀 가드).
+5. `prov.didntOpen` 외부 링크가 `href={url}`로 존재한다.
+6. `url=""`이면 아무것도 렌더하지 않는다.
+7. **url A로 복사한 뒤 url B로 리렌더하면 라벨이 `prov.copyLink`로 돌아온다**
+ (blocker #1 가드). 이 테스트는 effect를 지우면 실패해야 한다.
+
+버튼은 라벨 텍스트가 아니라 `.pwi-auth-url-actions button` 구조로 조회한다.
+
+## 범위 밖 (wp1)
+
+- 표면 A·B·C의 호출부 변경 — wp2/wp3.
+- CSS 클래스 리네임.
+- 서버/`src/` 변경.
+
+## 완료 기준
+
+- `cd gui && bun x tsc -b` exit 0 — 단 이는 `src`만 덮는다.
+ `gui/tsconfig.app.json`의 `include`는 `src`뿐이므로 **신규 테스트 파일의 타입은
+ 이 명령이 증명하지 않는다**(A 감사 #8). 테스트 타입 오류는 `bun test`에서만 드러난다.
+- `cd gui && bun test tests/login-url-block.test.tsx` 전건 통과
+- 컴포넌트를 되돌리면 신규 테스트가 실패한다(가드 실효 확인)
diff --git a/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md
new file mode 100644
index 0000000000..3025122d29
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md
@@ -0,0 +1,106 @@
+# 020 — Codex 계정 추가/재인증 모달 채택 (wp2)
+
+이 루프의 본체다. 사용자가 보고한 증상("계정 추가할 때 복사 버튼 관련")이
+바로 이 표면이다.
+
+## 변경 1 — `gui/src/components/add-codex-account-waiting-step.tsx`
+
+현재:
+
+```tsx
+
+```
+
+변경 후: 위 버튼을 `` 하나로 교체한다.
+
+**빈 URL 구간의 거동 변화(A 감사 #5).** 지금은 `disabled={!authUrl}` 버튼이
+회색으로 자리를 지킨다. 블록은 `url=""`이면 `null`을 반환하므로 그 자리가 빈다.
+재인증 진입은 `initialAddCodexAccountUiState`가 곧바로 `step: "oauth-waiting"`을
+주므로(`AddCodexAccountModal.tsx:20`) `startOAuth` 응답 전 첫 렌더가 실제로 그 상태다.
+받아들인다 — 대기 화면에는 이미 스피너와 `codexAuth.oauthWaiting` 문구가 있어
+"준비 중"이라는 신호가 중복으로 존재하고, 눌리지 않는 버튼보다 아무것도 없는 쪽이
+거짓 어포던스를 만들지 않는다. `cancelLogin`이 `authUrl`을 비우면 블록이 사라지는
+것도 같은 이유로 옳다.
+
+- `copied` prop 제거. `onCopyLoginLink` prop 제거.
+- import에서 `IconLink` 제거(더 이상 이 파일이 아이콘을 직접 쓰지 않는다).
+- 배치: `
` 바로 아래, 수동 코드 입력 블록 위.
+ 대기 화면의 읽기 순서는 "기다리는 중이다 → URL은 여기 있다 → 안 되면 코드를
+ 붙여넣어라"가 되어야 한다. 지금은 복사 버튼이 full-width로 떠 있어 위계가 없다.
+- 모달 폭이 440px(`AddCodexAccountModal.tsx`)이고 OAuth URL은 길다.
+ `.pwi-auth-url`이 `overflow-wrap: anywhere`를 이미 갖고 있어 그대로 통한다.
+
+## 변경 2 — `gui/src/components/use-add-codex-account-oauth.ts`
+
+`copyLoginLink`(253-273, 닫는 `};` 포함) 전체를 삭제한다.
+
+- `document.execCommand("copy")` 수동 textarea 폴백 삭제. 저장소 공용 래퍼
+ `copyTextToClipboard`가 비보안 컨텍스트에서 `false`를 돌려주는 계약이고,
+ 그 실패를 3-상태 라벨로 보여주는 것이 이제 컴포넌트 책임이다.
+- 복사 실패 시 `set-error` 디스패치 제거. 로그인 에러 채널을 복사 실패가
+ 오염하던 문제가 여기서 사라진다.
+- 반환 객체에서 `copyLoginLink` 제거(`:311`).
+- 훅의 `ui` 파라미터 타입(`:20-26`)에는 `copied`가 없다. 시그니처 변경은 없다.
+
+## 변경 3 — `gui/src/components/add-codex-account-reducer.ts`
+
+- `AddCodexAccountUiState`에서 `copied: boolean` 필드 제거.
+- `initialAddCodexAccountUiState`에서 `copied: false` 제거.
+- 액션 유니온에서 `{ type: "set-copied"; copied: boolean }` 제거.
+- reducer의 `case "set-copied"` 제거.
+
+이 상태는 이제 컴포넌트 지역 상태다. 모달 reducer가 복사 피드백 같은
+순간적 UI 상태를 들고 있을 이유가 없다.
+
+## 변경 4 — `gui/src/components/AddCodexAccountModal.tsx`
+
+- `oauth` 구조분해에서 `copyLoginLink` 제거.
+- ``에서 `copied={ui.copied}` /
+ `onCopyLoginLink={...}` prop 제거.
+
+## i18n
+
+새 키는 필요 없다. 표면 C가 `prov.copyLink` / `prov.linkCopied` /
+`prov.linkCopyUnavailable` / `prov.didntOpen`을 쓰게 된다.
+
+`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` /
+`codexAuth.loginLinkCopyFailed` 3개 키는 소비처가 0이 된다. 6개 로케일에서
+삭제한다 — 죽은 키를 남기면 다음 사람이 이 표면에 별도 규약이 있다고 오해한다.
+(5919779d 조사에서 "i18n 키는 살아 있고 소비처만 0건"이 오히려 혼란의 근거였다.)
+
+**반드시 6개 파일을 같은 커밋에서 함께 지운다(A 감사 blocker #2).**
+삭제 위치: `en.ts:1006-1008`, `ko.ts:707-709`, `zh.ts:707-709`,
+`ja.ts:960-962`, `de.ts:690-692`, `ru.ts:1005-1007`.
+정합 게이트는 `lint:i18n`이 **아니다** — `gui/eslint.config.js:12`가
+`src/i18n/**`를 globalIgnores에 넣어 로케일 키에 대해 아무 의견이 없다.
+실제 게이트는 두 개다: `gui/tests/claude-desktop-locale.test.ts`의
+"locale key sets stay identical to the English source"(텍스트 파싱 집합 비교)와,
+`en.ts:1361`의 `TKey = keyof typeof en` 때문에 발생하는 `tsc` 오류.
+
+## 신규 테스트 — `gui/tests/add-codex-account-login-url.test.tsx`
+
+기존 `gui/tests/add-codex-account-oauth.test.tsx`의 fetch 스텁 하네스를 따른다.
+`/api/codex-auth/login`이 `{ url, flowId }`를 반환하도록 하고 모달을 마운트한다.
+
+1. 대기 단계에서 인증 URL 전문이 DOM에 렌더된다(현재는 렌더되지 않음 → 실패해야 함).
+2. `prov.didntOpen` 외부 링크가 그 URL을 가리킨다.
+3. 복사 클릭 시 클립보드에 URL이 들어가고 라벨이 `prov.linkCopied`로 바뀐다.
+4. 클립보드 부재 시 라벨이 `prov.linkCopyUnavailable`이 되고,
+ **에러 notice(`.notice-err`)는 뜨지 않는다** — 복사 실패의 에러 채널 오염 금지.
+5. 재인증 진입(`reauthAccountId` 지정)에서도 같은 블록이 렌더된다.
+ **`startOAuth` 응답을 명시적으로 `act`로 기다린 뒤 단언한다** — 첫 렌더는
+ `authUrl: ""`이라 기다리지 않으면 flaky다(A 감사 #5).
+
+## 완료 기준
+
+- `cd gui && bun x tsc -b` exit 0
+- `cd gui && bun test tests` 전건 통과(기존 `add-codex-account-oauth.test.tsx`,
+ `claude-desktop-locale.test.ts` 포함)
+- `bun test tests/codex-auth-modal-status.test.ts` (루트) 통과 — 이 테스트가
+ `add-codex-account-waiting-step.tsx`의 `aria-live="polite"`와 수동 코드
+ disabled 조건을 소스 텍스트로 고정하고 있으므로 대기 화면 편집 시 함께 본다.
+- 변경을 되돌리면 신규 테스트가 실패한다
+- `rg "copyLoginLink|set-copied|loginLinkCopyFailed|loginLinkCopied" gui/src` → 0건
+ (`loginLinkCopied`를 빼면 로케일 잔존을 놓친다 — A 감사 #9)
diff --git a/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md
new file mode 100644
index 0000000000..e6608541d7
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md
@@ -0,0 +1,84 @@
+# 030 — 표면 A·B 이관 + i18n 정합 + 전체 검증 (wp3)
+
+## 변경 1 — `gui/src/components/provider-workspace/ProviderAuthPanel.tsx`
+
+- `linkCopyState` / `linkCopyTimer` / `copyLoginUrl` / 관련 `useEffect` 정리 훅 삭제.
+- `hintForThis.url &&` 블록을 ``로 교체.
+ 기존 `prov.didntOpen` 링크는 블록이 소유하므로 별도 렌더를 제거한다.
+- import 정리(A 감사 #4로 실측 확정): `IconLink`, `IconExternal`, `useEffect`,
+ `useRef`가 미사용이 되어 제거 대상이다. **`useState`는 남긴다** —
+ `addingKey`/`newKey`/`keyBusy`/`deviceCodeCopied`(`:49-52`)와
+ `copiedDoctorFor`(`:68`)가 계속 쓴다.
+ `gui/tsconfig.app.json`이 `noUnusedLocals: true`라 틀리면 `tsc`가 즉시 잡는다.
+- `copyTextToClipboard`는 doctor 복사에서 계속 쓰므로 import 유지.
+
+### 보류 — 기기 코드 복사 (A 감사 #10으로 범위에서 제외)
+
+같은 파일 147-152의 `prov.copyCode` 버튼이 `navigator.clipboard.writeText`를
+직접 부르고 실패를 `.catch(() => {})`로 삼킨다. 실제 결함이다. 그러나 이 루프의
+인과 사슬(로그인 URL 어포던스) 밖이고, 테스트가 없는 경로이며, 이 계획이 스스로
+내건 "되돌리면 테스트가 실패한다" 보증을 흐린다. **별도 항목으로 남긴다.**
+
+## 변경 2 — `gui/src/components/add-provider-oauth-pane.tsx`
+
+- `linkCopyState` / `linkCopyTimer` / `copyAuthUrl` / 정리 `useEffect` 삭제.
+- `oauthBusy && oauthUrl` 블록을 ``로 교체
+ (렌더 조건 `oauthBusy &&`는 유지 — 스테일 URL 노출 방지 계약).
+- import에서 `IconExternal` / `IconLink` / `copyTextToClipboard` /
+ `useEffect` / `useRef` / `useState` 중 미사용분 제거.
+- **`oauthUrlProvider` 필터와 reducer의 `set-oauth-url` 가드는 건드리지 않는다.**
+ A/B 로그인 경쟁 회귀(`add-provider-oauth-url-leak.test.tsx`)를 지키는 계약이다.
+
+## 변경 3 — i18n 6개 로케일
+
+- `codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` /
+ `codexAuth.loginLinkCopyFailed` 삭제(en/ko/ja/zh/de/ru).
+- 나머지 키 변경 없음.
+- 정합 게이트는 `lint:i18n`이 아니라
+ `cd gui && bun test tests/claude-desktop-locale.test.ts`다
+ (`gui/eslint.config.js:12`가 `src/i18n/**`를 무시한다 — A 감사 blocker #2).
+ `lint:i18n`은 UI 파일의 하드코딩 문자열 규칙용이므로 별도로 계속 돌린다.
+
+## 변경 4 — 기존 테스트 무수정 통과 확인
+
+`gui/tests/provider-auth-login-copy-link.test.tsx`와
+`gui/tests/add-provider-oauth-url-leak.test.tsx`는 라벨 텍스트와 구조
+(`.pwi-auth-url-actions button`)로 조회하므로 이관 후에도 그대로 통과해야 한다.
+**테스트를 고쳐서 통과시키지 않는다.** 통과하지 않으면 이관이 동등하지 않다는 뜻이다.
+
+## 검증 (wp3 종료 조건)
+
+| 명령 | 기대 |
+|------|------|
+| `bun run typecheck` | exit 0 |
+| `cd gui && bun x tsc -b` | exit 0 |
+| `cd gui && bun test tests` | 전건 통과 |
+| `bun run lint:gui` | exit 0 |
+| `cd gui && bun run lint:i18n` | exit 0 (로케일 키 정합은 이 명령이 보지 않는다) |
+| `cd gui && bun test tests/claude-desktop-locale.test.ts` | 로케일 키 집합 동일 |
+| `bun test tests/codex-auth-modal-status.test.ts` | 대기 화면 소스 계약 유지 |
+| `bun run privacy:scan` | exit 0 |
+| `bun run test` (루트) | 기준선 대비 신규 실패 0 |
+
+루트 스위트는 착수 전 기준선을 먼저 측정해 비교한다(과거 이 저장소에서
+`tests/management-provider-validation.test.ts`가 병렬 포트 경합으로 flake를
+낸 전례가 있다 — 기준선 없이 "5건 실패"를 신규로 오판하면 안 된다).
+
+## 소비처 확인
+
+`rg -n "LoginUrlBlock" gui/src` → 표면 A·B·C 3건 + 컴포넌트 정의 1건.
+`rg -n "pwi-auth-url-wrap" gui/src --glob '*.tsx'` → 컴포넌트 1건만.
+
+## docs-site
+
+`rg`로 확인한 결과 `docs-site/`에 copy-login-link 어포던스를 언급하는 문장이
+0건이다. 표면 C가 URL과 외부 링크를 얻는 것은 사용자에게 보이는 변화지만,
+문서가 이 화면의 구성 요소를 열거한 적이 없으므로 모순되는 서술이 생기지 않는다.
+**문서 변경 없음 — 근거를 남기고 넘어간다**(A 감사 #11).
+
+## 범위 밖
+
+- `pwi-` 클래스 접두사 리네임(별도 항목).
+- `prov.copyCode` 기기 코드 복사 규약 통일(별도 항목).
+- 계정 풀/자동 전환 로직.
+- push, 배포.
diff --git a/devlog/_plan/260727_login_url_copy_parity/040_outcome.md b/devlog/_plan/260727_login_url_copy_parity/040_outcome.md
new file mode 100644
index 0000000000..0bd1ee3457
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/040_outcome.md
@@ -0,0 +1,83 @@
+# 040 — 실행 결과 (wp1~wp4 종료)
+
+## 무엇이 바뀌었나
+
+세 로그인 표면이 하나의 `LoginUrlBlock`을 쓴다. 사용자가 지적한 표면
+(ChatGPT 계정 추가·재인증 모달)은 이제 인증 URL 전문, 수동 열기 링크,
+3-상태 복사 피드백을 갖는다.
+
+| | 이전 | 이후 |
+|---|---|---|
+| Workspace 패널 | URL + 3-상태 복사 (복제본 1) | 공용 블록 |
+| 프로바이더 추가 모달 | URL + 3-상태 복사 (복제본 2) | 공용 블록 |
+| Codex 계정 모달 | 복사 버튼만, 실패는 에러 notice | 공용 블록 |
+| 비보안 컨텍스트 복사 | 표면별로 제각각 | `copyTextToClipboard` 폴백 통일 |
+
+## 커밋 (dev, 전부 미푸시)
+
+| 커밋 | 내용 |
+|------|------|
+| `ef656be2` | wp0 문서 4건 |
+| `c136ecec` | `LoginUrlBlock` 신설 + 테스트 6건 |
+| `555c3d63` | 복사 피드백을 effect 리셋에서 파생 상태로 (lint 규칙) |
+| `3f05fa00` | Codex 계정 모달 채택 + 죽은 i18n 키 3개 제거 |
+| `853c4088` | 표면 A·B 이관, 중복 2벌 제거 |
+| `a19ce5dd` | 비보안 컨텍스트 클립보드 폴백 복원 |
+
+## 계획에서 벗어난 지점
+
+**1. url 변경 리셋을 effect가 아니라 파생 상태로 구현했다.**
+010은 `useEffect(() => setState("idle"), [url])`를 적었지만
+`react-hooks/set-state-in-effect`가 막았다. 규칙이 옳다 — effect로 상태를
+되돌리면 캐스케이딩 렌더가 된다. 피드백이 자기 url을 함께 들고 다니게 해
+렌더 시점 불일치로 읽히게 바꿨다. 계약은 동일하고 effect만 사라졌다.
+
+**2. wp4가 계획에 없었다.**
+C 단계 독립 검증에서 나온 인접 결함이다. `execCommand` 폴백이 Codex 모달의
+옛 코드에만 있었고 공용 래퍼로 옮기며 사라졌다. `hostname: 0.0.0.0`으로 LAN에
+노출하면 평문 HTTP라 보안 컨텍스트가 아니고 `navigator.clipboard`가 없다.
+즉 그 배포에서 복사가 영구히 죽는다. 조기 종료 대신 work-phase를 붙여
+공용 래퍼에서 한 번에 고쳤다 — 세 표면이 동시에 수혜를 본다.
+
+**3. 기기 코드 복사(`prov.copyCode`) 통일은 계획에서 뺐다.**
+A 감사가 범위 이탈로 지목했고 동의했다. 별도 항목.
+
+## 검증 (실측)
+
+| 명령 | 결과 |
+|------|------|
+| `bun run typecheck` | exit 0 |
+| `cd gui && bun x tsc -b` | exit 0 |
+| `cd gui && bun test tests` | 319 pass / 0 fail |
+| `bun run test` (루트) | 4985 pass / 0 fail |
+| `bun run lint:gui` | exit 0 |
+| `cd gui && bun run lint:i18n` | exit 0 |
+| `bun run privacy:scan` | passed |
+| `bun test tests/claude-desktop-locale.test.ts` | 6개 로케일 키 1324개로 동일 |
+
+가드 실효(수정을 되돌리면 실패):
+
+- url 스코프 피드백 제거 → 1 fail
+- 재클릭 `clearTimer` 제거 → 1 fail
+- `LoginUrlBlock` 렌더 제거(Codex 모달) → 3 fail
+- 빈 url 가드 제거 → 2 fail
+- `execCommand` 폴백 제거 → 2 fail
+
+이관 동등성의 증거는 기존 테스트 두 파일
+(`provider-auth-login-copy-link.test.tsx`, `add-provider-oauth-url-leak.test.tsx`)이
+한 줄도 수정되지 않고 9건 전부 통과한다는 사실이다.
+
+## 독립 검증
+
+두 차례 독립 감사를 받았다.
+
+- wp0 계획 감사: `GO-WITH-FIXES (blockers=2)`. 지적 11건 전부 문서에 반영.
+ blocker는 (a) url 변경 시 stale 복사 상태, (b) 로케일 정합 게이트 오인
+ (`lint:i18n`은 `src/i18n/**`를 globalIgnores로 제외한다).
+- wp3 종료 후 최종 검증: `PASS (blockers=0)`. 지적 [M] 1건이 wp4가 됐다.
+
+## 남은 항목
+
+- `pwi-` 클래스 접두사 리네임 (모달에서도 쓰이는데 이름이 provider workspace를 가리킨다)
+- `prov.copyCode` 기기 코드 복사 규약 통일
+- push 및 배포는 사용자 승인 대기
diff --git a/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md b/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md
new file mode 100644
index 0000000000..a07c1f3767
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md
@@ -0,0 +1,162 @@
+# 050 — 기기 코드 복사를 공용 복사 규약으로 (wp1)
+
+040의 "남은 항목" 두 건 중 첫째. wp3 감사에서 범위 이탈로 뺐던 항목을
+독립 사이클로 되가져온다.
+
+## 문제
+
+`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:133-138`
+
+```tsx
+
+```
+
+같은 대기 패널 안에서 규약이 둘로 갈린다.
+
+| | 기기 코드 복사 | 로그인 URL 복사 |
+|---|---|---|
+| 구현 | `navigator.clipboard.writeText` 직접 | `copyTextToClipboard` |
+| 비보안 컨텍스트 | **TypeError로 즉시 죽음** | execCommand 폴백 |
+| 실패 처리 | `.catch(() => {})` 침묵 | `unavailable` 라벨 |
+| 타이머 | ref 없는 `setTimeout` | ref + clearTimeout |
+
+비보안 컨텍스트 문제가 특히 나쁘다. `navigator.clipboard`가 `undefined`면
+`.writeText`를 읽는 순간 TypeError가 나고, `.catch()`는 **Promise 거부만**
+잡으므로 이 동기 예외를 못 잡는다. LAN 바인딩(`hostname: 0.0.0.0`, 평문 HTTP)에서
+기기 코드 복사 버튼을 누르면 아무 일도 일어나지 않고 콘솔에만 예외가 남는다.
+a19ce5dd가 URL 복사에 대해 고친 바로 그 문제가 여기 그대로 있다.
+
+## A 감사가 바꾼 방향 — 훅으로 규약을 실제로 공유한다
+
+초안은 기기 코드에 3-상태를 손으로 한 벌 더 짜는 계획이었다. 감사가
+정확히 반박했다: "공유하는 것은 복사 규약"이라 써놓고 규약을 공유하지 않으면
+같은 프로토콜의 손수 구현이 **셋**이 된다.
+
+| 표면 | 상태 모양 | 라벨 | 타이머 |
+|------|-----------|------|--------|
+| `LoginUrlBlock` | `{url, outcome} \| null` | 인라인 삼항 | `timerRef` |
+| 기기 코드 (초안대로면) | `"idle"\|"copied"\|"unavailable"` | 인라인 삼항 | 또 다른 ref |
+| doctor 복사 | `DoctorCopyFeedback{accountId, outcome}` | `doctorCopyButtonLabel` | ref 없는 `setTimeout` |
+
+게다가 doctor 복사(`ProviderAuthPanel.tsx:182`)에는 **URL 복사가 이미 겪은
+타이머 회귀가 그대로 남아 있다**. functional update로 `accountId`+`outcome`을
+비교해 완화했지만, 같은 계정을 같은 결과로 연속 클릭하면 앞 타이머가 뒤
+피드백을 지운다. 5919779d 감사가 "functional update 가드는 불충분하다"고
+결론 낸 바로 그 형태다.
+
+세 번째 손수 구현을 추가하는 대신 훅을 뽑는다. 셋 다 이미 스코프 키를 갖고
+있다 — URL, (없음), accountId. 하나의 추상이 자연스럽게 맞는다.
+
+### 신규 `gui/src/components/use-copy-feedback.ts`
+
+```ts
+export type CopyOutcome = "copied" | "unavailable";
+
+export function useCopyFeedback(): {
+ outcomeFor: (scope: K) => CopyOutcome | null;
+ copy: (text: string, scope: K) => void;
+};
+```
+
+- 피드백을 `{ scope, outcome }`로 들고, 읽을 때 스코프가 일치할 때만 결과를
+ 돌려준다. `LoginUrlBlock`이 url 변경 시 자동으로 idle이 되는 그 방식과 동일하다
+ (`react-hooks/set-state-in-effect`가 effect 리셋을 금지하므로 파생이 유일한 길).
+- 타이머는 훅이 ref로 소유한다. 재클릭 시 `clearTimeout`, 언마운트 시 정리.
+- `copyTextToClipboard`를 부른다 — execCommand 폴백이 세 표면 모두에 붙는다.
+- 스코프가 없는 표면은 `K = void`로 쓴다.
+
+### 세 소비처 이관
+
+- `LoginUrlBlock`: 지역 `useState`/`timerRef`/`copy`를 훅 호출로 교체. 스코프는 `url`.
+- 기기 코드: 훅 호출. 스코프 없음. 라벨은 아래 표.
+- doctor 복사: `copiedDoctorFor`/`setTimeout`을 훅으로 교체. 스코프는 `accountId`.
+ `doctorCopyButtonLabel`은 `DoctorCopyFeedback` 대신 `CopyOutcome | null`을 받도록
+ 좁힌다 — 스코프 비교는 이제 훅이 한다.
+ `CodexAccountPool.tsx`와 `codex-account-pool-cards.tsx`도 같은 시그니처로 따라간다.
+
+## 변경 — `ProviderAuthPanel.tsx`
+
+기기 코드 복사도 URL 복사와 같은 3-상태를 갖는다. 코드 전문이 ``에
+이미 보이므로 `unavailable`은 dead-end가 아니다 — `.pwi-device-code`가
+`user-select: all`이라 수동 선택 경로가 화면에 남아 있다(css:33).
+
+- `deviceCodeCopied: boolean` 상태와 인라인 `setTimeout` 제거 → `useCopyFeedback()`.
+- 타이머·폴백·스코프는 전부 훅이 소유한다.
+- 라벨: `idle` → `prov.copyCode`, `copied` → `prov.codeCopied`,
+ `unavailable` → `prov.linkCopyUnavailable` 재사용.
+ 새 i18n 키를 만들지 않는다. "클립보드를 사용할 수 없음"은 복사 대상이
+ 링크든 코드든 같은 사실을 말하고, 6개 로케일 문안도 대상 중립적이다
+ (en "Clipboard unavailable", ko "클립보드를 사용할 수 없음").
+- 라벨 span에 `aria-live="polite"`. URL 복사와 같은 고지 규약.
+
+`LoginUrlBlock`으로 흡수하지 않는다. 그 컴포넌트는 URL이라는 하나의 대상에
+묶인 URL 전문 + 외부 열기 링크를 함께 소유한다. 기기 코드는 외부 링크가 없고
+표시 형태(`.pwi-device-code`, 큰 자간 강조)도 다르다. 억지로 합치면 props가
+분기되어 소유의 이점이 사라진다. 공유하는 것은 **복사 규약**이고, 그 규약은
+위 훅이 소유한다 — 렌더가 아니라 동작을 공유하는 것이 이 분리의 요점이다.
+
+## i18n — ja 미번역 3건 (A 감사 #2)
+
+`gui/src/i18n/ja.ts:1310-1312`의 `prov.deviceCode` / `prov.copyCode` /
+`prov.codeCopied`가 영어 그대로다. `sync-locale-keys.mjs`가 키를 채울 때
+영문을 붙여두고 파일 맨 아래에 남긴 것으로, `prov.*` 블록에서도 떨어져 있다.
+이 사이클이 기기 코드 복사 라벨을 건드리므로 함께 번역한다.
+
+- `"prov.deviceCode": "デバイスコード"`
+- `"prov.copyCode": "コードをコピー"`
+- `"prov.codeCopied": "コードをコピーしました"`
+
+위치도 `prov.*` 인접 블록(`ja.ts:248` 근처)으로 옮겨 다른 로케일과 같은 배치로
+맞춘다. 어떤 게이트도 이걸 잡지 못한다 — `lint:i18n`은 `src/i18n/**`를
+globalIgnores로 빼고, `claude-desktop-locale.test.ts`는 키 **집합**만 보지
+번역 여부는 보지 않는다. 사람이 보지 않으면 영원히 영어로 남는다.
+
+`prov.linkCopyUnavailable`은 6개 로케일 전부 대상 중립적이라
+(en "Clipboard unavailable", ja "クリップボードを使用できません",
+de "Zwischenablage nicht verfügbar") 코드 복사에 재사용해도 어색하지 않다.
+감사가 6개 전부 확인했다.
+
+## 회귀 테스트 — `gui/tests/provider-auth-device-code-copy.test.tsx`
+
+`provider-auth-login-copy-link.test.tsx`의 하네스를 그대로 쓴다
+(`loginHint`에 `deviceCode`를 넣으면 같은 대기 패널이 렌더된다).
+
+마운트 조건(감사가 실측): `busy`가 참이고 `loginHint.provider === item.name`,
+`item.authMode === "oauth"`. `url`은 선택이므로 `deviceCode`만 주면 기기 코드
+블록만 렌더된다.
+
+**하네스를 그대로 복사하면 안 된다(A 감사 blocker #4).** happy-dom에는
+`document.execCommand`가 없다(`typeof`가 `undefined`). 클립보드 스텁만 있는
+기존 하네스를 복사하면 폴백 케이스가 조용히 unavailable 경로를 타고 **틀린
+이유로 통과한다**. `gui/tests/clipboard-fallback.test.ts:20`처럼
+`Object.defineProperty(win.document, "execCommand", ...)` 헬퍼를 함께 둔다.
+
+1. 기기 코드 전문이 렌더되고 복사 버튼이 그 코드를 클립보드에 넣는다.
+2. 클립보드 API 부재 + execCommand 스텁 → 폴백으로 복사되고 라벨이
+ `prov.codeCopied`가 된다.
+3. 둘 다 부재 → 라벨이 `prov.linkCopyUnavailable`이 된다.
+ **`expect(() => click()).not.toThrow()`로 쓰지 않는다** — React 19가 동기
+ 예외를 dev 에러 경로로 흘려보내 happy-dom과 섞이면 단언이 불안정하다.
+ "라벨이 unavailable이 된다"는 긍정 단언이 오늘 도달 불가능하므로 그것으로
+ 충분한 가드다.
+4. 2.5초 창 안에서 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다(훅 타이머).
+5. doctor 복사도 같은 재클릭 회귀를 갖지 않는다 — 같은 계정·같은 결과로
+ 연속 클릭해도 뒤 피드백이 살아남는다(훅 이관의 실질 이득).
+
+버튼은 `.pwi-device-code-wrap button`으로 구조 조회한다.
+
+## 검증
+
+- `bun run typecheck` exit 0 (루트 — AGENTS.md 요구)
+- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0
+- `cd gui && bun test tests` 전건 통과
+- `bun run test` (루트) 신규 실패 0 — `tests/provider-workspace-auth.test.ts:160`은
+ `pwi-device-code` 부분 문자열 매치라 이 사이클이 바꾸는 것을 제약하지 않는다
+ (감사 #5: 게이트가 아니라 무영향)
+- `bun run privacy:scan` 통과
+- 변경을 되돌리면 신규 테스트 3·4·5번이 실패한다
diff --git a/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md
new file mode 100644
index 0000000000..95280fbd81
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md
@@ -0,0 +1,115 @@
+# 060 — 로그인 URL 블록 스타일의 소속 정리 (wp2)
+
+040의 "남은 항목" 둘째.
+
+## 문제
+
+`LoginUrlBlock`은 세 표면이 공유하는 컴포넌트인데, 그 스타일은 이름도 파일도
+한 표면의 소유로 남아 있다.
+
+- 클래스: `.pwi-auth-url-wrap` / `.pwi-auth-url` / `.pwi-auth-url-actions` /
+ `.pwi-auth-open-link`. `pwi`는 provider **w**orkspace **i**tem이다.
+- 파일: `gui/src/styles/provider-workspace-settings.css:36-39`.
+ 헤더 주석이 "WP091 — provider workspace settings / auth / JSON editor"이고
+ `─ ProviderAuthPanel ─` 섹션 아래 있다.
+
+실제 소비처는 셋이다: 워크스페이스 패널, 프로바이더 추가 모달, Codex 계정 모달.
+뒤 둘은 워크스페이스가 아니다. 지금은 `styles.css`가 모든 부분 스타일시트를
+전역 import(`styles.css:13`)해서 동작하지만, 이름이 소속을 속이고 있어
+다음 사람이 워크스페이스 CSS를 정리할 때 모달을 깨뜨릴 수 있다.
+
+## 컨벤션 근거
+
+이 저장소는 이미 컴포넌트/기능 단위 스타일 파일 + 그 이름을 딴 접두사를 쓴다.
+`gui/src/styles/provider-catalog.css`가 `.provider-catalog*`를 소유하는 식이다.
+새 규칙을 만드는 게 아니라 기존 관행을 따른다.
+
+## 변경 1 — 신규 `gui/src/styles/login-url-block.css`
+
+`provider-workspace-settings.css`의 네 규칙(`.pwi-auth-open-link`가 `:36`,
+나머지 셋이 `:37-39`)을 옮기고 재명명한다.
+
+| 이전 | 이후 |
+|------|------|
+| `.pwi-auth-url-wrap` | `.login-url-block` |
+| `.pwi-auth-url` | `.login-url-block-text` |
+| `.pwi-auth-url-actions` | `.login-url-block-actions` |
+| `.pwi-auth-open-link` | `.login-url-block-open` |
+
+접두사를 컴포넌트/파일 이름과 정확히 일치시킨다(A 감사 #7). 초안의 `.login-url`은
+저장소 관행(`.provider-catalog*`, `.quota-*` 같은 기능 스코프 접두사)에 비해 너무
+일반적이라 나중에 충돌 여지를 남긴다. 지금 붙여두면 비용이 0이다.
+현재 `gui/src`의 9개 CSS 파일 어디에도 `.login*` 셀렉터가 없음을 감사가 확인했다.
+
+속성값은 한 글자도 바꾸지 않는다. 이번 사이클은 소속 정리이지 시각 변경이
+아니다. 렌더 결과가 달라지면 그건 실패다.
+
+`.pwi-auth-open-link`는 `LoginUrlBlock` 밖에 소비처가 없다(rg로 확인).
+따라서 함께 옮긴다.
+
+## 변경 2 — `gui/src/styles.css`
+
+`@import "./styles/login-url-block.css";`를 추가한다. 삽입 위치는
+`provider-workspace-settings.css` 다음 줄 — 기존 import 블록의 인접성을 유지한다.
+
+**import 순서는 결과에 영향이 없다(A 감사 #8).** 네 규칙은 전부 단일 클래스
+선택자(명시도 0-1-0)이고, `provider-workspace-settings.css`에 `@media`도
+`!important`도 없으며, 어떤 GUI 스타일시트도 같은 요소를 같은 명시도로 겨루지
+않는다. 경쟁 선택자가 없으므로 소스 순서가 캐스케이드를 바꿀 수 없다.
+인접 배치는 가독성 때문이지 정확성 때문이 아니다.
+
+## 변경 3 — `gui/src/components/login-url-block.tsx`
+
+네 개의 `className`을 새 이름으로 교체. 구조·속성 변경 없음.
+
+## 변경 4 — 테스트 셀렉터 4파일
+
+| 파일 | 셀렉터 |
+|------|--------|
+| `gui/tests/login-url-block.test.tsx:76,152` | `.pwi-auth-url-actions button`, `.pwi-auth-url-wrap` |
+| `gui/tests/provider-auth-login-copy-link.test.tsx:104` | `.pwi-auth-url-actions button` |
+| `gui/tests/add-codex-account-login-url.test.tsx:101` | `.pwi-auth-url-actions button` |
+| `gui/tests/add-provider-oauth-url-leak.test.tsx:122,138,167` | `.pwi-auth-url` |
+
+**셀렉터만 바꾸고 단언은 한 글자도 바꾸지 않는다.** 이것이 이 사이클의
+안전 조건이다. 단언이 함께 바뀌면 "리네임이 렌더를 보존했다"는 증거가 사라진다.
+커밋 diff에서 테스트 변경이 전부 셀렉터 문자열 한 줄짜리인지 눈으로 확인한다.
+
+여기에 단언 하나를 **추가**한다(A 감사 #9). `login-url-block.test.tsx`에
+렌더된 래퍼의 클래스가 `login-url-block`임을 단언한다. 리네임 자체에는 동작
+변화가 없어 테스트가 없으면 "소속을 정리했다"는 주장이 검증 불가능한 신념으로
+남는다. 이 한 줄이 그 주장을 집행 가능하게 만든다.
+
+## 건드리지 않는 것
+
+- `.pwi-device-code*`: `ProviderAuthPanel` 전용이고 다른 표면에 없다.
+ 이름과 소속이 일치하므로 그대로 둔다.
+- `.pwi-auth-wait` / `.pwi-auth-wait-copy` / `.pwi-auth-section` 등 나머지
+ `pwi-` 계열 전부. 워크스페이스 패널 전용이다.
+- `tests/provider-workspace-auth.test.ts:160`의 `pwi-device-code` 단언.
+
+즉 이 사이클은 `pwi-` 접두사를 일괄 개명하는 작업이 **아니다**. 소속이
+어긋난 네 개만 옮긴다.
+
+## 검증
+
+- `bun run typecheck` exit 0 (루트)
+- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0
+- `cd gui && bun test tests` 전건 통과 — 특히 위 4파일이 셀렉터 갱신만으로 통과
+- `rg "pwi-auth-url|pwi-auth-open-link" gui/` → 0건
+- `rg 'className="login-url' gui/src` → 컴포넌트 4건 (파일명 매치가 섞이는
+ `rg "login-url-"`은 게이트로 쓰지 않는다 — 이미 import 3건이 걸린다, A 감사 #10)
+- `rg "login-url-block" gui/src/styles/login-url-block.css` → CSS 4건
+- `bun run build:gui` 성공(CSS import 경로 확인)
+- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과
+
+## 사이클 배치 (A 감사 #9)
+
+감사는 060을 050의 꼬리 커밋으로 접으라고 권했다 — 동작 변화가 없는 커밋
+하나만으로 별도 리뷰 라운드를 도는 건 값이 안 맞는다는 것이다. 절반만 받는다.
+
+**커밋은 분리하되 work-phase는 050과 같은 사이클에 두지 않는다.** 리네임은
+7개 파일의 셀렉터를 동시에 건드리므로, 기기 코드 복사 변경과 한 커밋에 섞이면
+gui 테스트가 깨졌을 때 원인이 훅 이관인지 셀렉터 교체인지 분리할 수 없다.
+별도 work-phase(wp2) + 별도 커밋으로 두되, 리뷰는 wp3의 최종 검증에서
+한 번에 받는다. 감사의 "리뷰 라운드 하나" 취지는 지키고 이등분 가능성은 살린다.
diff --git a/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md
new file mode 100644
index 0000000000..36ea7c19cf
--- /dev/null
+++ b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md
@@ -0,0 +1,114 @@
+# 070 — PR #544 Codex 리뷰 P2 2건 (wp1)
+
+`chatgpt-codex-connector`가 `171885ec29`에 남긴 인라인 지적 둘. 둘 다
+`useCopyFeedback` 도입으로 **새로 생긴** 결함이라 머지 전에 닫는다.
+
+## P2-1 — 기기 코드 복사가 스코프를 안 쓴다
+
+`gui/src/components/provider-workspace/ProviderAuthPanel.tsx`
+
+```tsx
+const deviceCodeCopy = useCopyFeedback(); // K = void
+const deviceCodeOutcome = deviceCodeCopy.outcomeFor(undefined);
+onClick={() => deviceCodeCopy.copy(hintForThis.deviceCode ?? "", undefined)}
+```
+
+훅은 "피드백이 자기 스코프를 들고 다녀 스코프가 바뀌면 idle로 읽힌다"는
+계약으로 만들었는데, 세 소비처 중 여기만 그 계약을 쓰지 않는다. URL은
+`url`을, doctor는 `account.id`를 스코프로 넘긴다. 기기 코드만 `undefined`다.
+
+### 재현
+
+1. 기기 코드 A가 뜬 상태에서 복사 → 라벨이 "코드 복사됨".
+2. 2.5초 안에 취소를 누른다. `cancelLoginOAuth`가
+ `setLoginInfo(current => current?.provider === provider ? null : current)`
+ 로 힌트만 비운다(`use-providers-oauth.ts:53`). **패널은 언마운트되지 않는다** —
+ `busy && hintForThis` 블록만 사라진다.
+3. 다시 로그인하면 `loginOAuth`가 새 코드 B로 `setLoginInfo`한다(`:84`).
+ 같은 컴포넌트 인스턴스, 같은 `undefined` 스코프.
+4. 코드 B 위에 A의 "복사됨"이 남는다. 클립보드에는 A가 들어 있다.
+
+`Providers.tsx:210`의 `key={item.name}`은 프로바이더가 바뀔 때만 리마운트하므로
+같은 프로바이더 재로그인에서는 보호가 없다. 이건 `LoginUrlBlock`이 이미
+막아둔 것과 **동일한 거짓 성공**이다.
+
+### 수정
+
+```tsx
+const deviceCodeCopy = useCopyFeedback();
+const deviceCode = hintForThis?.deviceCode ?? "";
+const deviceCodeOutcome = deviceCodeCopy.outcomeFor(deviceCode);
+onClick={() => deviceCodeCopy.copy(deviceCode, deviceCode)}
+```
+
+복사 대상 문자열이 곧 스코프다 — URL 복사와 같은 형태(`copy(url, url)`).
+
+## P2-2 — 겹친 복사에서 오래된 완료가 최신 결과를 덮어쓴다
+
+`gui/src/components/use-copy-feedback.ts:38-47`
+
+```ts
+void copyTextToClipboard(text).then((ok) => {
+ clearTimer();
+ setFeedback({ scope, outcome: ok ? "copied" : "unavailable" });
+ timerRef.current = setTimeout(...);
+});
+```
+
+`copyTextToClipboard`는 async다. `navigator.clipboard.writeText`가 권한
+프롬프트나 포커스 대기로 지연되면 첫 시도가 둘째보다 늦게 resolve될 수 있다.
+그때 첫 시도의 `.then`이 무조건 상태와 타이머를 덮어쓴다.
+
+- 스코프가 다르면(예: 코드 A→B) 현재 버튼이 남의 스코프 피드백을 받아 idle이 된다.
+- 스코프가 같으면 오래된 결과를 최신인 양 보고한다(A 실패→B 성공이면 "사용 불가").
+- 타이머도 늦은 쪽이 다시 걸어 피드백 수명이 어긋난다.
+
+`clearTimer`는 이 경합을 못 막는다. 순서 문제이지 타이머 문제가 아니다.
+
+### 수정
+
+요청 세대 카운터를 둔다.
+
+```ts
+const generationRef = useRef(0);
+
+const copy = useCallback((text: string, scope: Scope) => {
+ const generation = ++generationRef.current;
+ void copyTextToClipboard(text).then((ok) => {
+ if (generationRef.current !== generation) return; // 스테일 완료는 버린다
+ clearTimer();
+ setFeedback({ scope, outcome: ok ? "copied" : "unavailable" });
+ timerRef.current = setTimeout(() => { ... }, FEEDBACK_MS);
+ });
+}, [clearTimer]);
+```
+
+만료 타이머도 자기 세대를 확인하게 해, 늦게 도착한 클릭이 앞 타이머의
+만료로 지워지지 않도록 한다. 이 저장소의 다른 경합 가드와 같은 방식이다
+(`use-providers-oauth.ts`의 `oauthLoginGenerationRef`,
+`use-add-codex-account-oauth.ts`의 `pollSession`).
+
+## 회귀 테스트
+
+### `gui/tests/use-copy-feedback-race.test.tsx` (신규)
+
+클립보드 `writeText`를 테스트가 붙잡았다 놓는 스텁으로 순서를 고정한다.
+
+1. **늦게 끝난 오래된 시도는 무시된다.** A를 클릭(보류) → B를 클릭(즉시 성공)
+ → A를 성공으로 해제. 라벨은 B의 결과를 유지한다.
+2. **결과가 갈려도 마찬가지.** A는 실패, B는 성공으로 두고 A를 나중에 해제해도
+ "복사됨"이 남는다.
+3. **정상 순서는 그대로 동작한다.** 겹치지 않으면 마지막 클릭 결과가 뜬다.
+
+### `gui/tests/provider-auth-device-code-copy.test.tsx` (기존에 추가)
+
+1. **기기 코드가 바뀌면 라벨이 초기화된다.** 코드 A 복사 → 같은 패널에
+ 코드 B로 리렌더 → 라벨이 `prov.copyCode`로 돌아온다.
+
+## 검증
+
+- `bun run typecheck` exit 0, `cd gui && bun x tsc -b` exit 0
+- `cd gui && bun run lint` / `lint:i18n` exit 0
+- `cd gui && bun test tests` 전건 통과
+- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과
+- 스코프를 `undefined`로 되돌리면 기기 코드 테스트가, 세대 가드를 지우면 경합 테스트가 실패한다
diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx
index 969ad82e65..0217dd3af0 100644
--- a/gui/src/components/AddCodexAccountModal.tsx
+++ b/gui/src/components/AddCodexAccountModal.tsx
@@ -22,7 +22,7 @@ export default function AddCodexAccountModal({
const dialogRef = useRef(null);
const oauth = useAddCodexAccountOAuth({ apiBase, reauthAccountId, ui, dispatch, t });
- const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, copyLoginLink, submitManualCode } = oauth;
+ const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, submitManualCode } = oauth;
useEffect(() => {
bindCallbacks(onAdded, onClose);
@@ -69,7 +69,6 @@ export default function AddCodexAccountModal({
{ void copyLoginLink(); }}
onManualCodeChange={value => dispatch({ type: "set-manual-code", manualCode: value })}
onSubmitManualCode={() => { void submitManualCode(); }}
onClose={closeModal}
diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx
index 9e0027e36c..9031cb139f 100644
--- a/gui/src/components/CodexAccountPool.tsx
+++ b/gui/src/components/CodexAccountPool.tsx
@@ -15,7 +15,8 @@ import { CodexAccountResetModal } from "./codex-account-reset-modal";
import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card";
import { redeemResetCredit } from "./codex-account-pool-handlers";
import type { CodexAccountEntry } from "./codex-account-pool-types";
-import { accountNeedsReauth, copyTextToClipboard, type DoctorCopyFeedback } from "../oauth-health-display";
+import { accountNeedsReauth } from "../oauth-health-display";
+import { useCopyFeedback } from "./use-copy-feedback";
// Single definition lives with the controller that owns this data (WP3).
export type { CodexAccountEntry } from "../hooks/useCodexAccountPool";
@@ -65,20 +66,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
const [redeeming, setRedeeming] = useState(false);
const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null);
const [creditDetailsLoading, setCreditDetailsLoading] = useState(false);
- const [copiedDoctorFor, setCopiedDoctorFor] = useState(null);
+ const doctorCopy = useCopyFeedback();
const copyDoctor = useCallback((accountId: string) => {
- void copyTextToClipboard(DOCTOR_CMD).then((ok) => {
- const feedback: DoctorCopyFeedback = {
- accountId,
- outcome: ok ? "copied" : "unavailable",
- };
- setCopiedDoctorFor(feedback);
- setTimeout(() => setCopiedDoctorFor(current => (
- current?.accountId === accountId && current.outcome === feedback.outcome ? null : current
- )), 2500);
- });
- }, []);
+ doctorCopy.copy(DOCTOR_CMD, accountId);
+ }, [doctorCopy]);
// The controller owns loading and polling. This surface only feeds the auto-switch
// threshold observer and leases a pause while an OAuth modal is open.
@@ -256,7 +248,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
onSwitch={setConfirm}
onOpenReset={openResetPopup}
onCopyDoctor={copyDoctor}
- copiedDoctorFor={copiedDoctorFor}
+ doctorCopyOutcomeFor={doctorCopy.outcomeFor}
/>