diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json deleted file mode 100644 index 937dba65c..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json +++ /dev/null @@ -1,356 +0,0 @@ -{ - "objective": "opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.", - "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", - "createdAt": "2026-07-27T00:41:29.352Z", - "updatedAt": "2026-07-27T04:58:54.547Z", - "activeWorkPhaseId": "wp8", - "workPhases": [ - { - "id": "wp1", - "title": "docs-only 로드맵 사이클: devlog/_plan/260727_governance_intake/ 000/010/020/030 작성", - "status": "done", - "tasks": [ - { - "id": "wp1-t1", - "title": "000 현황 조사: MAINTAINERS.md/CODEOWNERS/AGENTS.md/CONTRIBUTING 및 브랜치 실측", - "status": "done" - }, - { - "id": "wp1-t2", - "title": "010 dev2-go 기반 PR + 포팅/리베이스 PR 허용 정책 diff-level 안", - "status": "done" - }, - { - "id": "wp1-t3", - "title": "020 대상 PR 분할 안 (어떤 PR을 어떤 경계로 두 개로)", - "status": "done" - }, - { - "id": "wp1-t4", - "title": "030 Wibias 메인테이너 추가 diff-level 안", - "status": "done" - } - ], - "criteriaIds": [ - "c1" - ] - }, - { - "id": "wp2", - "title": "브랜치 정책 문서화 (문서 전용): dev2-go 통합선 + 포팅/리베이스 PR 환영", - "status": "done", - "tasks": [ - { - "id": "wp2-t1", - "title": "AGENTS.md Branch policy + Review guidelines", - "status": "done" - }, - { - "id": "wp2-t2", - "title": "CONTRIBUTING.md Branches 절", - "status": "done" - }, - { - "id": "wp2-t3", - "title": "MAINTAINERS.md 리뷰/머지 정책", - "status": "done" - } - ], - "criteriaIds": [ - "c2" - ] - }, - { - "id": "wp4", - "title": "Wibias 메인테이너 추가 (MAINTAINERS.md + CODEOWNERS)", - "status": "done", - "tasks": [ - { - "id": "wp4-t1", - "title": "MAINTAINERS.md Current maintainers 표에 @Wibias 추가", - "status": "done" - }, - { - "id": "wp4-t2", - "title": ".github/CODEOWNERS 기본 리뷰어 및 보안 경로 갱신", - "status": "done" - }, - { - "id": "wp4-t3", - "title": "Maintainer changes 절차 이행 기록", - "status": "done" - } - ], - "criteriaIds": [ - "c4" - ] - }, - { - "id": "wp3", - "title": "대상 PR 두 개로 분할 (WP4 이후 — 분할 대상 #518은 Wibias 소유이므로 메인테이너 지위가 선행)", - "status": "done", - "tasks": [ - { - "id": "wp3-t1", - "title": "020 문서가 지정한 분할 대상 및 경계 확정", - "status": "done" - }, - { - "id": "wp3-t2", - "title": "분할 브랜치 생성 및 커밋 분리", - "status": "done" - }, - { - "id": "wp3-t3", - "title": "typecheck + 관련 테스트 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c3" - ] - }, - { - "id": "wp5", - "title": "PR 타깃 게이트 재설계 (enforce-pr-target.yml) — 사용자 승인 완료, allow-list 방식 채택", - "status": "done", - "tasks": [ - { - "id": "wp5-t1", - "title": "040 문서에 승인 결과와 채택안(c: allow-list) 확정 기록", - "status": "done" - }, - { - "id": "wp5-t2", - "title": "enforce-pr-target.yml: EXPECTED_BASE -> ALLOWED_BASES allow-list", - "status": "done" - }, - { - "id": "wp5-t3", - "title": "tests/ci-workflows.test.ts: dev2-go 통과 / 그 외 차단 / 복원 시나리오", - "status": "done" - }, - { - "id": "wp5-t4", - "title": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 서술을 실제 동작에 동기화", - "status": "done" - }, - { - "id": "wp5-t5", - "title": "독립 감사 + 변이 회귀 재실행 (신규 allow-list 우회 변이 포함)", - "status": "pending" - }, - { - "id": "wp5-t6", - "title": "CI 트리거 갭 해소: ci.yml/service-lifecycle.yml pull_request에 dev2-go 추가 + 드리프트 테스트", - "status": "done" - } - ], - "criteriaIds": [ - "c5" - ] - }, - { - "id": "wp6", - "title": "enforce-pr-target.yml 특성화 테스트 — 현재 동작을 고정 (승인 불필요)", - "status": "done", - "tasks": [ - { - "id": "wp6-t1", - "title": "현재 워크플로 계약을 tests/ci-workflows.test.ts에 고정", - "status": "done" - }, - { - "id": "wp6-t2", - "title": "050에서 관측한 상태 마커 정합성 결함을 테스트로 표현", - "status": "done" - }, - { - "id": "wp6-t3", - "title": "bun test tests/ci-workflows.test.ts 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c6" - ] - }, - { - "id": "wp7", - "title": "특성화 테스트 하드닝 — 감사 2/3라운드가 찾은 우회 봉쇄 (wp6 조기 종료 후속)", - "status": "done", - "tasks": [ - { - "id": "wp7-t1", - "title": "감사 2라운드가 찾은 6가지 우회 봉쇄 (잡 인벤토리/잡 권한/스텝 수/블록 주석/복원 분기/pull_number 바인딩)", - "status": "done" - }, - { - "id": "wp7-t2", - "title": "알려진 변이 10종 재주입 검증, 워크플로 원복 확인", - "status": "done" - }, - { - "id": "wp7-t3", - "title": "감사 3라운드 (독립 서브에이전트, 새 각도)", - "status": "done" - }, - { - "id": "wp7-t4", - "title": "3라운드 지적 반영 후 커밋 및 dev 푸시", - "status": "done" - }, - { - "id": "wp7-t5", - "title": "감사 3라운드 FAIL(14건) → 부정 목록을 허용 목록으로 반전", - "status": "done" - }, - { - "id": "wp7-t6", - "title": "감사 4라운드 FAIL(12건, 전부 스크립트 내부) → 실행 하네스 도입", - "status": "done" - }, - { - "id": "wp7-t7", - "title": "감사 5라운드 FAIL(6건, 하네스 충실도) → 하네스를 실제 러너에 맞춤 + 시나리오 3개", - "status": "done" - }, - { - "id": "wp7-t8", - "title": "감사 6~10라운드 대응 (하네스 충실도 + 계약 어설션)", - "status": "done" - }, - { - "id": "wp7-t9", - "title": "감사 11~14라운드 대응 (트리거 모양, 도달 가능 상태, 버전 스큐, 스키마 truthiness 계약)", - "status": "done" - } - ], - "criteriaIds": [ - "c7" - ] - }, - { - "id": "wp8", - "title": "게이트를 main으로 승격 (fast-forward) — pull_request_target은 기본 브랜치 워크플로를 실행하므로 이것 없이는 발효되지 않음", - "status": "deferred", - "tasks": [ - { - "id": "wp8-t1", - "title": "dev HEAD b4a9fe5c 호스팅 CI success 확인 (Cross-platform CI + Service lifecycle)", - "status": "deferred" - }, - { - "id": "wp8-t2", - "title": "main..dev 98커밋 범위에 릴리스 파이프라인/보안 리뷰 대상 변경이 있는지 확인", - "status": "deferred" - }, - { - "id": "wp8-t3", - "title": "git push origin b4a9fe5c:main (fast-forward, 로컬 체크아웃은 dev 유지)", - "status": "deferred" - }, - { - "id": "wp8-t4", - "title": "승격 후 원격 main의 enforce-pr-target.yml에 ALLOWED_BASES 존재 확인 + c5 met 기록", - "status": "deferred" - } - ], - "criteriaIds": [], - "note": "사용자가 세션 범위를 dev로 좁힘(\"일단 dev에서만 처리해\"). 사전 감사도 DO NOT PROMOTE 판정: 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이고, main 푸시는 deploy-docs(실제 Pages 배포)를 트리거한다. 메인테이너의 명시적 수용이 선행 조건. 계획서: devlog/_plan/260727_governance_intake/070_main_promotion.md", - "blocker": "EXTERNAL: main 승격은 (a) 사용자가 이번 세션 범위에서 제외했고(\"일단 dev에서만 처리해\"), (b) 사전 감사가 DO NOT PROMOTE 판정을 냈다 — 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이며 main 푸시는 deploy-docs(실제 GitHub Pages 배포)를 트리거하므로 메인테이너의 명시적 수용이 선행 조건이다. 에이전트 권한으로 해소할 수 없다." - }, - { - "id": "wp9", - "title": "ocx account 인증 코드를 셸 인자에서 stdin으로 (WP8 사전 감사 High 지적)", - "status": "done", - "tasks": [ - { - "id": "wp9-t1", - "title": "stdin 읽기 헬퍼 + 주입 가능한 deps (runtime-api.ts)", - "status": "done" - }, - { - "id": "wp9-t2", - "title": "account code 위치 인자 / login --code를 선택으로, 기본 stdin, '-'는 명시적 stdin", - "status": "done" - }, - { - "id": "wp9-t3", - "title": "인자 사용 시 stderr 경고 (값은 절대 에코 금지)", - "status": "done" - }, - { - "id": "wp9-t4", - "title": "tests/cli-account.test.ts 회귀 + 독립 감사", - "status": "done" - } - ], - "criteriaIds": [] - } - ], - "criteria": [ - { - "id": "c1", - "scenario": "WP1 docs-only 사이클이 끝나면 devlog/_plan/260727_governance_intake/ 아래 000/010/020/030 문서가 존재하고 각각 변경 파일 경로와 정확한 문구를 담는다. 프로덕션 코드 변경은 0건이다.", - "expectedEvidence": "ls devlog/_plan/260727_governance_intake/ 출력 + git show --stat 으로 src/ 변경 0건 확인", - "capturedEvidence": "ls devlog/_plan/260727_governance_intake/ → 000_survey.md, 010_branch_policy.md, 011_audit_round1.md, 012_audit_round2.md, 013_audit_round3_and_scope_split.md, 020_pr_split.md, 030_maintainer_wibias.md, 040_pr_target_gate.md (8개). git diff --name-only edf3b2c6..HEAD | rg -v '^devlog/' | wc -l → 0 (프로덕션 코드 변경 0건). 커밋 f43ead8c..e0d600f3 (9개). 독립 감사 6회: 1~5차 FAIL, 6차 NEAR-PASS.", - "status": "met" - }, - { - "id": "c2", - "scenario": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 세 파일 모두에서 dev2-go가 정식 통합선으로 문서화되고, 포팅/리베이스 PR이 환영 대상으로 명시되며, 현재 자동화가 dev2-go PR에 [WRONG BRANCH]를 붙인다는 사실이 숨겨지지 않고 적힌다. .github/ 변경은 0건이다.", - "expectedEvidence": "rg -n 'dev2-go' 세 파일 + rg -n -i 'porting|rebase' + rg -n 'WRONG BRANCH' + git diff --name-only에 .github/ 없음", - "capturedEvidence": "rg -c dev2-go AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md → 2/1/2. rg -c -i \"porting|rebase\" AGENTS.md CONTRIBUTING.md → 1/1. rg -c \"WRONG BRANCH\" → 2/1/1 (세 파일 모두 현 자동화 동작 명시). git diff --name-only | rg \"^\\.github/\" → 0건 (워크플로 미변경). docs-site 5개 로케일(en/ko/ja/ru/zh-cn) 각 dev2-go 1건. 커밋 a37cc55b, e4062e32. 독립 감사 2라운드 VERDICT: PASS.", - "status": "met" - }, - { - "id": "c3", - "scenario": "지정된 PR이 두 개의 독립적인 변경으로 분리되고, 각각 bun run typecheck 통과 및 관련 테스트 통과가 증명된다.", - "expectedEvidence": "git log --oneline 분리 브랜치 + bun run typecheck exit 0 + bun test 관련 파일 결과", - "capturedEvidence": "SRC=d93b46932b58b963ea5dfa27dee5c63f3a7b0f2a 고정. B(codex/catalog-written-signal, PR #526): typecheck exit 0, 22 pass 0 fail, 6파일. A(codex/app-server-restart, PR #527): typecheck exit 0, 22 pass 1 skip 0 fail, 15파일. 합집합 검증 diff pr518-manifest split-union → IDENTICAL (21파일). SRC 불변 재확인. 계획 경계 가정 3회 실측 정정: config-routes.ts, gui i18n syncStaleHint, codex-models-cache-invalidate.test.ts 전부 A 소속.", - "status": "met" - }, - { - "id": "c4", - "scenario": "MAINTAINERS.md의 Current maintainers 표에 @Wibias 행이 있고 .github/CODEOWNERS의 기본 리뷰어에 @Wibias가 포함되며, Maintainer changes 절차 이행이 기록된다.", - "expectedEvidence": "rg -n 'Wibias' MAINTAINERS.md .github/CODEOWNERS 출력", - "capturedEvidence": "rg -c Wibias MAINTAINERS.md → 2 (표 행 + change log). rg -c Wibias .github/CODEOWNERS → 1 (기본 리뷰어 * 규칙만). 보안 경로 미포함 검증 → 0건. MAINTAINERS.md 표 3행. 감사 확인: CODEOWNERS 마지막 매칭 우선 규칙으로 @Wibias는 /src/oauth/, /.github/, /scripts/release.ts, /MAINTAINERS.md, /SECURITY.md, /package.json, /bun.lock의 코드오너가 아님(GitHub API errors:[]). 커밋 01e831d0, 71e43d9c, 491373f3.", - "status": "met" - }, - { - "id": "c5", - "scenario": "enforce-pr-target.yml이 dev2-go PR을 정당하게 통과시키고, 그 외 타깃은 차단·복원 경로가 회귀 테스트로 덮이며, PR을 받는 브랜치는 CI 트리거도 함께 받는다. actor 검증 + head SHA 바인딩은 040 문서의 근거 셋에 따라 채택하지 않았다 (강제력 없는 자동 판정 대신 리뷰가 잡는다). main 승격으로 실제 발효된다.", - "expectedEvidence": "워크플로 diff + 신규 테스트 통과 출력 + main 승격 확인", - "capturedEvidence": "커밋: d761e880 (ALLOWED_BASES allow-list, 워크플로 5지점) / 10b1d2aa (문서 8종, 5개 로케일 포함) / 5229717b (ci.yml+service-lifecycle.yml PR 트리거에 dev2-go) / 76c25710 (하네스 node24 충실도 + 트리거 키집합 고정 + live base 코멘트 어설션) / 2b03e908 (ci.yml paths 양쪽 트리거 정확 집합 고정) / 99679376 (040 문서에 15~17라운드 기록). 검증: bun x tsc --noEmit exit 0; bun run test 4945 pass / 0 fail / 24352 expect(); bun test tests/ci-workflows.test.ts 52 pass / 0 fail / 548 expect(). 변이 회귀: mut16 12/12, mut17 5/5, mut18 5/5 (봉쇄 전 5종 전부 SURVIVED), mut20 8/8 (봉쇄 전 4종 SURVIVED) 전부 CAUGHT. 독립 감사 3라운드: 15=FAIL(5건) -> 76c25710, 16=FAIL(1건) -> 2b03e908, 17=PASS (12/12 CAUGHT, 무해 생존 2건 근거 기록). 미충족 잔여: main 승격 (승인 완료, 다음 work-phase). [세션 종료 시점] dev 쪽 조건은 전부 충족(워크플로 diff + ci-workflows 테스트 통과 + 변이 회귀 전건 CAUGHT). 남은 조건인 'main 승격으로 발효'만 미충족이며, 이는 사용자가 이번 세션 범위에서 제외했다. origin/main은 origin/dev의 조상이라 fast-forward 가능한 상태로 남아 있다.", - "status": "deferred", - "blocker": "EXTERNAL: 'main 승격으로 발효' 조건만 미충족. 승격 권한/수용은 메인테이너 결정이며 사용자가 범위에서 제외했다." - }, - { - "id": "c6", - "scenario": "tests/ci-workflows.test.ts에 enforce-pr-target.yml 테스트가 존재하고, 현재 동작(pull_request_target 트리거 집합, EXPECTED_BASE, 제목 prefix, draft 전환, checkout/PR코드실행 없음, 최소 권한)을 고정한다. WP5에서 게이트를 바꿀 때 이 테스트가 회귀 그물이 된다.", - "expectedEvidence": "bun test tests/ci-workflows.test.ts 통과 출력 + rg enforce-pr-target tests/ci-workflows.test.ts", - "capturedEvidence": "tests/ci-workflows.test.ts에 PR target enforcement 테스트 3개(rg -c → 3). bun test → 14 pass 0 fail 282 expect(). bun run typecheck exit 0. 4라운드 적대적 변이 감사: 1차 FAIL(문자열 매칭 구멍 3), 2차 FAIL(YAML 문법 우회 5), 3차 FAIL(함수 shadow), 4차 PASS. 최종적으로 Bun.YAML 파싱 + quote-aware 주석 제거 + 함수 선언 개수/mutation 결속. 통과하는 변이는 전부 고의적 무력화만 남음.", - "status": "met" - }, - { - "id": "c7", - "scenario": "enforce-pr-target.yml 특성화 테스트가 알려진 모든 우회 변이를 잡는다", - "expectedEvidence": "변이 주입 스크립트 출력에서 알려진 변이 전부 CAUGHT, survived=none. 기준선 bun test 통과, bun run typecheck 오류 0. 독립 감사(gpt-5.6-terra) verdict PASS 또는 NEAR-PASS.", - "capturedEvidence": "변이 회귀 12개 스크립트 131종 전수: 알려진 무해 6종(context-eventname, core-tostring, promise-identity, err-tostringtag, response-headers, comment-after-write)과 들여쓰기 no-op 2종 외 전부 CAUGHT, git status --short .github/ clean. 기준선 bun test tests/ci-workflows.test.ts -> 44 pass / 0 fail / 485 expect(). bun run test -> 4937 pass / 0 fail / 24289 expect() exit 0. bun x tsc --noEmit exit 0. 독립 감사(gpt-5.6-terra, agent 019fa18f) 14라운드 verdict NEAR-PASS, 잔여 지적 1건은 dbd558e1로 봉쇄 후 CAUGHT 확인.", - "status": "met" - }, - { - "id": "c9", - "scenario": "ocx account login/code가 인증 코드를 셸 히스토리와 프로세스 목록에 남기지 않는 경로를 기본으로 제공하고, 인자 경로를 쓰면 값을 노출하지 않는 경고가 나온다.", - "expectedEvidence": "테스트 통과 출력 + 인자 경고 어설션 + 값 미노출 어설션", - "capturedEvidence": "구현: d4dfa24e(stdin 기본 경로), e7f5bef5(공백 문법 redact + 중복 --code 거부), d3dc9d79(미지 플래그를 코드로 먹지 않음), f10c5060(비밀 옵션 뒤 토큰은 모양 무관 은닉, -- 구분자 건너뜀, account code 잔여 위치 인자 은닉, 종료된 stdin 즉시 실패), 1de43286(CR/LF 줄바꿈 고정). 검증: bun run test 4965 pass / 0 fail / 24429 expect(); bun x tsc --noEmit exit 0; bun test tests/cli-account.test.ts 62 pass / 0 fail / 276 expect(). 변이: mut23 10/10 CAUGHT, mut21 9/9 CAUGHT, mut22 4/4 CAUGHT. 독립 감사 2라운드: R2 FAIL(High 2 + Medium 1 + Low 1, 전부 프로브로 실측 재현) → 수정 → R3 VERDICT: PASS. 잔여: `--code --nope`는 --nope를 값으로 간주해 은닉(사용 오류 메시지는 --code를 여전히 지목) — 진단성보다 노출 차단 우선.", - "status": "met" - } - ], - "host": { - "armed": false, - "armedAt": null, - "source": "none" - } -} \ No newline at end of file diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl deleted file mode 100644 index 4cabb8387..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"ts":"2026-07-27T00:41:29.353Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"created","detail":"init objective=\"opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.\" criteria=0"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp1"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp6"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts": "2026-07-27T02:12:28.095252Z", "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", "event": "workphase_started", "detail": "started wp7 (P-phase amendment: wp6 closed before the round-2 audit returned FAIL; hardening is the next unit, LOOP-UNIT-CHAIN-01)"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:11:25Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"re-activated wp7: rounds 7-10 hardening was the unit actually worked; wp5 remains blocked on user approval and was parked back to pending"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:40:13Z","event":"work-phase-complete","workPhaseId":"wp7","criterion":"c7","status":"met","evidence":"rounds 11-14 audited; 131 mutation variants CAUGHT except 6 proven-harmless; suite 4937 pass/0 fail; typecheck 0; commits 5f7afc21, dbd558e1"} -{"ts":"2026-07-27T04:17:39.364Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp5"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp9"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp8"} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json deleted file mode 100644 index bf89c77b1..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "objective": "OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.", - "slug": "opencodex-live-unfinished-issues-and-prs-triage", - "createdAt": "2026-07-27T10:35:02.724Z", - "updatedAt": "2026-07-27T12:25:15.899Z", - "activeWorkPhaseId": "WP8", - "workPhases": [ - { - "id": "WP0", - "title": "Live GitHub triage manifest and priority table", - "status": "done", - "tasks": [ - { - "id": "WP0-T1", - "title": "Refresh dev branch/worktree state from origin/dev without touching main/preview/release branches", - "status": "done" - }, - { - "id": "WP0-T2", - "title": "Query all current open PRs and issues via gh with state/base/head/mergeable/checks/reviews/labels", - "status": "done" - }, - { - "id": "WP0-T3", - "title": "Write numbered docs-first devlog manifest and priority table", - "status": "done" - }, - { - "id": "WP0-T4", - "title": "Append follow-up one-item work-phases for safe merge/fix/close candidates discovered by the manifest", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP0-LIVE-MANIFEST" - ] - }, - { - "id": "WP1", - "title": "PR #526 catalog write signal rebase and coverage decision", - "status": "done", - "tasks": [ - { - "id": "WP1-T1", - "title": "Re-check PR #526 live head, checks, diff, and independent review", - "status": "done" - }, - { - "id": "WP1-T2", - "title": "Take over rebase/tests or leave a documented blocker for PR #526 only", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP1-PR526" - ] - }, - { - "id": "WP9", - "title": "Dev baseline checkout blocker from devlog gitlinks", - "status": "done", - "tasks": [ - { - "id": "WP9-T1", - "title": "Confirm current dev gitlink/.gitmodules mismatch and whether devlog chase checkouts are required tracked inputs", - "status": "done" - }, - { - "id": "WP9-T2", - "title": "Remove or repair only the accidental devlog gitlinks on a dev-target branch", - "status": "done" - }, - { - "id": "WP9-T3", - "title": "Verify checkout/submodule commands and open/merge the dev baseline CI fix before rerunning PR #526", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP9-DEVLOG-GITLINK" - ] - }, - { - "id": "WP10", - "title": "Dev baseline Desktop 3P target-platform path fix", - "status": "done", - "tasks": [ - { - "id": "WP10-T1", - "title": "Confirm hosted Windows failure and local Desktop 3P path resolver cause", - "status": "done" - }, - { - "id": "WP10-T2", - "title": "Patch target-platform-specific path joining and regression tests on a dev-target branch", - "status": "done" - }, - { - "id": "WP10-T3", - "title": "Verify locally, push PR, wait for hosted checks, and squash merge if green", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP10-DESKTOP3P-PATH" - ] - }, - { - "id": "WP2", - "title": "PR #528 image bridge credential-origin request changes", - "status": "pending", - "tasks": [ - { - "id": "WP2-T1", - "title": "Re-check PR #528 live head, checks, diff, and independent review", - "status": "pending" - }, - { - "id": "WP2-T2", - "title": "Post request-changes comment for credential-origin binding and stale checks", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP2-PR528" - ] - }, - { - "id": "WP11", - "title": "PR #526 final rerun and merge after dev baseline fixes", - "status": "done", - "tasks": [ - { - "id": "WP11-T1", - "title": "Rebase PR #526 branch onto current origin/dev after WP9/WP10", - "status": "done" - }, - { - "id": "WP11-T2", - "title": "Run local targeted verification and push the rebased PR head", - "status": "done" - }, - { - "id": "WP11-T3", - "title": "Wait for hosted checks and squash merge PR #526 if the latest head is clean", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP11-PR526-FINAL" - ] - }, - { - "id": "WP3", - "title": "Issue #543 Claude queued_command support reply", - "status": "pending", - "tasks": [ - { - "id": "WP3-T1", - "title": "Verify existing debug capture switch and issue context", - "status": "pending" - }, - { - "id": "WP3-T2", - "title": "Post one maintainer comment requesting marker frames", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP3-ISSUE543" - ] - }, - { - "id": "WP4", - "title": "Issue #547 Claude Desktop custom-model visibility reply", - "status": "pending", - "tasks": [ - { - "id": "WP4-T1", - "title": "Verify issue #547 context and likely evidence gaps", - "status": "pending" - }, - { - "id": "WP4-T2", - "title": "Post one maintainer comment requesting generated config/log/profile evidence", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP4-ISSUE547" - ] - }, - { - "id": "WP5", - "title": "Issue #545 64-token classifier investigation", - "status": "pending", - "tasks": [ - { - "id": "WP5-T1", - "title": "Trace max_tokens/max_output_tokens path for Desktop 3P classifier requests", - "status": "pending" - }, - { - "id": "WP5-T2", - "title": "Fix if local bug is proven; otherwise comment with exact evidence needed", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP5-ISSUE545" - ] - }, - { - "id": "WP6", - "title": "PR #527 wrong-base handling", - "status": "done", - "tasks": [ - { - "id": "WP6-T1", - "title": "Re-check #527 after #526 decision", - "status": "pending" - }, - { - "id": "WP6-T2", - "title": "Retarget/request rebase or leave blocker; do not merge in same phase as #526", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP6-PR527" - ] - }, - { - "id": "WP7", - "title": "Issue #418 V2 custom delegation investigation", - "status": "done", - "tasks": [ - { - "id": "WP7-T1", - "title": "Read custom-parent/custom-child delegation code path and issue evidence", - "status": "done" - }, - { - "id": "WP7-T2", - "title": "Fix or comment with code pointers and required reproduction evidence", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP7-ISSUE418" - ] - }, - { - "id": "WP8", - "title": "Issue #509 JS heap watchdog investigation", - "status": "in_progress", - "tasks": [ - { - "id": "WP8-T1", - "title": "Trace RSS/heap watchdog logic and issue evidence", - "status": "pending" - }, - { - "id": "WP8-T2", - "title": "Fix or comment with code pointers and blocker", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP8-ISSUE509" - ] - } - ], - "criteria": [ - { - "id": "C-WP0-LIVE-MANIFEST", - "scenario": "Live GitHub state has been refreshed and every open PR/open issue is classified into the requested triage buckets.", - "expectedEvidence": "devlog/_plan/260727_live_unfinished_triage/ numbered manifest files with gh snapshot timestamp, PR/issue lists, classification rationale, and priority table.", - "capturedEvidence": "WP0 closed by cxc D at 2026-07-27T10:46:14Z. Commit 58b247ac records devlog/_plan/260727_live_unfinished_triage. C check: open PR count 14 with numbers 355,424,429,447,461,491,493,495,498,512,526,527,528,533; open issue count 23 with numbers 42,92,95,177,178,201,241,294,386,401,414,415,417,418,425,462,476,509,521,540,543,545,547; manifest has unsafe merge-now entries 0.", - "status": "met" - }, - { - "id": "C-WP1-PR526", - "scenario": "PR #526 is either taken over with rebase/direct coverage or left open with a fresh blocker comment.", - "expectedEvidence": "live PR head/checks, independent review verdict, test evidence or comment URL.", - "capturedEvidence": "WP1 closed by cxc D at 2026-07-27T11:01:22Z. Same-repo branch codex/catalog-written-signal was rebased onto origin/dev@7fcaa9119253d010393cb457427a2868cd935718 and pushed at 43d0efff4569711ed192e09d4d87b62fc803153c. Local pre-push passed typecheck, lint:gui, bun test 5051 pass/0 fail/24881 assertions, privacy scan, and React Doctor. Hosted Issue quality tests/test failed before tests during checkout with fatal missing .gitmodules mapping for devlog/_chase/_cca; origin/dev has the same gitlink mismatch. Merge held pending WP9 baseline checkout repair.", - "status": "met" - }, - { - "id": "C-WP9-DEVLOG-GITLINK", - "scenario": "The dev baseline checkout blocker caused by tracked devlog gitlinks without .gitmodules mappings is fixed or proven to require human direction.", - "expectedEvidence": "git tree/submodule evidence, commit/PR URL, local checkout/submodule verification, and hosted CI rerun evidence if pushed.", - "capturedEvidence": "WP9 closed by cxc D at 2026-07-27T11:11:58Z. Branch codex/devlog-gitlink-ci-fix removed exactly three accidental 160000 gitlinks without .gitmodules mappings: devlog/_chase/_cca, devlog/_chase/_litellm, and devlog/_fin/opencode-cursor. Local verification: git ls-files -s found no 160000 entries; git submodule status --recursive exit 0; git diff --check origin/dev exit 0. PR #550 https://github.com/lidge-jun/opencodex/pull/550 passed hosted checks and was squash-merged into origin/dev at ff831858388179d3f76f4dd7c119d84470214fa6.", - "status": "met" - }, - { - "id": "C-WP10-DESKTOP3P-PATH", - "scenario": "The dev baseline Desktop 3P path resolver returns target-platform separators across hosted OSes and unblocks PR #526 CI.", - "expectedEvidence": "code pointers, targeted Bun test output, TypeScript output, diff-check output, PR URL, hosted check evidence, and merge SHA if green.", - "capturedEvidence": "WP10 branch codex/desktop3p-path-windows-ci commit f6d2881dd422830eece502e0ba8de493205fe9d1 patched src/claude/desktop-3p-paths.ts to use target-platform posix/win32 joins and updated tests/desktop-3p.test.ts plus tests/claude-desktop-config-path.test.ts. Local verification: bun test tests/desktop-3p.test.ts tests/claude-desktop-config-path.test.ts = 30 pass/0 fail; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Prepush full gate: 5047 pass/0 fail, privacy scan passed, GUI doctor skipped. Independent C review Aquinas PASS. PR #552 https://github.com/lidge-jun/opencodex/pull/552 passed hosted CodeRabbit, enforce-target, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and all npm-global matrix jobs; squash-merged at 2026-07-27T11:43:58Z as origin/dev 7c74e0a22ec96dd5849d3d7253758f0ab15d9737. Remote topic branch deleted.", - "status": "met" - }, - { - "id": "C-WP2-PR528", - "scenario": "PR #528 receives a request-changes comment for credential-origin binding and stale checks.", - "expectedEvidence": "live PR head/checks, independent review verdict, and comment/review URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP11-PR526-FINAL", - "scenario": "PR #526 is rebased after dev baseline blockers, verified on the latest head, and merged if clean.", - "expectedEvidence": "rebase head SHA, local test/typecheck/diff-check output, pushed head SHA, hosted check list, merge commit URL/SHA or blocker evidence.", - "capturedEvidence": "WP11 closed by cxc D at 2026-07-27T12:05:44Z merge evidence. Branch codex/catalog-written-signal was rebased on origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737 and pushed at ce716cc117ab23e4420c8c9fe860959968f66cdc. Local targeted verification passed: bun test tests/codex-refresh.test.ts tests/codex-sync-api.test.ts tests/injection-model-api.test.ts = 24 pass/0 fail/112 assertions; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Pre-push full gate passed: bun run test = 5051 pass/0 fail/24881 assertions, privacy scan passed, GUI doctor skipped. Hosted checks on PR #526 head ce716cc117ab23e4420c8c9fe860959968f66cdc all succeeded: CodeRabbit, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and npm-global ubuntu/macos/windows. Pre-merge gate passed: remote dev stayed 7c74e0a22ec96dd5849d3d7253758f0ab15d9737, PR head matched ce716cc117ab23e4420c8c9fe860959968f66cdc, mergeable MERGEABLE/CLEAN and REST mergeable_state clean. PR #526 https://github.com/lidge-jun/opencodex/pull/526 was squash-merged into dev at 2026-07-27T12:05:44Z as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Remote codex/catalog-written-signal branch was preserved at ce716cc117ab23e4420c8c9fe860959968f66cdc.", - "status": "met" - }, - { - "id": "C-WP3-ISSUE543", - "scenario": "Issue #543 receives a concrete support reply naming the existing debug capture switch and requested evidence.", - "expectedEvidence": "comment URL plus code/docs pointers for `ocx debug claude` or `OCX_CLAUDE_DEBUG=1`.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP4-ISSUE547", - "scenario": "Issue #547 receives a concrete evidence request for Claude Desktop custom-model visibility.", - "expectedEvidence": "comment URL plus requested config/log/profile evidence.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP5-ISSUE545", - "scenario": "Issue #545 is narrowed to a proven local fix or exact remaining evidence need.", - "expectedEvidence": "code pointers, test/command output if fixed, PR/comment URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP6-PR527", - "scenario": "PR #527 wrong-base state is resolved or documented after PR #526 decision.", - "expectedEvidence": "live base/check state and retarget/request-rebase/comment URL.", - "capturedEvidence": "WP6 closed by cxc D at 2026-07-27T12:16:35Z. Live PR #527 remained OPEN on base codex/catalog-written-signal@ce716cc117ab23e4420c8c9fe860959968f66cdc with head codex/app-server-restart@a64aa585630f664a83c25253497a62810133e832, mergeable CONFLICTING and mergeStateStatus DIRTY. PR #526 had merged to dev as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Topology showed #527 still carried duplicate old #526 commit 1ba588eff663a5be846a8723b90a452dca8cd04c; merge-tree against current dev conflicted in tests/codex-refresh.test.ts and tests/injection-model-api.test.ts. Independent A review returned GO-WITH-FIXES and the blocker was folded into the comment plan. Posted maintainer comment https://github.com/lidge-jun/opencodex/pull/527#issuecomment-5091163284 requesting a clean rebuild from current dev, dropping 1ba588e, preserving the Grok diagnostic, and retargeting after rebuild. No retarget, push, merge, or branch deletion was performed.", - "status": "met" - }, - { - "id": "C-WP7-ISSUE418", - "scenario": "Issue #418 has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "WP7 closed as NOOP/comment-request-changes at 2026-07-27. Live issue #418 remains OPEN with bug label. Existing comments already satisfy this phase's intended maintainer action: owner request https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5069836945 asks for raw provider tool-call, Responses event, and child lifecycle trace; reporter acknowledgement https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5070272410 says same-run failing spawn_agent trace is still unavailable until usage limit clears; collaborator cross-link https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5085535548 keeps #418 separate from #92 pending the three-boundary capture. Code review found no proven local argument-drop path: parser copies tool parameters at src/responses/parser.ts:134-139; OpenAI-compatible adapter forwards parameters at src/adapters/openai-chat.ts:432-445; provider function.arguments fragments are accumulated at src/adapters/openai-chat.ts:749-768; bridge forwards deltas at src/bridge.ts:610-617 and materializes {} only when accumulated bytes are empty at src/bridge.ts:366-375. No GitHub comment, close, merge, or code change was performed.", - "status": "met" - }, - { - "id": "C-WP8-ISSUE509", - "scenario": "Issue #509 heap watchdog gap has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "", - "status": "open" - } - ], - "host": { - "armed": true, - "armedAt": "2026-07-27T10:35:02.725Z", - "source": "freeze" - } -} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl deleted file mode 100644 index 07d4aeae8..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl +++ /dev/null @@ -1,24 +0,0 @@ -{"ts":"2026-07-27T10:35:02.725Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"created","detail":"init objective=\"OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.\" criteria=0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP9"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP10"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:47:00.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T1 rebased codex/catalog-written-signal onto origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T2 verified locally and pushed ce716cc117ab23e4420c8c9fe860959968f66cdc"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T3 hosted checks passed and PR #526 squash-merged as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"criterion_met","detail":"C-WP11-PR526-FINAL evidence in devlog/_plan/260727_wp11-pr526-final-rerun-merge/011_phase1_evidence.md"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP3"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"steering","detail":"corrected next active work-phase from WP3 to WP6 because PR #527 explicitly depends on the PR #526 decision; WP2 and WP3 remain pending"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP8"} diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..fc363e66f --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,26 @@ +# GitHub automation instructions + +This file applies to `.github/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Security boundary + +Every workflow, ownership, branch-enforcement, release, or repository-automation +change requires explicit security review under `MAINTAINERS.md`. + +## Workflow rules + +- Grant the minimum required `permissions`. +- Pin third-party actions to immutable full commit SHAs. +- Preserve the human-readable version comment beside each pinned action. +- Do not run untrusted pull-request code with secrets or write permissions. +- Treat `pull_request_target`, workflow dispatch, reusable workflows, artifacts, caches, and generated command input as trust boundaries. +- Do not broaden triggers, write permissions, token exposure, release eligibility, or publish capability without an explicit task requirement. +- Preserve cross-platform coverage where the workflow currently promises Linux, macOS, and Windows behavior. +- Keep branch-enforcement text synchronized with `AGENTS.md`, `MAINTAINERS.md`, and the public contributing guide. + +## Validation + +- Inspect the complete workflow diff, including event triggers, permissions, conditions, interpolation, and shell behavior. +- Run the local commands represented by changed workflow steps where possible. +- Run `bun run prepush` for CI, release, dependency, packaging, or cross-platform workflow changes. +- Do not claim the workflow itself passed until GitHub Actions reports success for the exact commit. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 556398275..297343edf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,5 +14,7 @@ /src/server/management-api.ts @lidge-jun @Ingwannu # Governance and security policy +/AGENTS.md @lidge-jun @Ingwannu +**/AGENTS.md @lidge-jun @Ingwannu /MAINTAINERS.md @lidge-jun @Ingwannu /SECURITY.md @lidge-jun @Ingwannu diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d85f47ee5..07fd1d1cf 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Report a security vulnerability (private) + url: https://github.com/lidge-jun/opencodex/security/advisories/new + about: Report undisclosed vulnerabilities privately to the maintainers. Do not open a public issue. - name: Security policy url: https://github.com/lidge-jun/opencodex/blob/main/SECURITY.md about: Read the supported-version and reporting guidance before sharing security-sensitive details. diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs new file mode 100644 index 000000000..79c3dbd26 --- /dev/null +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -0,0 +1,75 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +describe("enforce-pr-target workflow", () => { + const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml"); + const workflow = fs.readFileSync(workflowPath, "utf8"); + + it("uses pull_request_target without checking out PR head code", () => { + assert.match(workflow, /pull_request_target:/); + assert.doesNotMatch( + workflow, + /ref:\s*\$\{\{\s*github\.event\.pull_request\.head/, + "enforcer must not check out untrusted PR head code", + ); + }); + + it("grants contents:write so draft GraphQL mutations work with GITHUB_TOKEN", () => { + // convertPullRequestToDraft / markPullRequestReadyForReview fail with + // "Resource not accessible by integration" when contents stays unset/read + // (seen on #626). Assert the real permissions block, not comment text + // that also mentions these scopes. + const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m); + assert.ok(permissionsBlock, "workflow must declare a top-level permissions block"); + const lines = permissionsBlock[1] + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .sort(); + assert.deepEqual(lines, ["contents: write", "pull-requests: write"]); + }); + + it("fails the required check on a wrong base even if draft conversion fails", () => { + assert.match(workflow, /core\.setFailed\(/); + assert.match(workflow, /draftConversionFailed/); + assert.match(workflow, /Could not convert pull request to draft/); + }); + + it("soft-fails ready-for-review restoration the same way", () => { + assert.match(workflow, /readyConversionFailed/); + assert.match(workflow, /Could not mark pull request ready for review/); + }); + + it("listens for synchronize so rebase can clear ancestry failures", () => { + assert.match(workflow, /synchronize/); + }); + + it("checks out trusted default-branch scripts only (never PR head)", () => { + assert.match(workflow, /actions\/checkout@[0-9a-f]{40}/); + assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/); + assert.match(workflow, /sparse-checkout:\s*\.github\/scripts/); + assert.match(workflow, /persist-credentials:\s*false/); + assert.doesNotMatch(workflow, /ref:\s*\$\{\{\s*github\.event\.pull_request\.head/); + }); + + it("loads pr-quality via require from the checked-out scripts", () => { + assert.match(workflow, /pr-quality\.cjs/); + assert.match(workflow, /collectPrQualityFailures/); + }); + + it("strips stale WRONG BRANCH prefix on failure when base is corrected", () => { + const failureBlock = workflow.match( + /if \(failures\.length > 0\) \{([\s\S]*?)core\.setFailed\(/, + ); + assert.ok(failureBlock, "workflow must have a failure path"); + const failurePath = failureBlock[1]; + assert.match(failurePath, /shouldStripTitlePrefix/); + assert.match(failurePath, /!hasWrongBase/); + assert.match(failurePath, /titlePrefixedByBot = false/); + assert.match(failurePath, /pr\.title\.slice\(TITLE_PREFIX\.length\)/); + }); +}); diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index b2c778301..9dccb742f 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -25,19 +25,29 @@ function unwrapSingleEnclosingFence(text) { return match[2]; } -function isPlaceholderOnlyValue(raw) { - if (typeof raw !== "string") return false; +/** + * Shared strip/trim/unwrap used by placeholder and unusable-stand-in matchers. + * Returns null when the value is absent after normalisation. + */ +function normalizeRawSectionValue(raw) { + if (typeof raw !== "string") return null; let value = raw.replace(//g, "").trim(); - if (!value) return false; + if (!value) return null; - // A lone fenced block whose entire body is a placeholder is still placeholder - // text (e.g. ```text\nN/A\n```), not a real example. + // A lone fenced block whose entire body is a stand-in is still a stand-in + // (e.g. ```text\nN/A\n```), not a real example. const unwrapped = unwrapSingleEnclosingFence(value); if (unwrapped !== null) { value = unwrapped.trim(); - if (!value) return false; + if (!value) return null; } + return value; +} + +function isPlaceholderOnlyValue(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; return PLACEHOLDER_ONLY_RE.test(value); } @@ -374,7 +384,10 @@ function detectIssueKind(issue) { // --------------------------------------------------------------------------- function isEmpty(text) { - return clean(text).length === 0; + const c = clean(text); + if (c.length === 0) return true; + // Stand-ins like "...", "…", "---" are not actionable report content. + return /^[\p{P}\p{S}\s]+$/u.test(c); } function allSameCanonical(sections) { @@ -395,6 +408,20 @@ function isPlaceholder(text) { return isPlaceholderOnlyValue(text); } +/** + * True when Version is an "I don't know" stand-in rather than an install id. + * Kept separate from PLACEHOLDER_ONLY_RE so legacy N/A / No response soft-pass + * behaviour is unchanged. + */ +const UNUSABLE_VERSION_RE = + /^[\s_*~`]*(?:unknown|unkown|uknown|don'?t\s+know|do\s+not\s+know|idk|dunno|not\s+sure|unsure|\?+|모름|잘\s*모름|모르겠(?:습니다|음)?|不明|わからない|分からない|不知道|不清楚|keine\s+ahnung|wei[sß]{1,2}\s+nicht)[\s_*~`]*[.!?]*$/i; + +function isUnusableVersion(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; + return UNUSABLE_VERSION_RE.test(value); +} + const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu; @@ -430,6 +457,16 @@ function isTooTerseFeatureSection(text) { return true; } +/** + * Bug Reproduction needs steps or concrete signals. A title-like phrase with + * no commands, paths, digits, or product keywords is not actionable. + */ +function isTooTerseBugReproduction(text) { + if (isEmpty(text) || isPlaceholder(text)) return false; + if (hasConcreteDetail(text)) return false; + return countWords(text) < 12; +} + /** * Check if raw section text is a placeholder-only variant without relying on * clean() first. Used to distinguish intentionally blank optional fields @@ -625,6 +662,8 @@ function validateIssue(issue) { const repro = extractSection(body, "Reproduction"); const version = extractSection(body, "Version"); const os = extractSection(body, "Operating system") ?? extractSection(body, "OS"); + // New Bug report template always includes Client or integration. + const isNewBugForm = extractSection(body, "Client or integration") !== null; if (isEmpty(summary) && isEmpty(repro)) { // Soft-pass substantial non-English / freeform structured reports once @@ -642,16 +681,65 @@ function validateIssue(issue) { reasons.push("Both Summary and Reproduction are empty."); guidance.push("Describe what happened and how to reproduce it."); } + } else { + // Each mapped field is required on its own — a filled Summary with an + // empty / ellipsis Reproduction (e.g. #598) must not pass. + if (isEmpty(summary)) { + reasons.push("Summary is empty."); + guidance.push("Describe what happened (the symptom or error)."); + } + if (isEmpty(repro)) { + reasons.push("Reproduction is empty."); + guidance.push("List the exact steps to reproduce the problem."); + } else if (!softPass && isTooTerseBugReproduction(repro)) { + reasons.push("Reproduction is too vague to act on."); + guidance.push("List exact steps, commands, and the observed failure — not only a short phrase."); + } } - // Required environment fields removed after submission. - // Only fire when the headings exist in the body (new form). Legacy bug - // reports never had Version or OS fields, so null means absent, not removed. - // Skip when the raw value is a "No response" placeholder -- the old form had - // both fields as optional, so legacy issues legitimately contain those headings - // with the GitHub placeholder. Only close when the field was actively cleared. - if (!softPass && version !== null && os !== null && isEmpty(version) && isEmpty(os) && - !isRawPlaceholder(version) && !isRawPlaceholder(os)) { + // Version "Unknown" / "모름" / "idk" is never actionable, on any form. + if (!softPass && version !== null && isUnusableVersion(version)) { + reasons.push("Version is missing or unknown."); + guidance.push("Report the installed `@bitkyc08/opencodex` version (for example `2.7.42`) or a commit SHA from `ocx --version`."); + } else if ( + !softPass && + isNewBugForm && + (version === null || isEmpty(version) || isRawPlaceholder(version)) + ) { + // New form requires Version (including when the heading was removed). + // Legacy N/A / No response soft-pass stays only for bodies without + // Client or integration. + reasons.push("Version is missing."); + guidance.push("Add your OpenCodex version so we can reproduce the environment."); + } + + if (!softPass && isNewBugForm && os !== null && isUnusableVersion(os)) { + reasons.push("Operating system is missing or unknown."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } else if ( + !softPass && + isNewBugForm && + (os === null || isEmpty(os) || isRawPlaceholder(os)) + ) { + reasons.push("Operating system is missing."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } + + // Required environment fields removed after submission on bodies that are + // not the new form (no Client or integration). Legacy reports never had + // Version or OS fields, so null means absent, not removed. Skip when the + // raw value is a "No response" placeholder — the old form had both fields + // as optional. Only close when the field was actively cleared. + if ( + !softPass && + !isNewBugForm && + version !== null && + os !== null && + isEmpty(version) && + isEmpty(os) && + !isRawPlaceholder(version) && + !isRawPlaceholder(os) + ) { reasons.push("Version and Operating system are both missing."); guidance.push("Add your OpenCodex version and OS so we can reproduce the environment."); } @@ -859,6 +947,7 @@ module.exports = { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, labelForKind, diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index c6a58996a..c629a3d78 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -16,6 +16,7 @@ const { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, rejectsWorkflowDispatchPullRequest, @@ -362,8 +363,8 @@ describe("validateIssue - feature", () => { assert.equal(result.kind, "feature"); assert.equal(result.valid, false, `Expected terse goal "${goal}" to be invalid`); assert.ok( - result.reasons.some((r) => r.includes("too vague")), - `Expected too vague reason for "${goal}", got: ${result.reasons.join(", ")}`, + result.reasons.some((r) => r.includes("too vague") || /missing or empty/i.test(r)), + `Expected too vague or empty reason for "${goal}", got: ${result.reasons.join(", ")}`, ); } }); @@ -536,6 +537,70 @@ describe("validateIssue - bug", () => { assert.equal(result.valid, false); }); + it("rejects a bug with Summary filled but Reproduction empty", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.", + "### Reproduction", + "No response", + "### Version", + "2.7.42", + "### Operating system", + "macOS", + ].join("\n"); + const result = validateIssue({ title: "Open Codex Error", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Reproduction is empty/i.test(r))); + assert.ok(!result.reasons.some((r) => /Summary is empty/i.test(r))); + }); + + it("rejects a bug whose Reproduction is only an ellipsis (#598)", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "{\"detail\":\"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.\"}", + "### Reproduction", + "...", + "### Version", + "2.7.42", + "### Operating system", + "mac os", + ].join("\n"); + const result = validateIssue({ title: "Open Codex Error", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Reproduction is empty/i.test(r))); + }); + + it("rejects a bug with Reproduction filled but Summary empty", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "", + "### Reproduction", + "1. Run ocx start\n2. Send a request", + "### Version", + "2.7.42", + "### Operating system", + "macOS", + ].join("\n"); + const result = validateIssue({ title: "Crash", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Summary is empty/i.test(r))); + }); + it("accepts a terse real crash report", () => { const body = [ "### Client or integration", @@ -629,6 +694,202 @@ describe("validateIssue - bug", () => { assert.equal(result.valid, false); assert.ok(result.reasons.some((r) => r.includes("Version"))); }); + + it("rejects unknown / don't-know Version values (#624)", () => { + const versions = [ + "Unknown", + "Uknown", + "unkown", + "Don't know", + "dont know", + "idk", + "모름", + "잘 모름", + "?", + "???", + ]; + for (const version of versions) { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + version, + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal( + result.valid, + false, + `Expected unusable Version "${version}" to be invalid, got: ${result.reasons.join("; ")}`, + ); + assert.ok( + result.reasons.some((r) => /Version/i.test(r) && /unknown|missing/i.test(r)), + `Expected Version unknown/missing reason for "${version}", got: ${result.reasons.join("; ")}`, + ); + } + }); + + it("rejects issue #624-style low-effort new-form bug", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "CLI로 확인해봤는데 오픈코덱스 프록시가 중간에 자꾸 연결이 끊어져서 그런거라고 합니다.", + "", + "수정 바랍니다.", + "### Reproduction", + "예기치않게중단됨", + "### Version", + "모름", + "### Operating system", + "윈11", + "### Provider and model", + "_No response_", + "### Logs or error output", + "```shell", + "", + "```", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Version/i.test(r))); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague|empty/i.test(r))); + }); + + it("rejects a new-form bug with a usable Version but placeholder OS", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "Proxy returns 502 when streaming is enabled on Windows.", + "### Reproduction", + "1. ocx start", + "2. Send a streaming /v1/responses request", + "### Version", + "2.7.42", + "### Operating system", + "No response", + ].join("\n"); + const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); + + it("rejects a new-form bug when the Version heading was removed", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "Proxy returns 502 when streaming is enabled on Windows.", + "### Reproduction", + "1. ocx start", + "2. Send a streaming /v1/responses request", + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Version/i.test(r) && /missing/i.test(r))); + }); + + it("rejects a new-form bug when the Operating system heading was removed", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "Proxy returns 502 when streaming is enabled on Windows.", + "### Reproduction", + "1. ocx start", + "2. Send a streaming /v1/responses request", + "### Version", + "2.7.42", + ].join("\n"); + const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r) && /missing/i.test(r))); + }); + + it("rejects a new-form bug whose Reproduction is only a vague phrase", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "Unexpected interruption", + "### Version", + "2.7.42", + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague/i.test(r))); + }); + + it("rejects unknown Operating system stand-ins on the new bug form", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + "2.7.42", + "### Operating system", + "Unknown", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); }); // --------------------------------------------------------------------------- @@ -775,6 +1036,16 @@ describe("normalisation", () => { assert.equal(clean("Not available!"), ""); }); + it("detects unusable Version stand-ins without treating them as generic placeholders", () => { + for (const value of ["Unknown", "Uknown", "모름", "idk", "don't know"]) { + assert.equal(isUnusableVersion(value), true, value); + assert.equal(isPlaceholderOnlyValue(value), false, value); + } + for (const value of ["2.7.42", "N/A", "No response", "main@abc1234"]) { + assert.equal(isUnusableVersion(value), false, value); + } + }); + it("does not treat sentences containing placeholder phrases as empty", () => { assert.equal(clean("This is N/A for voice mode today."), "This is N/A for voice mode today."); assert.equal(clean("Not applicable to Claude Code."), "Not applicable to Claude Code."); diff --git a/.github/scripts/pr-quality.cjs b/.github/scripts/pr-quality.cjs new file mode 100644 index 000000000..ff286acdc --- /dev/null +++ b/.github/scripts/pr-quality.cjs @@ -0,0 +1,172 @@ +"use strict"; + +const path = require("node:path"); +const { + clean, + isPlaceholderOnlyValue, + hasSubstantialStructuredContent, +} = require(path.join(__dirname, "issue-quality.cjs")); + +const ANCESTRY_BEHIND_THRESHOLD = 20; +/** Cap on ahead_by vs main so stale `dev` forks (many commits ahead of main) are not flagged. */ +const ANCESTRY_AHEAD_MAIN_MAX = 5; +const MIN_SECTION_LEN = 40; +const MIN_RICH_SECTIONS = 2; +const UNSTRUCTURED_MIN_LEN = 120; +const UNSTRUCTURED_MIN_BLOCKS = 2; + +/** + * Exact instruction / checklist lines from `.github/PULL_REQUEST_TEMPLATE.md`. + * Untouched templates must not count as substance. + */ +const PR_TEMPLATE_BOILERPLATE_LINES = new Set([ + "explain the user-visible or maintainer-facing change.", + "list the commands or checks you ran.", + "scope stays focused and avoids unrelated cleanup.", + "docs or release notes were updated when needed.", + "security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.", +]); + +function isWrongAncestry({ + behindMain, + behindBase, + aheadMain = 0, + threshold = ANCESTRY_BEHIND_THRESHOLD, + aheadMainMax = ANCESTRY_AHEAD_MAIN_MAX, +}) { + return ( + behindMain === 0 && + behindBase >= threshold && + aheadMain <= aheadMainMax + ); +} + +function authorHasPushPermission(permission) { + return permission === "admin" || permission === "maintain" || permission === "write"; +} + +/** + * True when the body uses literal backslash-n as the dominant line break + * (agent bug seen on #644) rather than real newlines. + */ +function hasEscapedNewlines(text) { + const escaped = (text.match(/\\n/g) || []).length; + if (escaped < 2) return false; + const real = (text.match(/\n/g) || []).length; + return escaped > real; +} + +function countContentBlocks(text) { + const blocks = text + .split(/\n\s*\n/) + .map((b) => b.trim()) + .filter(Boolean); + if (blocks.length >= 2) return blocks.length; + const bullets = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => /^[-*+]\s+\S/.test(l)); + return Math.max(blocks.length, bullets.length); +} + +function normalizeTemplateLine(line) { + return line + .replace(/^\s*[-*+]\s+/, "") + .replace(/^\s*\[[ xX]\]\s+/, "") + .replace(/^\s*#{1,6}\s+/, "") + .trim() + .toLowerCase(); +} + +/** Drop stock PR template headings, instructions, and checklist lines. */ +function stripPrTemplateBoilerplate(text) { + return text + .split("\n") + .filter((line) => { + const normalized = normalizeTemplateLine(line); + if (!normalized) return true; + if (PR_TEMPLATE_BOILERPLATE_LINES.has(normalized)) return false; + if (/^(summary|verification|checklist)$/.test(normalized)) return false; + return true; + }) + .join("\n"); +} + +function assessPrDescription(body) { + if (typeof body !== "string" || !body.trim()) { + return { ok: false, reason: "empty" }; + } + if (hasEscapedNewlines(body)) { + return { ok: false, reason: "escaped_newlines" }; + } + const withoutTemplate = stripPrTemplateBoilerplate(body); + const cleaned = clean(withoutTemplate); + if (!cleaned) { + const strippedComments = withoutTemplate.replace(//g, "").trim(); + if (!strippedComments) return { ok: false, reason: "empty" }; + if (isPlaceholderOnlyValue(strippedComments)) { + return { ok: false, reason: "placeholder" }; + } + return { ok: false, reason: "empty" }; + } + if (isPlaceholderOnlyValue(cleaned)) { + return { ok: false, reason: "placeholder" }; + } + if (hasSubstantialStructuredContent(cleaned, MIN_SECTION_LEN, MIN_RICH_SECTIONS)) { + return { ok: true }; + } + if ( + cleaned.length >= UNSTRUCTURED_MIN_LEN && + countContentBlocks(cleaned) >= UNSTRUCTURED_MIN_BLOCKS + ) { + return { ok: true }; + } + return { ok: false, reason: "thin" }; +} + +function collectPrQualityFailures({ + baseRef, + allowedBases, + body, + behindMain, + behindBase, + aheadMain = 0, + authorPermission, + permissionLookupFailed = false, + ancestryLookupFailed = false, +}) { + const failures = []; + const wrongBase = !allowedBases.includes(baseRef); + if (wrongBase) { + failures.push({ code: "wrong_base" }); + } else { + // Permission lookup fails closed (still evaluate ancestry). Compare API + // failures skip ancestry — zeros would falsely pass the #644 heuristic. + const skipAncestry = + ancestryLookupFailed || + (!permissionLookupFailed && authorHasPushPermission(authorPermission)); + if ( + !skipAncestry && + isWrongAncestry({ behindMain, behindBase, aheadMain }) + ) { + failures.push({ code: "wrong_ancestry" }); + } + } + + const desc = assessPrDescription(body); + if (!desc.ok) { + failures.push({ code: "bad_description", reason: desc.reason }); + } + return failures; +} + +module.exports = { + ANCESTRY_BEHIND_THRESHOLD, + ANCESTRY_AHEAD_MAIN_MAX, + isWrongAncestry, + authorHasPushPermission, + assessPrDescription, + collectPrQualityFailures, + hasEscapedNewlines, + stripPrTemplateBoilerplate, +}; diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs new file mode 100644 index 000000000..a2ac0da0b --- /dev/null +++ b/.github/scripts/pr-quality.test.cjs @@ -0,0 +1,245 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + ANCESTRY_BEHIND_THRESHOLD, + isWrongAncestry, + authorHasPushPermission, + assessPrDescription, + collectPrQualityFailures, +} = require("./pr-quality.cjs"); + +describe("isWrongAncestry", () => { + it("flags #644-shaped compares (0 behind main, far behind base, few ahead of main)", () => { + assert.equal( + isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 1 }), + true, + ); + }); + + it("uses threshold 20 by default", () => { + assert.equal(ANCESTRY_BEHIND_THRESHOLD, 20); + assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20, aheadMain: 1 }), true); + assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19, aheadMain: 1 }), false); + }); + + it("passes when head is behind main (not sitting on main tip)", () => { + assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44, aheadMain: 1 }), false); + }); + + it("passes stale dev-based branches that are many commits ahead of main", () => { + assert.equal( + isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 50 }), + false, + ); + }); +}); + +describe("authorHasPushPermission", () => { + it("accepts write/maintain/admin only", () => { + assert.equal(authorHasPushPermission("admin"), true); + assert.equal(authorHasPushPermission("maintain"), true); + assert.equal(authorHasPushPermission("write"), true); + assert.equal(authorHasPushPermission("triage"), false); + assert.equal(authorHasPushPermission("read"), false); + assert.equal(authorHasPushPermission(null), false); + }); +}); + +describe("assessPrDescription", () => { + it("rejects empty and comment-only bodies", () => { + assert.equal(assessPrDescription("").ok, false); + assert.equal(assessPrDescription(" ").ok, false); + assert.equal( + assessPrDescription("\n\n").reason, + "empty", + ); + }); + + it("rejects placeholder-only bodies", () => { + assert.equal(assessPrDescription("N/A").reason, "placeholder"); + assert.equal(assessPrDescription("TODO").reason, "placeholder"); + }); + + it("rejects literal escaped newlines like #644", () => { + const body = + "## What changed\\n- make the Windows tray launcher resolve Codex home\\n\\n## Validation\\n- git diff --check"; + assert.equal(assessPrDescription(body).reason, "escaped_newlines"); + }); + + it("rejects thin real-newline bodies", () => { + assert.equal(assessPrDescription("fix stuff").reason, "thin"); + }); + + it("rejects an untouched GitHub PR template as empty/thin", () => { + const body = [ + "## Summary", + "", + "- Explain the user-visible or maintainer-facing change.", + "", + "## Verification", + "", + "- List the commands or checks you ran.", + "", + "## Checklist", + "", + "- [ ] Scope stays focused and avoids unrelated cleanup.", + "- [ ] Docs or release notes were updated when needed.", + "- [ ] Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.", + ].join("\n"); + const result = assessPrDescription(body); + assert.equal(result.ok, false); + assert.ok(result.reason === "empty" || result.reason === "thin"); + }); + + it("accepts two rich markdown sections", () => { + const body = [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"); + assert.equal(assessPrDescription(body).ok, true); + }); + + it("accepts unstructured bodies that are long enough with multiple blocks", () => { + const p1 = + "Updates the Windows tray launcher to resolve the active Codex home through the shared helper so listener and launcher stay aligned."; + const p2 = + "Validated with git diff --check on the changed tray module; typecheck was not available in that session so CI must cover it."; + assert.equal(assessPrDescription(`${p1}\n\n${p2}`).ok, true); + }); +}); + +describe("collectPrQualityFailures", () => { + const allowed = ["dev", "dev2-go"]; + + it("reports wrong_base without requiring ancestry inputs", () => { + const failures = collectPrQualityFailures({ + baseRef: "main", + allowedBases: allowed, + body: "## Summary\n" + "x".repeat(50) + "\n\n## Test plan\n" + "y".repeat(50), + behindMain: 0, + behindBase: 0, + authorPermission: "read", + }); + assert.ok(failures.some((f) => f.code === "wrong_base")); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("reports wrong_base and bad_description together for main + empty body", () => { + const failures = collectPrQualityFailures({ + baseRef: "main", + allowedBases: allowed, + body: "", + behindMain: 0, + behindBase: 0, + authorPermission: "read", + }); + assert.ok(failures.some((f) => f.code === "wrong_base")); + assert.ok(failures.some((f) => f.code === "bad_description")); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("reports wrong_ancestry for contributor on #644-shaped compare", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 44, + aheadMain: 1, + authorPermission: "read", + }); + assert.deepEqual( + failures.map((f) => f.code), + ["wrong_ancestry"], + ); + }); + + it("skips ancestry for push permission but still flags bad description", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: "", + behindMain: 0, + behindBase: 44, + aheadMain: 1, + authorPermission: "write", + }); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + assert.ok(failures.some((f) => f.code === "bad_description")); + }); + + it("applies ancestry when permission lookup failed (fail closed)", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 44, + aheadMain: 1, + authorPermission: null, + permissionLookupFailed: true, + }); + assert.ok(failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("does not flag stale dev-based branches that are far ahead of main", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 44, + aheadMain: 50, + authorPermission: "read", + }); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("skips ancestry when compare lookup failed (cannot evaluate)", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 0, + aheadMain: 0, + authorPermission: "read", + ancestryLookupFailed: true, + }); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); +}); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index da76509d9..8864b25da 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -7,8 +7,15 @@ on: - reopened - edited - ready_for_review + - synchronize +# pull-requests:write covers title/comment updates. +# contents:write is required for convertPullRequestToDraft / +# markPullRequestReadyForReview GraphQL mutations with GITHUB_TOKEN +# (otherwise: "Resource not accessible by integration"). This workflow +# never checks out PR head code. permissions: + contents: write pull-requests: write concurrency: @@ -19,30 +26,39 @@ jobs: runs-on: ubuntu-latest steps: - - name: Require dev as PR target + - name: Checkout trusted PR-quality scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Enforce PR target, ancestry, and description uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | + const path = require("path"); + const { collectPrQualityFailures } = require( + path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), + ); + const ALLOWED_BASES = ["dev", "dev2-go"]; const DEFAULT_BASE = "dev"; const TITLE_PREFIX = "[WRONG BRANCH] "; - const COMMENT_MARKER = ""; + const COMMENT_MARKER = ""; + const LEGACY_COMMENT_MARKER = ""; const STATE_PATTERN = - //; + //; const { owner, repo } = context.repo; const pull_number = context.payload.pull_request.number; - // Fetch the latest PR state instead of relying on a possibly - // outdated event payload. const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); - // Find the workflow's existing comment. Its hidden state records - // whether the workflow changed the title or draft status. const comments = await github.paginate( github.rest.issues.listComments, { @@ -56,8 +72,10 @@ jobs: const botComment = comments.find( comment => comment.user?.login === "github-actions[bot]" && - comment.body?.includes(COMMENT_MARKER) + (comment.body?.includes(COMMENT_MARKER) || + comment.body?.includes(LEGACY_COMMENT_MARKER)) ); + let botCommentId = botComment?.id ?? null; function parseState(body) { const match = body?.match(STATE_PATTERN); @@ -79,7 +97,7 @@ jobs: function stateMarker(state) { return ( - "" ); @@ -90,23 +108,24 @@ jobs: } async function upsertComment(body) { - if (botComment) { + if (botCommentId) { await github.rest.issues.updateComment({ owner, repo, - comment_id: botComment.id, + comment_id: botCommentId, body }); return; } - await github.rest.issues.createComment({ + const created = await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + botCommentId = created.data.id; } async function convertToDraft() { @@ -153,82 +172,303 @@ jobs: ); } + function descriptionFailureLines(reason) { + switch (reason) { + case "empty": + return [ + "The pull request body is empty after stripping HTML comments.", + "", + "Include a real description: a **Summary** of what changed and why, plus a **Test plan** (or equivalent substance)." + ]; + case "placeholder": + return [ + "The pull request body contains only placeholder text (for example `N/A`, `TODO`, or `No response`).", + "", + "Replace placeholders with a **Summary** and **Test plan**, or another description with at least two substantive sections or paragraphs." + ]; + case "escaped_newlines": + return [ + "The pull request body uses literal `\\n` escape sequences instead of real line breaks.", + "", + "Fix the formatting so the body uses normal markdown line breaks, then add a **Summary** and **Test plan**." + ]; + case "thin": + default: + return [ + "The pull request description is too thin to review.", + "", + "Add a **Summary** and **Test plan** (two sections with at least 40 characters each), or an unstructured body of at least 120 characters with two paragraphs or bullet groups." + ]; + } + } + + function buildFailureSections(failures) { + const sections = []; + + if (failures.some(failure => failure.code === "wrong_base")) { + sections.push( + "⚠️ **Wrong target branch**", + "", + `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + "", + `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏` + ); + } + + if (failures.some(failure => failure.code === "wrong_ancestry")) { + sections.push( + "⚠️ **Wrong branch ancestry**", + "", + `This pull request targets ${inlineCode(pr.base.ref)}, but its head appears to sit on the current ${inlineCode("main")} tip while being far behind ${inlineCode(pr.base.ref)}.`, + "", + `@${pr.user.login} Rebase onto the current ${inlineCode(pr.base.ref)} branch instead of opening from ${inlineCode("main")}. That keeps already-released commits out of the integration branch.` + ); + } + + const badDescription = failures.find( + failure => failure.code === "bad_description" + ); + if (badDescription) { + sections.push( + "⚠️ **Pull request description**", + "", + ...descriptionFailureLines(badDescription.reason) + ); + } + + return sections; + } + + function failureSummary(failures) { + return failures + .map(failure => { + if (failure.code === "wrong_base") { + return `wrong base (${pr.base.ref})`; + } + if (failure.code === "wrong_ancestry") { + return "wrong ancestry"; + } + if (failure.code === "bad_description") { + return `bad description (${failure.reason})`; + } + return failure.code; + }) + .join("; "); + } + const storedState = parseState(botComment?.body); - const wrongBase = !ALLOWED_BASES.includes(pr.base.ref); - - // Wrong branch: - // - add the title prefix - // - convert ready PRs to draft - // - post or update the explanation - // - remember which changes were made by this workflow - if (wrongBase) { + + let authorPermission = null; + let permissionLookupFailed = false; + try { + const { data: permissionData } = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: pr.user.login + }); + authorPermission = permissionData.permission; + } catch (error) { + permissionLookupFailed = true; + core.warning( + `Could not look up collaborator permission: ${error.message}` + ); + } + + let behindMain = 0; + let behindBase = 0; + let aheadMain = 0; + let ancestryLookupFailed = false; + const baseAllowed = ALLOWED_BASES.includes(pr.base.ref); + + if (baseAllowed) { + const headSha = pr.head.sha; + try { + const { data: mainCompare } = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `main...${headSha}` + }); + behindMain = mainCompare.behind_by; + aheadMain = mainCompare.ahead_by; + + const { data: baseCompare } = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${pr.base.ref}...${headSha}` + }); + behindBase = baseCompare.behind_by; + } catch (error) { + ancestryLookupFailed = true; + core.warning( + `Could not compare commits for ancestry check: ${error.message}` + ); + } + } + + const failures = collectPrQualityFailures({ + baseRef: pr.base.ref, + allowedBases: ALLOWED_BASES, + body: pr.body, + behindMain, + behindBase, + aheadMain, + authorPermission, + permissionLookupFailed, + ancestryLookupFailed + }); + + if (failures.length > 0) { + const hasWrongBase = failures.some( + failure => failure.code === "wrong_base" + ); + const willPrefixTitle = + hasWrongBase && !pr.title.startsWith(TITLE_PREFIX); const state = storedState?.active ? { ...storedState } : { version: 1, active: true, autoDraftedByBot: false, - titlePrefixedByBot: false + titlePrefixedByBot: false, + ancestryFailed: false, + descriptionFailed: false }; - if (!pr.draft) { - state.autoDraftedByBot = true; - } + state.ancestryFailed = failures.some( + failure => failure.code === "wrong_ancestry" + ); + state.descriptionFailed = failures.some( + failure => failure.code === "bad_description" + ); + + const shouldStripTitlePrefix = + !hasWrongBase && + state.titlePrefixedByBot && + pr.title.startsWith(TITLE_PREFIX); - if (!pr.title.startsWith(TITLE_PREFIX)) { + // Claim title ownership before adding a prefix. Do not clear + // ownership until strip succeeds — a failed update must retry. + if (willPrefixTitle) { state.titlePrefixedByBot = true; } - const draftExplanation = state.autoDraftedByBot - ? "This pull request is being kept as a draft automatically. Once the target branch is corrected, it will be marked ready for review again." - : "This pull request was already a draft. Its draft status will be preserved after the target branch is corrected."; + let draftConversionFailed = false; + const failureSections = buildFailureSections(failures); - // Store the restoration state before changing the PR, allowing - // a rerun to recover if an API request fails partway through. await upsertComment( [ COMMENT_MARKER, stateMarker(state), "", - "⚠️ **Wrong target branch**", - "", - `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + ...failureSections, "", - `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`, - "", - `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏`, - "", - draftExplanation + "Recording ownership state before applying title/draft changes…" ].join("\n") ); - if (!pr.title.startsWith(TITLE_PREFIX)) { + if (willPrefixTitle) { await github.rest.pulls.update({ owner, repo, pull_number, title: `${TITLE_PREFIX}${pr.title}` }); + } else if (shouldStripTitlePrefix) { + await github.rest.pulls.update({ + owner, + repo, + pull_number, + title: pr.title.slice(TITLE_PREFIX.length) + }); + state.titlePrefixedByBot = false; + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + ...failureSections, + "", + "Stale title prefix removed; continuing…" + ].join("\n") + ); } if (!pr.draft) { - await convertToDraft(); + // Claim draft ownership before the mutation so a successful + // convert followed by a failed comment still restores later. + state.autoDraftedByBot = true; + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + ...failureSections, + "", + "Draft conversion pending…" + ].join("\n") + ); + try { + await convertToDraft(); + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + ...failureSections, + "", + "Draft conversion succeeded; finalising explanation…" + ].join("\n") + ); + } catch (error) { + draftConversionFailed = true; + state.autoDraftedByBot = false; + core.warning( + `Could not convert pull request to draft: ${error.message}` + ); + } + } + + const draftExplanation = draftConversionFailed + ? "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required `enforce-target` check will keep failing until every issue above is resolved." + : state.autoDraftedByBot + ? "This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again." + : "This pull request was already a draft. Its draft status will be preserved after every issue above is resolved."; + + const finalSections = [...failureSections]; + + if (hasWrongBase && state.titlePrefixedByBot) { + finalSections.push( + "", + `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.` + ); } + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + ...finalSections, + "", + draftExplanation + ].join("\n") + ); + + core.setFailed(`PR quality gate failed: ${failureSummary(failures)}`); return; } - // The target is correct and the workflow has nothing to restore. if (!storedState?.active) { core.info( - "Target branch is correct and there is no active bot state." + "All PR quality gates passed and there is no active bot state." ); return; } - // Remove only the exact prefix added by this workflow. Other title - // edits made by the contributor are preserved. if ( storedState.titlePrefixedByBot && pr.title.startsWith(TITLE_PREFIX) @@ -241,38 +481,57 @@ jobs: }); } - // Mark the PR ready only if this workflow originally converted it - // to draft. Intentionally drafted PRs remain drafts. + let readyConversionFailed = false; if ( storedState.autoDraftedByBot && pr.draft ) { - await markReadyForReview(); + try { + await markReadyForReview(); + } catch (error) { + readyConversionFailed = true; + core.warning( + `Could not mark pull request ready for review: ${error.message}` + ); + } } - const completedState = { - version: 1, - active: false, - autoDraftedByBot: false, - titlePrefixedByBot: false - }; + const completedState = readyConversionFailed + ? { + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: false, + ancestryFailed: false, + descriptionFailed: false + } + : { + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false, + ancestryFailed: false, + descriptionFailed: false + }; const titleResult = storedState.titlePrefixedByBot ? `The ${inlineCode(TITLE_PREFIX.trim())} title prefix has been removed.` : "The title was left unchanged."; - const draftResult = storedState.autoDraftedByBot - ? "The pull request has been marked ready for review again." - : "Its existing draft status has been preserved."; + const draftResult = readyConversionFailed + ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft." + : storedState.autoDraftedByBot + ? "The pull request has been marked ready for review again." + : "Its existing draft status has been preserved."; await upsertComment( [ COMMENT_MARKER, stateMarker(completedState), "", - "✅ **Target branch corrected**", + "✅ **PR quality gates passed**", "", - `This pull request now targets ${inlineCode(pr.base.ref)}.`, + `This pull request now targets ${inlineCode(pr.base.ref)} with acceptable ancestry and description.`, "", `${titleResult} ${draftResult}` ].join("\n") diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index 76d7e97ad..8c6d0da14 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -6,8 +6,11 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/scripts/issue-quality.cjs" - ".github/scripts/issue-quality.test.cjs" + - ".github/scripts/pr-quality.cjs" + - ".github/scripts/pr-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -15,6 +18,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -23,8 +27,11 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/scripts/issue-quality.cjs" - ".github/scripts/issue-quality.test.cjs" + - ".github/scripts/pr-quality.cjs" + - ".github/scripts/pr-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -32,6 +39,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -52,7 +60,9 @@ jobs: - name: Run validator tests run: | node --test .github/scripts/issue-quality.test.cjs + node --test .github/scripts/pr-quality.test.cjs node --test .github/scripts/pr-labeler.test.cjs + node --test .github/scripts/enforce-pr-target.test.cjs node --test .github/scripts/issue-translation.test.cjs node --test .github/scripts/issue-triage.test.cjs node --test .github/scripts/parse-issue-translation-response.test.cjs diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index f81b046e0..b9076bcb8 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -1,13 +1,13 @@ # React Doctor — finds security, performance, correctness, accessibility, # bundle-size, and architecture issues in React codebases. # -# Advisory-only and least-privilege: findings appear in the step log and the -# Actions run summary. All write-scoped outputs (sticky PR comments, inline -# review comments, commit statuses) are explicitly disabled so the workflow -# needs no write permissions. Do not re-add write scopes without revisiting -# tests/ci-workflows.test.ts, which pins this contract. +# Gating and least-privilege: findings fail the job (`blocking: warning`). +# Write-scoped outputs (sticky PR comments, inline review comments, commit +# statuses) stay disabled so the workflow needs no write permissions. Do not +# re-add write scopes without revisiting tests/ci-workflows.test.ts, which +# pins this contract. # -# Docs: https://www.react.doctor/ci +# Docs: https://www.react.doctor/docs/ci-and-prs/github-actions-setup # Source: https://github.com/millionco/react-doctor name: React Doctor @@ -24,7 +24,7 @@ permissions: contents: read # Needed so the action can list PR files for --changed-files-from. # Without this, listFiles fails, the changed-files file is never written, - # and the CLI exits 1 on ENOENT even with blocking: none (fork PRs). + # and the CLI exits 1 on ENOENT even for fork PRs. pull-requests: read # Cancels any in-flight scan for the same PR (or branch, on push) the moment a @@ -50,9 +50,9 @@ jobs: directory: gui # Pin the npm engine — the action wrapper would otherwise fetch # react-doctor@latest, silently skewing CI from the local pinned runs. - version: "0.9.1" - # Advisory contract: report to the step log only; never gate, never write. - blocking: none + version: "0.9.2" + # Fail the job on any finding (errors or warnings). + blocking: warning comment: false review-comments: false commit-status: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edeaaba43..091058da3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,20 +154,15 @@ jobs: echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}" - # Compare within the release channel so preview and stable gates use matching history. - if [[ "$RELEASE_VERSION" == *-preview.* ]]; then - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | - grep -- '-preview\.' | - head -n 1 || true - )" - else - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | - grep -v -- '-preview\.' | - head -n 1 || true - )" - fi + # Notes / service baseline: + # - Preview: newest prior release of either channel (stable or preview). A + # preview→preview-only baseline skips a shipped stable and restates it. + # - Stable: newest prior stable only (matching preview carry adjusts the + # generate-notes range separately below). + previous_tag="$( + git tag --merged HEAD --list 'v[0-9]*' | + bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION" + )" if [ -n "$previous_tag" ]; then changed_files="$(git diff --name-only "${previous_tag}..HEAD")" else @@ -299,22 +294,12 @@ jobs: exit 1 fi - # Channel previous tag (stable↔stable / preview↔preview) for the Full Changelog link. - if [[ "$RELEASE_VERSION" == *-preview.* ]]; then - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | - grep -- '-preview\.' | - grep -vx "$release_tag" | - head -n 1 || true - )" - else - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | - grep -v -- '-preview\.' | - grep -vx "$release_tag" | - head -n 1 || true - )" - fi + # Channel previous tag for Full Changelog + default notes baseline. + # Preview baselines any prior release; stable baselines prior stable only. + previous_tag="$( + git tag --merged HEAD --list 'v[0-9]*' | + bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION" + )" npm_metadata="Published to npm as \`@bitkyc08/opencodex@${RELEASE_VERSION}\` with dist-tag \`${NPM_DIST_TAG}\`." # Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the diff --git a/.gitignore b/.gitignore index 8ee0b5d9c..e35cc4ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,14 @@ devlog/ .opencode/ # Local agent/session artifacts +# These are per-machine agent state (goalplans, ledgers, evidence scratch). +# They are never part of the product and must not be committed, not even with +# `git add -f` — see tests/repo-hygiene.test.ts, which fails if any path here +# becomes tracked again. .codexclaw/ +**/.codexclaw/ .omo/ +**/.omo/ # Test-generated artifacts tests/.tmp-*/ diff --git a/AGENTS.md b/AGENTS.md index b3e9989d2..cc24cd4c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,16 @@ Bun-native TypeScript with no separate server compile step. `tests/helpers/`, broader scenarios in `tests/e2e-style/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. +- `go/` — Go native runtime (primary on the `dev2-go` line during transition). - `structure/` — maintainer invariants and architecture notes; read before changing shared subsystems. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. - `devlog/` — planning and investigation artifacts (mostly gitignored). +Read the nearest nested `AGENTS.md` before changing files in a scoped +directory (`src/`, `gui/`, `docs-site/`, `scripts/`, `.github/`). + ## Commands ```bash @@ -52,6 +56,21 @@ non-trivial change. CI runs these on Linux, Windows, and macOS. from `dev` (releases, docs deploys). Do not open feature PRs against `main`. - `preview` — prerelease train (`x.y.z-preview.*` versions). +### Transition to `dev2-go` + +The project is moving its primary runtime to the Go native port, so `dev2-go` +has to keep receiving everything that lands on `dev`. Pull requests against +`dev` stay welcome and unchanged — the extra work belongs to the maintainer who +merges them. + +A merge into `dev` does not finish the task. The merging maintainer also +rebases that work onto `dev2-go`, ports whatever needs a Go counterpart under +`go/`, and merges the port. The item is done only when both lines carry the +change. If a change has no Go counterpart, say so in the merge or tracking +issue; if the port has to wait, open a `needs-go-port` tracking issue against +`dev2-go` naming the source commits before closing out the `dev` merge. +[`MAINTAINERS.md`](./MAINTAINERS.md) holds the authoritative wording. + The Claude Desktop integration formerly carried on the `claudedesktop` branch is now fully merged into `dev`, and that branch has been retired. Desktop work continues as normal pull requests against `dev`. @@ -61,6 +80,13 @@ integration line to another, or rebasing a stale branch onto the current head, is ordinary maintenance rather than noise — open it as a normal pull request and name the source commits in the description. +The **`enforce-target`** CI check rejects pull requests whose head +ancestry sits on the **`main`** tip while far behind **`dev`** or **`dev2-go`**, +and rejects empty, thin, or malformed descriptions; authors with repository +push permission skip the ancestry heuristic only. As with approval requirements +in [`MAINTAINERS.md`](./MAINTAINERS.md), this is enforced by convention until +branch protection is configured. + [`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for review and merge policy (approvals, CI requirements, security review, promotion). This file summarizes; it never overrides it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbf673adf..4a99de54f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,13 @@ Thanks for helping with opencodex. - `main` — releases only; moves by maintainer-controlled promotion from `dev`. - `preview` — prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for +contributors: keep opening pull requests against `dev`. After merging, a +maintainer rebases the work onto `dev2-go` and ports whatever needs a Go +counterpart, and the item is finished only once both lines carry it. See +[`MAINTAINERS.md`](./MAINTAINERS.md) for the full rule. + Porting and rebase pull requests are welcome: carrying a fix across integration lines, or rebasing a stale branch onto the current head, is normal contribution. Note the source commits in the description. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index bd5f3a487..9848f0b35 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -8,12 +8,16 @@ review and merge policy. | GitHub account | Project role | Responsibilities | | --- | --- | --- | | [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | -| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | -| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` integration, and provider/CI maintenance | +| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, security review, and repository maintenance | +| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, and provider/CI maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. +Integration duty spans both lines while the Go native port is in transition: a +maintainer who merges into `dev` also carries that work onto `dev2-go`. The +rule is written out under [Review and merge policy](#review-and-merge-policy). + ## Review and merge policy - Pull requests target `dev` by default. `dev2-go` is a parallel integration @@ -23,6 +27,33 @@ through GitHub repository settings. distinguish scoped Go port work from anything else aimed at `dev2-go`, so that boundary is enforced in review: redirect an out-of-scope pull request to `dev` rather than treating the automation's silence as approval. +- **Transition to `dev2-go` (current phase).** The project is moving its + primary runtime to the Go native port, so `dev2-go` has to keep receiving + everything that lands on `dev`. Contributors still open pull requests against + `dev`, and that stays allowed — the extra work is the maintainer's, not + theirs. A merge into `dev` is therefore not the end of the task. The + maintainer who merges it also rebases that work onto `dev2-go`, ports + whatever needs a Go counterpart under `go/`, and merges the port. The item is + finished only when both lines carry the change. + - Do the rebase and the port in the same session as the merge. A `dev` merge + left unported is the failure mode this rule exists to prevent: `dev2-go` + silently falls behind, and the divergence surfaces later as a conflict + nobody has context for. + - When a change genuinely has no Go counterpart — docs, TypeScript-only + paths the port does not cover yet, GUI-only work — record that decision in + the merge or the tracking issue. An unexplained missing port is + indistinguishable from a forgotten one. + - If the port cannot be completed immediately (a blocking dependency, a + subsystem the port has not reached), open a tracking issue against + `dev2-go` before closing out the `dev` merge, label it `needs-go-port`, + and name the source commits. That label is the durable signal that a + deferred port is intentional, not forgotten. +- The **`enforce-target`** CI check rejects pull requests whose head + ancestry sits on the **`main`** tip while far behind **`dev`** or **`dev2-go`**, + and rejects empty, thin, or malformed descriptions; authors with repository + push permission skip the ancestry heuristic only. As with the approval + requirement above, this is enforced by convention until branch protection is + configured (see the note under the change log). - A pull request requires approval from at least one maintainer and successful required CI checks before merge. - Authors do not approve their own pull requests. @@ -46,16 +77,24 @@ Adding or removing a maintainer requires: - 2026-07-27 — [@Wibias](https://github.com/Wibias) added as a maintainer. Requirement 1 (agreement from the project owner) is met: the owner requested - the addition. **Requirement 2 (review by another current maintainer) is still - open** and is satisfied when this change is reviewed and merged; until then - this entry records an in-progress change, not a completed procedure. - Requirement 3 is met by this file and `.github/CODEOWNERS`. - - Scope covers issue and pull-request triage, `dev` integration, and - provider/CI maintenance. Security-boundary ownership in `.github/CODEOWNERS` - is deliberately unchanged: authentication, credential handling, GitHub - Actions, and release automation keep the two owners already listed for those - paths, so this addition does not widen the review surface for them. + the addition. **Requirement 2 (review by another current maintainer) was + never satisfied in the form this document describes.** The three commits that + carried the addition (`a2693c02`, `dc3a4ade`, `02bbd47a`) landed on `dev` as + direct owner pushes with no associated pull request, so no second maintainer + reviewed them. Requirement 3 is met by this file and `.github/CODEOWNERS`. + The addition is in effect regardless: @Wibias holds write access on the + repository and has been merging pull requests since 2026-07-26. This entry + records the gap rather than papering over it — a later maintainer change + should go through a reviewed pull request. + + Scope covers issue and pull-request triage, `dev` and `dev2-go` integration, + and provider/CI maintenance. While the Go native port is in transition, + integration duty includes carrying merged `dev` work onto `dev2-go` as + described in the review and merge policy above. Security-boundary ownership + in `.github/CODEOWNERS` is deliberately unchanged: authentication, credential + handling, GitHub Actions, and release automation keep the two owners already + listed for those paths, so this addition does not widen the review surface + for them. CODEOWNERS requests reviews rather than enforcing them — no branch protection rule is configured on this repository, so code-owner approval is a convention diff --git a/README.md b/README.md index 4cbd50f62..86b30f082 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,15 @@ next Codex session. opencodex keeps these behaviors: - **Existing sessions keep affinity.** A thread id is bound to the selected account and reused on later turns, so a long request or a mobile/SSH-attached session keeps using the same account. + Pausing an account clears its affinity map: in-flight requests keep captured credentials, but + subsequent turns are re-routed and cannot reuse the paused account. - **New sessions can auto-route.** When auto-switch is enabled, opencodex compares the hottest known quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the - request log labels pool traffic with non-PII account ordinals. + request log labels pool traffic with non-PII account ordinals. **Pause exhausted** refreshes + eligible accounts that have credentials and pauses only those whose relevant quota window is + freshly confirmed at 100%; accounts without credentials and unknown or failed refreshes stay unchanged. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another credential silently; 429 quota responses put the account in cooldown and can fail over future work to another eligible pool account. @@ -505,6 +509,9 @@ The public docs — install, providers, routing, sidecars, Codex integration, Co Maintainer source-of-truth notes live under [`structure/`](./structure). Historical investigations remain under [`docs/`](./docs). Contributor setup lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md), and security reporting guidance lives in [`SECURITY.md`](./SECURITY.md). +Report undisclosed vulnerabilities privately through +[GitHub private vulnerability reporting](https://github.com/lidge-jun/opencodex/security/advisories/new), +not a public issue. ## Development diff --git a/SECURITY.md b/SECURITY.md index a9802c920..b39af65d2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,13 +17,19 @@ or the latest published package before triage continues. Please avoid posting undisclosed vulnerabilities as public GitHub issues. -- Prefer this repository's GitHub private vulnerability reporting or GitHub Security Advisory flow - when that option is available in the repository UI. -- If no private reporting option is available, do not include exploit details, secrets, or live - targets in a public issue. Open a minimal issue that asks maintainers for a safe coordination path. -- Include affected versions, reproduction steps, impact, and any required configuration details. +Report privately through GitHub private vulnerability reporting, which is enabled on this +repository: -The project does not publish a dedicated private security email in this repository. +**** + +The same form is reachable from the repository's **Security** tab under **Report a vulnerability**. +It is private between you and the maintainers, and it is the only channel this project offers for +undisclosed vulnerabilities — there is no dedicated private security email. + +Include affected versions, reproduction steps, impact, and any required configuration details. + +If the form is ever unreachable for you, open a minimal public issue that asks maintainers for a +safe coordination path. Do not include exploit details, secrets, or live targets in that issue. ## Response Expectations diff --git a/devlog/.DS_Store b/devlog/.DS_Store deleted file mode 100644 index 9ac38fcbe..000000000 Binary files a/devlog/.DS_Store and /dev/null differ diff --git a/devlog/_plan/.DS_Store b/devlog/_plan/.DS_Store deleted file mode 100644 index 3a1ad0021..000000000 Binary files a/devlog/_plan/.DS_Store and /dev/null differ diff --git a/devlog/_plan/260728_live_state_sync/000_plan.md b/devlog/_plan/260728_live_state_sync/000_plan.md new file mode 100644 index 000000000..fea16a6d9 --- /dev/null +++ b/devlog/_plan/260728_live_state_sync/000_plan.md @@ -0,0 +1,76 @@ +# 000 — 라이브 상태 재동기화 유닛 계획 + +세션: `019fa826-aba9-7032-8b43-9fc0fbcb56f1` +goalplan: `opencodex-2026-07-28-devlog-260728-live-state-sy` +기준: `origin/dev` = `7710185c0`, 로컬 `dev` = `7710185c0` (FF 정렬 완료) +측정 시각: 2026-07-28 (KST 저녁) + +## 목표 + +리모트/브랜치/PR/이슈의 현재 사실을 실측하고, 오너 판단 없이 처리할 수 있는 것을 +실제로 처리한다. 처리 = 이미 고쳐진 이슈 클로즈, 미해결 항목 상태 코멘트, +결정 불요 버그 판정 보고. + +## 선행 문서 (재조사 금지, 델타만 기록) + +| 문서 | 상태 | +| --- | --- | +| `260727_owner_decision_ledger/007_delta_260728.md` | 기준 `origin/dev`=`461de3961`. **stale** — 4단계 전진 | +| `260728_bug_bundle_resolution/000_plan.md` | 기준 `origin/dev`=`f195e90bc`. **stale** — WP2(#573) 머지 완료 | + +두 문서의 결론은 유효하되 번호·상태는 `010_live_snapshot.md`가 우선한다. + +## 제약 + +- 브랜치: `main` 직접 변경 금지, force push 금지, 타인 PR 강제 머지 금지. +- git: FF 외 이력 조작 금지. 다른 worktree 11곳의 더티 작업 보존. +- 이슈 클로즈: **코드 근거**(머지 커밋 + 테스트)가 있는 것만. 추정 클로즈 금지. +- 보안 경계: `.github/CODEOWNERS`가 지정한 경로(`src/oauth/`, + `src/server/auth-cors.ts`, `src/codex/auth-context.ts`, + `src/server/management-api.ts`, `/.github/`, `scripts/release.ts`, + `package.json`, `bun.lock`)는 두 메인테이너 리뷰 대상 → `NEEDS_HUMAN`. +- 프라이버시: 코멘트에 토큰·계정 식별자·요청 본문 금지. + +## 스코프 밖 + +| 항목 | 이유 | +| --- | --- | +| 프로바이더 채택 (#561, #562, #611, #540, #177, #178, #201) | 채택 기준은 오너 정책 | +| 로드맵 라벨 정직성 (#42, #95, #294) | 오너 판단 | +| PR #533 / #557 진행선 선택 | 의존성 설치 경계 + 오너 결정 | +| 업스트림 추적 (#92, #241, #417, #462) | 우리가 닫을 수 없음 | +| 커뮤니티 PR 머지 (#611, #610, #607, #599, #583, #582, #581, #569, #565, #562, #512) | 리뷰/머지 권한은 사람 | + +## work-phase 맵 (의존 순, PHASE-SPLIT-01) + +측정이 먼저다. 무엇이 이미 고쳐졌는지 모르면 클로즈 판단이 불가능하고, +클로즈 판단이 서지 않으면 "결정 불요 버그" 목록도 허구가 된다. + +| # | decade doc | 대상 | 계층 | +| --- | --- | --- | --- | +| WP1 | `010_live_snapshot.md` | 브랜치/PR/이슈 실측 + 원장 델타 | 측정 (최하부) | +| WP2 | `020_issue_disposition.md` | 이슈 클로즈 + 상태 코멘트 | 외부 상태 변경 | +| WP3 | `030_no_decision_bugs.md` | 결정 불요 버그 판정 보고 | 판정/보고 | + +WP1은 이 문서를 포함한 docs-only 사이클이다 (LOOP-DOCS-FIRST-01). + +## 성공 기준 + +| id | 시나리오 | 증거 | +| --- | --- | --- | +| c1 | 로컬 `dev` == `origin/dev`, FF 외 조작 없음 | `git rev-parse` 출력 | +| c2 | 이 유닛에 000 + 010/020/030이 존재하고 커밋됨 | `ls` + 커밋 해시 | +| c3 | 클로즈 대상이 있으면 증거 코멘트와 함께 CLOSED. 없으면 **무클로즈 판정 근거**가 이슈별로 문서화 | `gh issue view --json state` 또는 020 §A/§B 판정표 | +| c4 | 닫지 않은 항목에 현재 상태 코멘트 존재 | 코멘트 URL | +| c5 | 결정 불요 버그 목록이 이슈번호·코드경로·경계 판정과 함께 문서화 | 문서 경로 + 커밋 해시 | + +## SoT 동기화 대상 (SOT-SYNC-01) + +이 유닛은 코드 변경이 없다. `docs-site/`·`structure/` 패치 대상 없음. +코드 변경이 발생하면 그 시점에 해당 work-phase가 SoT 대상을 지명한다. + +## 터미널 판정 기준 + +- `DONE` — 문서 커밋 + 이슈 상태 변화(또는 무변경 판정 근거) + 보고서 작성 +- `BLOCKED` — 리포터/업스트림 응답 대기 +- `NEEDS_HUMAN` — CODEOWNERS 보안 경계 또는 오너 정책 판단 diff --git a/devlog/_plan/260728_live_state_sync/010_live_snapshot.md b/devlog/_plan/260728_live_state_sync/010_live_snapshot.md new file mode 100644 index 000000000..3667ba0c5 --- /dev/null +++ b/devlog/_plan/260728_live_state_sync/010_live_snapshot.md @@ -0,0 +1,113 @@ +# 010 — 라이브 상태 실측 스냅샷 (WP1) + +측정: 2026-07-28 KST 저녁, `gh` + `git` 실측. 기억이나 이전 원장 인용 아님. + +## 1. 브랜치 / 리모트 + +`git fetch origin --prune` 후: + +| 브랜치 | 로컬 | origin | 관계 | +| --- | --- | --- | --- | +| `dev` | `7710185c0` | `7710185c0` | 동일 (FF 4커밋 적용) | +| `main` | `7cb15bff4` | `7cb15bff4` | 동일 | +| `preview` | `b04b8729e` | `b04b8729e` | 동일 | +| `dev2-go` | `2bdb748e1` | `2bdb748e1` | 동일 | + +`origin/dev` 기준 ahead/behind (`git rev-list --left-right --count origin/dev...`): + +| 브랜치 | origin/dev만 가진 것 | 해당 브랜치만 가진 것 | +| --- | --- | --- | +| `main` | 42 | 3 | +| `preview` | 42 | 4 | +| `dev2-go` | 188 | 335 | + +`main`/`preview`가 `origin/dev`에 없는 3~4 커밋을 가진 건 릴리스 프로모션 이력이라 +정상이다. `dev2-go`의 335는 Go 포트 라인 고유 작업이다. + +### FF로 흡수한 4커밋 + +``` +7710185c0 fix stale update jobs and combo quota fallback +d482086bf fix(windows): grant owner ACE before ACL inheritance removal (#601) +406a522fe refactor(responses): single-pass SSE payload rewrite composition (#602) +c380ef72a feat(auth): account pool round-robin and fill-first strategies (#593) +``` + +45파일 +3407/-331. rebase/reset 없이 `git merge --ff-only`만 사용했다. + +### 워크트리 (11개, 전부 보존) + +`260727-live-triage`, `260727-pr533-current`, `260727-pr551-image-relay`, +`260728-pr527`, `260728-preview`, `404d`(main), `6cce`(dev2-go), +`e4f5`(go-tray-daemon), 그리고 detached 2개 + macos-app. +이 유닛은 메인 워크트리에서만 작업한다. + +## 2. 열린 PR — 15건 실측 + +| # | base<-head | mergeable | state | 실패 체크 | 작성자 | +| --- | --- | --- | --- | --- | --- | +| 611 | dev<-feat/volcengine-providers | MERGEABLE | UNSTABLE | — | yrooogerg | +| 610 | dev<-fix/catalog-runtime-probe-cache | MERGEABLE | UNSTABLE | — | mihneaptu | +| 607 | dev<-fix/gui-chrome-forms (draft) | MERGEABLE | UNSTABLE | ubuntu, macos | Wibias | +| 599 | dev<-fix/codex-spark-quota-scope | MERGEABLE | UNSTABLE | — | akrock | +| 583 | dev<-chore/agent-guidance-hardening | MERGEABLE | CLEAN | — | Wibias | +| 582 | dev<-feat/video-bridge-v2 (draft) | MERGEABLE | CLEAN | — | tizerluo | +| 581 | dev<-feat/zh-tw-localization | CONFLICTING | DIRTY | — | letr1n1ty | +| 576 | dev<-codex/pr527-rebase | CONFLICTING | DIRTY | windows | lidge-jun | +| 575 | dev<-codex/260728-tls-altname-diagnosis | MERGEABLE | CLEAN | — | lidge-jun | +| 569 | dev<-agent/macos-post-sync-readiness (draft) | CONFLICTING | DIRTY | — | diegocantarero | +| 565 | dev<-agent/codex-account-pause | CONFLICTING | DIRTY | — | Alvin0412 | +| 562 | dev<-feat/modelsell-provider (draft) | MERGEABLE | UNSTABLE | — | modelsell | +| 557 | dev<-codex/pr533-update-recovery-hardening (draft) | CONFLICTING | DIRTY | — | lidge-jun | +| 533 | dev<-fix/gui-update-install-failure-recovery (draft) | CONFLICTING | DIRTY | CHANGES_REQ | WZBbiao | +| 512 | dev<-split/426-01-namespace-foundation | MERGEABLE | CLEAN | — | chrisae9 | + +전부 base=`dev`. 잘못 타깃된 PR 없음 → AGENTS.md 브랜치 타깃 규칙 위반 0건. + +> A 게이트 정정: 최초 측정에서 #569/#562/#512가 `UNKNOWN`으로 잡혔던 것은 GitHub이 +> mergeable을 비동기 계산 중이었기 때문이다. 재폴링 결과를 위 표에 반영했다. + +우리 소유 PR 3건: #575(CLEAN, 머지 대기), #576(CONFLICTING + windows 체크 실패), +#557(draft, CONFLICTING). + +## 3. 열린 이슈 — 27건, `--label bug` 필터로 14건 + +``` +612 608 606 604 591 586 570 553 545 543 418 417 241 92 +``` + +enhancement/roadmap/upstream 계열 13건은 이 유닛의 처리 대상이 아니다. + +## 4. 원장 델타 — 이전 문서가 틀린 것 + +| 이전 기재 | 현재 사실 | +| --- | --- | +| `260728_bug_bundle_resolution` WP2 = 진행 예정 | **완료**. `e2da6f6df` → PR #573 머지 (`3a2b2ea8c`) | +| `007_delta` 기준 `origin/dev`=`461de3961` | `7710185c0` (그 뒤 다수 머지) | +| 열린 PR 15 (ready 5 / draft 10) | 열린 PR 15 (ready 9 / draft 6) — 구성이 다름 | +| 열린 이슈 23 | 27 | +| needs-info 3 (`462,543,553`) | **4** (`591,553,543,462`). #462는 `upstream-tracking`을 **추가로** 얻었을 뿐 `needs-info`를 잃지 않았고, #591도 이미 needs-info다 | + +### 07-27 저녁 이후 머지된 PR 20건 + +`602 601 600 597 595 594 593 589 588 585 580 579 578 577 574 573 571 568 567 566` + +### 07-27 저녁 이후 클로즈된 이슈 (해결 완료) 주요 항목 + +`609 605 603 598 596 592 590 587 584 563 560 549 548 547 546 542 541 539 538` + +## 5. 이슈 ↔ 코드 대조 결과 + +| 이슈 | 코드 현황 | 판정 | +| --- | --- | --- | +| #570 | `src/server/auth-cors.ts:51` `isLoopbackRequestHost`가 포트 비교를 제거하고 hostname만 검사. `tests/server-loopback-host-gate.test.ts:16`이 `localhost:20100`/`127.0.0.1:20100`/`[::1]:20100`을 통과로 고정 | **부분 해결 — 6항목 중 1(a)/2만. alias(항목 4)는 여전히 403. 클로즈는 `NEEDS_HUMAN` → `020_issue_disposition.md` §A** | +| #612 | `src/lib/windows-secret-acl.ts:79` 여전히 `Bun.spawnSync(["icacls.exe"...])` 동기 호출 | 미해결 | +| #608 | `src/service.ts:1080`이 `taskXmlString()`(`"`→`"`)로 이스케이프한 문자열과 원문 XML을 `includes()` 비교 | 미해결, 원인 확정 | +| #606 | `src/codex/catalog/bundled.ts:146` `loadBundledCodexCatalog`가 캐시 히트 판정 전에 `resolveAndPersistCodexRuntime`(→ `--version` 프로브, `timeout: 8_000`)를 호출. cacheKey 자체가 프로브 결과로 만들어지는 구조 | 미해결, PR #610이 이미 제출됨 | +| #586 | `src/server/management/provider-routes.ts:137` PATCH 엔드포인트 존재. GUI에는 `CodexAuth.tsx` 배너만 있고 전환 컨트롤 없음 | 미해결 | +| #604 | 재현 정보가 Cursor Auto + PowerShell 5.1 환경 의존 | 리포터 대기 | +| #591 | `schtasks.exe /create` 권한 거부. 리포터는 **관리자 권한으로는 설치에 성공**했고 그 이후 사용에서 문제가 남는다고 후속 코멘트에 적었다. Ingwannu가 이미 ccswitch 충돌 부정 + 추가 정보 요청 코멘트를 남겼고 `needs-info` 라벨도 붙어 있다 | 리포터 응답 대기 (추가 코멘트 불필요) | +| #553 | PR #575(OPEN, MERGEABLE/CLEAN)가 진단 메시지 **분류**를 분리. TLS altname 불일치 자체는 미해결 | PR 대기 + 잔여 결함 | +| #545 | 메인테이너가 OAuth identity 제거를 안전하지 않다고 판정 | NEEDS_HUMAN | +| #543 #418 | 리포터 캡처 대기 | BLOCKED | +| #92 #241 #417 | 업스트림 | BLOCKED | diff --git a/devlog/_plan/260728_live_state_sync/020_issue_disposition.md b/devlog/_plan/260728_live_state_sync/020_issue_disposition.md new file mode 100644 index 000000000..39617d6c5 --- /dev/null +++ b/devlog/_plan/260728_live_state_sync/020_issue_disposition.md @@ -0,0 +1,75 @@ +# 020 — 이슈 처분 (WP2) + +`010_live_snapshot.md` §5의 대조 결과를 실제 GitHub 상태 변경으로 옮긴다. + +## 원칙 + +클로즈는 **머지된 커밋 + 그 동작을 고정하는 테스트**가 둘 다 있을 때만. +"고쳐졌을 것 같다"는 클로즈 사유가 아니다. 나머지는 현재 상태 코멘트만 남기고 +열어 둔다 — 침묵보다 낫고, 오판 클로즈보다 훨씬 낫다. + +## A. 클로즈 후보 → **오너 판단으로 이관** (A 게이트 정정) + +### #570 Port-remapped tunnels rejected by the loopback Host check + +근거 체인: + +| 항목 | 값 | +| --- | --- | +| 수정 커밋 | `e2da6f6df` fix(server): treat forwarded loopback ports as loopback | +| 머지 커밋 | `3a2b2ea8c` Merge pull request #573 | +| 브랜치 | `origin/dev` 포함 확인 (`git branch --contains e2da6f6df`) | +| 코드 | `src/server/auth-cors.ts:37-52` — 포트 비교 제거, hostname만 신뢰 경계 | +| 테스트 | `tests/server-loopback-host-gate.test.ts:16-26` | + +테스트가 고정하는 것: `localhost:20100`, `127.0.0.1:20100`, `[::1]:20100`이 전부 +loopback으로 통과하고, 비루프백 hostname은 여전히 거부된다. + +### 왜 닫지 않는가 — A 게이트 블로커 1 (High) + +#570은 단일 결함 리포트가 아니라 **6항목 하드닝 계획**이다. 머지된 것은 항목 +1(a)과 항목 2뿐이다. + +| 항목 | 상태 | +| --- | --- | +| 1(a) Host를 Origin과 정렬 | **머지됨** (`e2da6f6df`) | +| 1(b) `trustedRequestHosts` 허용목록 | 미착수 — 설계 결정 | +| 1(c) 루프백 바인드 인증 opt-in | 미착수 — 설계 결정 | +| 2 회귀 테스트 | **머지됨** | +| 3 base URL 보고 (`api-access.ts:72-75`, `api-keys-utils.ts:18-24`) | 미착수 | +| 4 hostname alias (`myhost.lan`) | **여전히 403** — `auth-cors.ts:51`이 hostname만 보고 alias는 loopback이 아님 | +| 5 docs "Remote access" SSH 레시피 | 미착수 | +| 6 터널 위 OAuth (`CALLBACK_PORT = 1455`) | 미착수 | + +리포터가 측정한 `ALIAS | Host=myhost.lan:56030 -> 403`은 지금도 재현된다. +게다가 `src/server/auth-cors.ts`는 CODEOWNERS 인증 경계이고 항목 4는 이슈 본문이 +"Decide explicitly whether ... are in scope"라고 적은 **오너 결정**이다. + +→ 처분: 상태 코멘트만 남기고 **열어 둔다**. 클로즈 여부는 `NEEDS_HUMAN`. + +코멘트 골자 (영문, 리뷰 언어 규칙): + +- 항목 1(a)/2가 `e2da6f6df` (PR #573)로 머지됐고 `origin/dev`에 있다 +- 바뀐 predicate와 이유 (포트는 신뢰 경계가 아니다), 회귀 테스트 파일:라인 +- 항목 3/4/5/6은 미해결이며 alias 케이스는 여전히 403이라는 점을 명시 +- 잔여 항목을 별도 이슈로 쪼갤지, 이 이슈를 열어 둘지는 메인테이너 결정 + +## B. 상태 코멘트만 — 클로즈하지 않음 + +| 이슈 | 코멘트 내용 | 클로즈 안 하는 이유 | +| --- | --- | --- | +| #606 | PR #610이 프로브 **재사용/메모이제이션**을 고친다 (cacheKey 도출 순서는 저자가 의도적으로 유지 — PR 본문 "I left the ordering alone to keep this change minimal"). 측정된 개선: warm 6300ms → ~25ms | 수정 미머지 | +| #608 | `src/service.ts:1080` + `taskXmlString()` 이스케이프 불일치를 근본 원인으로 확인, 결정 불요 수정으로 분류 | 수정 미작성 | +| #612 | `src/lib/windows-secret-acl.ts:79` 동기 spawnSync 확인, tmp 경로 키잉 문제도 재현 | 수정 미작성 | +| #586 | PATCH `/api/providers?name=openai`는 존재(`provider-routes.ts:137-167`), GUI 컨트롤만 부재 | GUI 설계 결정 필요 | +| ~~#591~~ | **코멘트하지 않음** — Ingwannu가 이미 ccswitch 충돌 부정 + 정보 요청 코멘트를 남겼고 `needs-info` 라벨도 있다. 리포터는 관리자 권한 설치에 이미 성공했다고 답했으므로 "관리자로 재시도" 안내는 틀린 조언이다 | 중복 코멘트 회피 | +| #553 | PR #575(OPEN, MERGEABLE/CLEAN)는 오류 **귀속**만 개선한다 — 연결 불가와 TLS hostname 불일치를 구분해 보여줄 뿐, `ERR_TLS_CERT_ALTNAME_INVALID` 자체는 그대로다. #575가 머지돼도 이 이슈는 닫히지 않는다 | 머지 대기 + 잔여 결함 | + +## C. 손대지 않음 + +`#604`(리포터 캡처 대기), `#543` `#418`(리포터 대기), `#545`(오너 판정), +`#92` `#241` `#417` `#462`(업스트림). 이미 각 이슈에 최신 트리아지 코멘트가 있다. + +## 검증 + +`gh issue view 570 --json state,closedAt` 및 각 코멘트 URL 회수. diff --git a/devlog/_plan/260728_live_state_sync/030_no_decision_bugs.md b/devlog/_plan/260728_live_state_sync/030_no_decision_bugs.md new file mode 100644 index 000000000..8a3a9a48c --- /dev/null +++ b/devlog/_plan/260728_live_state_sync/030_no_decision_bugs.md @@ -0,0 +1,79 @@ +# 030 — 오너 결정 없이 고칠 수 있는 버그 (WP3) + +"결정 불요"의 정의: 동작이 무엇이어야 하는지에 이견이 없고, 수정 범위가 국소적이며, +CODEOWNERS 보안 경계 밖이고, 회귀 테스트를 쓸 수 있는 것. 셋 중 하나라도 어긋나면 +오너 판단 항목으로 분류한다. + +## 판정 기준표 + +| 축 | 통과 조건 | +| --- | --- | +| 기대 동작 | 명세·주석·기존 테스트가 정답을 이미 규정 | +| 범위 | 단일 모듈, 공개 계약 변경 없음 | +| 경계 | `.github/CODEOWNERS` 보호 경로가 아니고, `MAINTAINERS.md`의 주제별 보안 리뷰 대상(인증·크리덴셜 처리·GitHub Actions·릴리스 자동화·의존성 설치)도 아님 | +| 검증 | 분기를 실제로 발화시키는 테스트 작성 가능 (C-ACTIVATION-GROUNDING-01) | + +## 결정 불요 — 1건 (A 게이트에서 3 → 1로 축소) + +### 1. #608 Windows scheduler task가 영구 stale로 보고됨 + +| 항목 | 값 | +| --- | --- | +| 원인 | `src/service.ts:1080` — 등록 XML을 `taskXmlString()`으로 이스케이프한 문자열과 `includes()` 비교. `taskXmlString`(`:886`)은 `"`를 `"`로 바꾸지만 Task Scheduler 내보내기는 리터럴 `"`를 준다 | +| 기대 동작 | 이견 없음. 등록한 값과 같으면 healthy | +| 범위 | `src/service.ts` 단일 함수. `taskXmlOptionalValueEquals`(#432 수정)와 같은 층 | +| 경계 | CODEOWNERS 밖 | +| 수정 | `` 텍스트를 XML 언이스케이프한 뒤 비교하는 헬퍼 추가 (`"`/`&`/`<`/`>`/`'` 역변환) | +| 활성화 증거 | 이스케이프된 XML과 canonical XML 둘 다 healthy=true, 실제로 다른 launcher 경로면 false | +| 영향 | stale 래치 → `viable=false` → 스케줄러 백엔드 포기. 사용자가 재설치로 못 고침 | + +## 결정 필요 — 오너 몫 + +### #612 Windows ACL 하드닝이 이벤트 루프를 막음 — `NEEDS_HUMAN` (A 게이트 블로커 3) + +원인 분석 자체는 유효하다: `src/lib/windows-secret-acl.ts:79`의 `Bun.spawnSync`가 +`hardenSecretPath`(`:311`) → `atomicWriteFile` 경로에서 동기 실행되고, 타임아웃 +캐시가 매번 달라지는 임시 파일명으로 키잉된다. + +그런데 결정 불요가 아니다: + +- `hardenSecretPath`는 크리덴셜 파일 보호 장치다. `MAINTAINERS.md`가 "Authentication, + credential handling ... require explicit security review"라고 규정한 주제에 정확히 + 해당한다. 경로 글로브만 보고 CODEOWNERS 밖이라 판정한 것이 오류였다. +- 초안이 제안한 "캐시 키를 대상 디렉터리로 완화"는 이슈 본문이 명시적으로 거부한 + 방향이다: 부모 디렉터리가 더 넓은 ACE를 허용할 수 있어 파일 단위 하드닝을 건너뛰면 + 안 된다고 리포터가 적었다. +- 리포터가 제안한 안전한 방향(비동기 ACL 러너 + single-flight 큐 + 종료 시 flush)은 + 네 단계짜리 설계 변경이다. + +→ 수정 자체는 가치가 있으나 착수 전에 보안 리뷰 결정이 필요하다. + +### #606 — 중복 구현 금지, PR #610 리뷰가 정답 + +`src/codex/catalog/bundled.ts:146-170`에서 cacheKey가 프로브 결과로 만들어지는 +구조는 사실이다. 다만 PR #610이 고치는 것은 그 **순서**가 아니라 프로브 메모이제이션이다 +(저자 본문: "I left the ordering alone to keep this change minimal"). 저자가 순서 +변경을 후속 제안으로 따로 적어 두었으므로, 우리가 별도 수정을 얹는 것은 충돌만 만든다. + +### 그 밖에 오너 결정이 필요한 항목 + +| 이슈 | 필요한 결정 | +| --- | --- | +| #586 | Providers 페이지에 모드 전환 UI를 넣을지 / Codex Auth 배너를 컨트롤로 승격할지 — GUI 정보구조 결정 | +| #545 | Anthropic OAuth identity 블록 처리 — 메인테이너가 이미 "안전한 수정 아님" 판정 | +| #604 | Cursor Auto 루프 — 재현 환경 확보 + 어댑터 정책 결정 | +| #591 | 관리자 권한 설치는 이미 성공. 그 이후 실패 원인 미상 — 리포터 응답 대기 | +| #553 | PR #575 머지 여부 (진단 메시지 정책) | +| #570 | 항목 1(a)/2만 머지됨. alias(항목 4)·base URL(항목 3)·터널 OAuth(항목 6)는 미해결이고 인증 경계 결정 | + +## 보안 경계 확인 + +경계 판정은 두 축으로 한다. `.github/CODEOWNERS`의 **경로 글로브**와 +`MAINTAINERS.md`의 **주제별 보안 리뷰 규정**(인증·크리덴셜 처리·GitHub Actions· +릴리스 자동화·의존성 설치). 경로만 보면 놓친다 — #612가 그 사례였다. + +| 항목 | 경로 경계 | 주제 경계 | 결론 | +| --- | --- | --- | --- | +| #608 `src/service.ts` | 밖 | 밖 (Task Scheduler XML 비교) | 결정 불요 | +| #612 `src/lib/windows-secret-acl.ts` | 밖 | **안** (크리덴셜 파일 보호) | NEEDS_HUMAN | +| #570 `src/server/auth-cors.ts` | **안** | **안** | NEEDS_HUMAN | diff --git a/devlog/_plan/260728_pr_rebuild_unit/000_plan.md b/devlog/_plan/260728_pr_rebuild_unit/000_plan.md new file mode 100644 index 000000000..3c1572d11 --- /dev/null +++ b/devlog/_plan/260728_pr_rebuild_unit/000_plan.md @@ -0,0 +1,125 @@ +# 000 — 리빌딩 유닛 계획 + +세션: `019fa826-aba9-7032-8b43-9fc0fbcb56f1` +goalplan: `opencodex-pr-devlog-plan-260728-pr-rebuild-unit` +기준: `origin/dev` = `7710185c0`, 로컬 `dev` = `9265d922d` (직전 유닛 문서커밋 1개 앞섬) +작성: 2026-07-28 (WP1 docs-only 사이클) + +## 목표 + +정당한 결함 위에 서 있으나 기여자 PR을 그대로 머지할 수 없는 항목 중, **오너 설계 +결정이 필요 없는 것**만 골라 우리가 다시 만든다. 사용자가 커밋·푸시를 명시 승인했다. + +## 선별 기준 (4관문 전부 통과해야 대상) + +| 관문 | 질문 | +| --- | --- | +| (a) 정당성 | 뒤에 실재하는 결함/이슈가 있는가 | +| (b) 리빌딩 필요 | 그대로 머지 못 하는 이유가 있는가 (CONFLICTING, 스코프 과다, 설계 불일치) | +| (c) 결정 불요 | 동작의 정답이 이미 명확해서 오너 판단이 필요 없는가 | +| (d) 경계 밖 | CODEOWNERS 경로 + MAINTAINERS.md 주제(인증·크리덴셜·Actions·릴리스·의존성) 밖인가 | + +## 전수 심사 — 열린 PR 16건 + +> **A 게이트 정정.** 초안은 `mergeable=MERGEABLE`을 "건강함"으로 읽었다. 그건 머지 +> 충돌이 없다는 뜻일 뿐 테스트가 돌았다는 뜻이 아니다. 아래 표는 `mergeStateStatus`와 +> **실제 체크 롤업 내용**을 기준으로 다시 썼다. + +| # | (a) | (b) | (c) | (d) | 판정 | +| --- | --- | --- | --- | --- | --- | +| 613 reset credit 만료시각 | ✅ | ❌ 저자 활발, 소품 5f | — | ✅ | 리뷰 대기 — **CI 미실행** | +| 611 Volcengine 프로바이더 | ✅ | ❌ | ❌ 채택 기준=오너 정책 | ✅ | 오너 결정 | +| 610 catalog probe 캐시 | ✅ #606 | ❌ P1을 저자가 `056aa2d6e`에서 해결 | — | ✅ | 리뷰 대기 — **CI 미실행** | +| 607 GUI chrome polish (draft) | ✅ | ❌ draft, 저자 작업중 | ❌ 시각 판단 | ✅ | 저자 진행 | +| 599 Spark 쿨다운 스코프 | ✅ #590 | ❌ | — | ❌ **`src/codex/auth-context.ts` = CODEOWNERS 인증 경로** | 보안 리뷰 필요 | +| 583 agent guidance 문서 | ✅ | ❌ CLEAN | ❌ 정책 문구 | ✅ | 오너 결정 | +| 582 Grok video bridge (draft) | ✅ | ❌ draft | ❌ 표면 확장 | ✅ | 오너 결정 | +| 581 zh-TW 로케일 | ✅ | ✅ CONFLICTING 57f | ❌ 로케일 채택=오너 정책 | ✅ | 오너 결정 | +| **576 stale app-server 경고** | ✅ | ✅ CONFLICTING + windows fail | ✅ 우리가 쓴 PR, 동작 확정 | ✅ | **대상 WP4** | +| 575 TLS altname 진단 | ✅ #553 | ❌ CLEAN, 우리 PR | — | ✅ | 머지 대기 | +| 569 /readyz 준비성 (draft) | ✅ | ✅ CONFLICTING | ❌ liveness/readiness 계약=설계 | ✅ | 오너 결정 | +| 565 계정 pause 정책 | ✅ | ✅ CONFLICTING 35f | ❌ 계정 정책=오너 | ✅ | 오너 결정 | +| 562 Modelsell 프리셋 | ✅ | ❌ draft | ❌ 채택 기준 | ✅ | 오너 결정 | +| 557 npm 캐시 복구 (draft) | ✅ | ✅ CONFLICTING | ❌ | ❌ **의존성 설치 경계** | 오너 결정 | +| 533 npm 캐시 원본 (draft) | ✅ | ✅ CONFLICTING | ❌ | ❌ 동일 경계 | 오너 결정 | +| 512 계정 네임스페이스 | ✅ #425 | ❌ CLEAN | ❌ 스키마 설계 | ✅ | 오너 결정 | + +### CI 신호가 없는 PR 3건 (#613, #610, #599) + +셋 다 `mergeStateStatus=UNSTABLE`이고 체크 롤업에 `enforce-target`/`label`/CodeRabbit만 +있다. 전체 매트릭스(`windows-latest`, `ubuntu-latest`, `macos-latest`, `npm-global ×3`, +`linux-systemd`, `macos-launchd`, `windows-schtasks`)를 도는 #575와 대조된다. +포크 PR의 워크플로가 `action_required`로 승인 대기 중이기 때문이다. + +**머지 판단 전에 워크플로 승인이 선행돼야 한다.** 특히 #599는 +1109/-130, 11파일이 +`src/codex/routing.ts`와 `src/server/responses/core.ts`를 건드리는데 CI 증거가 0이다. + +### WP2가 `dev`의 결함이 된 경위 (A 게이트 2라운드) + +1라운드에서 리뷰어가 #610의 미해결 P1을 찾아냈다. 2라운드에서 같은 리뷰어가 그 +P1이 **이미 저자에 의해 해결됐음**을 확인했다 — `056aa2d6e` "fix(test): address +runtime cache review feedback", 09:16 KST. GitHub UI가 미해결로 보이는 건 resolve +버튼을 누른 사람이 없어서다. + +그런데 저자는 자기가 손댄 테스트만 고쳤다. 같은 결함 패턴이 +`origin/dev:tests/codex-runtime.test.ts:360`과 `pull/610/head:514`에 남아 있다. +이건 `dev`에 원래 있던 것이고 #610이 만든 게 아니다. + +그래서 WP2의 대상은 **PR이 아니라 `dev` 자신**이다. 4관문으로 보면: (a) `PATH=""`에서 +런처가 exit 127로 죽는 실재 결함, (b) 이 결함에 대응하는 PR이 아예 없어 우리가 쓸 수밖에 +없음, (c) 정답이 명확(셸 빌트인 사용 — 저자가 이미 CI로 검증한 형태), (d) `tests/`는 +기본 리뷰어 범위. + +**대상 2건: `dev` 런처 결함(WP2), #576(WP4).** WP3는 심사 중 발견한 별개 테스트 결함이다. +나머지 15건은 (b)·(c)·(d) 중 하나에서 탈락한다. + +### #576이 4관문을 통과하는 이유 + +- (a) `codex/app-server-processes.ts` — 카탈로그 기록 후 stale app-server를 경고하지 + 않으면 사용자가 옛 모델 목록을 계속 본다. 이슈 #241 계열의 실사용 불편. +- (b) `origin/dev`와 **3파일 로직 충돌** — `src/cli/index.ts`, + `src/server/management/config-routes.ts`, + `gui/src/pages/dashboard-overview-sections.tsx`. 헝크 단위 해소가 필요하다 + (상세는 `030`). 더해 windows-latest 체크가 실패 중이다. +- (c) 우리가 직접 쓴 PR이고 동작은 이미 리뷰로 확정됐다. 새 결정이 없다. +- (d) `src/codex/`, `src/cli/`, `gui/`, `docs-site/` — 전부 기본 리뷰어 범위. + +## work-phase 맵 (의존 순, PHASE-SPLIT-01) + +테스트 안정성이 먼저다. #576의 windows 실패가 무관한 테스트 결함이라면, 그것을 먼저 +고쳐야 리베이스 결과의 CI 신호를 믿을 수 있다. + +| # | decade doc | 대상 | 계층 | +| --- | --- | --- | --- | +| WP1 | 이 문서 | 심사 + 로드맵 락 | docs-only | +| WP2 | `010_pr610_posix_launcher.md` | #610 P1 — POSIX 런처 `PATH` | 테스트 기반 (최하부) | +| WP3 | `020_usage_debug_flake.md` | `tests/usage-debug.test.ts` Windows 타임아웃 | 테스트 기반 | +| WP4 | `030_pr576_rebase.md` | #576 리베이스 + 푸시 | PR 정리 (최상부) | + +WP2·WP3가 먼저인 이유는 둘 다 **CI 신호의 신뢰성**을 다루기 때문이다. 테스트가 +환경 의존적으로 깨지는 상태에서 리베이스하면, 그 결과의 빨간불이 리베이스 탓인지 +기존 결함 탓인지 구분할 수 없다. + +## 제약 + +- 브랜치: `dev` 대상 PR만. `main` 직접 변경·force push 금지. +- 검증: `bun run typecheck` + 대상 `tests/*.test.ts` 실제 출력. +- 푸시: 사용자 승인 범위 = 이 유닛의 작업. 타인 브랜치 푸시 금지. +- 보존: 다른 worktree 11곳, 로컬 미푸시 커밋. + +## 성공 기준 + +| id | 시나리오 | 증거 | +| --- | --- | --- | +| c1 | 이 유닛에 000 + 010 + 020 + 030이 존재하고 커밋됨 | `ls` + 커밋 해시 | +| c2 | 수정 **전**에 런처가 exit 127 + `dirname: No such file or directory`를 내는 것이 캡처되고, 수정 **후**에는 사라지며, 그 차이를 잡아내도록 강화된 단정이 통과한다 | 전후 stderr 캡처 + `bun test tests/codex-runtime.test.ts` 출력 | +| c3 | usage-debug 테스트가 결정적으로 통과하고 회전 계약 불변 | `bun test tests/usage-debug.test.ts` 출력 | +| c4 | typecheck + GUI 빌드 통과 | `bun run typecheck` / `bun run build:gui` 출력 | +| c5 | #576이 MERGEABLE로 갱신되고 푸시됨 | `gh pr view 576 --json mergeable` | +| c6 | 원 PR에 리빌딩 내역 코멘트 | 코멘트 URL | + +## 터미널 판정 기준 + +- `DONE` — 커밋 + 검증 출력 + 푸시 + PR 상태 변화 +- `BLOCKED` — CI 인프라/업스트림 외부 의존 +- `NEEDS_HUMAN` — 진행 중 오너 설계 결정이 드러난 경우 그 항목만 중단 diff --git a/devlog/_plan/260728_pr_rebuild_unit/010_pr610_posix_launcher.md b/devlog/_plan/260728_pr_rebuild_unit/010_pr610_posix_launcher.md new file mode 100644 index 000000000..b5ad33c15 --- /dev/null +++ b/devlog/_plan/260728_pr_rebuild_unit/010_pr610_posix_launcher.md @@ -0,0 +1,308 @@ +# 010 — `dev`의 POSIX 런처가 `PATH=""`에서 깨진다 (WP2) + +## 배경 — 그리고 A 게이트 2라운드의 정정 + +PR #610(이슈 #606 수정)의 회귀 테스트에 `chatgpt-codex-connector`가 **P1**을 남겼다. +초안은 "저자가 아직 응답하지 않았다"고 적었는데, **재감사 시점에는 이미 고쳐져 있었다.** + +``` +1310dd20b 2026-07-28T08:56:25Z fix(catalog): stop respawning the codex --version probe +056aa2d6e 2026-07-28T09:16:18Z fix(test): address runtime cache review feedback ← P1 대응 +``` + +`refs/pull/610/head` 실측: + +``` +327: "d=${0%/*}", +333: `while IFS= read -r line || [ -n "$line" ]; do` +``` + +저자의 형태가 우리 초안보다 낫다. `|| [ -n "$line" ]` 가드가 개행 없이 끝나는 마지막 +줄을 잃지 않는다. GitHub에서 P1이 미해결로 보이는 건 아무도 resolve를 누르지 않아서다. + +**따라서 #610의 (b) 관문은 ❌** — 미해결 블로커가 없으니 리뷰·머지 대상으로 돌아간다. + +### 그런데 결함은 살아남았다 + +저자는 **자기가 손댄 테스트만** 고쳤다. 같은 파일의 다른 테스트는 그대로다: + +| 위치 | 상태 | +| --- | --- | +| `origin/dev:tests/codex-runtime.test.ts:360` | `cat "$(dirname "$0")/catalog.json"` — 결함 존속 | +| `pull/610/head:tests/codex-runtime.test.ts:514` | 동일 — 저자가 건드리지 않음 | +| `pull/610/head:327-335` | 고쳐짐 | + +`dev`에 이미 있던 결함이고 #610이 도입한 게 아니다. 그러므로 이 work-phase의 대상은 +#610이 아니라 **`dev` 자신**이다. 우리가 고칠 근거가 사라지기는커녕 더 분명해졌다. + +## P1 내용 (원문) + +> Preserve a usable PATH for the POSIX launcher. On Linux and macOS, clearing `PATH` +> prevents the generated shell launcher from finding both `dirname` and `cat`. +> Consequently `d` is empty, the probe is written outside `probeLog` (or cannot be +> written), catalog output fails, and the assertions at lines 354-355 fail. + +## 코드 실측 — `dev` 기준 + +`dev`의 테스트가 만드는 POSIX 런처 +([tests/codex-runtime.test.ts:353-362](/Users/jun/developer/new/700_projects/opencodex/tests/codex-runtime.test.ts)): + +```sh +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "codex-cli 0.133.0" + exit 0 +fi +cat "$(dirname "$0")/catalog.json" +``` + +그리고 같은 테스트가 실행 직전에: + +```ts +process.env.PATH = ""; // tests/codex-runtime.test.ts:372 +``` + +`cat`과 `dirname`은 셸 빌트인이 아니라 `/bin`, `/usr/bin`의 외부 실행 파일이다. +`PATH`가 비면 `sh`가 둘 다 찾지 못한다. `$(dirname "$0")`는 빈 문자열로 축약되고 +`cat`은 애초에 실행되지 않으므로, 카탈로그 출력이 나오지 않는다. + +`--version` 분기는 `echo`가 POSIX 셸 빌트인이라 우연히 살아남는다. 그래서 프로브 +횟수를 세는 쪽은 통과하고, 카탈로그를 읽는 쪽만 조용히 깨진다. + +## 이것이 결정 불요인 이유 + +테스트가 `PATH`를 비우는 의도는 "시스템에 설치된 진짜 `codex`를 우연히 집지 않게 +한다"이다. 그 의도는 유지해야 한다. 동시에 런처는 동작해야 한다. 두 요구가 +충돌하지 않는 해법이 존재하고 그것이 유일하게 옳다: **런처에서 외부 명령을 없앤다.** + +`PATH`를 되살리면 격리가 깨지므로 그쪽은 답이 아니다. + +## 변경 — MODIFY `tests/codex-runtime.test.ts` + +POSIX 런처를 셸 파라미터 확장과 빌트인만으로 다시 쓴다. + +`#610`의 저자가 이미 CI에서 검증한 형태를 그대로 채택한다. 우리가 다른 형태를 쓸 +이유가 없고, 같은 형태를 쓰면 두 테스트가 일관된다. + +```diff + } else { + writeFileSync(path, [ + "#!/bin/sh", + `if [ "$1" = "--version" ]; then`, + ` echo "codex-cli ${version}"`, + " exit 0", + "fi", +- `cat "$(dirname "$0")/catalog.json"`, ++ // This test empties PATH so a real `codex` on the machine can never be picked ++ // up. That also removes `dirname` and `cat`, which are external binaries — the ++ // launcher would die with exit 127. Parameter expansion and `read` are POSIX ++ // shell builtins and need no PATH lookup. The `|| [ -n "$line" ]` guard keeps a ++ // final line that has no trailing newline. ++ "d=${0%/*}", ++ `while IFS= read -r line || [ -n "$line" ]; do`, ++ ` printf "%s\\n" "$line"`, ++ `done < "$d/catalog.json"`, + "", + ].join("\n"), "utf8"); + chmodSync(path, 0o755); + } +``` + +`${0%/*}`는 `$0`에서 마지막 `/` 이후를 잘라내므로 `dirname`과 같은 결과를 낸다. +테스트가 항상 절대 경로로 런처를 부르므로(`join(oldDir, "codex")`) `/` 없는 경로가 +들어와 `$0` 전체가 남는 예외는 발생하지 않는다. + +## 활성화 증거 (C-ACTIVATION-GROUNDING-01) + +이 변경이 고치는 분기는 "카탈로그를 읽는 경로"다. 그 경로가 실제로 발화하고 +**의도한 값을 내는지** 확인해야 한다. 기존 테스트가 그 오라클을 이미 갖고 있다: +`newerAvailable` / 카탈로그 effort 목록 비교 (`:354-355` 계열). `PATH=""` 상태에서 +그 assertion이 통과하면 런처가 실제로 파일을 출력했다는 뜻이다. + +즉 **수정 전에는 실패하고 수정 후에는 통과**해야 한다. 그 전후 출력이 증거다. + +검증 명령: + +``` +bun test tests/codex-runtime.test.ts +``` + +## 결함은 `dev`에 이미 있다 — 그리고 조용히 통과 중이다 + +초안은 "이 테스트 구간이 #610이 신설하는 것"이라고 가정했다. **틀렸다.** + +``` +$ git show origin/dev:tests/codex-runtime.test.ts | rg -n 'dirname "\$0"|PATH = ""' +360: `cat "$(dirname "$0")/catalog.json"`, +373: process.env.PATH = ""; +``` + +`b6ece844d`("sandbox runtime probe CODEX_HOME") 시점부터 `dev`에 있다. #610은 이 +테스트를 확장할 뿐 결함을 도입하지 않았다. 리뷰 봇이 #610의 diff 맥락에서 지적했을 뿐, +**고쳐야 할 곳은 `dev`다.** + +### 재현 (P1이 맞다) + +``` +$ printf '#!/bin/sh\n...\ncat "$(dirname "$0")/catalog.json"\n' > $d/codex +$ env PATH="" "$d/codex" +.../codex: line 6: dirname: No such file or directory +.../codex: line 6: cat: No such file or directory +exit=127 +``` + +### 왜 테스트는 통과하는가 — 이게 진짜 문제다 + +`bun test tests/codex-runtime.test.ts` → 18 pass, 0 fail. 런처가 exit 127로 죽는데도. + +`loadBundledCodexCatalog()`가 실패하면 `null`을 반환하고, assertion은 이렇게 쓰여 있다 +([tests/codex-runtime.test.ts:382-385](/Users/jun/developer/new/700_projects/opencodex/tests/codex-runtime.test.ts)): + +```ts +expect(oldCatalog?.models?.[0]?.supported_reasoning_levels?.some( + level => (level as { effort?: string }).effort === "max", +)).toBe(false); +``` + +`oldCatalog`가 `null`이면 옵셔널 체이닝이 전부 `undefined`로 흘러 `.toBe(false)`가… +실패해야 한다. 그런데 통과한다는 것은 **이 경로에서 카탈로그가 실제로 로드되고 +있다**는 뜻이다. `--version` 분기는 `echo`(빌트인)라 살아남으므로 런타임 해석은 +성공하고, 카탈로그는 `codex debug models` 경로로 별도로 얻는다. + +즉 P1이 지적한 "카탈로그 출력 실패"는 `false` 단정 쪽에서는 드러나지 않고, +`.toBe(true)`를 요구하는 두 번째 단정(`:400-402`)에서만 드러난다. 그 단정이 통과한다는 +것은 새 런처의 카탈로그가 어떤 경로로든 읽혔다는 의미다. + +**따라서 B 단계의 첫 작업은 수정이 아니라 계측이다.** 런처 stderr를 캡처해서 +`dirname: No such file or directory`가 실제로 나는지 확인하고, 그럼에도 테스트가 통과하는 +경로를 특정한다. 계측 없이 런처만 고치면 "무엇이 고쳐졌는지" 증명할 수 없다 +(LOOP-MECHANISM-PROOF-01: 집계 통과는 활성화 증거가 아니다). + +### 분기별 처분 + +| 계측 결과 | 처분 | +| --- | --- | +| 런처가 127로 죽고 카탈로그는 다른 경로로 로드됨 | 런처를 빌트인으로 고치고, **단정을 강화**해 실제 로드 경로를 고정한다 | +| 런처가 정상 동작함 (재현 실패) | P1을 근거와 함께 rebut하고 #610에 기록. NOOP | + +## B 단계 계측 결과 — **P1은 오탐이었다 (NOOP)** + +계측을 실제로 돌린 결과, 두 번째 분기가 사실이다. 근거는 세 단계다. + +### 1. 테스트 안에서 카탈로그는 로드된다 + +`loadBundledCodexCatalog()` 반환값을 임시로 찍어 봤다 (계측 후 원복함): + +``` +[PROBE] oldCatalog = loaded, models=1 +[PROBE] newCatalog = loaded, models=1 +(pass) resolveCodexRuntime > persisted runtime stamp busts resolve memo [140.50ms] +``` + +`null`이 아니다. 런처가 실제로 카탈로그를 출력했다는 뜻이다. + +### 2. `null`이었다면 테스트는 실패했을 것이다 + +"옵셔널 체이닝이 `undefined`를 내니 `.toBe(false)`가 통과할 것"이라는 초안의 추론은 +틀렸다. 직접 확인했다: + +``` +expect(undefined).toBe(false) + Expected: false + Received: undefined +(fail) +``` + +즉 이 테스트는 카탈로그 로드 실패를 **잡아낸다**. 조용히 통과하는 구조가 아니었다. + +### 3. Bun이 시작 시점 환경을 스냅샷한다 — Node와 다르다 + +> **A 게이트 3라운드 정정.** 초안은 이를 "Node/Bun 공통 동작"이라고 적었다. 틀렸다. +> 이건 **Bun 고유 동작**이고, 그 구분이 결론의 의미를 바꾼다. + +`runCodexDebugModels` +([src/codex/catalog/bundled.ts:137-143](/Users/jun/developer/new/700_projects/opencodex/src/codex/catalog/bundled.ts))는 +`execFile`에 `env` 옵션을 넘기지 않는다. 같은 스크립트를 두 런타임으로 돌린 결과: + +``` +process.env.PATH = ""; execFileSync(bin, [], { encoding: "utf8" }) + +=== BUN === CHILD_PATH=[/Users/jun/.nvm/.../bin:/opt/homebrew/bin:...] ← 원본 PATH +=== NODE === CHILD_PATH=[] ← 반영됨 +``` + +Bun은 자식 스폰용 환경을 프로세스 시작 시점에 스냅샷하고, 이후의 +`process.env` 변경을 관찰하지 않는다. Node는 반영한다. + +세 조건 비교: + +``` +A: process.env.PATH='' , no env opt OK (Bun 한정) +B: explicit env {...process.env} THREW 127 +C: explicit env {PATH:''} THREW 127 +``` + +### 3-1. B는 가상이 아니라 실제 호출 지점이다 + +`src/codex/runtime.ts:261`이 정확히 조건 B다: + +```ts +env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome }, +``` + +즉 한 테스트 실행 안에서 **두 프로브가 서로 다른 환경을 본다.** + +| 프로브 | 호출 | 자식이 보는 PATH | +| --- | --- | --- | +| `--version` (`runtime.ts:261`) | `env` 명시 | `""` — 반영됨 | +| `debug models` (`bundled.ts:137`) | `env` 생략 | 원본 — Bun 스냅샷 | + +`--version` 쪽이 살아남는 건 `echo`가 셸 빌트인이라 PATH 조회가 필요 없기 때문이다. +이 비대칭이 테스트가 초록인 진짜 이유다. + +### 결론 + +리뷰 봇의 P1은 **Node 의미론에서는 옳다.** 다만 이 저장소는 Bun 네이티브 런타임이고 +(AGENTS.md), Bun에서는 그 전제가 성립하지 않아 발화하지 않는다. 봇이 틀린 게 아니라 +런타임이 다르다. + +주의할 점: 이 테스트의 통과가 **Bun 구현 세부에 의존한다.** Bun이 향후 Node와 +정렬되면 `tests/codex-runtime.test.ts:360`은 코드 변경 없이 실패하기 시작한다. + +### 정정 — `PATH=""`는 장식이 아니다 + +> 3라운드에서 뒤집힌 두 번째 주장. 초안은 "격리 의도가 달성되지 않는다"고 적었는데 +> **틀렸다.** + +`PATH`는 자식 프로세스만 쓰는 게 아니라 **in-process에서도 읽힌다.** +`pathCandidates()` +([src/codex/runtime.ts:308-312](/Users/jun/developer/new/700_projects/opencodex/src/codex/runtime.ts))가 +`deps.env ?? process.env`의 `PATH`를 분해해 후보 바이너리 목록을 만든다. in-process +읽기는 Bun의 스폰 스냅샷과 무관하게 변경을 즉시 본다. + +가짜 `codex`를 PATH에 심고 측정한 결과: + +``` +PATH= -> source: path version: 9.9.9 +PATH='' -> source: fallback version: null +``` + +`PATH=""`가 결과를 바꾼다. 즉 이 줄은 **머신에 설치된 진짜 `codex`가 후보 목록에 +들어오는 것을 실제로 막고 있다.** `CODEX_CLI_PATH` 고정은 두 번째 층이지 대체가 아니다. + +이 줄을 "장식이니 지워도 된다"고 판단했다면 테스트를 머신 의존으로 만들 뻔했다. + +### 최종 처분 + +터미널 판정: **NOOP** (코드 변경 없음). NOOP 판정 자체는 유효하다 — P1이 이 런타임에서 +발화하지 않는다는 결론은 바뀌지 않았다. 바뀐 것은 그 이유의 설명이고, 잘못 전달한 +두 진술은 #610에 정정 코멘트로 바로잡는다. + +`mihneaptu`의 포크 브랜치에는 push하지 않는다. 우리는 `dev` 대상 브랜치에 고치고, +#610에 "P1은 네가 `056aa2d6e`에서 이미 고쳤다. 다만 같은 파일의 다른 테스트(head:514, +`dev`:360)에 같은 패턴이 남아 있고 그건 `dev`의 기존 결함이라 우리가 따로 고쳤다"고 +코멘트한다. 저자가 P1 미해결로 오해받아 막히지 않게 하는 것이 목적이다. + +## SoT 동기화 + +없음. 테스트 전용 변경이며 사용자 노출 동작이 바뀌지 않는다. diff --git a/devlog/_plan/260728_pr_rebuild_unit/020_usage_debug_flake.md b/devlog/_plan/260728_pr_rebuild_unit/020_usage_debug_flake.md new file mode 100644 index 000000000..a7bcc9315 --- /dev/null +++ b/devlog/_plan/260728_pr_rebuild_unit/020_usage_debug_flake.md @@ -0,0 +1,140 @@ +# 020 — usage-debug 회전 테스트의 Windows 타임아웃 (WP3) + +## 증상 + +`windows-latest` 잡에서 이 테스트만 실패한다: + +``` +(fail) appendUsageDebug > keeps file size bounded by MAX_LINES across long runs [13624.12ms] +``` + +PR #576의 CI(run 30308795526 / job 90119324868)에서 관측했다. #576의 diff는 +`src/codex/app-server-processes.ts` 계열이고 `src/usage/debug.ts`를 건드리지 않으므로, +이 실패는 그 PR이 만든 것이 아니다. 다른 잡(ubuntu, macos)은 통과한다. + +## 원인 + +`appendUsageDebug()`가 append마다 파일 전체를 다시 읽는다 +([src/usage/debug.ts:58-68](/Users/jun/developer/new/700_projects/opencodex/src/usage/debug.ts)): + +```ts +appendFileSync(path, `${JSON.stringify(safeRecord)}\n`, ...); +try { chmodSync(path, 0o600); } catch { } +if (existsSync(path)) trimRollingFile(path); // ← readFileSync 전체 +``` + +`trimRollingFile()`은 `readFileSync` → `split` → 길이 검사를 무조건 수행한다 +(`:49-52`). 회전이 필요 없을 때도 전체 파일을 읽는다. + +테스트는 `MAX_LINES(200) + KEEP_LINES(100) + 25 = 325`회 append 한다 +([tests/usage-debug.test.ts:152-163](/Users/jun/developer/new/700_projects/opencodex/tests/usage-debug.test.ts)). + +append 1회당 동기 파일시스템 호출을 정확히 세면: + +| 호출 | 출처 | 빈도 | +| --- | --- | --- | +| `mkdirSync` | `ensureUsageDebugDir()` `:44` | 매번 | +| `chmodSync` (디렉터리) | `ensureUsageDebugDir()` `:45` | 매번 | +| `appendFileSync` | `:63` | 매번 | +| `chmodSync` (파일) | `:64` | 매번 | +| `existsSync` | `:65` | 매번 | +| `readFileSync` | `trimRollingFile` `:50` | 매번 | +| `writeFileSync` + `chmodSync` | `trimRollingFile` `:54-55` | 회전 시에만 (이 실행에서 2회) | + +정상 경로 6회 × 325 + 회전 2회 × 2 ≈ **1,954회**. 초안이 `mkdirSync`/`chmodSync` +쌍을 빠뜨렸는데, 이 둘은 매 append마다 돌면서 아래 제안한 어떤 수정에도 영향받지 않는다. + +Windows에서 이 조합이 특히 비싸다. Defender 실시간 검사가 열기마다 걸리고, +`chmodSync`는 Windows에서 의미가 거의 없으면서도 syscall 비용은 그대로 낸다. +전체 스위트를 동시 실행하는 CI 부하에서 5초 기본 타임아웃을 넘겼다 — 13.6초 소요. + +테스트 주석(`:153-154`)이 이미 "MAX*3 appends (that path times out under full-suite +load)"라고 적어 두었다. 한 번 줄인 적이 있고, 아직 부족했다. + +## 이것이 결정 불요인 이유 + +테스트가 검증하려는 계약은 "회전이 몇 번 일어나도 파일이 MAX를 넘지 않는다"이다. +그 계약을 확인하는 데 325회 append가 필요하지 않다. 회전 임계를 **두 번** 넘기면 +충분하고, 그건 상수를 낮추면 된다. 동작 변경이 아니라 테스트 비용 조정이다. + +## A 게이트 정정 — 폐기한 접근 + +초안은 `trimRollingFile`에 `statSync().size < USAGE_DEBUG_MAX_LINES * MIN_RECORD_BYTES` +조기 반환을 넣자고 했다. `MIN_RECORD_BYTES = 3`으로. **폐기한다.** + +실제 레코드는 약 200바이트다. 임계값 `200 × 3 = 600`바이트는 레코드 3개면 넘어간다. +즉 325회 append 중 조기 반환이 걸리는 건 앞 3회뿐 — 약 0.9%다. "대부분의 append에서 +읽기를 생략한다"는 초안의 주장은 사실이 아니었다. 보수적으로 잡은 하한이 게이트를 +무력화한 것이다. + +`MAX_LINES × 150` 같은 현실적 하한으로 바꾸면 게이트는 실제로 걸리지만, 그때는 +"레코드가 150바이트보다 작을 수 있는가"라는 안전성 질문이 생긴다. `bodySample`이 +비면 충분히 작아질 수 있다. 회전이 늦어지면 파일이 상한을 넘고, 그건 이 테스트가 +막으려던 바로 그 계약 위반이다. + +더 근본적으로, Windows에서 13.6초가 걸린 원인은 읽기 1회가 아니라 **열기 1회당 +비용**(Defender 실시간 검사)이고, 그건 남는 5회 호출에도 그대로 붙는다. 읽기 하나를 +제거해도 6분의 1만 준다. 근본 수정처럼 보이지만 아니다. + +## 변경 — MODIFY `tests/usage-debug.test.ts` + +타임아웃만 올린다. Bun의 `test()`는 세 번째 인자로 밀리초 타임아웃을 받는다. + +```diff + test("keeps file size bounded by MAX_LINES across long runs", () => { + // Cross the rotate threshold twice — enough to prove the bound holds across + // multiple rewrites without MAX*3 appends (that path times out under full-suite load). + const total = USAGE_DEBUG_MAX_LINES + USAGE_DEBUG_KEEP_LINES + 25; + for (let i = 0; i < total; i++) { + appendUsageDebug(sample({ requestId: `ocx-${i}`, ts: i })); + } + const path = usageDebugPath(); + const lines = readFileSync(path, "utf-8").split(/\r?\n/).filter(Boolean); + expect(lines.length).toBeLessThanOrEqual(USAGE_DEBUG_MAX_LINES); + expect(lines.length).toBeGreaterThanOrEqual(USAGE_DEBUG_KEEP_LINES); +- }); ++ }, 30_000); +``` + +주석도 함께 갱신해서, 다음 사람이 왜 이 숫자인지 알 수 있게 한다: + +```diff + test("keeps file size bounded by MAX_LINES across long runs", () => { + // Cross the rotate threshold twice — enough to prove the bound holds across + // multiple rewrites without MAX*3 appends (that path times out under full-suite load). ++ // ++ // The 325 appends here cost ~1,950 synchronous fs calls (6 per append: mkdir, chmod, ++ // append, chmod, exists, read). On windows-latest under full-suite load that measured ++ // 13.6s — past the 5s default — while ubuntu and macos stay well under it. The cost is ++ // per-open (Defender), not per-byte, so trimming one of the six calls would not move it ++ // enough to matter; the honest fix is to let the test have the time it needs. + const total = USAGE_DEBUG_MAX_LINES + USAGE_DEBUG_KEEP_LINES + 25; +``` + +## 검증 (C-ACTIVATION-GROUNDING-01) + +이 변경은 조건부 분기를 **추가하지 않는다** — 테스트 타임아웃 상수만 바꾼다. +따라서 활성화 증거의 대상은 새 분기가 아니라 기존 계약이다: + +- `rotates to the most recent USAGE_DEBUG_KEEP_LINES once ... exceeded` — MAX+1회 + append 후 정확히 KEEP 줄, 최신 레코드 생존 +- `keeps file size bounded by MAX_LINES across long runs` — 회전 2회 후에도 상한 유지 + +둘 다 그대로 통과해야 한다. 회전 로직은 한 줄도 건드리지 않으므로 통과가 기대치다. + +``` +bun test tests/usage-debug.test.ts +``` + +로컬(macOS)에서는 원래 통과하므로, 이 수정의 진짜 검증은 CI의 windows-latest다. +로컬 통과는 "회귀 없음"의 증거이지 "타임아웃 해결"의 증거가 아니다 — 그 구분을 +D 요약에 남긴다. + +## 스코프 밖 + +`appendUsageDebug`의 per-append 재읽기 최적화. 실제 비효율이 맞지만, 이 실패를 +고치는 데 필요하지 않고 크리덴셜 로그 파일의 회전·권한 동작을 건드린다. 별건이다. + +## SoT 동기화 + +없음. `usage-debug.jsonl`의 회전 계약은 바뀌지 않는다 — 사용자 노출 동작 무변경. diff --git a/devlog/_plan/260728_pr_rebuild_unit/030_pr576_rebase.md b/devlog/_plan/260728_pr_rebuild_unit/030_pr576_rebase.md new file mode 100644 index 000000000..a4edd736a --- /dev/null +++ b/devlog/_plan/260728_pr_rebuild_unit/030_pr576_rebase.md @@ -0,0 +1,157 @@ +# 030 — PR #576 리베이스 (WP4) + +## 현재 상태 + +| 항목 | 값 | +| --- | --- | +| 브랜치 | `codex/pr527-rebase` @ `935a0e977` (단일 커밋) | +| base | `dev` | +| mergeable | `CONFLICTING` / `DIRTY` | +| 체크 | `windows-latest` FAIL (WP3가 다루는 무관한 usage-debug 테스트), 나머지 전부 pass | +| 이력 | #527의 대체본. #527은 base가 `codex/catalog-written-signal`이라 enforce-target 실패 | + +## 충돌 3파일 — A 게이트에서 초안을 전면 정정 + +> 초안은 `git merge-tree `(구형 3-인자 형태)의 "changed in both" 출력을 +> 충돌로 오독했다. 그 줄은 **양쪽에서 수정된 파일** 목록이지 충돌 목록이 아니다. +> 대부분은 git이 자동 병합한다. 실제 충돌은 `--write-tree`로 확인해야 한다. + +`git merge-tree --write-tree --name-only origin/dev origin/codex/pr527-rebase` 실측: + +``` +4bbd96e83d80214734589bbc9c713b50ce90eb00 +gui/src/pages/dashboard-overview-sections.tsx +src/cli/index.ts +src/server/management/config-routes.ts +``` + +| 파일 | 성격 | +| --- | --- | +| `src/cli/index.ts` | **로직** — CLI 커맨드 분기 | +| `src/server/management/config-routes.ts` | **로직** — 관리 API 라우트 | +| `gui/src/pages/dashboard-overview-sections.tsx` | **로직** — 대시보드 섹션 렌더 | + +자동 병합되는 것(충돌 아님): `gui/src/i18n/{de,en,ja,ko,ru,zh}.ts` 6개, +`docs-site/.../codex-integration.md`, `docs-site/.../cli.md`. + +**초안이 말한 것과 정반대다.** i18n·docs는 문제가 아니고, 세 개의 로직 파일이 문제다. +"텍스트 추가라 양쪽 다 채택하면 된다"는 해소 규칙은 이 세 파일에 적용하면 위험하다 — +`dev`가 그 사이 넣은 오류 처리 경로를 덮어쓸 수 있다. + +## 해소 규칙 (파일별) + +세 파일 모두 **헝크 단위로 읽고 판단한다.** 일괄 규칙 금지. + +- `src/cli/index.ts` — `dev`가 #601/#602/#593로 커맨드 표를 바꿨다. 우리 PR이 넣는 + 건 stale app-server 경고 호출 한 지점이다. `dev`의 구조를 기준으로 삼고 우리 호출을 + 그 안에 다시 배치한다. +- `src/server/management/config-routes.ts` — 카탈로그 기록 후 신호를 내보내는 지점. + `dev`의 응답 형태가 바뀌었으면 그쪽을 따르고 우리 필드를 추가하는 방향으로. +- `gui/src/pages/dashboard-overview-sections.tsx` — `dev`가 drain-and-restart 카드를 + 넣었다(#580). 우리 경고 배너는 그와 별개 섹션이므로 공존시킨다. + +각 해소마다 `git diff origin/dev -- `로 **우리가 무엇을 추가했는지만** 남았는지 +확인한다. `dev`의 라인이 사라졌으면 잘못 해소한 것이다. + +## 절차 — 기존 워크트리 사용 + +> A 게이트 정정: `git worktree add`로 새 워크트리를 만들 수 없다. +> `codex/pr527-rebase`는 이미 `/Users/jun/.codex/worktrees/260728-pr527/opencodex`에 +> 체크아웃돼 있어서 git이 거부한다. + +``` +cd /Users/jun/.codex/worktrees/260728-pr527/opencodex +git status --porcelain # 더티면 중단 +git rev-parse HEAD # lease 기준 기록 +git rebase origin/dev +``` + +리베이스 전 그 워크트리가 깨끗한지 반드시 확인한다. 더티면 손대지 않고 중단한다 — +다른 세션의 작업일 수 있다. + +### 금지 + +- 다른 사람 브랜치 push 금지. +- `--force-with-lease`는 리베이스 직전 기록한 `origin/codex/pr527-rebase` 해시를 + 명시적으로 넘겨서 쓴다. + +## 검증 + +> A 게이트 정정: 초안의 검증 세트는 충돌 파일을 하나도 덮지 않았다. + +``` +bun run typecheck # src/ 만 커버 (tsconfig include: ["src"]) +bun run build:gui # gui/ 타입체크 — i18n Record 키 누락은 여기서만 잡힌다 +bun run test # 충돌 3파일이 어느 테스트에 걸리는지 전수로 확인 +bun run lint:gui +``` + +`bun run typecheck`는 `tsconfig.json`의 `"include": ["src"]` 때문에 `gui/`를 보지 +않는다. GUI 타입 오류는 `build:gui`로만 드러난다. + +`gui/scripts/sync-locale-keys.mjs`는 **검증 도구가 아니다** — 누락 키를 영어로 채워 +`writeFileSync`하는 생성기다. 리베이스 중에 돌리면 작업 트리를 조용히 바꾼다. 쓰지 않는다. + +## 완료 조건 + +- `gh pr view 576 --json mergeable` → `MERGEABLE` +- `gh pr checks 576`에서 잔여 실패가 없거나, 남은 실패가 이 PR과 무관함이 기록됨 +- PR에 리베이스 사실과 헝크별 해소 근거 코멘트 + +## 의존 + +WP3가 먼저다. usage-debug 타임아웃이 남아 있으면 리베이스 후 windows CI가 다시 +빨갛게 나오고, 그게 리베이스 탓인지 기존 결함 탓인지 구분할 수 없다. + +## 실행 결과 (2026-07-28) + +`935a0e977` → `98142f5c8`. `#576`은 `CONFLICTING/DIRTY` → `MERGEABLE/UNSTABLE`. + +### 충돌은 예측대로 3파일이었다 + +i18n 6개와 docs 2개는 자동 병합됐다. A 게이트의 정정이 맞았다. + +| 파일 | 해소 | +| --- | --- | +| `src/cli/index.ts` | `dev`의 `!synced.ok` 오류 경로 + 우리 `catalogWritten \|\| cacheSynced` 게이트 병존. `else`가 아니다 — `refreshCodexModelCatalog`가 `injectCodexConfig`보다 먼저 돌아서 `ok:false` + `catalogWritten:true`가 실재한다 (`src/codex/sync.ts:83-120`) | +| `src/server/management/config-routes.ts` | `{ ...attachStaleAppServerHint(result), ...(result.ok ? {} : { error: result.message }) }`. 키 집합이 서로 소(`staleAppServerHint` vs `error`) | +| `gui/src/pages/dashboard-overview-sections.tsx` | `dev`의 `nativeSubagentDefaultsWarning` + 우리 `` 인접 배치 | + +### A 게이트가 잡은 Critical — 리베이스가 실어온 회귀 + +`git diff origin/dev..HEAD -- src/cli/index.ts`의 **삭제 라인**을 보니 `dev`의 +`grokSyncFailureMessage()`와 세 개 에러 핸들러가 사라지고 있었다. `5451cd191`의 +작업이다. + +원인은 우리 해소가 아니었다. 원래 `935a0e977` 커밋에 이미 들어 있던 삭제가 +리베이스로 충실히 실려온 것이다. #527 계보의 편집 사고로 보인다. + +무서운 점은 **아무 테스트도 이걸 잡지 못한다는 것**이다. `rg "may still point at a +previous proxy port"`는 저장소 전체에서 0건이다. 전체 스위트가 초록이어도 통과한다. +`git checkout origin/dev -- src/cli/index.ts`로 복원한 뒤 `case "sync"`/`"sync-cache"` +훅만 재적용했고, 최종 삭제 라인은 1줄(우리가 `if`로 감싼 자리)뿐이다. + +교훈: 030 §해소 규칙의 "`dev`의 라인이 사라졌으면 잘못 해소한 것"은 **충돌 파일만이 +아니라 커밋 전체**에 적용해야 한다. + +### 소스-텍스트 테스트 충돌 + +전체 스위트에서 `tests/cli-restore-back.test.ts`가 실패했다. 이 테스트는 +`src/cli/index.ts`를 문자열로 읽어 `if (!synced.ok)`를 요구하는데, 우리 PR의 +`tests/codex-app-server-processes.test.ts:260`은 `syncResult.catalogWritten`을 요구한다. +한 변수를 두 이름으로 요구하는 셈이다. + +`dev`가 먼저 있던 계약이므로 `synced`로 통일하고 우리 테스트를 맞췄다. + +두 테스트 모두 소스 텍스트 매칭이라 순수 리네임에 실패하고 의미가 깨져도 통과할 수 +있다. 별건 후속으로 주입 가능한 seam을 통한 동작 검증이 필요하다. + +### 검증 + +``` +bun run typecheck clean +bun run build:gui exit 0 +bun run test 5722 pass, 1 skip, 0 fail (417 files, 180.82s) +``` + +PR 코멘트: `#576 issuecomment-5103955966`. diff --git a/docs-site/AGENTS.md b/docs-site/AGENTS.md new file mode 100644 index 000000000..eb3fa13fa --- /dev/null +++ b/docs-site/AGENTS.md @@ -0,0 +1,30 @@ +# Documentation-site instructions + +This file applies to `docs-site/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Source-of-truth rules + +- `docs-site/` is the public user-documentation source. +- Document current shipped or intentionally pending behavior. Do not copy claims from historical `docs/` or `devlog/` material without verifying them against current code and configuration. +- English documentation is the canonical source. Translated content must not contradict it. +- Keep commands, paths, configuration keys, defaults, branch names, and URLs synchronized with the repository. +- Do not edit generated build output. + +## Editing rules + +- Reuse the existing Astro and Starlight structure, navigation, components, and style. +- Update all directly affected pages when a user workflow changes. +- Do not add duplicated policy text when a stable canonical document can be linked instead. +- Use repository-relative links for repository files and site-relative links for documentation pages where the existing site does so. + +## Required validation + +Run: + +```bash +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +Do not claim documentation validation passed unless this build completes successfully. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 01b6c508f..f86b7ea19 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -88,6 +88,7 @@ export default defineConfig({ { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, + { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index ebc1de681..be9ba4ffb 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -98,11 +98,24 @@ bun run release:watch # watch the newest Release workflow run `dev`; do not open feature pull requests against it. - `preview` — the prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for you: +keep opening pull requests against `dev`. After a merge, a maintainer rebases +the work onto `dev2-go` and ports whatever needs a Go counterpart under `go/`, +and the item counts as finished only once both lines carry it. + Porting and rebase pull requests are welcome. Carrying a fix from one integration line to another, or rebasing a stale branch onto the current head, is normal contribution rather than noise — note the source commits in the description. +## Pull requests + +- Target **`dev`** (or **`dev2-go`** only for scoped Go native-port work). Do not open ordinary feature or fix pull requests against **`main`**. +- Branch from the current **`dev`** tip, not from **`main`**. The required **`enforce-target`** check rejects heads whose merge base sits on the **`main`** tip while the branch is far behind the pull request base (the failure mode seen in #644). +- Write a real description: a **Summary** of what changed and why, plus a **Test plan** (or equivalent substance). Empty bodies, placeholder-only text, and descriptions that use escaped `\n` instead of real line breaks fail the check. +- Workflow changes in this repository use **`pull_request_target`**. Updated enforcement logic applies only after the workflow is promoted to the repository default branch — the same operational caveat documented in #631. + ## Project maintainers The current maintainers, their responsibilities, and the review and merge policy are documented in diff --git a/docs-site/src/content/docs/getting-started/how-it-works.mdx b/docs-site/src/content/docs/getting-started/how-it-works.mdx index 7387cc26e..7ddff28cb 100644 --- a/docs-site/src/content/docs/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/getting-started/how-it-works.mdx @@ -32,9 +32,11 @@ account before the request is forwarded upstream. The rule is intentionally spli - **Existing thread ids keep affinity.** A thread is bound to the account generation that started it, so a long SSH, tmux, or mobile-attached Codex session keeps using one account instead of being rebalanced mid-conversation. -- **New sessions can rebalance.** For a new thread, opencodex compares known quota usage across 5h, - weekly, and 30d windows, skips accounts that need reauthentication or are in cooldown, and can - switch to a lower-usage eligible account when the active account crosses the configured threshold. +- **New sessions can rebalance.** For a new thread, opencodex picks among eligible accounts using + `accountPoolStrategy` (`quota` by default, or `round-robin` / `fill-first`). The `quota` strategy + compares known usage across 5h, weekly, and 30d windows and can switch to a lower-usage account + when the active account crosses `autoSwitchThreshold`. Accounts in cooldown or needing + reauthentication are skipped regardless of strategy. - **Quota and failure signals feed routing.** The dashboard can force a quota refresh with `GET /api/codex-auth/accounts?refresh=1`; successful upstream responses capture quota headers, 429 puts an account in cooldown, and 401/403 marks it for reauthentication. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832e..ba1786409 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -7,6 +7,33 @@ opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/respon Code can use every routed provider — OAuth logins, account pools, key failover and sidecars included — with zero extra auth work. +## Claude OAuth account pool (experimental) + +You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / +add-account). By default every request uses the **active** account only. + +An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky +session affinity and 429 cooldown failover across those OAuth accounts. For **new** sessions +only, `anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks +lowest known 5-hour usage when above `autoSwitchThreshold`; `round-robin` spreads evenly +(`stickyLimit`, default `1`); `fill-first` drains the active account until cooldown, +reauthentication, or threshold, then advances. It is **off by default**, shows a GUI warning, +and is not battle-tested — Anthropic may restrict accounts that look like automated rotation; +rotation does not protect against provider enforcement. + +Operational contract when enabled: + +- Upstream **429** cools that account using `Retry-After` when present (else a default backoff), + clears its affinities, and may rotate to another eligible account within the same request + (bounded). +- Affinity is **process-local** (lost on proxy restart). +- **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from + selection until re-authenticated. +- If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After` + when known. + +See [Configuration](/reference/configuration/#anthropicaccountpool-experimental). + ## Quickstart ```bash diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 9a781a4f1..b039b338d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -136,9 +136,10 @@ Set the mode from the Dashboard or Models page, `ocx v2 mode v1|default|v2`, or with `{ "multiAgentMode": "v1" }`. Changes apply to new Codex sessions. :::caution -On the v2 (`multi_agent_v2`) surface, spawned sub-agents inherit the parent session's model. The -dashboard's delegation model/effort picker is v1 prompt guidance, not a proxy-side per-spawn -cross-model router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical +On the v2 (`multi_agent_v2`) surface, a spawn without an explicit model can inherit the parent +session's model. OpenCodex guidance can ask Codex to pass the selected model/effort explicitly, and +the separate native-default opt-in can supply defaults after sync/restart. Neither mechanism is a +proxy-side per-spawn router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical behavior. ::: @@ -175,8 +176,9 @@ Codex sorts picker-visible catalog entries by ascending `priority` and advertise through `subagentModels` or the dashboard Subagents page; opencodex gives those entries priorities 0-4 in the chosen order. Other models remain callable by exact id. -The featured-model list is separate from the Dashboard's **Sub-agent delegation** guidance. In -particular, featured model overrides do not bypass v2's parent-model inheritance rule. +The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It +controls which overrides Codex offers first; it does not select a model or trigger delegation by +itself. ## Refreshing model state diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index cd4fe810a..bef498ce4 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -36,7 +36,11 @@ The proxy listens on port `10100` by default and serves `POST /v1/responses`, Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension POSTs `{base_url}/images/generations` (or `/images/edits` when reference images are attached) directly, with the same ChatGPT bearer auth it uses for chat. Because the injected `base_url` -points at opencodex, the proxy relays those calls to the OpenAI upstream: +points at opencodex, the proxy relays those calls to the OpenAI upstream. + +This is separate from the [Image Bridge](/guides/image-bridge/), which only activates when a +**Responses** turn lists the hosted `image_generation` tool while a non-OpenAI model is selected. +Standalone `/images/generations` calls never enter that bridge. - **One mode-aware forward candidate:** Pool selects an eligible main/added account; Direct uses the caller OAuth bearer. The configured mode applies consistently to the image request. @@ -46,11 +50,28 @@ points at opencodex, the proxy relays those calls to the OpenAI upstream: `openai-responses` provider whose endpoint implements the OpenAI Images API. Explicit selection fails closed and never falls back to a different paid upstream. Registry-managed provider ids are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. +- **Google Antigravity (CCA) fallback:** when neither an OpenAI forward candidate nor a keyed + provider is configured, `/v1/images/generations` (not `/images/edits`) falls back to the + Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. The fallback + also fires after OpenAI auth resolution fails (e.g. an expired or missing ChatGPT credential), + not only when no OpenAI candidate is configured. This + requires `ocx login google-antigravity`; the OAuth token is sent only to the pinned CCA registry + host, never to a config-level `baseUrl` override. The response is returned in the same + `{created, data:[{b64_json}]}` shape Codex expects. - **Neither:** the proxy returns a clear error instead of a generic 404. Routed providers - (Cursor, Gemini, Kiro, …) cannot serve image generation; if you don't want the tool offered at - all, disable it in Codex with `codex features disable image_generation` + (Cursor, Gemini, Kiro, …) cannot serve the `image_generation` tool relay; if you don't want the + tool offered at all, disable it in Codex with `codex features disable image_generation` (`[features] image_generation = false` in `config.toml`). +The tool declaration still travels with the model's Responses request. For API-key Responses +providers, opencodex lowers Codex's private `image_gen` namespace to an upstream-safe +`image_gen__` alias (for example `image_gen__imagegen`). When that usable alias replaces +the client declaration, opencodex removes a duplicate hosted `image_generation` declaration. It maps +the function call to the explicit `image_gen` namespace before Codex sees it, and encodes the native +call again when later history is replayed upstream. This keeps client-side image generation callable +on public-compatible upstreams that reserve the namespace or reject dotted function names. ChatGPT +forward mode remains untouched and keeps its native Responses Lite shape. + For an OpenAI-compatible custom gateway, configure a dedicated provider and select it only for standalone Images requests: @@ -75,6 +96,11 @@ The custom endpoint must accept `POST /v1/images/generations` and `/v1/images/ed OpenAI Images response shape expected by Codex. The provider's configured key replaces any caller bearer before the upstream request. +> **Note:** This refers only to the Codex `image_generation` tool (`/images/generations` relay). +> Gemini models that are image-capable produce inline images natively through the `google` adapter +> (via `responseModalities: ["TEXT", "IMAGE"]`), independent of this relay — see +> [Adapters](/reference/adapters/#google). + For a non-loopback `hostname`, Codex must send the generated API auth header. The injector therefore uses a dedicated provider instead: @@ -219,6 +245,11 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c independently of other providers. 5. **Cache and `ocx sync`** — live catalogs are cached for about five minutes (`modelCacheTtlMs`, default `300000`). Run `ocx sync` to force a fresh fetch and rewrite the catalog immediately. +6. **Running Codex `app-server`** — rewriting the on-disk catalog is not enough while a long-lived + Codex `app-server` (Desktop / CLI background host) keeps the previous list in memory. `ocx sync` + and `ocx sync-cache` warn when those processes are detected. Restart them with + `ocx sync --restart-codex` (or stop the matching `app-server` processes yourself), then let Codex + recreate them so the new list appears. :::caution[Other local writers] Catalog writes (`opencodex-catalog.json`, `config.toml`) are atomic **inside** opencodex, which only diff --git a/docs-site/src/content/docs/guides/image-bridge.md b/docs-site/src/content/docs/guides/image-bridge.md new file mode 100644 index 000000000..6606fee55 --- /dev/null +++ b/docs-site/src/content/docs/guides/image-bridge.md @@ -0,0 +1,95 @@ +--- +title: Image Bridge +description: Route image_generation hosted-tool calls to xAI Grok Imagine when using a non-OpenAI provider. +--- + +## Overview + +When you route Codex through a non-OpenAI model (Claude, Gemini, Grok, etc.), the +`image_generation` **hosted tool** normally doesn't work — it requires OpenAI's server-side +execution environment. The Image Bridge detects these calls and transparently reroutes them to +xAI Grok Imagine, so the model you're actually chatting with can still generate images. + +## Prerequisites + +- **Enable the bridge** by setting `images.bridgeEnabled: true` in your config (it is off by + default to avoid unexpected xAI charges — see [Configuration](#configuration) below). +- An `xai` provider entry with an **API key**. The bridge pins fulfillment to the registry xAI + Images endpoint (`https://api.x.ai/v1`); any configured `baseUrl` override is ignored for image + calls. OAuth / `ocx login xai` alone does **not** arm the bridge (the Grok CLI OAuth transport is + chat-oriented and is not used for `/images/*`). + + ```json + { + "providers": { + "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" } + } + } + ``` + +- A non-OpenAI model selected as your active provider. (When the active provider is OpenAI, + the native hosted tool is used directly and the bridge is bypassed.) + +## Configuration + +Image Bridge options live under `images` in `~/.opencodex/config.json`. Bridging is +**opt-in** — you must set `bridgeEnabled: true` to enable paid xAI Grok Imagine generation: + +```json +{ + "images": { + "bridgeEnabled": true, + "bridgeModel": "grok-imagine-image-quality", + "maxRounds": 3, + "timeoutMs": 60000 + } +} +``` + +| Option | Default | Description | +| --- | --- | --- | +| `bridgeEnabled` | `false` | Master switch. Set `true` to enable bridging. Off by default to avoid unexpected xAI charges. | +| `bridgeModel` | `grok-imagine-image-quality` | The xAI image model id to send prompts to. | +| `maxRounds` | `3` | Maximum image-generation loop iterations per turn. Floored to an integer and clamped to `[0, 10]`; non-finite values fall back to `3`. | +| `timeoutMs` | `60000` | Per-call xAI deadline in milliseconds. Finite positive values are floored and passed to the xAI request. | +| `artifactsKeepCount` | `200` | Maximum number of files retained under `artifacts/`. When exceeded, the oldest files are deleted after each fulfilled call. Set to `0` or a negative value to disable pruning. | + +## Artifact Retention + +Generated images are written to `~/.opencodex/artifacts/`. To prevent unbounded disk +growth in long-running sessions, the directory is pruned automatically after each fulfilled +image call (once the full batch for that call is on disk) — the oldest files (by modification +time) are deleted when the count exceeds the configured maximum (default 200, configurable via +`images.artifactsKeepCount`). Only paths that survive pruning are returned to the model. + +## How It Works + +The Image Bridge activates only on **Responses** turns that include the hosted +`image_generation` tool in the `/v1/responses` tools array while a **non-OpenAI** +model is selected. It does **not** intercept Codex's built-in `image_gen` tool, +which POSTs directly to `/v1/images/generations` (or `/images/edits`) — that path +is covered separately in [Codex Integration](/guides/codex-integration/#built-in-image-generation-image_gen). + +1. When a Responses request lists `image_generation` in `tools`, OpenCodex detects it + during request preprocessing. +2. The hosted tool is replaced with a **synthetic function tool** that the routed model can call + normally — the model sees a callable tool rather than an opaque hosted tool it can't execute. +3. When the model invokes that tool, OpenCodex intercepts the call and sends the prompt to xAI's + image generation API. +4. Generated images are saved to `~/.opencodex/artifacts/` and the **local file path** is returned + to the model as the tool result. +5. The model continues the conversation with knowledge of the generated image and its location. + +From the model's perspective nothing changed — it called a tool and got a result. From the user's +perspective, image generation works with any routed provider instead of silently failing. + +## Limitations + +- **Only xAI Grok Imagine is supported.** DALL-E and other image providers may be added later. +- **Web search takes priority** on adapters that support the web-search sidecar loop. If both web + search and image generation are requested in the same turn, web-search runs and image + generation is skipped. Cursor/`runTurn` adapters cannot use that sidecar today, so the image + bridge may still run for those dual-tool turns. +- **xAI costs apply.** Image generation via xAI requires an active xAI subscription or API credits. +- **Streaming only.** The bridge works by intercepting the SSE response stream; requests with + `stream: false` are rejected with a 400 error. diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md new file mode 100644 index 000000000..c54be6956 --- /dev/null +++ b/docs-site/src/content/docs/guides/opencode.md @@ -0,0 +1,106 @@ +--- +title: opencode +description: Use any routed model from opencode — opencodex injects a runtime provider block and leaves your own opencode config untouched. +--- + +opencode reads its providers from merged JSON config layers rather than environment +variables, so there is no `ANTHROPIC_BASE_URL`-style slot to inject. `ocx opencode` +bridges that gap: it ensures the proxy is running, builds a provider block from the +visible catalog, and injects it through OpenCode's inline runtime layer +(`OPENCODE_CONFIG_CONTENT`). + +## Quickstart + +```bash +ocx opencode +``` + +This ensures the proxy is running and launches opencode with only the generated +`provider.opencodex` block injected for that process. Extra arguments pass through: +`ocx opencode run "hello"`. + +Routed models appear in the picker under the `opencodex` provider: + +```text +opencodex/kiro/glm-5 +opencodex/gpt-5.6-sol # native slugs stay unprefixed +``` + +## Your own config is never modified + +The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, +project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. It may +read global or project config to detect a `provider.opencodex` override, while your +existing providers, agents, keybinds, MCP entries, and relative `{file:…}` references +keep resolving from their original files. + +For this launch only, opencodex adds the generated `provider.opencodex` block through +OpenCode's inline runtime layer. That layer merges after global/custom/project config +and overrides only conflicting keys for the child process. + +| Layer | Behavior with `ocx opencode` | +| --- | --- | +| Global / custom / project config | Left on disk exactly as you wrote it | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives only the generated `provider.opencodex` block | +| Relative `{file:…}` paths | Still resolve against the config file that originally defined them | + +If a global or project config also defines `provider.opencodex`, the launcher prints an +informational note: the runtime layer from `ocx opencode` overrides it for that launch. + +## The admission key is not written to disk + +When the proxy requires an API key, the inline runtime config carries opencode's +`{env:…}` reference rather than the secret. Loopback binds use that reference as +`apiKey`; non-loopback binds send it only through `x-opencodex-api-key` so proxy +admission stays separate from any upstream `Authorization` header. + +Loopback example: + +```json +"options": { + "baseURL": "http://127.0.0.1:10100/v1", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" +} +``` + +Non-loopback example: + +```json +"options": { + "baseURL": "http://192.168.1.10:10100/v1", + "headers": { + "x-opencodex-api-key": "{env:OPENCODEX_OPENCODE_API_KEY}" + } +} +``` + +The real value is passed only through the child process environment. +`OPENCODEX_API_AUTH_TOKEN` takes precedence, then the hardened service token file, then +a configured API key — which is what a non-loopback bind requires. + +## Reverting + +Nothing to undo — no generated config file is written under `~/.opencodex`. Run plain +`opencode` and it reads your own config exactly as before. + +## Model limits + +`limit.context` is written only when the catalog reports an authoritative context window; when it +does not, the whole `limit` block is omitted and opencode keeps its own defaults. + +opencode's schema rejects a `limit` block carrying `context` without `output`, and the catalog has +no authoritative per-model output field, so an `output` budget of `32000` is emitted alongside it, +clamped down to the context window so a small-context model is never given `output > context`. +That figure exists to satisfy the schema — it is not a claim about any specific model's true +maximum. + +The `opencodex` provider block is regenerated on every launch, so per-model tweaks made inside it +will not survive. Keep custom entries under a provider key of your own instead. + +## Requirements + +opencode must be installed and on `PATH`: + +```bash +npm install -g opencode-ai +``` diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8314f0ddc..4615c0726 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -85,19 +85,27 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Import-first login reuses the installed `kiro-cli` session — requires the Kiro CLI installed (`curl -fsSL https://cli.kiro.dev/install | bash`) and signed in via `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (install with `curl -fsSL https://cli.kiro.dev/install | bash`, then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), +opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request; +it never generates one. Kimi documents a stable session/task key as required to improve Code Plan +cache hit rates, while requests without a key remain keyless. If an opted-in upstream rejects the +field, opencodex does not strip it and retry or mutate saved configuration. Other providers remain +deny-by-default. + You can also start OAuth from the [web dashboard](/guides/web-dashboard/). ### Multiple OAuth accounts OAuth providers whose credentials include a stable account id or email can keep more than one login. The Providers page shows those accounts in a dropdown, lets you add another, and switches the -active account without logging the others out. Identity-less Kimi and Kiro credentials replace their -active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. +active account without logging the others out. Only identity-less Kimi credentials replace the +active slot; Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex +pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. ### OAuth reliability @@ -119,7 +127,10 @@ Terminal refresh failures mark the account as needing reauthentication instead o **Cooldowns (Codex pool).** Upstream `429` / quota responses set a hard cooldown from `Retry-After`, quota `reset` headers (capped), or a short default backoff. Accounts on an explicit `Retry-After` cooldown are not probed early; reset-derived cooldowns may receive a paced probe lease -so recovery can be detected without flooding the provider. +so recovery can be detected without flooding the provider. Reset-derived native-model cooldowns +also preserve known independent quota groups: `gpt-5.3-codex-spark` does not prevent the same account +from trying the shared GPT-5.6 Terra/Luna quota, while models in that shared group still protect one +another. Explicit `Retry-After` and default cooldowns always remain account-wide. **Session affinity.** Codex thread→account affinity is process-local (in-memory only; not persisted across proxy restarts). On credential failures (`401` / `403`) the account is quarantined for @@ -143,20 +154,35 @@ and WARN rows that include a recovery Action. When an OAuth provider account nee ### Kiro credential import Kiro login expects the Kiro CLI: install it (`curl -fsSL https://cli.kiro.dev/install | bash`) -and sign in with `kiro-cli login` first. Without a kiro-cli session, `ocx login kiro` falls +and sign in with `kiro-cli login` first. Without a `kiro-cli` session, `ocx login kiro` falls back to a pasted access token or the `KIRO_ACCESS_TOKEN` environment variable. -`ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two -environment variables make selection explicit without copying credentials into opencodex: +The `ocx login kiro` import path searches the platform Kiro CLI stores and opens SQLite databases +read-only. Two environment variables make the source and token row selection explicit: - `KIROCLI_DB_PATH` selects a nonstandard Kiro CLI SQLite database. The path must already exist; - opencodex does not create it or modify the database, WAL, or SHM files. + during this import path, opencodex does not create or modify the database, WAL, or SHM files. - `KIROCLI_TOKEN_KEY` selects the exact `auth_kv` token key when a database contains multiple otherwise ambiguous token rows. A missing selection fails login instead of guessing. +After a successful import, opencodex persists the imported credential to +`~/.opencodex/auth.json`. + Keep these variables and the selected database private. Do not attach database files or raw login diagnostics to bug reports. +**Add account** is a separate write workflow: it snapshots the current session, logs `kiro-cli` out, +and imports the fresh browser login. If the login is cancelled or fails, including while OpenCodex +persists the credential, rollback replaces the Kiro CLI database and removes its current WAL, SHM, +and journal sidecars before publishing the previous session snapshot. + +Because that rollback is only possible from a snapshot, **Add account** refuses to sign `kiro-cli` +out when a session store is present but cannot be captured (unreadable file, mismatched schema, or +an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redirect import reads away +from the live CLI store, or when an existing primary CLI database has no recognized token row. +Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import +selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. + ## 3. API-key catalog opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 764e8feee..ae9738ad9 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ The override is the final pass in both the live `/v1/models` catalog response an ### Delegation model and effort -The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. These are delegation guidance settings, not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. +The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. OpenCodex-authored delegation guidance can use these selections, but it remains controlled separately by **OpenCodex multi-agent guidance**. These settings are not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. + +The default-off **Use as native Codex subagent defaults** switch sets `syncCodexSubagentDefaults`. When OpenCodex manages the active Codex routing, a sync or restart applies the selected model and effort as native Codex `[agents]` defaults for newly created Codex tasks. External user-managed provider configs remain untouched. This does not trigger delegation. Existing user-owned `[agents]` defaults are preserved rather than overwritten, so they remain authoritative. `multiAgentGuidanceText` identifies the surface from the request's tools — including the Codex Desktop WebSocket path (`responses_lite`), where tools arrive inside an `additional_tools` input item instead of the request's `tools` array. @@ -56,7 +58,7 @@ To replace the built-in v2 guidance, set `injectionPrompt` (config key, or `PUT - **Dashboard** → first stat cell: click **v1**, **base**, or **v2**. - **Models** page → top-row segmented control. - Both pages have a **?** button that opens a help modal with a link back here. -- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. Enable **OpenCodex multi-agent guidance** for delegation instructions, or independently enable **Use as native Codex subagent defaults** to apply the selection to new Codex tasks after sync/restart. The defaults switch does not cause delegation, and existing user-owned `[agents]` defaults are preserved rather than overwritten. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Opt in to native Codex defaults for new tasks after sync/restart (requires a model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Set a custom guidance prompt ({{model}}/{{effort}}/{{roster}} placeholders) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` returns `model`, `effort`, `prompt`, the global `efforts` ladder, and enabled native/routed `available` models. For PUT, omitting `effort` or `prompt` keeps the current value, `null` clears it, and clearing `model` always clears the effort too. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. +`GET /api/injection-model` returns `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, the global `efforts` ladder, and enabled native/routed `available` models. PUT is partial: omitted fields keep their current values, `null` clears nullable values, and clearing `model` always clears both the effort and native-default opt-in. Enabling `syncCodexSubagentDefaults` requires a model. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. ## Reasoning effort -The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; it does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. +The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; with native-default sync enabled, it also becomes the `[agents]` reasoning default for new Codex tasks after sync/restart. It does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. `ultra` ranks above `max` in the Codex catalog and adds automatic-delegation semantics, but it never reaches a provider as a literal wire value. Codex converts `ultra` to `max` at the client boundary. opencodex then keeps the provider request valid: diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md new file mode 100644 index 000000000..4d0c4930f --- /dev/null +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -0,0 +1,85 @@ +--- +title: Video Bridge +description: Generate videos with Grok Imagine Video through a non-OpenAI model. +--- + +## Overview + +The Video Bridge lets you use xAI's Grok Imagine Video generation through any non-OpenAI model +routed by opencodex. When enabled, a synthetic `video_gen` tool is injected into the conversation. +The model calls it like any function tool; opencodex intercepts the call, submits a video generation +job to xAI, polls until completion, and downloads the result. + +## Prerequisites + +- An `xai` provider entry with an **API key** (`ocx login xai` alone is not sufficient — the video bridge requires key auth, not OAuth) +- A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini) +- opencodex configured to route through the non-OpenAI provider + +> **⚠ Provider key required:** The video bridge only activates when the `xai` provider uses +> API key auth. Add this to your config: +> +> ```json +> { +> "providers": { +> "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" } +> } +> } +> ``` +> +> If you onboarded via `ocx login xai` (OAuth), the provider stays in `authMode: "oauth"` +> and the bridge silently won't activate. Set `XAI_API_KEY` in the environment **or** +> hard-code the key as shown above. + +## Configuration + +Add `videoBridgeEnabled: true` to your `images` config: + +```json +{ + "images": { + "bridgeEnabled": true, + "videoBridgeEnabled": true, + "videoBridgeModel": "grok-imagine-video", + "videoMaxRounds": 2, + "videoTimeoutMs": 300000 + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `videoBridgeEnabled` | `false` | Master switch. Must be explicitly enabled. | +| `videoBridgeModel` | `"grok-imagine-video"` | xAI video model id. | +| `videoMaxRounds` | `2` | Max video-gen rounds before forced final answer. | +| `videoTimeoutMs` | `300000` (5 min) | Per-video timeout including polling. | + +## How It Works + +1. opencodex detects a non-OpenAI routed model with `videoBridgeEnabled: true` +2. A synthetic `video_gen` function tool is injected into the conversation +3. When the model calls `video_gen`, opencodex submits a job to xAI's `/videos/generations` +4. The bridge polls the job status every 5-15 seconds, sending heartbeat messages to keep the stream alive +5. When the video is ready, it's downloaded to the artifacts directory +6. The local file path is returned to the model as a tool result + +## Supported Parameters + +The `video_gen` tool accepts: + +| Parameter | Type | Range | Description | +|-----------|------|-------|-------------| +| `prompt` | string | required | Detailed video generation prompt | +| `duration` | integer | 1-15 | Video length in seconds | +| `resolution` | string | `"480p"`, `"720p"` | Video resolution | +| `aspect_ratio` | string | 7 ratios | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` | + +## Limitations + +- **xAI only**: Video generation is only available through xAI's Grok Imagine Video API +- **Asynchronous**: Video generation takes 30-120 seconds +- **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p) +- **One video per call**: Each `video_gen` call produces one video +- **Coexists with Image Bridge**: Both bridges can be enabled simultaneously +- **Web search priority**: When a web search sidecar is active for a turn (non-`runTurn` adapter), the video bridge is skipped — the two cannot run concurrently. A `console.warn` is emitted so you can detect this in logs. +- **Timeout covers submit + poll**: The `videoTimeoutMs` budget starts before job submission, so the submit call (60 s) and subsequent polling share the same deadline. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 194e54f57..6f5d16b21 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -26,20 +26,20 @@ bun run dev:gui | Area | What it does | | --- | --- | | **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. | -| **Sub-agent delegation** | Choose a native or routed guidance model and an optional reasoning effort for v1 delegation prompts. This is not a per-spawn router; see below. | +| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | +| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | -| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. Active sessions stay read-only. Cleanup is refused while Codex holds the newest/active `state_*.sqlite` locked. Quarantined files are **not** restorable from the dashboard — recover manually from `.trash//` using `manifest.json` if needed. | +| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | ### Linking to a section @@ -61,17 +61,29 @@ The **Models** switches show final Codex visibility: a routed model is on only w ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, -`injectionEffort`. On a v1 turn, opencodex injects guidance telling the parent agent which exact -model and reasoning effort to pass to `spawn_agent`. Choosing a model enables that guidance at any -parent reasoning effort; clearing the model also clears the stored effort. +`injectionEffort`. **OpenCodex multi-agent guidance** independently controls the delegation +instructions that use those values. On eligible v2 turns, that guidance tells the parent +agent which exact model and reasoning effort to pass to `spawn_agent`; clearing the model also clears +the stored effort. + +The default-off **Use as native Codex subagent defaults** switch applies the same selection to Codex's +native `[agents]` defaults on the next sync/restart when OpenCodex manages the active Codex routing. +External user-managed provider configs remain untouched. Those defaults affect newly created Codex tasks +and do not themselves cause delegation. Existing user-owned `[agents]` defaults are preserved rather +than overwritten, so they may continue to override the requested defaults. :::caution -This picker is delegation guidance for the v1 compatibility surface. On `multi_agent_v2`, the -current proxy does not append the v1 injection message, and every spawned sub-agent inherits the -parent session's model. It is not a proxy-side cross-model router. See +Neither control is a proxy-side cross-model spawn router. OpenCodex guidance asks Codex to pass +overrides to `spawn_agent`; native `[agents]` defaults apply only when Codex creates a new task after +they have been synchronized. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical v1/base/v2 behavior. ::: +The spawn override guarantee applies to the **built-in** v2 guidance text. A custom +`injectionPrompt` replaces that text entirely and must include `{{model}}` and `{{effort}}` +placeholders (and optionally `{{roster}}`) or those values will not appear in the injected +guidance. + The picker offers enabled native and routed models plus the global Codex effort ladder. The API validates the selected effort globally; Codex still validates a spawn effort against the target catalog entry. @@ -105,9 +117,9 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | | `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | | `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | -| `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. | +| `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. Worker PIDs are persisted so a crashed job recovers automatically; legacy no-PID jobs recover after ten minutes. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | -| `GET` / `PUT /api/injection-model` | Read or set the v1 delegation guidance model and optional effort. | +| `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | | `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, or remove providers. | | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 8194e3f54..f0727b0a9 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -90,6 +90,11 @@ bun run release:watch # 直近の Release ワークフロー run 出さないでください。 - `preview` — プレリリーストレイン。 +主要ランタイムを Go ネイティブポートへ移行している間、`dev` に入った変更は `dev2-go` にも +反映される必要があります。コントリビューターの手順は変わりません。これまでどおり `dev` に +PR を出してください。マージしたメンテナーがその作業を `dev2-go` にリベースし、`go/` 側で +対応が必要な部分をポートします。両方のラインに入って初めて完了とみなします。 + ポーティング PR とリベース PR を歓迎します。ある統合ラインの修正を別のラインへ運ぶこと、 古いブランチを現在の head にリベースすることは、ノイズではなく通常の貢献です。説明欄に 元のコミットを記載してください。 diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index b041abdcf..035e50075 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -112,10 +112,10 @@ opencodex は全カタログ項目の `multi_agent_version` を制御する 3 セッションから適用されます。 :::caution -v2(`multi_agent_v2`)サーフェスで生成されたサブエージェントは親セッションのモデルを継承します。ダッシュボードの -委任モデル/強度セレクターは v1 プロンプトガイダンスであり、プロキシがスポーンごとに別モデルにルーティングする機能では -ありません。正確な動作は[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を -参照してください。 +v2(`multi_agent_v2`)サーフェスでは、モデルを明示しないスポーンは親セッションのモデルを継承できます。 +OpenCodex ガイダンスは選択したモデル/強度を明示的に渡すよう Codex に指示でき、別のネイティブデフォルト設定は +sync/restart 後に既定値を提供できます。どちらもプロキシ側のスポーン単位ルーターではありません。正確な動作は +[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: ## 最上位推論段階 @@ -151,8 +151,8 @@ Codex はピッカーに表示されるカタログ項目を `priority` 昇順 ネイティブ ID または `provider/model` ID を最大 5 つ選ぶと opencodex が選択順に priority 0-4 を 付与します。残りのモデルも正確な ID で直接呼び出し可能です。 -フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** ガイダンスとは別物です。特にフィーチャー済みモデル -オーバーライドで v2 の親モデル継承ルールをバイパスできません。 +フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** 選択とは別物です。Codex が最初に提示する +オーバーライドを決めるだけで、モデルの選択や委任の開始は行いません。 ## モデル状態のリフレッシュ diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 80c4c530c..4530561a6 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -45,11 +45,27 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o API キー方式 `openai-responses` プロバイダーを指定できます。明示的な選択が失敗しても、別の 有料上流へフォールバックしません。組み込みプロバイダー id には使わず、既定の OpenAI 経路を 使う場合は省略してください。 -- **両方なし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 - Gemini、Kiro など)は既定では画像生成を提供できません。ツール自体をオフにしたい場合は Codex で +- **Google Antigravity (CCA) フォールバック:** OpenAI forward 候補も API キープロバイダーもない場合、 + `/v1/images/generations`(`/images/edits` を除く)は Antigravity **Cloud Code Assist** エンドポイントに + フォールバックし、`gemini-3.1-flash-image` モデルを使用します。OpenAI 認証の解決に失敗した場合 + (例: ChatGPT 認証情報が期限切れまたは不在)も同様にフォールバックが発火し、OpenAI 候補が全くない + 場合のみではありません。`ocx login google-antigravity` が + 必要です。OAuth トークンは CCA レジストリホストにのみ送信され、設定の `baseUrl` オーバーライドには + 送信されません。レスポンスは Codex が期待する `{created, data:[{b64_json}]}` 形式で返されます。 +- **いずれもなし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 + Gemini、Kiro など)は画像生成を提供できません。ツール自体をオフにしたい場合は Codex で `codex features disable image_generation`(`config.toml` の `[features] image_generation = false`)を 使ってください。 +ツール宣言はモデルへの Responses リクエストにも引き続き含まれます。API キー方式の Responses +プロバイダーでは、opencodex は Codex のプライベートな `image_gen` 名前空間を、上流で安全な +`image_gen__` エイリアス(例: `image_gen__imagegen`)に変換します。その利用可能な +エイリアスがクライアント宣言を置き換える場合にのみ、重複する hosted `image_generation` 宣言を +削除します。関数呼び出しは Codex に届く前に明示的な `image_gen` 名前空間へ戻され、後続の履歴を +上流へ再送するときは再びエンコードされます。これにより、名前空間を予約している、またはドットを +含む関数名を拒否する OpenAI 互換の上流でも、クライアント側の画像生成を呼び出せます。ChatGPT +forward モードは変更されず、ネイティブな Responses Lite 形式を維持します。 + `hostname` がループバックアドレスでない場合、Codex が自動生成した API 認証ヘッダーを送る必要があります。このとき専用 プロバイダーを注入します。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f1133444c..ef2dd1d54 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -80,20 +80,40 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | インストール済み `kiro-cli` ログインを優先取得。Kiro CLI のインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)と `kiro-cli login` が必要。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは Kiro CLI をインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)し、`kiro-cli login` でサインインした既存セッションを取り込みます。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | +正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 +opencodex は呼び出し元が指定した安定した `prompt_cache_key` だけを Chat Completions リクエストへ +転送し、自ら生成しません。Kimi のドキュメントでは、Code Plan のキャッシュヒット率を高めるために +安定したセッション/タスク key が必須とされています。key のないリクエストは keyless のままです。 +opt-in した上流がこのフィールドを拒否しても、opencodex はフィールドを削除して再試行したり、保存済み +設定を変更したりしません。他のプロバイダーは deny-by-default のままです。 + [ウェブダッシュボード](/ja/guides/web-dashboard/)からも OAuth を開始できます。 ### 複数の OAuth アカウント 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 -アカウント識別情報がない Kimi と Kiro はアクティブスロットを差し替え、`chatgpt` は Codex アカウントプールに別の保存場所が -あり常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 +アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。 +`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 +### Kiro 認証情報の取り込み + +Kiro のログインには Kiro CLI が必要です。`curl -fsSL https://cli.kiro.dev/install | bash` でインストールし、先に `kiro-cli login` でサインインしてください。`kiro-cli` セッションがない場合、`ocx login kiro` は貼り付けたアクセストークンまたは `KIRO_ACCESS_TOKEN` 環境変数にフォールバックします。 + +通常の `ocx login kiro` 取り込みは CLI の SQLite データベースを読み取り専用で開き、データベース、WAL、SHM を変更しません。 + +- `KIROCLI_DB_PATH` は標準外の Kiro CLI SQLite データベースを選択します。指定するデータベースは既に存在している必要があります。 +- `KIROCLI_TOKEN_KEY` は複数の曖昧なトークン行がある場合に、取り込む正確な `auth_kv` 行のキーを指定します。選択がない場合、推測せずログインに失敗します。 + +取り込んだ認証情報は `~/.opencodex/auth.json` に保存されます。**アカウントを追加**のロールバックは別処理で、以前のスナップショットを復元する際にデータベースを置き換え、現在の WAL、SHM、journal サイドカーを削除します。 + +ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、`KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` が実際の CLI ストアと異なるインポート先を指す場合、またはプライマリ CLI データベースに認識できるトークン行がない場合、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。通常の `kiro-cli` データパス上の壊れたデータベースを修復または削除し、インポート専用セレクタが設定されていれば解除してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 + ## 3. API キーカタログ opencodex v2.7.1 には組み込みプリセットが 50 個含まれています。キー方式 40、OAuth 6、ローカル 3、 diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index cc57ae65e..59dce3ccb 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル ### 委任モデルと推論強度 -ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。この値は委任ガイドを作る設定であり、プロキシがスポーンリクエストを別モデルに再ルーティングする設定ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 +ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは `multiAgentGuidanceEnabled` で別に制御されます。これらはプロキシがスポーンリクエストを別モデルに再ルーティングする規則ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 + +`syncCodexSubagentDefaults` を明示的に有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort が Codex ネイティブの `[agents]` サブエージェント既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される Codex タスクだけに適用され、設定自体が委任を発生させることはありません。既存のユーザー所有 `[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 `multiAgentGuidanceText` はリクエストに入ってきたツール一覧でサーフェスを判定します。Codex Desktop の WebSocket 経路(`responses_lite`)のようにツールがリクエストの `tools` 配列ではなく `additional_tools` input 項目として届く場合も認識します。 @@ -56,7 +58,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル - **ダッシュボード** → 最初のスタットセルで **v1**、**base**、**v2** を選択します。 - **モデル** ページ → 上部セグメントコントロールで選択します。 - 両ページとも **?** ボタンを押すとこのドキュメントに繋がるヘルプモーダルが開きます。 -- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。**ネイティブ Codex サブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart から新しい Codex タスクにも同じ選択値が適用されます。外部のユーザー管理 provider 設定は変更しません。このトグルは委任ガイダンスのトグルとは独立しています。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 選択値を Codex ネイティブのサブエージェント既定値へ同期するよう設定(モデルが必要) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # カスタムガイドプロンプトを設定({{model}}/{{effort}}/{{roster}} プレースホルダ) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` は `model`、`effort`、`prompt`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT で `effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`model` を消去すると推論強度も常に一緒に消去されます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 +`GET /api/injection-model` は `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT は部分更新です。`effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`syncCodexSubagentDefaults: true` には選択済みモデルが必要で、`model` を消去すると推論強度の消去とネイティブ既定値の同期解除も行われます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 ## 推論強度 -サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 +サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。`syncCodexSubagentDefaults` が有効で OpenCodex が有効な Codex ルーティングを管理している場合は、次回の sync または restart から新しい Codex タスクのネイティブなサブエージェント既定 effort としても使われます。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 `ultra` は Codex カタログで `max` より高い段階で自動委任の意味が加わりますが、プロバイダー wire に `ultra` という値がそのまま渡るわけではありません。Codex がクライアント境界で `ultra` を `max` に変え、opencodex がプロバイダーに合う有効な値に調整します。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index a83bad455..16a62950a 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -26,20 +26,20 @@ bun run dev:gui | 領域 | 機能 | --- | --- | | **ダッシュボード要約** | マルチエージェントモード、オンライン状態、バージョン、稼働時間、プロバイダー数、直近30日トークン合計、アクティブプロバイダーと利用可能なネイティブ/ルーティングモデルを表示します。 | -| **サブエージェント委任** | v1 委任プロンプトに入れるネイティブまたはルーティングモデルとオプションの推論強度を選びます。スポーンごとのルーターではありません。下記の説明を確認してください。 | +| **サブエージェント委任** | OpenCodex の委任ガイダンスとオプションの Codex ネイティブサブエージェント既定値で共有するネイティブ/ルーティングモデルと任意の推論強度を選びます。スポーンごとのルーターではありません。下記を参照してください。 | | **サイドカー** | ウェブ検索モデルと強度、画像説明モデルを選択します。次回リクエストから適用されます。 | | **メンテナンス** | Codex モデルカタログを再同期し、プロジェクトローカル設定のバイパス警告を確認し、latest/preview 更新を照会またはオプションのプロキシ再起動と共にインストールします。 | | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | -| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | +| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、要求された強度と(利用可能な場合は)実際に送信された強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。アダプターが reasoning パラメーターを送信した場合、詳細表示に正確な wire field も表示されます。 | | **使用量 / デバッグ** | トークン使用量の測定範囲と推移を見るか、オプションのプロバイダートランスポート/使用量抽出診断をオンにします。 | -| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中は拒否。隔離分はダッシュボードから復元不可 — 必要なら `.trash//` と `manifest.json` から手動復旧。 | +| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。 | ### セクションへのリンク @@ -56,14 +56,20 @@ bun run dev:gui ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を -保存します。v1 ターンでは opencodex が親エージェントに `spawn_agent` に渡す正確なモデルと推論 -強度を伝えるガイドを注入します。モデルを選ぶと親の現在の推論強度に関係なくこのガイドが -有効化され、モデルを消去すると保存された強度も一緒に消去されます。 +保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは +`multiAgentGuidanceEnabled` で別に制御されます。モデルを消去すると保存済み effort も消去され、 +ネイティブ既定値の同期も無効になります。 + +**Codex ネイティブサブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex +ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort がネイティブ +`[agents]` 既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される +Codex タスクだけに適用され、このオプション自体が委任を発生させることはありません。既存のユーザー所有 +`[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 :::caution -このセレクターは v1 互換サーフェス用の委任ガイドです。`multi_agent_v2` では現在プロキシは v1 注入 -メッセージを追加せず、生成されたすべてのサブエージェントが親セッションのモデルを継承します。プロキシが -スポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は +2 つのトグルは独立しています。OpenCodex の委任ガイダンスを無効にしてもネイティブ既定値の同期は +無効にならず、ネイティブ既定値の同期を有効にしても委任ガイダンスや委任そのものは有効になりません。 +どちらもプロキシがスポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は [サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: @@ -97,7 +103,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 自己更新作業を確認、実行、追跡します。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | -| `GET` / `PUT /api/injection-model` | v1 委任ガイドモデルとオプションの強度を読むか変えます。 | +| `GET` / `PUT /api/injection-model` | 委任ガイダンスのモデル/effort、ガイダンストグル、Codex ネイティブサブエージェント既定値の同期トグルを読み取りまたは変更します。 | | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、削除。 | | `GET /api/models` · `PUT /api/disabled-models` | ネイティブ/ルーティングモデル行を参照し共有 disabled model 一覧を更新します。 | diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index c71df875e..ea97ddbcf 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -45,7 +45,7 @@ interface ProviderAdapter { ## `anthropic` **対象:** Anthropic **Messages**(`/v1/messages`)。 -**認証:** `key`(`x-api-key`)または `oauth`(Bearer + `anthropic-beta`、Claude Pro/Max 用)。 +**認証:** `key`(デフォルトは `x-api-key`、または `apiKeyTransport: "bearer"` による `Authorization: Bearer`)または `oauth`(Bearer + `anthropic-beta`、Claude Pro/Max 用)。 - メッセージを Anthropic content block(text、base64 image、`tool_use`、`thinking`)に変換します。 - **Extended thinking の計算:** Anthropic は `max_tokens > thinking.budget_tokens` を要求します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index b42afd31c..f98785556 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -31,12 +31,13 @@ namespaced selected id を bare id に変えます。 | `openaiProviderTierVersion?` | `2` | 移行設定 | 単一の省略可能 OpenAI projection 完了マーカー。 | | `defaultProvider` | `string` | `"openai"` | ルーティングでより良い match が見つからなかったときに使うプロバイダー。 | | `subagentModels?` | `string[]` | `gpt-5.5`、GPT-5.6 3種、`gpt-5.4-mini` | Codex サブエージェントセレクターの先頭に表示するネイティブ slug または `provider/model` id。最大 5 つで、明示的な空配列もそのまま保存します。v2 ガイダンスのロスターは、Codex の picker-visible・v2 互換・priority 順の先頭 5 件との設定済みモデルの共通部分で、正規カタログ slug と利用可能な effort ラダーを使います。除外された項目も設定には残ります。 | -| `injectionModel?` | `string` | — | 注入される multi-agent 案内(v2 surface)に入るネイティブ/ルーティングモデル。委任案内でこのモデルを `fork_turns: "none"` とともに `spawn_agent` に渡します。 | -| `injectionEffort?` | `string` | — | 希望する `spawn_agent` reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ちます。 | +| `injectionModel?` | `string` | — | 希望するネイティブ/ルーティングのサブエージェントモデル。別の `multiAgentGuidanceEnabled` が制御する OpenCodex 作成の v2 委任ガイダンスで使われ、`syncCodexSubagentDefaults` のオプトインにより新しいタスクの Codex ネイティブ既定値にも適用できます。 | +| `injectionEffort?` | `string` | — | 希望するサブエージェント reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ち、委任ガイダンスとオプションの Codex ネイティブ既定値で使われます。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex が有効な Codex ルーティングを管理している場合、選択した `injectionModel` / `injectionEffort` を次回の sync または restart で Codex ネイティブの `[agents]` サブエージェント既定値へ適用するオプトイン設定。外部のユーザー管理 provider 設定は変更しません。新しく作成される Codex タスクだけに作用し、設定自体が委任を発生させることはありません。既存のユーザー所有対象項目は競合として上書きせず保持します。`injectionModel` が必要で、モデルを消去するとこのオプトインも解除されます。`GET/PUT /api/injection-model` の部分更新フィールドとして公開されます。 | | `effortCap?` | `string` | — | reasoning effort にリクエストごとに適用する強制上限。マルチエージェント V2 専用機能で、自身のツールリストに V2 協調 surface を持つメインターンと、`x-openai-subagent: collab_spawn` ヘッダーまたは `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"` 標識が正確に一致する spawn された子ターンに適用されます(標識のついた子は自身のツール surface と無関係に適用対象です)。通常のメインターンと V1 surface メインターンは触れず、コンパクションターンは常に上限をバイパスし、`multiAgentMode: "v1"` は上限機能全体を無効化します(ダッシュボードもパネルを隠します)。`low` から `ultra` を許可し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `subagentEffortCap?` | `string` | — | 同じ強制上限を codex-rs 標識が正確に一致する spawn された子ターンにだけ適用します: `x-openai-subagent: collab_spawn` または `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"`。それ以外の内部サブエージェントカテゴリ(レビュー、コンパクション、メモリ整理)はこの上限にかからず、`multiAgentMode: "v1"` は機能全体を無効化します。`low` から `ultra` を許可し両方の上限が設定されていればより低い値を適用し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `injectionPrompt?` | `string` | — | 注入される v2 案内本文を丸ごと差し替えるカスタムテキスト。`{{model}}`、`{{effort}}`、`{{roster}}` placeholder が置換され、発火条件はそのままです。`PUT /api/injection-model` の `prompt` キーでも設定できます。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は Codex ネイティブの `[agents]` 既定値、collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | | `disabledModels?` | `string[]` | — | Codex で隠すモデル。ルーティングされた `provider/model` id はカタログと `/v1/models` から除外します。`gpt-5.4` のような通常のネイティブ GPT slug はカタログ項目を `visibility: "hide"` に変え、通常の `/v1/models` 一覧から外します。ダッシュボードの Models ページでモデルごとに切り替えできます。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3 段階 multi-agent surface override。`"v1"` は上流 pin より優先してすべてのモデルを v1 に、`"default"` は上流 model pin(sol/terra=v2、luna=v1)に従い、`"v2"` はすべてを v2 に強制します。ダッシュボードの Models ページまたは `ocx v2 mode` で設定します。 | | `providerContextCaps?` | `Record` | `{}` | プロバイダー別の Codex 表示 context cap。既知の context window を下げるだけです。 | @@ -50,8 +51,12 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | -| `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。 | +| `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | +| `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する成功的新セッション bind 数。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` | 一時的な上流失敗が連続して起きたのち、以降の新しいセッションを別の適合 pool アカウントに failover する回数。`0` なら失敗ベースの failover をオフにします。 | | `modelCacheTtlMs?` | `number` | `300000` | プロバイダー別 `/models` キャッシュの有効期間(5 分)。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache ポリシー。オフ、5 分 ephemeral、1 時間 extended のいずれか。 | @@ -60,6 +65,15 @@ namespaced selected id を bare id に変えます。 | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 +model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 + `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の `$CODEX_HOME/config.toml` 内 `[features.multi_agent_v2]` に保存します。その table ができるように v2 を先にオンにしてください。 @@ -70,8 +84,14 @@ namespaced selected id を bare id に変えます。 :::note[Codex アカウントプール] pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 -保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは quota、cooldown、health に -応じて自動ルーティングされる場合があります。 +保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に +応じて自動ルーティングされます。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +一時停止するとそのアカウントの thread affinity map も消去されます。処理中のリクエストは取得済み credential を維持しますが、以降のターンは再ルーティングされ、一時停止中のアカウントは再利用できません。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は credential がある適格アカウントだけを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。credential がないアカウントや、quota が不明、または更新に失敗したアカウントは変更しません。 + +**rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: ### 管理型レコード形式 @@ -135,6 +155,7 @@ token の代わりに使えます。すべての候補は timing side channel | `responsesPath?` | `string` | `key` 認証の `openai-responses` リクエストに使う任意の相対 resource path。`/` で始め、URL scheme、query、fragment を含めてはいけません。省略時は従来の `/v1/responses` URL 構築を維持します。 | | `disabled?` | `boolean` | 設定はディスクに残すがルーティングとモデル/カタログ一覧から除外します。 | | `apiKey?` | `string` | API キーまたはリクエスト時に解釈する `${ENV_VAR}` / `$ENV_VAR` 参照。 | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API キーのヘッダー方式。デフォルトはネイティブの `x-api-key` です。`Authorization: Bearer ` が必要な互換 gateway では `"bearer"` を設定します。key 認証の `anthropic` プロバイダーでのみ有効です。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 複数キーを納める pool。`apiKey` はアクティブ項目を反映します。各項目には `id`、`key`、選択 `label`、選択数値 `addedAt` があります。 | | `defaultModel?` | `string` | 明示的なモデルなしでこのプロバイダーを選んだときに使うモデル。 | | `models?` | `string[]` | seed/fallback モデル一覧。`liveModels` が `false` ならここにあるモデルだけが発見されます。 | diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 2a808bfdd..17a2d6520 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -89,6 +89,11 @@ bun run release:watch # 가장 최근 Release workflow run 감시 올리지 않습니다. - `preview` — 프리릴리즈 트레인. +주 런타임을 Go 네이티브 포트로 옮기는 동안 `dev`에 들어간 변경은 `dev2-go`에도 그대로 +올라가야 합니다. 기여자가 할 일은 달라지지 않습니다. 지금처럼 `dev`로 PR을 올리면 됩니다. +머지한 메인테이너가 그 작업을 `dev2-go` 위로 리베이스하고 `go/` 쪽에 대응이 필요한 부분을 +포팅하며, 두 라인에 모두 반영되어야 작업이 끝난 것으로 봅니다. + 포팅 PR과 리베이스 PR을 환영합니다. 한 통합선의 수정을 다른 쪽으로 옮기거나, 오래된 브랜치를 현재 head 위로 리베이스하는 것은 잡음이 아니라 정상적인 기여입니다. 설명란에 출처 커밋을 적어주세요. diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 3dd14431c..f255a1a2c 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -114,10 +114,10 @@ Dashboard나 Models 페이지, `ocx v2 mode v1|default|v2`, 또는 세션부터 적용됩니다. :::caution -v2(`multi_agent_v2`) 서피스에서 생성된 서브에이전트는 부모 세션의 모델을 상속합니다. 대시보드의 -위임 모델/강도 선택기는 v1 프롬프트 안내이며, 프록시가 스폰마다 다른 모델로 라우팅하는 기능이 -아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 -참고하세요. +v2(`multi_agent_v2`) 서피스에서는 모델을 명시하지 않은 스폰이 부모 세션의 모델을 상속할 수 있습니다. +OpenCodex 안내는 선택한 모델/강도를 명시적으로 전달하도록 Codex에 요청할 수 있고, 별도의 네이티브 기본값 +옵트인은 sync/restart 후 기본값을 제공할 수 있습니다. 어느 쪽도 프록시가 스폰마다 모델을 정하는 라우터는 +아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: ## 최상위 reasoning 단계 @@ -153,8 +153,8 @@ Codex는 선택기에 표시되는 카탈로그 항목을 `priority` 오름차 네이티브 id 또는 `provider/model` id를 최대 5개 고르면 opencodex가 선택 순서대로 priority 0-4를 부여합니다. 나머지 모델도 정확한 id로 직접 호출할 수 있습니다. -featured 모델 목록은 Dashboard의 **Sub-agent delegation** 안내와 별개입니다. 특히 featured 모델 -override로 v2의 부모 모델 상속 규칙을 우회할 수 없습니다. +featured 모델 목록은 Dashboard의 **Sub-agent delegation** 선택과 별개입니다. Codex가 먼저 보여 줄 +override를 정할 뿐, 모델을 선택하거나 위임을 시작하지는 않습니다. ## 모델 상태 새로고침 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index bcc3c1977..3dd928e49 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -44,11 +44,27 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco - **명시적 커스텀 프로바이더:** `images.provider`에 OpenAI Images API를 구현한 커스텀 API-key `openai-responses` 프로바이더를 지정할 수 있습니다. 명시적 선택이 실패해도 다른 유료 업스트림으로 fallback하지 않습니다. 내장 프로바이더 id에는 사용하지 말고, 기본 OpenAI 경로를 쓰려면 생략하세요. -- **둘 다 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, - Gemini, Kiro 등)는 기본적으로 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 +- **Google Antigravity (CCA) 폴백:** OpenAI forward 후보와 API key 프로바이더 모두 없을 때, + `/v1/images/generations`(`/images/edits` 제외)가 Antigravity **Cloud Code Assist** 엔드포인트로 + 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. OpenAI 인증 해석이 실패할 때(예: ChatGPT 자격 + 증명이 만료되거나 누락된 경우)에도 동일하게 폴백이 트리거되며, OpenAI 후보가 아예 없을 때만 + 발생하는 것은 아닙니다. `ocx login google-antigravity`가 필요합니다. + OAuth 토큰은 CCA 레지스트리 호스트로만 전송되며 설정의 `baseUrl` 재정의로는 가지 않습니다. + 응답은 Codex가 기대하는 `{created, data:[{b64_json}]}` 형식으로 반환됩니다. +- **모두 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, + Gemini, Kiro 등)는 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 `codex features disable image_generation`(`config.toml`의 `[features] image_generation = false`)을 사용하세요. +도구 선언은 모델의 Responses 요청에도 계속 포함됩니다. API key 방식 Responses 프로바이더에서는 +opencodex가 Codex의 비공개 `image_gen` namespace를 업스트림에서 안전한 +`image_gen__` alias(예: `image_gen__imagegen`)로 변환합니다. 이 사용 가능한 alias가 +클라이언트 선언을 대체할 때만 중복된 hosted `image_generation` 선언을 제거합니다. 함수 호출은 +Codex에 도달하기 전에 명시적인 `image_gen` namespace로 복원되고, 이후 기록을 업스트림으로 +replay할 때 다시 인코딩됩니다. 따라서 namespace를 예약하거나 점이 포함된 함수 이름을 거부하는 +OpenAI 호환 업스트림에서도 클라이언트 측 이미지 생성을 호출할 수 있습니다. ChatGPT forward +모드는 변경되지 않으며 네이티브 Responses Lite 형식을 유지합니다. + `hostname`이 loopback 주소가 아니면 Codex가 자동 생성된 API 인증 헤더를 보내야 합니다. 이때는 전용 프로바이더를 주입합니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index c2a3f2480..208839d81 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -80,20 +80,40 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 설치된 `kiro-cli` 로그인을 먼저 가져옵니다. Kiro CLI 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)와 `kiro-cli login`이 필요합니다. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 Kiro CLI를 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)하고 `kiro-cli login`으로 로그인한 기존 세션을 가져옵니다. **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | +정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 +호출자가 제공한 안정적인 `prompt_cache_key`만 Chat Completions 요청으로 전달하며 직접 생성하지 +않습니다. Kimi 문서는 Code Plan 캐시 적중률을 높이기 위해 안정적인 세션/작업 key가 필요하다고 +명시합니다. key가 없는 요청은 keyless 상태로 유지됩니다. opt-in한 업스트림이 이 필드를 거부해도 +opencodex는 필드를 제거해 재시도하거나 저장된 설정을 변경하지 않습니다. 다른 프로바이더는 +deny-by-default 상태로 유지됩니다. + [웹 대시보드](/ko/guides/web-dashboard/)에서도 OAuth를 시작할 수 있습니다. ### 여러 OAuth 계정 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. -계정 식별 정보가 없는 Kimi와 Kiro는 활성 슬롯을 교체하며, `chatgpt`는 Codex 계정 풀에 별도 저장소가 -있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, +계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다. +`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. +### Kiro 자격 증명 가져오기 + +Kiro 로그인에는 Kiro CLI가 필요합니다. `curl -fsSL https://cli.kiro.dev/install | bash`로 설치하고 먼저 `kiro-cli login`으로 로그인하세요. `kiro-cli` 세션이 없으면 `ocx login kiro`는 붙여 넣은 액세스 토큰이나 `KIRO_ACCESS_TOKEN` 환경 변수로 폴백합니다. + +일반 `ocx login kiro` 가져오기는 CLI SQLite 데이터베이스를 읽기 전용으로 열며 데이터베이스, WAL, SHM을 수정하지 않습니다. + +- `KIROCLI_DB_PATH`는 비표준 Kiro CLI SQLite 데이터베이스를 선택하며, 지정한 데이터베이스는 이미 존재해야 합니다. +- `KIROCLI_TOKEN_KEY`는 모호한 토큰 행이 여러 개일 때 가져올 정확한 `auth_kv` 행의 키를 선택합니다. 선택값이 없으면 추측하지 않고 로그인이 실패합니다. + +가져온 자격 증명은 `~/.opencodex/auth.json`에 저장됩니다. **계정 추가** 롤백은 별도 절차로, 이전 스냅샷을 복원할 때 데이터베이스를 교체하고 현재 WAL, SHM, journal 사이드카를 제거합니다. + +롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호), `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE`이 실제 CLI 저장소와 다른 가져오기 경로를 가리키는 경우, 또는 기본 CLI 데이터베이스에 인식 가능한 토큰 행이 없는 경우 **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 일반 `kiro-cli` 데이터 경로의 손상된 데이터베이스를 수리하거나 제거하고, 가져오기 전용 선택자가 설정돼 있으면 해제한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. + ## 3. API 키 카탈로그 opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방식 40개, OAuth 6개, 로컬 3개, diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 733b9bd59..5623a6ae6 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 ### 위임 모델과 추론 강도 -대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 이 값은 위임 가이드를 만드는 설정이지, 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 설정이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. +대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되며, 이 가이드는 `multiAgentGuidanceEnabled`가 별도로 제어합니다. 이 값은 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 규칙이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. + +`syncCodexSubagentDefaults`를 명시적으로 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 경우 다음 sync 또는 restart에서 선택한 모델과 강도를 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용합니다. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되며 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 보존하므로, 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. `multiAgentGuidanceText`는 요청에 들어온 툴 목록으로 서피스를 판별합니다. Codex Desktop의 WebSocket 경로(`responses_lite`)처럼 툴이 요청의 `tools` 배열 대신 `additional_tools` input 항목으로 도착하는 경우도 인식합니다. @@ -56,7 +58,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 - **대시보드** → 첫 번째 스탯 셀에서 **v1**, **base**, **v2**를 선택합니다. - **모델** 페이지 → 상단 세그먼트 컨트롤에서 선택합니다. - 두 페이지 모두 **?** 버튼을 누르면 이 문서로 연결되는 도움말 모달이 열립니다. -- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. **Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리할 때 다음 sync 또는 restart부터 새 Codex task에도 같은 선택값을 적용합니다. 외부 사용자 관리 provider 설정은 그대로 유지합니다. 이 토글은 위임 가이드 토글과 별개입니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 선택값을 Codex 네이티브 서브에이전트 기본값으로 동기화하도록 설정(모델 필요) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 커스텀 가이드 프롬프트 설정 ({{model}}/{{effort}}/{{roster}} 플레이스홀더) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model`은 `model`, `effort`, `prompt`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT에서 `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `model`을 지우면 추론 강도도 항상 함께 지워집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. +`GET /api/injection-model`은 `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT은 부분 업데이트입니다. `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `syncCodexSubagentDefaults: true`에는 선택된 모델이 필요하며, `model`을 지우면 추론 강도와 네이티브 기본값 동기화도 함께 꺼집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. ## 추론 강도 -서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. +서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. `syncCodexSubagentDefaults`를 켜고 OpenCodex가 활성 Codex 라우팅을 관리하는 경우에는 다음 sync 또는 restart부터 새 Codex task의 네이티브 서브에이전트 기본 강도로도 사용됩니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. `ultra`는 Codex 카탈로그에서 `max`보다 높은 단계이며 자동 위임 의미가 더해지지만, 프로바이더 와이어에는 `ultra`라는 값이 그대로 전달되지 않습니다. Codex가 클라이언트 경계에서 `ultra`를 `max`로 바꾸고, opencodex가 프로바이더에 맞는 유효한 값으로 조정합니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index c4bb2e904..b5ea350c9 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -26,20 +26,20 @@ bun run dev:gui | 영역 | 기능 | | --- | --- | | **Dashboard 요약** | Multi-agent 모드, 온라인 상태, 버전, 가동 시간, 프로바이더 수, 최근 30일 토큰 합계, 활성 프로바이더와 사용 가능한 네이티브/라우팅 모델을 보여줍니다. | -| **Sub-agent delegation** | v1 위임 프롬프트에 넣을 네이티브 또는 라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | +| **Sub-agent delegation** | OpenCodex 위임 가이드와 선택적인 Codex 네이티브 서브에이전트 기본값이 함께 사용할 네이티브/라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | | **사이드카** | 웹 검색 모델과 강도, 이미지 설명 모델을 선택합니다. 다음 요청부터 적용됩니다. | | **Maintenance** | Codex 모델 카탈로그를 다시 동기화하고, 프로젝트 로컬 설정의 우회 경고를 확인하고, latest/preview 업데이트를 조회하거나 선택적 프록시 재시작과 함께 설치합니다. | | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | -| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | +| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청한 강도와 (사용 가능한 경우) 실제 전송 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. 어댑터가 reasoning 매개변수를 전송한 경우 상세 보기에 정확한 wire field도 표시됩니다. 클라이언트가 보낸 불투명 대화/세션 id로 필터하면 현재 로드된 Logs 링의 토큰·추정 정가 합계를 볼 수 있습니다. | | **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. | -| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 거절합니다. 격리 파일은 대시보드에서 복원할 수 없습니다 — 필요하면 `.trash//`와 `manifest.json`으로 수동 복구하세요. | +| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). | ### 섹션으로 바로 가기 @@ -56,13 +56,19 @@ bun run dev:gui ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 -저장합니다. v1 턴에서는 opencodex가 부모 에이전트에게 `spawn_agent`에 넘길 정확한 모델과 reasoning -강도를 알려 주는 안내를 주입합니다. 모델을 고르면 부모의 현재 reasoning 강도와 관계없이 이 안내가 -활성화되며, 모델을 지우면 저장된 강도도 함께 지워집니다. +저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되고, 이 가이드는 +`multiAgentGuidanceEnabled`가 별도로 제어합니다. 모델을 지우면 저장된 강도도 지워지고 네이티브 +기본값 동기화도 꺼집니다. + +**Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 +경우 다음 sync 또는 restart에서 선택한 모델과 강도를 네이티브 `[agents]` 기본값으로 적용합니다. 외부 +사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되고, +이 옵션 자체가 위임을 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 +보존하므로 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. :::caution -이 선택기는 v1 호환 서피스용 위임 안내입니다. `multi_agent_v2`에서는 현재 프록시가 v1 주입 -메시지를 덧붙이지 않으며, 생성된 모든 서브에이전트가 부모 세션의 모델을 상속합니다. 프록시가 +두 토글은 서로 독립적입니다. OpenCodex 위임 가이드를 꺼도 네이티브 기본값 동기화는 꺼지지 않고, +네이티브 기본값 동기화를 켜도 위임 가이드를 켜거나 위임을 발생시키지 않습니다. 어느 쪽도 프록시가 스폰마다 모델을 바꾸는 라우터가 아닙니다. v1/base/v2의 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: @@ -99,7 +105,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 자체 업데이트 작업을 확인, 실행, 추적합니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | -| `GET` / `PUT /api/injection-model` | v1 위임 안내 모델과 선택적 강도를 읽거나 바꿉니다. | +| `GET` / `PUT /api/injection-model` | 위임 가이드의 모델/강도, 가이드 토글, Codex 네이티브 서브에이전트 기본값 동기화 토글을 읽거나 바꿉니다. | | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 제거. | | `GET /api/models` · `PUT /api/disabled-models` | 네이티브/라우팅 모델 행을 조회하고 공용 disabled model 목록을 갱신합니다. | diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 9610162ae..ddfe86864 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -54,7 +54,7 @@ interface ProviderAdapter { ## `anthropic` **대상:** Anthropic **Messages**(`/v1/messages`). -**인증:** `key`(`x-api-key`) 또는 `oauth`(Bearer + `anthropic-beta`, Claude Pro/Max용). +**인증:** `key`(기본 `x-api-key`, 또는 `apiKeyTransport: "bearer"` 설정 시 `Authorization: Bearer`) 또는 `oauth`(Bearer + `anthropic-beta`, Claude Pro/Max용). - 메시지를 Anthropic content block(text, base64 image, `tool_use`, `thinking`)으로 변환합니다. - **Extended thinking 계산:** Anthropic은 `max_tokens > thinking.budget_tokens`를 요구합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index ba7b7c5ec..c3fa6f50c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -32,12 +32,13 @@ namespaced selected id를 bare id로 바꿉니다. | `openaiProviderTierVersion?` | `2` | migration 설정 | 단일 옵션형 OpenAI projection 완료 마커. | | `defaultProvider` | `string` | `"openai"` | 라우팅에서 더 나은 match를 찾지 못했을 때 쓸 프로바이더. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 3종, `gpt-5.4-mini` | Codex 서브에이전트 선택기 앞쪽에 표시할 네이티브 slug 또는 `provider/model` id. 최대 5개이며, 명시적인 빈 배열도 그대로 보존합니다. v2 가이던스 로스터는 설정 목록과 Codex의 picker-visible·v2 호환·priority 순 상위 5개의 교집합이며 정규 카탈로그 slug와 사용 가능한 effort 사다리를 씁니다. 제외된 항목도 설정에는 남습니다. | -| `injectionModel?` | `string` | — | 주입되는 multi-agent 안내(v2 표면)에 들어갈 네이티브/라우팅 모델. 위임 안내에서 이 모델을 `fork_turns: "none"`과 함께 `spawn_agent`에 넘기게 합니다. | -| `injectionEffort?` | `string` | — | 선호하는 `spawn_agent` reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있습니다. | +| `injectionModel?` | `string` | — | 선호하는 네이티브/라우팅 서브에이전트 모델. 별도 `multiAgentGuidanceEnabled`가 제어하는 OpenCodex 작성 v2 위임 가이드에서 사용하며, `syncCodexSubagentDefaults`를 선택하면 새 task의 Codex 네이티브 기본값으로도 적용할 수 있습니다. | +| `injectionEffort?` | `string` | — | 선호하는 서브에이전트 reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있으며, 위임 가이드와 선택적인 Codex 네이티브 기본값에서 사용합니다. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex가 활성 Codex 라우팅을 관리할 때 선택한 `injectionModel`/`injectionEffort`를 다음 sync 또는 restart에서 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용하는 선택 기능. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 새로 생성되는 Codex task에만 적용하고 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 대상 항목은 충돌로 취급해 덮어쓰지 않고 보존합니다. `injectionModel`이 필요하며 모델을 지우면 이 옵션도 꺼집니다. `GET/PUT /api/injection-model`의 부분 업데이트 필드로 제공됩니다. | | `effortCap?` | `string` | — | reasoning effort에 요청별로 적용하는 강제 상한입니다. 멀티 에이전트 V2 전용 기능으로, 자체 도구 목록에 V2 협업 표면이 있는 메인 턴과, `x-openai-subagent: collab_spawn` 헤더 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"` 표식이 정확히 일치하는 스폰된 자식 턴에 적용됩니다(표식이 붙은 자식은 자체 도구 표면과 무관하게 적용 대상입니다). 일반 메인 턴과 V1 표면 메인 턴은 건드리지 않고, 컴팩션 턴은 항상 상한을 우회하며, `multiAgentMode: "v1"`은 상한 기능 전체를 비활성화합니다(대시보드도 패널을 숨깁니다). `low`부터 `ultra`까지 허용하며 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `subagentEffortCap?` | `string` | — | 같은 강제 상한을 codex-rs 표식이 정확히 일치하는 스폰된 자식 턴에만 적용합니다: `x-openai-subagent: collab_spawn` 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"`. 그 외 내부 서브에이전트 범주(리뷰, 컴팩션, 메모리 정리)는 이 상한에 걸리지 않으며, `multiAgentMode: "v1"`은 기능 전체를 비활성화합니다. `low`부터 `ultra`까지 허용하며 두 상한이 모두 설정되면 더 낮은 값이 적용되고, 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `injectionPrompt?` | `string` | — | 주입되는 v2 안내 본문을 통째로 교체하는 커스텀 텍스트. `{{model}}`, `{{effort}}`, `{{roster}}` 플레이스홀더가 치환되며 발화 조건은 그대로입니다. `PUT /api/injection-model`의 `prompt` 키로도 설정할 수 있습니다. | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 Codex 네이티브 `[agents]` 기본값, collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | | `disabledModels?` | `string[]` | — | Codex에서 숨길 모델. 라우팅된 `provider/model` id는 카탈로그와 `/v1/models`에서 제외합니다. `gpt-5.4` 같은 일반 네이티브 GPT slug는 카탈로그 항목을 `visibility: "hide"`로 바꾸고 일반 `/v1/models` 목록에서 뺍니다. 대시보드 Models 페이지에서 모델별로 전환할 수 있습니다. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3단계 multi-agent surface override. `"v1"`은 업스트림 pin보다 우선해 모든 모델을 v1로, `"default"`는 업스트림 model pin(sol/terra=v2, luna=v1)을 따르고, `"v2"`는 모두 v2로 강제합니다. 대시보드 Models 페이지나 `ocx v2 mode`에서 설정합니다. | | `providerContextCaps?` | `Record` | `{}` | 프로바이더별 Codex 표시 context cap. 알려진 context window를 낮추기만 합니다. | @@ -51,8 +52,12 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | -| `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. | +| `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | +| `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 성공적 새 세션 bind 수. 범위 1–100. `accountPoolStrategy`가 `round-robin`일 때만 적용. | | `upstreamFailoverThreshold?` | `number` | `3` | 일시적인 업스트림 실패가 연속으로 발생한 뒤, 이후 새 세션을 다른 적합한 pool 계정으로 failover할 횟수. `0`이면 실패 기반 failover를 끕니다. | | `modelCacheTtlMs?` | `number` | `300000` | 프로바이더별 `/models` 캐시의 유효 기간(5분). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache 정책. 끔, 5분 ephemeral, 1시간 extended 중 하나입니다. | @@ -61,6 +66,15 @@ namespaced selected id를 bare id로 바꿉니다. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | | `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker +entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. + `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]`에 저장합니다. 해당 table이 생기도록 v2를 @@ -72,8 +86,18 @@ namespaced selected id를 bare id로 바꿉니다. :::note[Codex 계정 풀] pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 -보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 quota, cooldown, health에 따라 -자동 라우팅될 수 있습니다. +보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, +cooldown, health에 따라 자동 라우팅됩니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +일시 중지는 해당 계정의 thread affinity map도 지웁니다. 진행 중인 요청은 이미 확보한 credential을 유지하지만, 이후 턴은 다시 라우팅되며 일시 중지된 계정은 재사용할 수 없습니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 credential이 있는 적격 계정만 먼저 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. credential이 없는 계정과 quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. + +**rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 +최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 +성공 bind 수; `fill-first` — 활성 계정을 cooldown, 재인증 또는 threshold까지 소진(알 수 없는 usage는 +강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. rotation은 provider enforcement를 우회하지 +않습니다 — 다계정 사용은 ToS 위반일 수 있습니다. ::: ### 관리형 레코드 형태 @@ -138,6 +162,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | `responsesPath?` | `string` | `key` 인증 `openai-responses` 요청에 사용할 선택적 상대 resource path. `/`로 시작해야 하며 URL scheme, query, fragment를 포함할 수 없습니다. 생략하면 기존 `/v1/responses` URL 구성을 유지합니다. | | `disabled?` | `boolean` | 설정은 디스크에 남기되 라우팅과 모델/카탈로그 목록에서 제외합니다. | | `apiKey?` | `string` | API 키 또는 요청 시점에 해석할 `${ENV_VAR}` / `$ENV_VAR` 참조. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API 키 헤더 방식입니다. 기본값은 네이티브 `x-api-key`이며, `Authorization: Bearer `를 요구하는 호환 gateway에는 `"bearer"`를 설정합니다. key 인증 `anthropic` 프로바이더에서만 유효합니다. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 여러 키를 담는 pool. `apiKey`는 활성 항목을 반영합니다. 각 항목에는 `id`, `key`, 선택 `label`, 선택 숫자 `addedAt`이 있습니다. | | `defaultModel?` | `string` | 명시적인 모델 없이 이 프로바이더를 선택했을 때 쓸 모델. | | `models?` | `string[]` | seed/fallback 모델 목록. `liveModels`가 `false`이면 여기 있는 모델만 발견됩니다. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1cce67679..25cd3b962 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -54,7 +54,7 @@ streams the response back **untranslated**. ## `anthropic` **Targets:** Anthropic **Messages** (`/v1/messages`). -**Auth:** `key` (`x-api-key`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). +**Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). - Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`). - **Extended thinking math:** Anthropic requires `max_tokens > thinking.budget_tokens`. The adapter @@ -79,6 +79,15 @@ streams the response back **untranslated**. `functionDeclarations`. Data-URL images → `inline_data`. - Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real `thoughtSignature` values so reasoning continuity survives later turns. +- **Inline image output:** when the model is one of the explicit image-capable chat IDs + (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or + `gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. + Standalone media-generation IDs such as `gemini-3-pro-image` are not included. Returned + `inlineData` parts are materialized under the configured OpenCodex `artifacts/` directory and + surfaced as markdown image links to the authenticated opaque route + `/v1/opencodex/artifacts/` (not `file:` URIs or host filesystem paths). Each image is capped + at 50 MB and each response at 100 MB of decoded data; malformed base64 payloads are rejected. + Artifacts are pruned automatically when the count exceeds 200 files. ## `kiro` diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 010f4b6bf..59440bcb2 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -142,14 +142,21 @@ opencodex local config only if all restore steps succeeded. `remove` is an alias ## Models & Codex -### `ocx sync` +### `ocx sync [--restart-codex]` Fetch the live model list from every configured provider and re-inject the merged catalog into Codex. Run it after adding a provider or to refresh available models. -### `ocx sync-cache` +If long-lived Codex `app-server` processes are still running, `ocx sync` warns that they may keep +serving the previous in-memory model list even though `opencodex-catalog.json` / `models_cache.json` +were updated. Pass `--restart-codex` to send `SIGTERM` only to matching `codex … app-server` and +`codex-code-mode-host` processes owned by the current user (active turns may be interrupted). Broad +`pkill -f codex` matching is intentionally avoided. -Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. +### `ocx sync-cache [--restart-codex]` + +Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The +same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx sync` apply. ### `ocx v2 [subcommand]` @@ -530,3 +537,8 @@ Two dispatch targets are intentionally omitted from normal help: `__refresh-vers refreshes the update-notification cache in a detached process, and `__gui-update-worker [latest|preview] [restart]` runs a dashboard update job. They are implementation details, not stable user-facing commands. + +The dashboard persists the detached update worker PID and automatically recovers an active job if +that worker is no longer alive. Active records written by older releases without a PID are treated +as stale after ten minutes. A live worker remains protected from concurrent updates even if the +update takes longer than that window. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b56ac6fce..700cfd5d7 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -36,12 +36,13 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | | `defaultProvider` | `string` | `"openai"` | Provider used when routing finds no better match. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 trio, `gpt-5.4-mini` | Up to 5 native slugs or `provider/model` ids featured first in Codex's subagent picker. The v2 guidance roster is the configured intersection of Codex's picker-visible, v2-compatible, priority-sorted first five, using canonical catalog slugs and available effort ladders; excluded entries remain configured. An explicit empty list is preserved. | -| `injectionModel?` | `string` | — | Preferred native or routed model named in the injected multi-agent guidance (v2 surface); delegation is told to pass this exact model to `spawn_agent` with `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Preferred `spawn_agent` reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`. | +| `injectionModel?` | `string` | — | Preferred native or routed sub-agent model. OpenCodex-authored v2 guidance tells delegation to pass this exact model to `spawn_agent` with `fork_turns: "none"`; the separate `syncCodexSubagentDefaults` opt-in can also apply it as Codex's native default for new tasks. | +| `injectionEffort?` | `string` | — | Preferred sub-agent reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`; used by delegation guidance and, when opted in, the native Codex subagent default. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt in to applying `injectionModel` and optional `injectionEffort` as native Codex `[agents]` defaults during sync/restart when OpenCodex manages the active Codex routing. External user-managed provider configs remain untouched. The defaults affect newly created Codex tasks and do not themselves cause delegation. Unmarked user-owned target entries are preserved as conflicts instead of being overwritten and remain authoritative. Requires `injectionModel`; clearing the model clears this opt-in. Exposed by `GET/PUT /api/injection-model` as a partial-update field. | | `effortCap?` | `string` | — | Hard per-request ceiling for reasoning effort. A multi-agent V2 feature: it applies to main turns whose own tool list carries the V2 collab surface, plus spawned-child turns marked with exactly `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata` (marked children qualify regardless of their own tool surface). Plain and V1-surface main turns are untouched, compaction turns always bypass caps, and `multiAgentMode: "v1"` disables caps entirely (the Dashboard hides the panel). Accepts `low` through `ultra`; caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | The same hard ceiling, applied only to spawned-child turns identified by codex-rs markers matched exactly: `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata`. Other internal sub-agent categories (review, compaction, memory consolidation) never trip this cap, and `multiAgentMode: "v1"` disables it entirely. Accepts `low` through `ultra`; when both caps are set, the lower one wins, and caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Custom override for the injected v2 guidance body. Replaces the built-in text; `{{model}}`, `{{effort}}`, and `{{roster}}` placeholders are substituted. Firing gates are unchanged. Settable via `PUT /api/injection-model` (`prompt` key). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing native Codex `[agents]` defaults, the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | | `disabledModels?` | `string[]` | — | Models **hidden** from Codex's catalog and `/v1/models` (not blocked at the proxy). Routed `provider/model` ids are excluded from listings; bare native GPT slugs (e.g. `gpt-5.4`) flip their catalog entry to `visibility: "hide"` and drop from the bare `/v1/models` list. Exact model ids remain directly callable. Toggleable per model from the dashboard Models page. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3-state multi-agent surface override. `"v1"` forces all models to the v1 surface (overrides upstream pins); `"default"` respects upstream model pins (sol/terra=v2, luna=v1); `"v2"` forces all models to v2. Settable from the dashboard Models page or `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers known context windows. | @@ -50,13 +51,18 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `connectTimeoutMs?` | `number` | `200000` | Per-attempt deadline for DNS/TCP/TLS and final response headers only; it ends before response-body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | | `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. | +| `storageCleanupPolicy?` | `StorageCleanupPolicy` | unset / `enabled: false` | **Opt-in** archived auto-cleanup (issue #42 Phase 3). Default **OFF** — never enabled implicitly. When enabled, runs on `schedule` (`startup` / `daily` / `weekly` / `manual`) once archived bytes exceed `trigger.archivedBytesOver`, selecting oldest archives toward `target` (`reduceToBytes` or `removeOldestPercent`) in `mode` (`quarantine` default, or explicit `permanent`). Persists `lastRun` / `nextRun`. Configure via Storage page or `GET`/`PUT /api/storage/cleanup-policy`; `POST /api/storage/cleanup-policy/run` for manual. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Managed by the dashboard; entry fields are listed below. | | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. `false` makes `ocx ensure` a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | -| `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | +| `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | +| `accountPoolStickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only applies when `accountPoolStrategy` is `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | @@ -66,6 +72,15 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object +names are rejected. Each value is either a valid pool-account id (never the internal `__main__`) or +`"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are +checked case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, +and configured pool ids or selector targets also cannot reuse a selector. Keep raw +account ids and emails private—the selector is the public name. In this foundation layer the map is +inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. + `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding `max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's @@ -97,8 +112,59 @@ also force the same native-provider recovery with `ocx recover-history --legacy- :::note[Codex account pool] Use the dashboard's **Codex Auth** page to add pool accounts and refresh quotas. The config stores non-secret account metadata only; access and refresh tokens are kept in the hardened Codex account -credential store. Existing thread ids keep account affinity, while new sessions can auto-route based -on quota, cooldown, and health. +credential store. Existing thread ids keep account affinity, while new sessions auto-route based on +`accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool +account is retried once on an eligible alternate account in the same request (so Codex CLI does not +stall on a depleted primary while another account still has quota). +Pause keeps an account and its quota metadata visible, but excludes it from automatic switching, +retry/failover selection, cooldown recovery probes, and manual activation. Pausing also clears that +account's thread-affinity map: in-flight requests keep their captured credentials, but subsequent +turns are re-routed and cannot reuse the paused account. The exclusion survives +restarts; if every account is paused, Pool routing fails instead of silently selecting one. +**Pause exhausted** first refreshes eligible accounts that have credentials available and pauses +only those whose relevant quota window is freshly confirmed at 100%; accounts without credentials +and unknown or failed quota refreshes are left unchanged. +::: + +**Rotation strategies** (new sessions only; bound threads are unchanged): + +| Strategy | Behaviour | +| --- | --- | +| `quota` (default) | When the active account's known usage crosses `autoSwitchThreshold`, pick the lowest-usage eligible account across 5h, weekly, and 30d windows. `autoSwitchThreshold: 0` disables quota-based picking. | +| `round-robin` | Even spread across eligible accounts. `accountPoolStickyLimit` (default `1`, range 1–100) keeps that many successful new-session binds on one pick before advancing. | +| `fill-first` | Drain the active account until cooldown, reauthentication, or (when set) `autoSwitchThreshold`; unknown usage does not force a switch. Then advance to the next eligible account in stable sorted order. | + +Rotation strategies do not protect against provider enforcement — multi-account use may violate +provider terms of service. + +### anthropicAccountPool (experimental) + +Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json` +(issue [#294](https://github.com/lidge-jun/opencodex/issues/294)). **Default off.** This is +experimental and not battle-tested — enable only if you accept the risk that Anthropic may +restrict accounts that look like automated multi-account rotation. Accounts under the same +organization can share quota; pooling those will not help. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `anthropicAccountPool.enabled?` | `boolean` | `false` | When true, sticky session affinity + 429 cooldown failover across eligible Anthropic OAuth accounts. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For **new** sessions only: if the active account's **known** cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. Unknown usage does not force a switch. `0` disables quota-based picking (affinity + active only). Also used as the drain threshold for `fill-first`. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy when the pool is enabled. Same round-robin/fill-first semantics as `accountPoolStrategy`; `quota` uses 5-hour bars only (not Codex multi-window scoring). Applies to **new** sessions only. | +| `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only when `strategy` is `round-robin`. | + +Reliability contract when enabled: + +- A provider **429** records cooldown from `Retry-After` (capped) or a default backoff, clears + that account's affinities, and may rotate within the request (bounded attempts). +- Affinity maps are **process-local** (lost on restart) and size-bounded. +- Credential **401/403** failures mark `needsReauth` and exclude the account until login is fixed. +- When all eligible accounts are cooling, clients receive **429** with `Retry-After` when known — + not an authentication error. + +Toggle and warning also appear on **Providers → anthropic → Accounts** in the GUI. +:::caution[Experimental] +Leave this disabled unless you understand Anthropic account policy risk. Prefer manual +`ocx account use anthropic ` switching when unsure. ::: ### claudeCode (OcxClaudeCodeConfig) @@ -167,6 +233,39 @@ Binding to `0.0.0.0` exposes your proxy — and all configured provider credenti network. Only do this on trusted networks, and always set a strong `OPENCODEX_API_AUTH_TOKEN`. ::: +### SSH port forwarding + +You do not need a non-loopback bind to use a proxy on another machine. Forward the port +over SSH and leave `hostname` at its `127.0.0.1` default: + +```bash +ssh -L 20100:localhost:10100 you@remote +``` + +The local port does not have to match the remote one. opencodex treats any request whose +`Host` resolves to `localhost`, `127.0.0.1`, or `::1` as loopback regardless of port, so +`http://localhost:20100/v1` works for Codex CLI, Claude Code, the dashboard, and `curl`. + +Point the client at the forwarded port yourself — `ocx` only ever writes `127.0.0.1` with the +local default port into client config, so a forwarded setup needs the base URL set by hand. + +Provider OAuth login is the one flow a single forward does not cover: the login callback +listens on a fixed port on the *remote* machine. Either run `ocx login ` there, or +forward that port too: + +```bash +ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote +``` + +:::caution[Forwarded loopback is unauthenticated] +A loopback bind has no token authentication — that is what makes the default setup usable +without configuration. A plain `ssh -L` keeps the listener on your own loopback interface, so +nothing else can reach it. But `ssh -g -L`, container port publishing, and some devcontainer +or Codespaces forwarding modes bind the *client* side to `0.0.0.0`, which exposes both the +management API and the data plane to that network with no credential. Use `-L` without `-g`, +or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10100`). +::: + ## Providers (`OcxProviderConfig`) | Field | Type | Meaning | @@ -176,6 +275,7 @@ network. Only do this on trusted networks, and always set a strong `OPENCODEX_AP | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API-key header style. Defaults to native `x-api-key`; set `"bearer"` for compatible gateways that require `Authorization: Bearer `. Valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | | `models?` | `string[]` | Seed/fallback model list. When `liveModels` is `false`, these are the only discovered models. | diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 8f21c6303..3294e6348 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -91,6 +91,12 @@ bun run release:watch # наблюдение за последни открывайте сюда PR с функциональностью. - `preview` — ветка предрелизов. +Пока проект переводит основной рантайм на нативный порт Go, всё, что попадает в `dev`, +должно попадать и в `dev2-go`. Для контрибьюторов ничего не меняется: продолжайте +открывать pull request'ы в `dev`. После merge мейнтейнер выполняет rebase этой работы на +`dev2-go` и переносит то, чему нужен аналог в `go/`. Задача считается завершённой, только +когда изменение есть в обеих линиях. + Pull request'ы с портированием и ребейзом приветствуются. Перенос исправления между линиями интеграции или ребейз устаревшей ветки на текущий head — это обычный вклад, а не шум. Укажите исходные коммиты в описании. diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 7ac97ef49..f9345e147 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -142,10 +142,11 @@ opencodex добавляет трёхпозиционное переопреде запросом `PUT /api/v2` с `{ "multiAgentMode": "v1" }`. Изменения применяются к новым сессиям Codex. :::caution -На поверхности v2 (`multi_agent_v2`) порождённые подагенты наследуют модель родительской сессии. -Селектор модели/уровня делегирования в дашборде — это подсказка для промпта v1, а не -кросс-модельный маршрутизатор на стороне прокси для каждого порождения. Каноничное поведение -описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). +На поверхности v2 (`multi_agent_v2`) подагент без явно заданной модели может унаследовать модель +родительской сессии. Подсказка OpenCodex может попросить Codex явно передать выбранные модель и +уровень, а отдельная настройка нативных значений по умолчанию применяет их после sync/restart. +Ни один механизм не является маршрутизатором на стороне прокси для каждого порождения. Каноничное +поведение описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: ## Верхние уровни рассуждений @@ -185,9 +186,9 @@ id `provider/model` через `subagentModels` или страницу Subagent присваивает этим записям приоритеты 0–4 в выбранном порядке. Остальные модели по-прежнему можно вызывать по точному id. -Список избранных моделей не связан с подсказкой **Sub-agent delegation** на дашборде. В -частности, переопределения избранных моделей не обходят правило наследования родительской модели -в v2. +Список избранных моделей отделён от выбора **Sub-agent delegation** на дашборде. Он лишь определяет, +какие переопределения Codex показывает первыми, и сам по себе не выбирает модель и не запускает +делегирование. ## Обновление состояния моделей diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index d6083929b..e77970afd 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -50,12 +50,31 @@ fast_mode = true API-key-провайдер `openai-responses`, реализующий OpenAI Images API. Ошибка явного выбора не приводит к fallback на другую платную вышестоящую сторону. Встроенные id провайдеров здесь не используются; чтобы сохранить стандартный путь OpenAI, опустите это поле. -- **Ни того, ни другого:** прокси возвращает понятную ошибку вместо безликого 404. - Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) по умолчанию не могут обслуживать генерацию +- **Резерв Google Antigravity (CCA):** когда ни forward-кандидат OpenAI, ни провайдер с + API-ключом не настроены, `/v1/images/generations` (но не `/images/edits`) переключается на + эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Этот же резерв + срабатывает и при сбое разрешения аутентификации OpenAI (например, истёкшая или отсутствующая + учётная запись ChatGPT), а не только при отсутствии кандидата OpenAI. Требуется + `ocx login google-antigravity`; OAuth-токен отправляется только на закреплённый хост реестра + CCA, а не на `baseUrl` из конфигурации. Ответ возвращается в том же формате + `{created, data:[{b64_json}]}`, что ожидает Codex. +- **Ничего из перечисленного:** прокси возвращает понятную ошибку вместо безликого 404. + Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) не могут обслуживать генерацию изображений; если вы вообще не хотите предлагать этот инструмент, отключите его в Codex командой `codex features disable image_generation` (`[features] image_generation = false` в `config.toml`). +Объявление инструмента по-прежнему передаётся в Responses-запросе к модели. Для Responses- +провайдеров с API-ключом opencodex преобразует приватное пространство имён Codex `image_gen` +в безопасный для вышестоящей стороны псевдоним `image_gen__` (например, +`image_gen__imagegen`). Дублирующее объявление hosted-инструмента `image_generation` удаляется +только тогда, когда такой рабочий псевдоним заменяет клиентское объявление. Перед передачей в +Codex вызов функции восстанавливается с явным пространством имён `image_gen`, а при последующем +воспроизведении истории во вышестоящий сервис снова кодируется. Это сохраняет клиентскую +генерацию изображений у OpenAI-совместимых провайдеров, которые резервируют это пространство +имён или отклоняют имена функций с точками. Режим ChatGPT forward не изменяется и сохраняет +свой нативный формат Responses Lite. + Если `hostname` не является loopback-адресом, Codex должен отправлять сгенерированный заголовок API-аутентификации. Поэтому инжектор в этом случае использует выделенного провайдера: diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index bd8985942..d6df82506 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -88,11 +88,18 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Вход сначала импортирует и переиспользует сессию установленного `kiro-cli`. Требуется установленный Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и вход через `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и входа через `kiro-cli login`. **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) +opencodex передаёт в запрос Chat Completions только стабильный `prompt_cache_key`, предоставленный +вызывающей стороной, и никогда не создаёт его сам. Документация Kimi требует стабильный ключ +сессии/задачи для повышения доли попаданий в кэш Code Plan; запрос без ключа остаётся без ключа. +Если включённый провайдер отклоняет поле, opencodex не удаляет его для повторной попытки и не +изменяет сохранённую конфигурацию. Для остальных провайдеров действует deny-by-default. + OAuth можно запустить и из [веб-дашборда](/ru/guides/web-dashboard/). ### Несколько OAuth-аккаунтов @@ -100,10 +107,23 @@ OAuth можно запустить и из [веб-дашборда](/ru/guides OAuth-провайдеры, чьи учётные данные содержат стабильный id аккаунта или email, могут хранить несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. Учётные данные -Kimi и Kiro без идентификатора заменяют свой активный слот, а `chatgpt` всегда занимает один слот, -поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; +Только учётные данные Kimi без идентификатора заменяют активный слот; аккаунты Kiro сохраняются по ARN профиля. +`chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. +### Импорт учётных данных Kiro + +Для входа Kiro требуется Kiro CLI: установите его командой `curl -fsSL https://cli.kiro.dev/install | bash` и сначала выполните `kiro-cli login`. Если сессии `kiro-cli` нет, `ocx login kiro` использует вставленный токен доступа или переменную окружения `KIRO_ACCESS_TOKEN`. + +Обычный импорт `ocx login kiro` открывает базу SQLite CLI только для чтения и не изменяет базу, WAL или SHM. + +- `KIROCLI_DB_PATH` выбирает нестандартную базу SQLite Kiro CLI; указанная база должна уже существовать. +- `KIROCLI_TOKEN_KEY` выбирает точный ключ строки `auth_kv`, если найдено несколько неоднозначных строк с токенами. Без выбора вход завершается ошибкой, а не пытается угадать строку. + +Импортированные учётные данные сохраняются в `~/.opencodex/auth.json`. Откат **Добавить аккаунт** — отдельная операция: при восстановлении предыдущего снимка она заменяет базу и удаляет текущие sidecar-файлы WAL, SHM и journal. + +Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена), если `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` направляют импорт не на активное хранилище CLI, или если в основной базе CLI нет распознаваемой строки токена. Исправьте или удалите повреждённую базу по обычному пути данных `kiro-cli`, снимите селекторы только для импорта и повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. + ## 3. Каталог API-ключей opencodex поставляется с 53 встроенными пресетами: 42 на основе ключей, семь OAuth, три локальных и diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index ef3609caa..27510501d 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex позволяет выбрать поверхность мульти ### Модель делегирования и уровень рассуждений -Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Это настройки инструкции по делегированию, а не маршрутизатор порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. +Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Они не задают маршрутизацию порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. + +Если явно включить `syncCodexSubagentDefaults`, следующая синхронизация или перезапуск применит выбранные модель и уровень как нативные значения по умолчанию для подагентов Codex в `[agents]`, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Эти значения действуют только для вновь создаваемых задач Codex и сами по себе не запускают делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. `multiAgentGuidanceText` определяет поверхность по инструментам запроса — включая WebSocket-путь Codex Desktop (`responses_lite`), где инструменты приходят внутри входного элемента `additional_tools`, а не в массиве `tools` запроса. @@ -56,7 +58,7 @@ opencodex позволяет выбрать поверхность мульти - **Dashboard** → первая ячейка статистики: нажмите **v1**, **base** или **v2**. - Страница **Models** → сегментированный переключатель в верхнем ряду. - На обеих страницах есть кнопка **?**, открывающая модальное окно справки со ссылкой на эту страницу. -- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. Включите **Использовать как нативные значения по умолчанию для подагентов Codex**, чтобы после следующей синхронизации или перезапуска применять тот же выбор к новым задачам Codex, когда активной маршрутизацией управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Этот переключатель не зависит от переключателя руководства по делегированию. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Синхронизировать выбранные значения с нативными значениями подагентов Codex по умолчанию (нужна модель) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Задать пользовательский промпт инструкции (плейсхолдеры {{model}}/{{effort}}/{{roster}}) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. В PUT пропуск `effort` или `prompt` сохраняет текущее значение, `null` очищает его, а очистка `model` всегда очищает и уровень. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. +`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. PUT является частичным обновлением: пропуск `effort` или `prompt` сохраняет текущее значение, а `null` очищает его. Для `syncCodexSubagentDefaults: true` требуется выбранная модель; очистка `model` всегда очищает уровень и отключает синхронизацию нативных значений по умолчанию. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. ## Уровень рассуждений -Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. +Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При включённом `syncCodexSubagentDefaults` и маршрутизации под управлением OpenCodex после следующей синхронизации или перезапуска она также становится нативным уровнем подагента по умолчанию для новых задач Codex. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. `ultra` стоит выше `max` в каталоге Codex и добавляет семантику автоматического делегирования, но никогда не доходит до провайдера как буквальное значение в запросе. Codex преобразует `ultra` в `max` на границе клиента. Затем opencodex сохраняет запрос к провайдеру валидным: diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 3156d5fe5..c9fde72ee 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -26,20 +26,20 @@ bun run dev:gui | Раздел | Что делает | | --- | --- | | **Сводка Dashboard** | Мультиагентный режим, состояние онлайн, версия, время работы, число провайдеров, сумма токенов за 30 дней, активные провайдеры и доступные нативные/маршрутизируемые модели. | -| **Sub-agent delegation** | Выбор нативной или маршрутизируемой модели для инструкции и необязательного уровня рассуждений для v1-промптов делегирования. Это не маршрутизатор для каждого порождения; см. ниже. | +| **Sub-agent delegation** | Выбор нативной/маршрутизируемой модели и необязательного уровня рассуждений, общих для руководства OpenCodex по делегированию и опциональных нативных значений подагентов Codex по умолчанию. Это не маршрутизатор отдельных порождений; см. ниже. | | **Сайдкары** | Выбор модели и уровня рассуждений для веб-поиска, а также модели описания изображений. Изменения применяются со следующего запроса. | | **Maintenance** | Пересинхронизация каталога моделей Codex, просмотр предупреждений об обходе через проектную локальную конфигурацию, проверка последнего или предварительного выпуска и запуск обновления с необязательным перезапуском прокси. | | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | -| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | +| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный и, когда доступен, фактически отправленный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. Если адаптер отправляет параметр рассуждений, в подробностях также отображается точное wire-поле. Можно фильтровать по непрозрачному id диалога/сессии (если клиент его передаёт) и суммировать токены и оценочную стоимость по прайс-листу в пределах загруженного кольца Logs. | | **Usage / Debug** | Просмотр покрытия и трендов расхода токенов либо включение опциональной диагностики транспорта провайдеров и извлечения данных об использовании. | -| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. Активные сессии только для чтения. Очистка отклоняется, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. Из карантина **нельзя** восстановить через панель — вручную из `.trash//` и `manifest.json`. | +| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). | ### Ссылки на разделы @@ -57,15 +57,22 @@ bun run dev:gui ## Селектор делегирования и маршрутизация порождений Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, -`injectionEffort`. В ходах v1 opencodex внедряет инструкцию, сообщающую родительскому агенту, какие -именно модель и уровень рассуждений передать в `spawn_agent`. Выбор модели включает эту инструкцию -при любом уровне рассуждений родителя; очистка модели очищает и сохранённый уровень. +`injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по +делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Очистка модели также +очищает сохранённый уровень и отключает синхронизацию нативных значений по умолчанию. + +Если включить **Использовать как нативные значения подагентов Codex по умолчанию**, следующая +синхронизация или перезапуск применит выбранные модель и уровень как нативные значения `[agents]`, +когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация +провайдера остаётся неизменной. Они действуют только для вновь создаваемых задач Codex, и эта настройка сама по себе не запускает +делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, +поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. :::caution -Этот селектор — инструкция делегирования для поверхности совместимости v1. На `multi_agent_v2` -текущий прокси не добавляет v1-сообщение внедрения, и каждый порождённый подагент наследует модель -родительской сессии. Это не кросс-модельный маршрутизатор на стороне прокси. Каноничное поведение -v1/base/v2 описано на странице +Эти два переключателя независимы. Отключение руководства OpenCodex по делегированию не отключает +синхронизацию нативных значений по умолчанию; включение синхронизации не включает руководство и не +вызывает делегирование. Ни одна из настроек не является кросс-модельным маршрутизатором отдельных +порождений на стороне прокси. Каноничное поведение v1/base/v2 описано на странице [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: @@ -102,7 +109,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Проверка, запуск и мониторинг задач самообновления. | | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | -| `GET` / `PUT /api/injection-model` | Чтение или настройка модели v1-инструкции делегирования и необязательного уровня. | +| `GET` / `PUT /api/injection-model` | Чтение или настройка модели/уровня руководства по делегированию, его переключателя и переключателя синхронизации нативных значений подагентов Codex по умолчанию. | | `GET` / `PUT /api/v2` | Чтение или настройка режима поверхности, фиче-флага Codex и лимита потоков v2. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение или удаление провайдеров. | | `GET /api/models` · `PUT /api/disabled-models` | Список строк нативных/маршрутизируемых моделей и обновление общего набора отключённых моделей. | diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index fac4fc6cf..80c7ae647 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -57,7 +57,7 @@ interface ProviderAdapter { ## `anthropic` **Назначение:** Anthropic **Messages** (`/v1/messages`). -**Аутентификация:** `key` (`x-api-key`) или `oauth` (Bearer + `anthropic-beta`, для Claude Pro/Max). +**Аутентификация:** `key` (по умолчанию `x-api-key`, либо `Authorization: Bearer` при `apiKeyTransport: "bearer"`) или `oauth` (Bearer + `anthropic-beta`, для Claude Pro/Max). - Преобразует сообщения в блоки контента Anthropic (text, base64 image, `tool_use`, `thinking`). - **Арифметика extended thinking:** Anthropic требует `max_tokens > thinking.budget_tokens`. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 7e5457c12..d90d44766 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -36,12 +36,13 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `openaiProviderTierVersion?` | `2` | задаётся миграцией | Отмечает, что единая проекция OpenAI с учётом опций завершена. | | `defaultProvider` | `string` | `"openai"` | Провайдер, используемый, когда маршрутизация не находит лучшего совпадения. | | `subagentModels?` | `string[]` | `gpt-5.5`, тройка GPT-5.6, `gpt-5.4-mini` | До 5 нативных slug или id вида `provider/model`, отображаемых первыми в селекторе подагентов Codex. Список в руководстве v2 — пересечение настроенных моделей с первыми пятью видимыми в селекторе, совместимыми с v2 и отсортированными по priority записями Codex; используются канонические slug каталога и доступные уровни effort, а исключённые элементы остаются в конфигурации. Явно заданный пустой список сохраняется. | -| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель, указываемая во внедряемом multi-agent-руководстве (поверхность v2); руководству по делегированию предписывается передавать именно эту модель в `spawn_agent` с `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений для `spawn_agent` (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`. | +| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель подагента. Она используется в v2-руководстве по делегированию от OpenCodex, отдельно управляемом `multiAgentGuidanceEnabled`, а опциональное `syncCodexSubagentDefaults` также может сделать её нативным значением Codex по умолчанию для новых задач. | +| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений подагента (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`; используется руководством по делегированию и, при явном согласии, нативным значением Codex по умолчанию. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Явное согласие применить выбранные `injectionModel` / `injectionEffort` как нативные значения подагентов Codex по умолчанию в `[agents]` при следующей синхронизации или перезапуске, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Они влияют только на вновь создаваемые задачи Codex и сами по себе не запускают делегирование. Существующие пользовательские целевые записи сохраняются как конфликты, а не перезаписываются. Требует `injectionModel`; очистка модели отключает это поле. Доступно как поле частичного обновления `GET/PUT /api/injection-model`. | | `effortCap?` | `string` | — | Жёсткий потолок уровня рассуждений на каждый запрос. Функция multi-agent V2: применяется к основным ходам, чей собственный список инструментов несёт поверхность совместной работы V2, а также к ходам порождённых потомков, помеченным ровно `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata` (помеченные потомки подпадают под потолок независимо от их собственной поверхности инструментов). Обычные основные ходы и основные ходы с поверхностью V1 не затрагиваются, ходы compaction всегда обходят потолки, а `multiAgentMode: "v1"` полностью отключает потолки (дашборд скрывает панель). Принимает значения от `low` до `ultra`; потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | Тот же жёсткий потолок, применяемый только к ходам порождённых потомков, идентифицированным маркерами codex-rs с точным совпадением: `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata`. Другие внутренние категории подагентов (ревью, compaction, консолидация памяти) никогда не подпадают под этот потолок, а `multiAgentMode: "v1"` полностью его отключает. Принимает значения от `low` до `ultra`; когда заданы оба потолка, действует более низкий, и потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Пользовательская замена текста внедряемого v2-руководства. Заменяет встроенный текст; плейсхолдеры `{{model}}`, `{{effort}}` и `{{roster}}` подставляются. Условия срабатывания не меняются. Настраивается через `PUT /api/injection-model` (ключ `prompt`). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя нативные значения `[agents]` по умолчанию, поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | | `disabledModels?` | `string[]` | — | Модели, скрываемые от Codex. Маршрутизируемые id `provider/model` исключаются из каталога и `/v1/models`; «голые» нативные GPT-slug (например, `gpt-5.4`) переводят свою запись каталога в `visibility: "hide"` и исчезают из «голого» списка `/v1/models`. Переключается для каждой модели на странице Models дашборда. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | Трёхпозиционное переопределение multi-agent-поверхности. `"v1"` принудительно переводит все модели на поверхность v1 (перекрывает вышестоящие привязки); `"default"` учитывает вышестоящие привязки моделей (sol/terra=v2, luna=v1); `"v2"` принудительно переводит все модели на v2. Настраивается на странице Models дашборда или через `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Видимые Codex лимиты контекста по провайдерам. Лимит только понижает известные контекстные окна. | @@ -55,8 +56,12 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | +| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | -| `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. | +| `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | +| `accountPoolStickyLimit?` | `number` | `1` | Число успешных привязок новых сессий, удерживаемых на одном выборе round-robin перед переходом дальше. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Число подряд идущих временных сбоев вышестоящей стороны, после которого будущие новые сессии переключаются (failover) на другой подходящий аккаунт пула. Установите `0`, чтобы отключить переключение по сбоям. | | `modelCacheTtlMs?` | `number` | `300000` | Окно свежести кэша `/models` каждого провайдера (5 минут). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика кэша промптов Anthropic: отключён, эфемерный на 5 минут или расширенный на 1 час. | @@ -65,6 +70,16 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | | `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. +В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет +маршрутизацию Pool / Direct. + `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` в @@ -78,8 +93,21 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е Используйте страницу **Codex Auth** дашборда для добавления аккаунтов пула и обновления квот. Конфигурация хранит только несекретные метаданные аккаунтов; access- и refresh-токены хранятся в защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к -аккаунту, а новые сессии могут маршрутизироваться автоматически на основе квоты, cooldown и +аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Пауза также очищает карту affinity потоков +этого аккаунта: выполняющиеся запросы сохраняют захваченные учётные данные, но последующие ходы +перемаршрутизируются и не могут повторно использовать приостановленный аккаунт. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет только подходящие аккаунты с доступными учётными данными и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты без учётных данных, с неизвестной квотой или неудачным обновлением не меняются. + +**Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) +— выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное +распределение, `accountPoolStickyLimit` (по умолчанию `1`, 1–100) задаёт число успешных bind на один +выбор; `fill-first` — исчерпание активного аккаунта (cooldown, reauth или threshold; неизвестный +usage не принуждает к переключению), затем переход к следующему. Ротация не защищает от enforcement +провайдера — использование нескольких аккаунтов может нарушать ToS. ::: ### claudeCode (OcxClaudeCodeConfig) @@ -157,6 +185,7 @@ x-opencodex-api-key: your-secret-token | `responsesPath?` | `string` | Необязательный относительный путь ресурса для запросов `openai-responses` с аутентификацией `key`. Должен начинаться с `/` и не содержать схему URL, query или fragment. Если поле опущено, сохраняется прежнее построение URL `/v1/responses`. | | `disabled?` | `boolean` | Провайдер остаётся на диске, но исключается из маршрутизации и списков моделей/каталога. | | `apiKey?` | `string` | API-ключ или ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая в момент запроса. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Способ передачи API-ключа Anthropic. По умолчанию используется нативный `x-api-key`; для совместимых gateway, требующих `Authorization: Bearer `, задайте `"bearer"`. Допустимо только для `anthropic` provider с key-аутентификацией. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` отражает активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | | `defaultModel?` | `string` | Модель, используемая при выборе этого провайдера без явной модели. | | `models?` | `string[]` | Список seed/fallback-моделей. Когда `liveModels` равен `false`, обнаруживаются только эти модели. | diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 53f20242d..916431ffc 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -50,8 +50,13 @@ runtime the leak itself remains an upstream problem: whereas a flat `responseState` under rising observed memory points away from that store. The values are scalar-only — no request bodies, tokens, paths, or account identifiers — and the read is side-effect free (it never prunes or - evicts). The dashboard's read-only **Memory observability** card renders the - same fields. + evicts). The dashboard's **Memory observability** card renders the + same fields and offers a confirm-gated **Drain & restart** action: it shows + the current active-turn count, waits up to 60s for active turns (reusing + the existing 503 + `Retry-After` drain), then aborts any remaining turns and + restarts the proxy via `ocx start` on the live port (or a failure-only + service supervisor respawn) without tearing down Codex injection. That is a + longer, informed recycle than the short drain on `POST /api/stop`. - **A gated alternative stream path** — a bounded single-reader relay that removes the unbounded buffering shape entirely. It becomes the default automatically once a bundled Bun release verifiably carries the #32111 fix; diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index bd7723325..19c5c2154 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -84,6 +84,10 @@ bun run release:watch # 观察最新的 Release workflow run - `main` — 仅用于发布。只有维护者从 `dev` 提升时才会变动,请勿直接提功能 PR。 - `preview` — 预发布通道。 +在主运行时迁移到 Go 原生移植期间,进入 `dev` 的改动同样要进入 `dev2-go`。贡献者的流程 +不变:照常向 `dev` 提 pull request。合并之后,维护者会把这份工作变基到 `dev2-go`,并把 +需要 Go 对应实现的部分移植到 `go/`。只有两条线都带上该改动,这件事才算完成。 + 欢迎移植 PR 和变基 PR。把一条集成线上的修复带到另一条线,或把陈旧分支变基到当前 head, 都是正常的贡献而非噪音。请在描述中注明来源提交。 diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 15f4c90e3..9fda55528 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -108,8 +108,9 @@ opencodex 为每个目录条目的 `multi_agent_version` 提供三态 override `{ "multiAgentMode": "v1" }` 的 `PUT /api/v2` 设置该模式。变更从新的 Codex session 开始生效。 :::caution -在 v2(`multi_agent_v2`)界面中,生成的子代理会继承父 session 的模型。仪表盘中的委派模型/ -reasoning 选择器只是 v1 prompt 指引,并不是由代理在每次生成时执行跨模型路由。权威说明见 +在 v2(`multi_agent_v2`)界面中,未显式指定模型的子代理可能继承父 session 的模型。 +OpenCodex 指引可以要求 Codex 显式传递所选模型/reasoning,独立的原生默认值开关则可在 +sync/restart 后提供默认值。两者都不是由代理在每次生成时执行的代理侧路由。权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -145,8 +146,8 @@ override。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面选 `provider/model` id;opencodex 会按所选顺序赋予它们 0-4 的 priority。其他模型仍可通过精确 id 直接调用。 -置顶模型列表与 Dashboard 的 **Sub-agent delegation** 指引相互独立。尤其需要注意,置顶模型 -override 不能绕过 v2 的父模型继承规则。 +置顶模型列表与 Dashboard 的 **Sub-agent delegation** 选择相互独立。它只决定 Codex 优先显示 +哪些 override,本身不会选择模型或触发委派。 ## 刷新模型状态 diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 9fc3a5d4a..53fceb9c9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -43,11 +43,25 @@ OpenAI 上游: - **显式自定义 provider:** 可将 `images.provider` 设为一个自定义 API-key `openai-responses` provider;该 endpoint 必须实现 OpenAI Images API。显式选择失败时不会 fallback 到其他付费上游。内置 provider id 不适用于此字段;省略它即可使用默认 OpenAI 路径。 -- **两者都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 - Kiro 等)默认无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 +- **Google Antigravity(CCA)回退:** 当 OpenAI forward 候选和 API key 提供商都不存在时, + `/v1/images/generations`(不含 `/images/edits`)会回退到 Antigravity **Cloud Code Assist** + 端点,使用 `gemini-3.1-flash-image` 模型。当 OpenAI 认证解析失败(例如 ChatGPT 凭证过期或缺失)时, + 该回退同样会触发,而不仅仅在没有任何 OpenAI 候选时。需要 `ocx login google-antigravity`;OAuth token + 只发送到 CCA 注册端点,不会发送到配置中的 `baseUrl` 覆盖地址。返回格式与 Codex 期望的 + `{created, data:[{b64_json}]}` 一致。 +- **以上都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 + Kiro 等)无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 `codex features disable image_generation`(即 `config.toml` 的 `[features] image_generation = false`)。 +工具声明仍会随模型的 Responses 请求一同发送。对于 API key 方式的 Responses 提供商, +opencodex 会把 Codex 私有的 `image_gen` namespace 转换为上游安全的 +`image_gen__` alias(例如 `image_gen__imagegen`)。只有当这个可用 alias 替代了 +客户端声明时,才会移除重复的 hosted `image_generation` 声明。函数调用会在到达 Codex 前恢复为 +显式的 `image_gen` namespace,后续将历史记录重放到上游时再重新编码。因此,即使 OpenAI 兼容 +上游保留了该 namespace,或拒绝包含点号的函数名,客户端图像生成仍可正常调用。ChatGPT forward +模式保持不变,并继续使用其原生 Responses Lite 格式。 + 如果 `hostname` 不是 loopback 地址,Codex 必须发送自动生成的 API 认证请求头。此时注入器会改用 专用提供商: diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 93caa2a63..357ddbcac 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -74,19 +74,38 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 优先复用已安装的 `kiro-cli` 登录。需先安装 Kiro CLI(`curl -fsSL https://cli.kiro.dev/install | bash`)并执行 `kiro-cli login`。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | +对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex +只会把调用方提供的稳定 `prompt_cache_key` 转发到 Chat Completions 请求,绝不自行生成。Kimi +文档要求使用稳定的会话/任务 key 来提高 Code Plan 缓存命中率;没有 key 的请求仍保持不带 key。 +若已 opt-in 的上游拒绝该字段,opencodex 不会删除字段后重试,也不会改动已保存配置;其他 +provider 仍保持 deny-by-default。 + 你也可以从 [web 仪表盘](/zh-cn/guides/web-dashboard/) 启动 OAuth。 ### 多个 OAuth 账号 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 -账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。没有身份信息的 Kimi 和 Kiro 会替换 -当前 active slot;`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 +账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。只有没有身份信息的 Kimi 凭据会替换 +当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 +### Kiro 凭据导入 + +Kiro 登录需要 Kiro CLI:使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,并先运行 `kiro-cli login`。如果没有 `kiro-cli` 会话,`ocx login kiro` 会回退到粘贴的访问令牌或 `KIRO_ACCESS_TOKEN` 环境变量。 + +普通的 `ocx login kiro` 导入会以只读方式打开 CLI SQLite 数据库,不修改数据库、WAL 或 SHM。 + +- `KIROCLI_DB_PATH` 用于选择非标准位置的 Kiro CLI SQLite 数据库;指定的数据库必须已经存在。 +- `KIROCLI_TOKEN_KEY` 在存在多个含糊的令牌行时选择确切的 `auth_kv` 行键。缺少选择值时,登录会失败而不会猜测。 + +导入的凭据会保存到 `~/.opencodex/auth.json`。**添加账户**的回滚是独立流程:恢复之前的快照时会替换数据库,并删除当前的 WAL、SHM 和 journal 边车文件。 + +由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),当 `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` 将导入路径指向与活动 CLI 存储不同的位置时,或当主 CLI 数据库没有可识别的令牌行时,**添加账户**会拒绝将 `kiro-cli` 登出。请修复或删除常规 `kiro-cli` 数据路径下的损坏数据库,并取消仅用于导入的选择器后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 + ## 3. API 密钥目录 opencodex v2.7.1 内置 50 个预设:40 个密钥预设、6 个 OAuth 预设、3 个本地预设,以及默认的 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index e47bd7064..ad2828a28 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 ### 委托模型与推理强度 -仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。它们用于生成委托指引,并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 +仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 `multiAgentGuidanceEnabled` 单独控制。它们并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 + +显式启用 `syncCodexSubagentDefaults` 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把所选模型和 effort 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该设置本身不会触发委派。已有的用户自有 `[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 `multiAgentGuidanceText` 根据请求中的工具列表判断当前界面 —— 包括 Codex Desktop 的 WebSocket 路径(`responses_lite`),此时工具位于 `additional_tools` input 项中而不是请求的 `tools` 数组。 @@ -56,7 +58,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 - **Models** 页面 → 使用顶部的分段控件。 - 两个页面都有 **?** 按钮,可打开帮助弹窗并返回本文。 -- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把相同选择应用于新建 Codex 任务;外部用户管理的 provider 配置不会被修改。此开关与委派指引开关相互独立。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 将所选值同步为 Codex 原生子代理默认值(需先设置 model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 设置自定义指引提示词({{model}}/{{effort}}/{{roster}} 占位符) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 请求省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它;清除 `model` 一定会同时清除推理强度。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 +`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 为部分更新:省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它。`syncCodexSubagentDefaults: true` 要求已经选择模型;清除 `model` 一定会同时清除推理强度,并关闭原生默认值同步。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 ## 推理强度 -可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 +可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。启用 `syncCodexSubagentDefaults` 且 OpenCodex 管理当前 Codex 路由时,下一次同步或重启还会把它作为新建 Codex 任务的原生子代理默认 effort。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 在 Codex 目录中,`ultra` 的级别高于 `max`,并带有自动委托语义;但 provider 永远不会在线路上收到字面量 `ultra`。Codex 会在客户端边界将 `ultra` 转成 `max`,随后 opencodex 再确保 provider 收到有效值: diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index d7c1661b6..cac81c25c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -25,20 +25,20 @@ bun run dev:gui | 区域 | 作用 | | --- | --- | | **Dashboard 摘要** | 显示 multi-agent 模式、在线状态、版本、运行时间、provider 数量、30 天 token 总量、活动 provider 和可用的原生/路由模型。 | -| **Sub-agent delegation** | 为 v1 委派 prompt 选择原生或路由模型,并可指定 reasoning 强度。它不是逐次生成的路由器,详见下文。 | +| **Sub-agent delegation** | 选择供 OpenCodex 委派指引与可选的 Codex 原生子代理默认值共用的原生/路由模型和可选 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录,查看项目级配置绕过警告,检查 latest/preview 版本,并可在更新后重启代理。 | | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | -| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | +| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度以及(可用时)实际发送强度、实际模型、provider、状态、request id、耗时和错误详情。适配器发送 reasoning 参数时,详情中还会显示准确的 wire field。可按不透明会话/对话 ID(客户端提供时)筛选,并对当前已加载的 Logs 环形缓冲合计 token 与估算标价成本。 | | **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 | -| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理。隔离文件**不能**从仪表盘恢复——如需恢复,请根据 `manifest.json` 将文件从 `.trash//` 手动移回原位置。 | +| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 | ### 链接到某个部分 @@ -55,13 +55,16 @@ bun run dev:gui ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 -`injectionEffort`。在 v1 turn 中,opencodex 会注入一段指引,告诉父代理调用 `spawn_agent` 时应 -传入哪个精确模型和 reasoning 强度。只要选定模型,无论父代理当前使用何种 reasoning 强度,都会 -启用这段指引;清除模型时也会清除已保存的强度。 +`injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 +`multiAgentGuidanceEnabled` 单独控制。清除模型时也会清除已保存的强度,并关闭原生默认值同步。 + +启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会 +把所选模型和强度应用为原生 `[agents]` 默认值;外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该选项本身不会触发委派。已有的用户自有 +`[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 :::caution -该选择器是面向 v1 兼容界面的委派指引。在 `multi_agent_v2` 中,当前代理不会附加 v1 注入消息, -而且所有生成的子代理都会继承父 session 的模型。它不是代理侧的跨模型路由器。v1/base/v2 的 +两个开关相互独立:关闭 OpenCodex 委派指引不会关闭原生默认值同步;启用原生默认值同步也不会 +启用委派指引或触发委派。两者都不是代理侧的逐次跨模型路由器。v1/base/v2 的 权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -94,7 +97,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 检查、运行和监控自更新任务。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | -| `GET` / `PUT /api/injection-model` | 读取或设置 v1 委派指引模型及可选强度。 | +| `GET` / `PUT /api/injection-model` | 读取或设置委派指引模型/强度、指引开关及 Codex 原生子代理默认值同步开关。 | | `GET` / `PUT /api/v2` | 读取或设置界面模式、Codex feature flag 和 v2 thread 上限。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用或删除 provider。 | | `GET /api/models` · `PUT /api/disabled-models` | 列出原生/路由模型,并更新共享的 disabled-model 集合。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 6b52365cf..6a3d89a98 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -52,7 +52,7 @@ interface ProviderAdapter { ## `anthropic` **目标:** Anthropic **Messages**(`/v1/messages`)。 -**认证:** `key`(`x-api-key`)或 `oauth`(Bearer + `anthropic-beta`,用于 Claude Pro/Max)。 +**认证:** `key`(默认 `x-api-key`,或设置 `apiKeyTransport: "bearer"` 后使用 `Authorization: Bearer`)或 `oauth`(Bearer + `anthropic-beta`,用于 Claude Pro/Max)。 - 把消息转换成 Anthropic content block(text、base64 image、`tool_use`、`thinking`)。 - **Extended thinking 计算:** Anthropic 要求 `max_tokens > thinking.budget_tokens`。adapter 把 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 8b9e67334..0c0e2a5e5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -30,12 +30,13 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `openaiProviderTierVersion?` | `2` | migration 设置 | 单一选项式 OpenAI projection 完成标记。 | | `defaultProvider` | `string` | `"openai"` | 路由找不到更优匹配时使用的 provider。 | | `subagentModels?` | `string[]` | `gpt-5.5`、三款 GPT-5.6、`gpt-5.4-mini` | 最多 5 个原生 slug 或 `provider/model` id,优先显示在 Codex subagent picker 中。v2 指引清单是已配置模型与 Codex 中 picker 可见、兼容 v2、按 priority 排序后前五项的交集,并使用规范目录 slug 与可用 effort 档位;被排除的条目仍保留在配置中。显式空数组会被保留。 | -| `injectionModel?` | `string` | — | 注入 multi-agent 指南(v2 界面)的首选原生或路由模型;委派指南会要求把该模型连同 `fork_turns: "none"` 一起传给 `spawn_agent`。 | -| `injectionEffort?` | `string` | — | 首选 `spawn_agent` reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义。 | +| `injectionModel?` | `string` | — | 首选原生或路由子代理模型。它用于由独立 `multiAgentGuidanceEnabled` 控制、OpenCodex 编写的 v2 委派指引;选择 `syncCodexSubagentDefaults` 后,也可将其用作新任务的 Codex 原生默认值。 | +| `injectionEffort?` | `string` | — | 首选子代理 reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义;它用于委派指引,并可在选择同步后作为 Codex 原生默认值。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | 当 OpenCodex 管理当前 Codex 路由时,选择是否在下一次同步或重启把已选的 `injectionModel` / `injectionEffort` 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建 Codex 任务,本身不会触发委派。已有的用户自有目标条目会作为冲突保留而不会被覆盖。此字段要求 `injectionModel`;清除模型会同时关闭该选项。它作为 `GET/PUT /api/injection-model` 的部分更新字段提供。 | | `effortCap?` | `string` | — | reasoning effort 的逐请求硬上限。这是多代理 V2 专属功能:适用于工具列表带有 V2 协作表面的主轮次,以及标记精确匹配 `x-openai-subagent: collab_spawn` 或 `x-codex-turn-metadata` 中 `"subagent_kind": "thread_spawn"` 的派生子轮次(带标记的子轮次无论自身工具表面如何都会被覆盖)。普通主轮次与 V1 表面主轮次不受影响,压缩(compaction)轮次始终绕过上限,`multiAgentMode: "v1"` 会完全禁用上限功能(仪表盘同时隐藏该面板)。接受 `low` 到 `ultra`;只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `subagentEffortCap?` | `string` | — | 同样的硬上限,但只用于 codex-rs 标记精确匹配的派生子轮次:`x-openai-subagent: collab_spawn`,或 `x-codex-turn-metadata` 中的 `"subagent_kind": "thread_spawn"`。其他内部子代理类别(评审、压缩、记忆整理)不会触发此上限,`multiAgentMode: "v1"` 会完全禁用该功能。接受 `low` 到 `ultra`;两个上限同时设置时取较低者,且只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `injectionPrompt?` | `string` | — | 整体替换注入的 v2 指南正文的自定义文本。`{{model}}`、`{{effort}}`、`{{roster}}` 占位符会被替换,触发条件保持不变。也可通过 `PUT /api/injection-model` 的 `prompt` 键设置。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变 Codex 原生 `[agents]` 默认值、协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | | `disabledModels?` | `string[]` | — | 从 Codex 隐藏的模型。路由 `provider/model` id 会从目录和 `/v1/models` 排除;bare 原生 GPT slug(如 `gpt-5.4`)的目录条目会改成 `visibility: "hide"`,并从 bare `/v1/models` 列表移除。可在仪表盘 Models 页面按模型切换。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 三态 multi-agent surface override。`"v1"` 覆盖 upstream pin,强制全部模型使用 v1;`"default"` 遵循 upstream model pin(sol/terra=v2,luna=v1);`"v2"` 强制全部模型使用 v2。可在仪表盘 Models 页面或 `ocx v2 mode` 中设置。 | | `providerContextCaps?` | `Record` | `{}` | provider 级 Codex 可见 context cap。只会降低已知 context window。 | @@ -49,8 +50,12 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | -| `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | +| `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | +| `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的成功新 session 绑定数。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache 策略:禁用、5 分钟 ephemeral 或 1 小时 extended。 | @@ -59,6 +64,14 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。在此 foundation layer 中, +该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 + `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex `$CODEX_HOME/config.toml` 的 `[features.multi_agent_v2]` 下;请先启用 v2,确保该 table 存在。 @@ -69,7 +82,17 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 :::note[Codex 账号池] 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 -保留 account affinity,新 session 可按 quota、cooldown 和 health 自动路由。 +保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停还会清除该账号的 thread affinity map:进行中的请求保留已捕获的 credential,但后续 turn 会重新路由,无法再使用已暂停账号。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新有 credential 的合格账号,只暂停相关 quota window 本次明确返回 100% 的账号;无 credential、未知额度或刷新失败的账号保持不变。 + +**轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 +`autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, +1–100)控制一次选择保留多少成功绑定;`fill-first` — 耗尽活跃账号(cooldown、需重新认证或 +threshold;未知 usage 不强制切换)后按稳定排序进入下一个。轮换不能规避 provider enforcement — +多账号使用可能违反服务条款。 ::: ### 受管 record 形状 @@ -130,6 +153,7 @@ x-opencodex-api-key: your-secret-token | `responsesPath?` | `string` | `key` 认证的 `openai-responses` 请求可选相对 resource path。必须以 `/` 开头,且不得包含 URL scheme、query 或 fragment。省略时保留原有的 `/v1/responses` URL 构造。 | | `disabled?` | `boolean` | 配置保留在磁盘上,但从路由和模型/目录列表排除。 | | `apiKey?` | `string` | API key,或在请求时解析的 `${ENV_VAR}` / `$ENV_VAR` 引用。 | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API key 的请求头方式。默认使用原生 `x-api-key`;兼容 gateway 要求 `Authorization: Bearer ` 时设为 `"bearer"`。仅适用于使用 key 认证的 `anthropic` provider。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key pool。`apiKey` 映射当前活动条目;每项包含 `id`、`key`、可选 `label` 和可选数字 `addedAt`。 | | `defaultModel?` | `string` | 选中该 provider 但未指定明确模型时使用的模型。 | | `models?` | `string[]` | seed/fallback 模型列表。`liveModels` 为 `false` 时,只会发现这些模型。 | diff --git a/docs/superpowers/plans/2026-07-28-pr-quality-gates.md b/docs/superpowers/plans/2026-07-28-pr-quality-gates.md new file mode 100644 index 000000000..fe04ac5aa --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-pr-quality-gates.md @@ -0,0 +1,645 @@ +# PR Quality Gates (Ancestry + Description) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `enforce-pr-target` so PRs like #644 fail: wrong ancestry (branched from `main` while targeting `dev`/`dev2-go`) and empty/thin/malformed descriptions, with the same draft + comment + `setFailed` UX as wrong-base. + +**Architecture:** Pure validators live in `.github/scripts/pr-quality.cjs` (reusing `issue-quality` helpers for placeholders / structured sections). The workflow checks out **trusted** scripts from the repository default branch (sparse, no PR head), then the existing `github-script` step requires that module, evaluates base + ancestry + description, and drafts/`setFailed`s when any gate fails. + +**Tech Stack:** Node CommonJS (Actions scripts), `node:test` for script unit tests, Bun/`tests/ci-workflows.test.ts` + `enforce-pr-target-harness.ts` for workflow behavioral coverage, Astro docs-site for contributing copy. + +**Spec:** `docs/superpowers/specs/2026-07-28-pr-quality-gates-design.md` + +## Global Constraints + +- `ANCESTRY_BEHIND_THRESHOLD = 20` +- Ancestry fail when `behind_main === 0 && behind_base >= 20` +- Release compare ref = `main` +- Description: min section length `40`, min rich sections `2`, unstructured min length `120`, min blocks `2` +- Skip ancestry only for authors with push/maintain/admin on the base repo; permission API failure → fail closed (apply ancestry) +- Do **not** skip description for maintainers +- `[WRONG BRANCH]` title prefix only for wrong **base** +- `pull_request_target`: never check out PR head; checkout only `github.event.repository.default_branch` + sparse `.github/scripts` +- Permissions stay `contents: write` + `pull-requests: write` +- Add trigger type `synchronize` +- No live GitHub in unit tests + +## File map + +| File | Role | +| --- | --- | +| `.github/scripts/pr-quality.cjs` | Pure ancestry + description assessment + failure collectors | +| `.github/scripts/pr-quality.test.cjs` | Node unit tests for pure rules | +| `.github/workflows/enforce-pr-target.yml` | Checkout + require + multi-gate orchestration | +| `.github/scripts/enforce-pr-target.test.cjs` | Static workflow assertions (checkout safety, synchronize, paths) | +| `tests/helpers/enforce-pr-target-harness.ts` | Allow `require` of scripts; mock compare + permission; PR `body` | +| `tests/ci-workflows.test.ts` | Structural allowlist + behavioral scenarios | +| `.github/workflows/issue-quality-tests.yml` | Path filters for new script/tests | +| `docs-site/src/content/docs/contributing.md` | User-facing branch + description rules | +| `AGENTS.md` / `MAINTAINERS.md` | One-line CI policy note | + +--- + +### Task 1: Pure `pr-quality.cjs` (ancestry + description) + +**Files:** +- Create: `.github/scripts/pr-quality.cjs` +- Create: `.github/scripts/pr-quality.test.cjs` +- Modify: `.github/workflows/issue-quality-tests.yml` (add path filters + `node --test` line) + +**Interfaces:** +- Produces: + - `ANCESTRY_BEHIND_THRESHOLD` number (`20`) + - `isWrongAncestry({ behindMain, behindBase, threshold? }) → boolean` + - `authorHasPushPermission(permission: string | null | undefined) → boolean` — true for `admin` \| `maintain` \| `write` + - `assessPrDescription(body: string | null | undefined) → { ok: true } | { ok: false, reason: "empty" | "placeholder" | "escaped_newlines" | "thin" }` + - `collectPrQualityFailures({ baseRef, allowedBases, body, behindMain, behindBase, authorPermission, permissionLookupFailed? }) → Array<{ code: "wrong_base" | "wrong_ancestry" | "bad_description", reason?: string }>` + - `wrong_base` when `!allowedBases.includes(baseRef)` + - `wrong_ancestry` only when base allowed, and (`permissionLookupFailed` or `!authorHasPushPermission(authorPermission)`), and `isWrongAncestry(...)` + - `bad_description` whenever `assessPrDescription` is not ok (even if wrong_base) + +- [ ] **Step 1: Write the failing tests** + +Create `.github/scripts/pr-quality.test.cjs`: + +```js +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + ANCESTRY_BEHIND_THRESHOLD, + isWrongAncestry, + authorHasPushPermission, + assessPrDescription, + collectPrQualityFailures, +} = require("./pr-quality.cjs"); + +describe("isWrongAncestry", () => { + it("flags #644-shaped compares (0 behind main, far behind base)", () => { + assert.equal( + isWrongAncestry({ behindMain: 0, behindBase: 44 }), + true, + ); + }); + + it("uses threshold 20 by default", () => { + assert.equal(ANCESTRY_BEHIND_THRESHOLD, 20); + assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20 }), true); + assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19 }), false); + }); + + it("passes when head is behind main (not sitting on main tip)", () => { + assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44 }), false); + }); +}); + +describe("authorHasPushPermission", () => { + it("accepts write/maintain/admin only", () => { + assert.equal(authorHasPushPermission("admin"), true); + assert.equal(authorHasPushPermission("maintain"), true); + assert.equal(authorHasPushPermission("write"), true); + assert.equal(authorHasPushPermission("triage"), false); + assert.equal(authorHasPushPermission("read"), false); + assert.equal(authorHasPushPermission(null), false); + }); +}); + +describe("assessPrDescription", () => { + it("rejects empty and comment-only bodies", () => { + assert.equal(assessPrDescription("").ok, false); + assert.equal(assessPrDescription(" ").ok, false); + assert.equal( + assessPrDescription("\n\n").reason, + "empty", + ); + }); + + it("rejects placeholder-only bodies", () => { + assert.equal(assessPrDescription("N/A").reason, "placeholder"); + assert.equal(assessPrDescription("TODO").reason, "placeholder"); + }); + + it("rejects literal escaped newlines like #644", () => { + const body = + "## What changed\\n- make the Windows tray launcher resolve Codex home\\n\\n## Validation\\n- git diff --check"; + assert.equal(assessPrDescription(body).reason, "escaped_newlines"); + }); + + it("rejects thin real-newline bodies", () => { + assert.equal(assessPrDescription("fix stuff").reason, "thin"); + }); + + it("accepts two rich markdown sections", () => { + const body = [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"); + assert.equal(assessPrDescription(body).ok, true); + }); + + it("accepts unstructured bodies that are long enough with multiple blocks", () => { + const p1 = + "Updates the Windows tray launcher to resolve the active Codex home through the shared helper so listener and launcher stay aligned."; + const p2 = + "Validated with git diff --check on the changed tray module; typecheck was not available in that session so CI must cover it."; + assert.equal(assessPrDescription(`${p1}\n\n${p2}`).ok, true); + }); +}); + +describe("collectPrQualityFailures", () => { + const allowed = ["dev", "dev2-go"]; + + it("reports wrong_base without requiring ancestry inputs", () => { + const failures = collectPrQualityFailures({ + baseRef: "main", + allowedBases: allowed, + body: "## Summary\n" + "x".repeat(50) + "\n\n## Test plan\n" + "y".repeat(50), + behindMain: 0, + behindBase: 0, + authorPermission: "read", + }); + assert.ok(failures.some((f) => f.code === "wrong_base")); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("reports wrong_ancestry for contributor on #644-shaped compare", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 44, + authorPermission: "read", + }); + assert.deepEqual( + failures.map((f) => f.code), + ["wrong_ancestry"], + ); + }); + + it("skips ancestry for push permission but still flags bad description", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: "", + behindMain: 0, + behindBase: 44, + authorPermission: "write", + }); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + assert.ok(failures.some((f) => f.code === "bad_description")); + }); + + it("applies ancestry when permission lookup failed (fail closed)", () => { + const failures = collectPrQualityFailures({ + baseRef: "dev", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 44, + authorPermission: null, + permissionLookupFailed: true, + }); + assert.ok(failures.some((f) => f.code === "wrong_ancestry")); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL (module missing)** + +Run: + +```bash +node --test .github/scripts/pr-quality.test.cjs +``` + +Expected: FAIL — `Cannot find module './pr-quality.cjs'` + +- [ ] **Step 3: Implement `.github/scripts/pr-quality.cjs`** + +```js +"use strict"; + +const path = require("node:path"); +const { + clean, + isPlaceholderOnlyValue, + hasSubstantialStructuredContent, +} = require(path.join(__dirname, "issue-quality.cjs")); + +const ANCESTRY_BEHIND_THRESHOLD = 20; +const MIN_SECTION_LEN = 40; +const MIN_RICH_SECTIONS = 2; +const UNSTRUCTURED_MIN_LEN = 120; +const UNSTRUCTURED_MIN_BLOCKS = 2; + +function isWrongAncestry({ behindMain, behindBase, threshold = ANCESTRY_BEHIND_THRESHOLD }) { + return behindMain === 0 && behindBase >= threshold; +} + +function authorHasPushPermission(permission) { + return permission === "admin" || permission === "maintain" || permission === "write"; +} + +/** + * True when the body uses literal backslash-n as the dominant line break + * (agent bug seen on #644) rather than real newlines. + */ +function hasEscapedNewlines(text) { + const escaped = (text.match(/\\n/g) || []).length; + if (escaped < 2) return false; + const real = (text.match(/\n/g) || []).length; + return escaped > real; +} + +function countContentBlocks(text) { + const blocks = text + .split(/\n\s*\n/) + .map((b) => b.trim()) + .filter(Boolean); + if (blocks.length >= 2) return blocks.length; + const bullets = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => /^[-*+]\s+\S/.test(l)); + return Math.max(blocks.length, bullets.length); +} + +function assessPrDescription(body) { + if (typeof body !== "string" || !body.trim()) { + return { ok: false, reason: "empty" }; + } + if (hasEscapedNewlines(body)) { + return { ok: false, reason: "escaped_newlines" }; + } + const cleaned = clean(body); + if (!cleaned) { + const strippedComments = body.replace(//g, "").trim(); + if (!strippedComments) return { ok: false, reason: "empty" }; + if (isPlaceholderOnlyValue(strippedComments)) { + return { ok: false, reason: "placeholder" }; + } + return { ok: false, reason: "empty" }; + } + if (isPlaceholderOnlyValue(cleaned)) { + return { ok: false, reason: "placeholder" }; + } + if (hasSubstantialStructuredContent(cleaned, MIN_SECTION_LEN, MIN_RICH_SECTIONS)) { + return { ok: true }; + } + if ( + cleaned.length >= UNSTRUCTURED_MIN_LEN && + countContentBlocks(cleaned) >= UNSTRUCTURED_MIN_BLOCKS + ) { + return { ok: true }; + } + return { ok: false, reason: "thin" }; +} + +function collectPrQualityFailures({ + baseRef, + allowedBases, + body, + behindMain, + behindBase, + authorPermission, + permissionLookupFailed = false, +}) { + const failures = []; + const wrongBase = !allowedBases.includes(baseRef); + if (wrongBase) { + failures.push({ code: "wrong_base" }); + } else { + const skipAncestry = + !permissionLookupFailed && authorHasPushPermission(authorPermission); + if (!skipAncestry && isWrongAncestry({ behindMain, behindBase })) { + failures.push({ code: "wrong_ancestry" }); + } + } + + const desc = assessPrDescription(body); + if (!desc.ok) { + failures.push({ code: "bad_description", reason: desc.reason }); + } + return failures; +} + +module.exports = { + ANCESTRY_BEHIND_THRESHOLD, + isWrongAncestry, + authorHasPushPermission, + assessPrDescription, + collectPrQualityFailures, + hasEscapedNewlines, +}; +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +node --test .github/scripts/pr-quality.test.cjs +``` + +Expected: all tests pass. + +- [ ] **Step 5: Wire path filters in `issue-quality-tests.yml`** + +In both `pull_request` and `push` `paths:` lists, add: + +```yaml + - ".github/scripts/pr-quality.cjs" + - ".github/scripts/pr-quality.test.cjs" +``` + +In the test step commands, add: + +```yaml + node --test .github/scripts/pr-quality.test.cjs +``` + +- [ ] **Step 6: Commit** + +```bash +git add .github/scripts/pr-quality.cjs .github/scripts/pr-quality.test.cjs .github/workflows/issue-quality-tests.yml +git commit -m "feat(ci): add pure PR ancestry and description quality checks" +``` + +--- + +### Task 2: Workflow orchestration (checkout + multi-gate) + +**Files:** +- Modify: `.github/workflows/enforce-pr-target.yml` +- Modify: `.github/scripts/enforce-pr-target.test.cjs` + +**Interfaces:** +- Consumes: `collectPrQualityFailures` from `pr-quality.cjs` +- Produces: workflow that on any failure drafts (soft-fail) + upserts multi-section comment + `core.setFailed`; on all-clear restores prior bot draft/title state + +- [ ] **Step 1: Extend static workflow tests (fail until yml updated)** + +Append to `.github/scripts/enforce-pr-target.test.cjs`: + +```js + it("listens for synchronize so rebase can clear ancestry failures", () => { + assert.match(workflow, /synchronize/); + }); + + it("checks out trusted default-branch scripts only (never PR head)", () => { + assert.match(workflow, /actions\/checkout@[0-9a-f]{40}/); + assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/); + assert.match(workflow, /sparse-checkout:\s*\.github\/scripts/); + assert.match(workflow, /persist-credentials:\s*false/); + assert.doesNotMatch(workflow, /ref:\s*\$\{\{\s*github\.event\.pull_request\.head/); + }); + + it("loads pr-quality via require from the checked-out scripts", () => { + assert.match(workflow, /pr-quality\.cjs/); + assert.match(workflow, /collectPrQualityFailures/); + }); +``` + +- [ ] **Step 2: Run static test — expect FAIL** + +```bash +node --test .github/scripts/enforce-pr-target.test.cjs +``` + +Expected: FAIL on new assertions (no synchronize / checkout / pr-quality yet). + +- [ ] **Step 3: Rewrite `enforce-pr-target.yml` job steps** + +Replace the single-step job with two steps. Keep the existing GraphQL helpers, comment marker, title prefix, and soft-fail draft pattern from #631. + +1. Add `synchronize` to `on.pull_request_target.types`. +2. First step: trusted checkout + +```yaml + - name: Checkout trusted PR-quality scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts +``` + +3. Second step: `github-script` that: + +```js +const path = require("path"); +const { collectPrQualityFailures } = require( + path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), +); +``` + +Then: + +- `pulls.get` for live PR (include `body`, `head.sha`, `user.login`, `base.ref`, `draft`, `title`, `node_id`) +- `repos.getCollaboratorPermissionLevel` — on error set `permissionLookupFailed = true` and warn +- If base allowed: `repos.compareCommitsWithBasehead` for `main...${headSha}` and `${base}...${headSha}`; read `behind_by` +- `failures = collectPrQualityFailures({...})` +- If `failures.length > 0`: + - Upsert one comment listing each failure section (`wrong_base`, `wrong_ancestry`, `bad_description`) + - Title-prefix **only** when `wrong_base` + - Soft-fail `convertToDraft` like #631; checkpoint ownership in comment state + - `core.setFailed(summary)` and return +- If no failures and `storedState?.active`: restore title/ready like #631; success comment +- If no failures and no active state: `core.info` and return + +Accept bot comments containing either `` or `` when locating state. + +Hidden state keeps `version`, `active`, `autoDraftedByBot`, `titlePrefixedByBot`; may add `ancestryFailed` / `descriptionFailed` booleans. + +- [ ] **Step 4: Run static tests — expect PASS** + +```bash +node --test .github/scripts/enforce-pr-target.test.cjs .github/scripts/pr-quality.test.cjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/enforce-pr-target.yml .github/scripts/enforce-pr-target.test.cjs +git commit -m "feat(ci): enforce PR ancestry and description in target gate" +``` + +--- + +### Task 3: Harness + behavioral CI tests + +**Files:** +- Modify: `tests/helpers/enforce-pr-target-harness.ts` +- Modify: `tests/ci-workflows.test.ts` + +**Interfaces:** +- Consumes: workflow script that `require`s `pr-quality.cjs` and calls compare/permission APIs +- Produces: harness options for `body`, compare fixtures, permission; scoped `require` for `.github/scripts/*` only + +- [ ] **Step 1: Extend harness** + +1. Add to `RunOptions`: + - `authorPermission?: string` (default `"read"`) + - `failPermissionLookup?: boolean` + - `compareByBasehead?: Record` +2. Ensure `pr.body` and `pr.head.sha` are present on `pulls.get` payload. +3. DEFAULT_PR must include a **passing** description (two rich sections) and default compares that are **not** wrong ancestry (`main...sha` → `behind_by: 5`, `dev...sha` → `behind_by: 0`). +4. Replace `require: forbidden("require")` with a scoped loader: + +```ts +import { createRequire } from "node:module"; +import path from "node:path"; + +const nodeRequire = createRequire(path.join(process.cwd(), "package.json")); +const scriptsRoot = path.resolve(process.cwd(), ".github", "scripts"); + +function scopedRequire(id: string) { + calls.push({ method: "require", args: [id] }); + const resolved = path.isAbsolute(id) ? id : path.resolve(process.cwd(), id); + if (!resolved.startsWith(scriptsRoot + path.sep) && resolved !== scriptsRoot) { + // Also allow require of files already under scripts via absolute path from path.join + const norm = resolved.replace(/\\/g, "/"); + if (!norm.includes("/.github/scripts/")) { + throw new Error(`the script must not require ${id}`); + } + } + return nodeRequire(resolved); +} +``` + +5. Record `repos.getCollaboratorPermissionLevel` and `repos.compareCommitsWithBasehead` on the fake github client. + +- [ ] **Step 2: Update structural allowlist in `tests/ci-workflows.test.ts`** + +- Steps length **2**: checkout then github-script +- Pin checkout SHA `11bd71901bbe5b1630ceea73d27597364c9af683`, default_branch ref, `persist-credentials: false`, sparse `.github/scripts`, no PR head ref +- Types sorted: `edited`, `opened`, `ready_for_review`, `reopened`, `synchronize` +- Keep permissions object unchanged +- Keep “no `${{` in script” assertion on the github-script step only + +- [ ] **Step 3: Add behavioral scenarios** + +1. **Ancestry fail (#644):** base `dev`, permission `read`, `main...sha` behind 0 / `dev...sha` behind 44, good body → `setFailed`, draft attempted, ancestry in comment, **no** title prefix. +2. **Maintainer ancestry skip:** permission `write`, same compares, good body → no `setFailed`. +3. **Empty description:** good ancestry, `body: ""` → `setFailed` + draft. +4. **Escaped newlines:** body with literal `\\n` → `setFailed`. +5. **Clear after active:** prior bot state `active` + `autoDraftedByBot`, now good → mark ready, no `setFailed`. +6. Existing wrong-base scenarios still pass (title prefix + setFailed); give them a good body so description does not double-fail unless intended. + +- [ ] **Step 4: Run focused tests** + +```bash +node --test .github/scripts/pr-quality.test.cjs .github/scripts/enforce-pr-target.test.cjs +bun test tests/ci-workflows.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/helpers/enforce-pr-target-harness.ts tests/ci-workflows.test.ts +git commit -m "test(ci): cover PR ancestry and description enforcement paths" +``` + +--- + +### Task 4: Docs and agent policy notes + +**Files:** +- Modify: `docs-site/src/content/docs/contributing.md` +- Modify: `AGENTS.md` +- Modify: `MAINTAINERS.md` + +- [ ] **Step 1: Contributing (English)** + +Add a short **Pull requests** subsection: + +- Target `dev` (or `dev2-go` only for scoped Go work); never open ordinary PRs at `main`. +- Branch from current `dev`, not from `main`. CI rejects heads that sit on the `main` tip while far behind the PR base (the #644 failure mode). +- Include a real description (Summary + Test plan, or equivalent substance). Empty, placeholder-only, or escaped-`\n` bodies fail the required `enforce-target` check. +- Note that `pull_request_target` workflow updates apply after promotion to the repository default branch (same ops caveat as #631). + +Do not bulk-edit ja/ko/ru/zh-cn in this task. + +- [ ] **Step 2: AGENTS.md / MAINTAINERS.md** + +One sentence each: CI rejects main-based ancestry into `dev`/`dev2-go` and empty/thin/malformed PR descriptions; push-permission authors skip the ancestry heuristic only. + +- [ ] **Step 3: Commit** + +```bash +git add docs-site/src/content/docs/contributing.md AGENTS.md MAINTAINERS.md +git commit -m "docs: document PR ancestry and description quality gates" +``` + +--- + +### Task 5: Final validation + +- [ ] **Step 1: Run required gates** + +```bash +node --test .github/scripts/pr-quality.test.cjs .github/scripts/enforce-pr-target.test.cjs +bun test tests/ci-workflows.test.ts +bun run typecheck +bun run privacy:scan +``` + +Expected: all PASS. + +- [ ] **Step 2: Docs-site build** + +```bash +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Diff review** + +Confirm no unrelated files; no PR-head checkout; title prefix only on wrong base; description still enforced for maintainers. + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +| --- | --- | +| Wrong ancestry rule + threshold 20 | Task 1 | +| Maintainer push escape hatch; fail-closed permission | Task 1–3 | +| Description option 2 | Task 1 | +| Collect all failures; draft + setFailed | Task 2–3 | +| No `[WRONG BRANCH]` for ancestry/description | Task 2–3 | +| `synchronize` trigger | Task 2–3 | +| Trusted checkout only | Task 2–3 | +| Unit + harness + ci-workflows tests | Task 1–3, 5 | +| Contributing + AGENTS/MAINTAINERS | Task 4 | +| Default-branch promotion ops note | Task 4 | + +## Placeholder / consistency self-review + +- No TBD/TODO left in steps. +- `collectPrQualityFailures` / `assessPrDescription` names consistent across tasks. +- Checkout action SHA matches issue-quality (`11bd7190…`). +- DEFAULT_PR body/compares updated so legacy harness scenarios stay green. diff --git a/docs/superpowers/specs/2026-07-28-pr-quality-gates-design.md b/docs/superpowers/specs/2026-07-28-pr-quality-gates-design.md new file mode 100644 index 000000000..a5f00449b --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-pr-quality-gates-design.md @@ -0,0 +1,173 @@ +# PR quality gates (ancestry + description) — Design + +**Date:** 2026-07-28 +**Status:** Approved (brainstorm) +**Related:** #631 (wrong-base enforcer), #644 (motivating bad PR), `AGENTS.md` / `MAINTAINERS.md` branch table +**Branch base for implementation:** tip of #631 (`fix/enforce-pr-target-draft-fallback`) or `dev` after #631 merges + +## Problem + +`enforce-pr-target` only rejects wrong **base** refs (`main`, etc.). It does not catch: + +1. **Wrong ancestry** — head branched from `main` (or another release tip) while targeting `dev` / `dev2-go`, so the PR diff dumps already-released or unrelated commits into the integration branch (seen on #644: 0 behind `main`, 44 behind `dev`). +2. **Empty or low-quality descriptions** — blank bodies, comment-only bodies (e.g. only CodeRabbit release notes), placeholder-only text, literal `\n` escapes instead of real newlines, or thin bodies that lack a minimum “what/why” structure (option 2 from brainstorm). + +Wrong-base UX already drafts the PR, comments, and `setFailed`s the required check. New gates should reuse that pattern without overloading the `[WRONG BRANCH]` title prefix. + +## Goals + +- Fail the required `enforce-target` check when ancestry or description is unacceptable. +- Convert ready PRs to draft (soft-fail GraphQL, same as #631) and leave a single bot comment listing **all** open violations. +- Clear draft/comment/`setFailed` only when every gate passes (including after `synchronize` / body edits). +- Keep `pull_request_target` safe: **no checkout of PR head**; GitHub compare/API only; pure validators unit-tested offline. +- Escape hatch for maintainers with repo `push` so intentional release / promotion work is not blocked by the ancestry heuristic. + +## Non-goals + +- Rejecting local machine paths (`E:\…`), “tests not run” admissions, or forcing a fixed PR template (deferred). +- Auto-retargeting or auto-rebasing contributor branches. +- Retitling with `[WRONG BRANCH]` for ancestry/description (prefix stays wrong-**base** only). +- Blocking Dependabot / GitHub App bots beyond what the existing workflow already does (document any bot skips if added). + +## Approach + +**Extend the existing enforcer (Approach A):** extract pure checks into `.github/scripts/pr-quality.cjs`, orchestrate from `enforce-pr-target.yml` (or a thin shared runner), reuse draft/comment/`setFailed` state machine. + +Rejected alternatives: soft gate only (B — weaker); separate workflow (C — duplicated draft/comment machinery). + +## Gate composition + +Evaluate in order; collect **all** failures before mutating: + +1. **Wrong base** (existing) — `base ∉ {dev, dev2-go}` +2. **Wrong ancestry** (new) — when base is allowed +3. **Bad description** (new) — always when base is allowed (and optionally also when base is wrong, so authors fix body while retargeting; **default: run description whenever we have a PR body**, independent of ancestry) + +If any failure is active: + +- Upsert one bot comment (existing marker family, extended state) listing every open issue. +- Draft the PR if ready (soft-fail). +- `core.setFailed` with a concise summary (required check red). + +If previously active and now all clear: restore ready only if bot drafted; update comment to success; do not leave `setFailed`. + +## Wrong ancestry + +### Inputs (API only) + +For `base ∈ {dev, dev2-go}` and head SHA `H`: + +- `GET /repos/{owner}/{repo}/compare/main...{H}` → `{ ahead_by, behind_by }` +- `GET /repos/{owner}/{repo}/compare/{base}...{H}` → `{ ahead_by, behind_by }` + +No `actions/checkout` of the PR head. + +### Rule + +Flag **wrong ancestry** when: + +```text +behind_main === 0 +AND behind_base >= ANCESTRY_BEHIND_THRESHOLD # default 20 +``` + +This matches #644 (`behind_main = 0`, `behind_dev = 44`). + +Optional refinement (not required for v1): also require `ahead_main <= ahead_base` so a long-lived fork that somehow sits on `main` tip but is not dumping main-only commits is less likely to false-positive. Prefer shipping the two-clause rule first with tests against recorded compare fixtures. + +### Escape hatch + +Skip the ancestry gate when the PR author has **push** permission on the base repository (`GET /repos/{owner}/{repo}/collaborators/{login}/permission` → `admin` | `maintain` | `write`). Contributors / fork authors without push remain gated. + +Do **not** skip description quality for maintainers (empty/bad bodies remain rejected). + +### Threshold + +`ANCESTRY_BEHIND_THRESHOLD = 20` constant in the script. Document in comment why (tolerant of slightly stale `dev` forks; catches “branched from current main”). + +## Description quality (option 2) + +### Normalize body + +1. Strip HTML comments (``), including CodeRabbit release-notes blocks. +2. Trim; treat placeholder-only whole body / lines like issue-quality (`N/A`, `TODO`, `No response`, …) as empty. +3. Detect **escaped newlines**: if the cleaned body contains few or no real `\n` characters but contains the two-character sequence `\` + `n` (or `\` + `r` + `\` + `n`) as a dominant separator, classify as **malformed** (fail). #644’s API body showed literal `\n` sequences. + +### Accept when (after normalize) + +**Substantial structured content**, either: + +- **Structured path:** ≥ 2 markdown sections (h2–h4) whose cleaned text is each ≥ 40 characters, **or** +- **Unstructured path:** cleaned body length ≥ 120 characters **and** at least 2 bullets and/or paragraph breaks (real newlines separating non-empty blocks). + +### Reject when + +| Condition | Code / message key | +| --- | --- | +| Empty after strip | `empty` | +| Placeholder-only | `placeholder` | +| Escaped-newline malformed | `escaped_newlines` | +| Not substantial by either path | `thin` | + +Reuse helpers from `issue-quality.cjs` where practical (placeholder / section richness), or duplicate minimal copies to avoid coupling PR and issue workflows if import paths are awkward in Actions. Prefer shared tiny helpers over drift. + +## Workflow / UX + +### Triggers + +Extend `pull_request_target` types with **`synchronize`** so a rebase onto `dev` re-evaluates ancestry and can clear the failure. Keep: `opened`, `reopened`, `edited`, `ready_for_review`. + +### Comment shape + +Single bot comment (existing `` marker **or** rename to a neutral `` with migration: accept either marker when finding the comment). Body sections: + +- Wrong target branch (existing copy) +- Wrong branch ancestry (rebase onto current `base`; do not open from `main`) +- Pull request description (what failed + what “good enough” means) + +Hidden JSON state extended with flags such as `ancestryFailed`, `descriptionFailed`, plus existing `autoDraftedByBot` / `titlePrefixedByBot` / `active`. + +Title prefix `[WRONG BRANCH]` remains **only** for wrong base. + +### Permissions + +Unchanged from #631: `contents: write` + `pull-requests: write` for draft GraphQL; still no untrusted checkout. + +## Testing + +| Layer | Coverage | +| --- | --- | +| `.github/scripts/pr-quality.test.cjs` | Pure rules: #644-like compare fixture fails ancestry; behind_main>0 passes; maintainer skip N/A at pure layer; empty / comment-only / escaped `\n` / thin / good structured / good unstructured bodies | +| `enforce-pr-target.test.cjs` / harness | Workflow wires `synchronize`; calls quality module; `setFailed` when ancestry or description fails; soft-fail draft still; no checkout | +| `tests/ci-workflows.test.ts` | Permissions + trigger types stay in sync | + +Offline fixtures only — no live GitHub in unit tests. + +## Docs / policy + +- Short note in contributing docs (docs-site) when user-facing: PRs must target `dev`, be based on current `dev` (not `main`), and include a real description. +- `AGENTS.md` / `MAINTAINERS.md` already define branch targets; add one sentence that CI rejects main-based ancestry and empty/thin PR bodies once shipped. +- Reminder: `pull_request_target` only picks up workflow changes after promotion to the **default branch** (and typically `dev` for fork PR path consistency) — same ops note as #631. + +## Security + +- No execution of PR head code. +- Compare API uses base-repo token; head SHA is attacker-influenced only as an opaque ref for compare (GitHub-side). Do not interpolate head ref into shell. +- Collaborator permission lookup is read-only; failure of that API should **fail closed for ancestry** (treat as non-maintainer) or soft-skip with warning — choose **fail closed** (apply ancestry gate) to avoid accidental bypass. + +## Rollout + +1. Land #631 if not already merged. +2. Implement this design on a follow-up branch from #631 tip / `dev`. +3. After merge + default-branch promotion, verify on a synthetic fork PR (main-based head → `dev`, empty body) that draft + red check appear, then fix body + rebase and confirm clear. + +## Open constants (locked for v1) + +| Name | Value | +| --- | --- | +| `ANCESTRY_BEHIND_THRESHOLD` | `20` | +| Min section length | `40` | +| Min rich sections | `2` | +| Unstructured min length | `120` | +| Unstructured min blocks | `2` | +| Release compare ref | `main` | diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 4216f1543..61459e58b 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -1,5 +1,14 @@ # OpenCodex GUI — agent rules +This file applies to `gui/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Ownership and generated output + +- `gui/` is the React + Vite dashboard. +- `gui/dist/` is generated packaged output. Do not edit it by hand. +- Use the existing component, state, routing, styling, and data-access patterns before introducing a new abstraction. +- Keep dashboard behavior aligned with the management API and provider configuration model. + ## Text and i18n - **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. @@ -19,7 +28,33 @@ - Keep **code comments** (including shell `# …` comments in samples). Never strip them to “satisfy” i18n. - Run `bun run lint:i18n` after UI copy changes; fix real violations before committing. If a hit is technical, extend the allowlist or put the string in `
`/`` — do not invent nonsense translation keys.
 
+## Implementation rules
+
+- Preserve accessibility: keyboard operation, labels, focus behavior, semantic controls, and readable validation errors.
+- Do not introduce a dependency for behavior already provided by the current stack or a small local implementation.
+- Dependency changes require explicit security review.
+- Update `docs-site/` when dashboard behavior, setup, or configuration changes for users.
 
 ## Failure mode
 
 Hardcoding English (or German) in JSX to “fix” a bad translation is **not** allowed. Add or fix the key in all locale files instead.
+
+## Required validation
+
+Run all of the following for every functional `gui/` change:
+
+```bash
+cd gui
+bun test tests
+bun run lint
+bun run build
+```
+
+After any UI-copy or locale change, also run:
+
+```bash
+cd gui
+bun run lint:i18n
+```
+
+Run the repository-level checks required by any non-GUI files changed in the same work.
diff --git a/gui/README.md b/gui/README.md
index fdbd4f2be..8981908b9 100644
--- a/gui/README.md
+++ b/gui/README.md
@@ -34,8 +34,8 @@ the package layout used by `ocx gui`.
 ```bash
 cd gui
 bun run lint         # ESLint — hard local/CI gate (`GUI lint` in CI)
-bun run doctor       # React Doctor vs origin/main (changed-scope, advisory)
-bun run doctor:full  # Full-project React Doctor scan
+bun run doctor       # React Doctor vs origin/main (changed-scope, gates on findings)
+bun run doctor:full  # Full-tree React Doctor (gates on findings)
 ```
 
 From the repo root:
@@ -49,6 +49,6 @@ bun run setup:hooks             # pre-push runs doctor when gui/ changed
 | Tool | Role |
 |------|------|
 | **ESLint** (`bun run lint`) | Hard gate in CI and expected before merge |
-| **React Doctor** (`bun run doctor`) | Advisory React health check pinned to react-doctor 0.9.1. Pre-push runs it only if `gui/` changed and never blocks the push. The CI workflow reports to the step log only |
+| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.2 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding |
 
 Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage.
diff --git a/gui/doctor.config.json b/gui/doctor.config.json
index f9bd272c2..46169c5bf 100644
--- a/gui/doctor.config.json
+++ b/gui/doctor.config.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://react.doctor/schema/config.json",
   "scope": "full",
-  "blocking": "none",
+  "blocking": "warning",
   "ignore": {
     "files": ["dist/**", "node_modules/**"]
   },
diff --git a/gui/package.json b/gui/package.json
index b8e26c169..ce7f602b0 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -9,8 +9,8 @@
     "lint": "eslint .",
     "test": "bun test tests",
     "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx",
-    "doctor": "npx --yes react-doctor@0.9.1 --verbose --scope changed --base origin/main --no-telemetry",
-    "doctor:full": "npx --yes react-doctor@0.9.1 --verbose --scope full --no-telemetry",
+    "doctor": "npx --yes react-doctor@0.9.2 --verbose --scope changed --base origin/main --no-telemetry",
+    "doctor:full": "npx --yes react-doctor@0.9.2 --verbose --scope full --no-telemetry",
     "preview": "vite preview"
   },
   "dependencies": {
diff --git a/gui/src/App.tsx b/gui/src/App.tsx
index 81a38269c..d60e839b8 100644
--- a/gui/src/App.tsx
+++ b/gui/src/App.tsx
@@ -21,6 +21,7 @@ import { installApiAuthFetch } from "./api";
 import { readJsonIfOk } from "./fetch-json";
 import { type Page } from "./app-routing";
 import { useAppRouteState } from "./use-app-route-state";
+import { requestProxyStop } from "./stop-proxy";
 
 installApiAuthFetch();
 
@@ -185,17 +186,16 @@ export default function App() {
   const handleStop = async () => {
     if (!confirm(t("dash.stopConfirm"))) return;
     setStopping(true);
-    try {
-      const res = await fetch(`${API_BASE}/api/stop`, { method: "POST" });
-      // A refusal (409: a service under another home owns this proxy) returns normally instead
-      // of dropping the connection, so the button would otherwise sit in "stopping…" forever
-      // with nothing explaining why.
-      if (!res.ok) {
-        setStopping(false);
-        const detail = await res.json().catch(() => null) as { message?: string } | null;
-        if (detail?.message) alert(detail.message);
-      }
-    } catch { /* connection drops — the proxy is going down as expected */ }
+    const outcome = await requestProxyStop(API_BASE, {
+      formatFailure: status => t("dash.stopFailed", { status: String(status) }),
+    });
+    // Refusals and restore failures return normally instead of dropping the connection.
+    // In both cases the proxy did not reach a clean-stop result, so re-enable the control
+    // and surface the server's remediation instead of leaving "stopping…" stuck forever.
+    if (!outcome.accepted) {
+      setStopping(false);
+      alert(outcome.message);
+    }
   };
 
   const brand = (
diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts
new file mode 100644
index 000000000..4ff6b860c
--- /dev/null
+++ b/gui/src/account-pool-strategy.ts
@@ -0,0 +1,69 @@
+export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first";
+
+export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [
+  "quota",
+  "round-robin",
+  "fill-first",
+] as const;
+
+export const DEFAULT_ACCOUNT_POOL_STRATEGY: AccountPoolStrategy = "quota";
+export const DEFAULT_ACCOUNT_POOL_STICKY_LIMIT = 1;
+export const MIN_ACCOUNT_POOL_STICKY_LIMIT = 1;
+export const MAX_ACCOUNT_POOL_STICKY_LIMIT = 100;
+
+const STRATEGY_SET = new Set(ACCOUNT_POOL_STRATEGIES);
+
+export function normalizeAccountPoolStrategy(value: unknown): AccountPoolStrategy {
+  return typeof value === "string" && STRATEGY_SET.has(value)
+    ? value as AccountPoolStrategy
+    : DEFAULT_ACCOUNT_POOL_STRATEGY;
+}
+
+export function normalizeAccountPoolStickyLimit(value: unknown): number {
+  return typeof value === "number"
+    && Number.isInteger(value)
+    && value >= MIN_ACCOUNT_POOL_STICKY_LIMIT
+    && value <= MAX_ACCOUNT_POOL_STICKY_LIMIT
+    ? value
+    : DEFAULT_ACCOUNT_POOL_STICKY_LIMIT;
+}
+
+/** Strict draft parse for sticky-limit inputs (1–100 integer). */
+export function parseAccountPoolStickyLimitDraft(value: string): number | null {
+  const trimmed = value.trim();
+  if (!/^\d+$/.test(trimmed)) return null;
+  const n = Number(trimmed);
+  return n >= MIN_ACCOUNT_POOL_STICKY_LIMIT && n <= MAX_ACCOUNT_POOL_STICKY_LIMIT ? n : null;
+}
+
+export type PoolStrategyFetch = (input: string, init: RequestInit) => Promise;
+
+export async function putCodexPoolStrategy(
+  apiBase: string,
+  body: { strategy?: AccountPoolStrategy; stickyLimit?: number },
+  fetchImpl: PoolStrategyFetch = (input, init) => fetch(input, init),
+): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> {
+  if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false };
+  try {
+    const response = await fetchImpl(`${apiBase}/api/codex-auth/pool-strategy`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        ...(body.strategy !== undefined ? { strategy: body.strategy } : {}),
+        ...(body.stickyLimit !== undefined ? { stickyLimit: body.stickyLimit } : {}),
+      }),
+    });
+    if (!response.ok) return { ok: false };
+    const json = await response.json() as {
+      accountPoolStrategy?: unknown;
+      accountPoolStickyLimit?: unknown;
+    };
+    return {
+      ok: true,
+      strategy: normalizeAccountPoolStrategy(json.accountPoolStrategy ?? body.strategy),
+      stickyLimit: normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit ?? body.stickyLimit),
+    };
+  } catch {
+    return { ok: false };
+  }
+}
diff --git a/gui/src/api.ts b/gui/src/api.ts
index fd6a777bb..664cde6a9 100644
--- a/gui/src/api.ts
+++ b/gui/src/api.ts
@@ -1,5 +1,12 @@
 let installed = false;
+/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */
 let promptInFlight: Promise | null = null;
+/**
+ * After the user cancels (or submits blank) once, suppress further prompts for this page
+ * lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex).
+ * A full reload clears module state and allows prompting again.
+ */
+let promptCancelled = false;
 
 function needsApiAuth(input: RequestInfo | URL): boolean {
   try {
@@ -31,6 +38,11 @@ function clearToken(): void {
   memoryToken = null;
 }
 
+/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
+function clearTokenIfCurrent(expected: string | null): void {
+  if (expected != null && readToken() === expected) clearToken();
+}
+
 function clearLegacySessionToken(): void {
   try {
     sessionStorage.removeItem(LEGACY_TOKEN_KEY);
@@ -46,11 +58,31 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke
   return [input, { ...init, headers }];
 }
 
-async function promptForToken(): Promise {
+/**
+ * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard
+ * fan-out does not open one window.prompt per /api request (#647). Re-reads memoryToken before
+ * prompting so waiters that wake after another request already stored a token do not re-prompt.
+ */
+async function resolveTokenAfter401(failedToken: string | null): Promise {
+  if (promptCancelled) return null;
   if (promptInFlight) return promptInFlight;
-  promptInFlight = Promise.resolve()
-    .then(() => window.prompt("OpenCodex API token")?.trim() || null)
-    .finally(() => { promptInFlight = null; });
+
+  promptInFlight = (async () => {
+    if (promptCancelled) return null;
+    const current = readToken();
+    if (current && current !== failedToken) return current;
+
+    const prompted = window.prompt("OpenCodex API token")?.trim() || null;
+    if (prompted) {
+      storeToken(prompted);
+      return prompted;
+    }
+    promptCancelled = true;
+    return null;
+  })().finally(() => {
+    promptInFlight = null;
+  });
+
   return promptInFlight;
 }
 
@@ -68,14 +100,23 @@ export function installApiAuthFetch(): void {
     const response = await originalFetch(firstInput, firstInit);
     if (response.status !== 401) return response;
 
-    if (token) clearToken();
-    const nextToken = await promptForToken();
+    // Another request may have stored a token while this one was in flight (or while prompt blocked).
+    const refreshed = readToken();
+    if (refreshed && refreshed !== token) {
+      const [retryInput, retryInit] = withToken(input, init, refreshed);
+      const retry = await originalFetch(retryInput, retryInit);
+      if (retry.status !== 401) return retry;
+      clearTokenIfCurrent(refreshed);
+    } else {
+      clearTokenIfCurrent(token);
+    }
+
+    const nextToken = await resolveTokenAfter401(token);
     if (!nextToken) return response;
 
-    storeToken(nextToken);
     const [retryInput, retryInit] = withToken(input, init, nextToken);
     const retry = await originalFetch(retryInput, retryInit);
-    if (retry.status === 401) clearToken();
+    if (retry.status === 401) clearTokenIfCurrent(nextToken);
     return retry;
   };
 }
@@ -85,4 +126,5 @@ export function resetApiAuthFetchForTests(): void {
   installed = false;
   memoryToken = null;
   promptInFlight = null;
+  promptCancelled = false;
 }
diff --git a/gui/src/bounded-fetch.ts b/gui/src/bounded-fetch.ts
new file mode 100644
index 000000000..bec2d83ed
--- /dev/null
+++ b/gui/src/bounded-fetch.ts
@@ -0,0 +1,31 @@
+/**
+ * Bound a fetch with AbortSignal.timeout when available; otherwise a manual
+ * timer that must be cleared after settlement / unmount.
+ */
+
+export type BoundedFetch = {
+  controller: AbortController;
+  signal: AbortSignal;
+  clear: () => void;
+};
+
+export function createBoundedFetch(ms: number): BoundedFetch {
+  const controller = new AbortController();
+  if (
+    typeof AbortSignal !== "undefined"
+    && typeof AbortSignal.any === "function"
+    && typeof AbortSignal.timeout === "function"
+  ) {
+    return {
+      controller,
+      signal: AbortSignal.any([controller.signal, AbortSignal.timeout(ms)]),
+      clear: () => undefined,
+    };
+  }
+  const timeoutId = setTimeout(() => controller.abort(), ms);
+  return {
+    controller,
+    signal: controller.signal,
+    clear: () => clearTimeout(timeoutId),
+  };
+}
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index 9634c885c..bc29bff70 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -15,6 +15,7 @@ export function intersectComboEfforts(
 ): ComboEffort[] {
   const complete = targets.filter((t) => t.provider.trim() && t.model.trim());
   if (complete.length === 0) return [...COMBO_EFFORTS];
+  const effortSet = new Set(COMBO_EFFORTS);
   let common: string[] | null = null;
   for (const target of complete) {
     const key = `${target.provider.trim()}/${target.model.trim()}`;
@@ -23,12 +24,16 @@ export function intersectComboEfforts(
     // supportedLadderFor is undefined (#488 / Codex review).
     const member: string[] = listed === undefined
       ? []
-      : listed.filter((effort) => (COMBO_EFFORTS as readonly string[]).includes(effort));
-    common = common === null
-      ? member
-      : common.filter((effort) => member.includes(effort));
+      : listed.filter((effort) => effortSet.has(effort));
+    if (common === null) {
+      common = member;
+    } else {
+      const memberSet = new Set(member);
+      common = common.filter((effort) => memberSet.has(effort));
+    }
   }
-  return COMBO_EFFORTS.filter((effort) => common?.includes(effort) === true);
+  const commonSet = new Set(common ?? []);
+  return COMBO_EFFORTS.filter((effort) => commonSet.has(effort));
 }
 
 export interface ComboTarget {
diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx
new file mode 100644
index 000000000..57e315abe
--- /dev/null
+++ b/gui/src/components/AccountPoolStrategyControls.tsx
@@ -0,0 +1,91 @@
+import { useT } from "../i18n/shared";
+import {
+  ACCOUNT_POOL_STRATEGIES,
+  type AccountPoolStrategy,
+} from "../account-pool-strategy";
+
+const STRATEGY_LABEL_KEYS = {
+  quota: "accountPool.strategyQuota",
+  "round-robin": "accountPool.strategyRoundRobin",
+  "fill-first": "accountPool.strategyFillFirst",
+} as const;
+
+export interface AccountPoolStrategyControlsProps {
+  strategy: AccountPoolStrategy;
+  stickyDraft: string;
+  disabled?: boolean;
+  strategySelectId?: string;
+  stickyInputId?: string;
+  onStrategyChange(strategy: AccountPoolStrategy): void;
+  onStickyDraftChange(value: string): void;
+  onStickyCommit(): void;
+}
+
+/**
+ * Shared strategy select + round-robin sticky limit for Codex / Anthropic account pools.
+ */
+export default function AccountPoolStrategyControls({
+  strategy,
+  stickyDraft,
+  disabled = false,
+  strategySelectId = "account-pool-strategy",
+  stickyInputId = "account-pool-sticky-limit",
+  onStrategyChange,
+  onStickyDraftChange,
+  onStickyCommit,
+}: AccountPoolStrategyControlsProps) {
+  const t = useT();
+  return (
+    
+ +
+ {t("accountPool.strategyHint")} +
+ {strategy === "round-robin" && ( + + )} +
+ ); +} diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index a17753fa4..333971bbb 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -144,6 +144,7 @@ export default function AddProviderModal({ : p.baseUrl, authMode: p.auth, apiKey: "", + apiKeyTransport: undefined, defaultModel: p.defaultModel ?? "", allowPrivateNetwork: false, }, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9031cb139..c5a212e21 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -7,6 +7,7 @@ import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/u import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; +import CodexPoolStrategySetting from "./CodexPoolStrategySetting"; import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; import { readJsonIfOk } from "../fetch-json"; import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; @@ -28,7 +29,7 @@ const DOCTOR_CMD = "ocx doctor"; * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the * /api/config fetch); `banner` is an optional slot rendered above the main card * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page - * chrome — currently a no-op stub reserved for the Providers workspace. + * title chrome while retaining the shared account actions in the Providers workspace. */ export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController }: { apiBase: string; @@ -54,7 +55,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, load } = controller; + const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -102,7 +103,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) : null; - const activePoolNeedsReauth = accountNeedsReauth(activePoolAccount); + const activePoolNeedsReauth = !activePoolAccount?.paused && accountNeedsReauth(activePoolAccount); useEffect(() => { onActiveNeedsReauthChange?.(activePoolNeedsReauth); @@ -155,6 +156,20 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setToast(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed")); }; + const togglePaused = async (account: CodexAccountEntry) => { + const paused = !account.paused; + const result = await controller.setAccountPaused(account.id, paused); + if (!result.ok && result.reason === "busy") return; + setConfirm(current => current?.id === account.id ? null : current); + setToastError(!result.ok); + setToast(t(result.ok + ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded" + : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { + email: account.alias ?? account.email, + })); + setTimeout(() => setToast(""), 5000); + }; + const remove = async (id: string) => { const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" }); if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; @@ -177,6 +192,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; + const pauseExhausted = async () => { + const result = await controller.pauseExhaustedAccounts(); + if (!result.ok && result.reason === "busy") return; + setToastError(!result.ok); + setToast(result.ok + ? result.pausedCount > 0 + ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) }) + : t("codexAuth.pauseExhaustedNone") + : t("codexAuth.pauseExhaustedFailed")); + setTimeout(() => setToast(""), 5000); + }; + const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); setResetConfirm(false); @@ -215,7 +242,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const main = accounts.find(a => a.isMain); const pool = accounts.filter(a => !a.isMain); - const isMainActive = !activeId || activeId === "__main__"; + const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); return ( @@ -224,7 +251,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban t={t} embedded={embedded} refreshingQuota={refreshingQuota} + pausingExhausted={pausingExhausted} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onRefresh={() => { void refreshQuotas(); }} + onPauseExhausted={() => { void pauseExhausted(); }} /> {toast && {toast}} @@ -246,6 +276,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} switchActionLabel={switchActionLabel} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} doctorCopyOutcomeFor={doctorCopy.outcomeFor} @@ -273,6 +306,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} onOpenReset={openResetPopup} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onReauth={openReauth} onEditAlias={editAlias} onRemove={remove} @@ -297,6 +333,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }} /> + + {confirm && ( (null); + const [stickyLimit, setStickyLimit] = useState(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT); + const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(false); + const [error, setError] = useState(null); + + const applyServer = useCallback((json: { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }) => { + const nextStrategy = normalizeAccountPoolStrategy(json.accountPoolStrategy); + const nextSticky = normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit); + setStrategy(nextStrategy); + setStickyLimit(nextSticky); + setStickyDraft(String(nextSticky)); + setLoadError(false); + setError(null); + }, []); + + const load = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/codex-auth/active`); + if (!res.ok) throw new Error("load"); + applyServer(await res.json() as { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }); + } catch { + setLoadError(true); + } + }, [apiBase, applyServer]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${apiBase}/api/codex-auth/active`); + if (!res.ok) throw new Error("load"); + const json = await res.json() as { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }; + if (cancelled) return; + applyServer(json); + } catch { + if (!cancelled) setLoadError(true); + } + })(); + return () => { + cancelled = true; + }; + }, [apiBase, applyServer]); + + const save = useCallback(async (next: { + strategy?: AccountPoolStrategy; + stickyLimit?: number; + }) => { + const previousStrategy = strategy; + const previousSticky = stickyLimit; + if (next.strategy !== undefined) setStrategy(next.strategy); + if (next.stickyLimit !== undefined) { + setStickyLimit(next.stickyLimit); + setStickyDraft(String(next.stickyLimit)); + } + setSaving(true); + setError(null); + const result = await putCodexPoolStrategy(apiBase, next); + if (result.ok) { + setStrategy(result.strategy); + setStickyLimit(result.stickyLimit); + setStickyDraft(String(result.stickyLimit)); + } else { + setError(t("accountPool.strategyUpdateFailed")); + if (previousStrategy) setStrategy(previousStrategy); + setStickyLimit(previousSticky); + setStickyDraft(String(previousSticky)); + } + setSaving(false); + }, [apiBase, stickyLimit, strategy, t]); + + const ready = strategy !== null; + const loading = !ready && !loadError; + + return ( +
+ {t("accountPool.strategy")} +
+ {loadError + ? t("accountPool.strategyLoadFailed") + : loading + ? t("common.loading") + : t("accountPool.strategyDesc")} +
+ {loadError && ( + + )} + {ready && ( + { + if (next === strategy) return; + void save({ strategy: next }); + }} + onStickyDraftChange={setStickyDraft} + onStickyCommit={() => { + const parsed = parseAccountPoolStickyLimitDraft(stickyDraft); + if (parsed === null) { + setStickyDraft(String(stickyLimit)); + setError(t("accountPool.stickyLimitInvalid")); + return; + } + if (parsed === stickyLimit) { + setStickyDraft(String(parsed)); + return; + } + void save({ stickyLimit: parsed }); + }} + /> + )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/gui/src/components/MemoryObservabilityCard.tsx b/gui/src/components/MemoryObservabilityCard.tsx index 4cccbeaab..d703f5262 100644 --- a/gui/src/components/MemoryObservabilityCard.tsx +++ b/gui/src/components/MemoryObservabilityCard.tsx @@ -2,14 +2,13 @@ import { useEffect, useState } from "react"; import { formatUptime } from "../formatUptime"; import { IconActivity } from "../icons"; import { useI18n, type Locale } from "../i18n/shared"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; /** - * Read-only Memory observability card. Polls GET /api/system/memory (the #314 WP3 - * service-process introspection surface) every 5s and renders scalar diagnostics - * only: no sliders, no restart toggle, no PUT. Observed memory is the largest - * of RSS, external, and ArrayBuffers so Windows working-set trimming does not - * hide committed retention; a rising continuation-store total under rising - * observed memory points at conversation retention. + * Memory observability card. Polls GET /api/system/memory (#314 WP3) every 5s + * and renders scalar diagnostics. Also hosts the confirm-gated Drain & restart + * action (#563): longer 60s drain, then respawn via ensure/service — not the + * short /api/stop teardown path. */ interface MemorySample { @@ -33,6 +32,7 @@ interface ResponseState { } interface SystemMemory { + pid?: number; rss: number; heapUsed: number; heapTotal: number; @@ -43,9 +43,14 @@ interface SystemMemory { jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null; /** Absent on older proxies whose /api/system/memory predates the continuation-store metrics. */ responseState?: ResponseState; + /** Absent on older proxies predating the drain-and-restart action (#563). */ + activeTurnCount?: number; + isDraining?: boolean; watchdog: { warnThresholdBytes: number; lastWarnAt: number | null; observedBytes?: number; observedMetric?: MemoryMetric; samples: MemorySample[] } | null; } +type RestartPhase = "idle" | "draining" | "reconnecting" | "error"; + /** * Render a byte count with a binary-scaled unit; non-finite/zero inputs render as "0 B". * The divisor is 1024, so the labels must be the binary ones (KiB/MiB/...). Labelling a @@ -127,46 +132,80 @@ function Stat({ label, value }: { label: string; value: string }) { ); } +const DRAIN_TIMEOUT_S = 60; +const RECONNECT_POLL_MS = 1500; +const RECONNECT_GIVE_UP_MS = 120_000; + export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }) { const { locale, t } = useI18n(); const [data, setData] = useState(null); const [unavailable, setUnavailable] = useState(false); + const [restartPhase, setRestartPhase] = useState("idle"); + const [restartError, setRestartError] = useState(null); + const [noSupervisor, setNoSupervisor] = useState(false); + const [supportsRestart, setSupportsRestart] = useState(false); + const [restartFromPid, setRestartFromPid] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${apiBase}/api/startup-health`); + if (!res.ok || cancelled) return; + const json = await res.json() as { protection?: string }; + if (!cancelled) setNoSupervisor(json.protection === "none"); + } catch { + /* older proxies / offline — leave warning off */ + } + })(); + return () => { cancelled = true; }; + }, [apiBase]); useEffect(() => { let cancelled = false; let inFlight = false; - let activeController: AbortController | null = null; + let active: BoundedFetch | null = null; const fetchMemory = async () => { // Serialize polls: a stalled request must not stack up or let an older // payload land after a newer one. if (inFlight) return; inFlight = true; - // Bound each poll so a hung request cannot pin inFlight forever and - // starve the unavailable fallback. Prefer AbortSignal.timeout; fall back - // to a manual timer when the browser lacks AbortSignal.any/timeout. - const controller = new AbortController(); - activeController = controller; - let timeoutId: ReturnType | undefined; - const signal = typeof AbortSignal !== "undefined" && "any" in AbortSignal && "timeout" in AbortSignal - ? AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) - : (() => { - timeoutId = setTimeout(() => controller.abort(), 10_000); - return controller.signal; - })(); + // Bound each poll so a hung request cannot pin inFlight forever. + const bounded = createBoundedFetch(10_000); + active = bounded; try { - const res = await fetch(`${apiBase}/api/system/memory`, { signal }); + const res = await fetch(`${apiBase}/api/system/memory`, { signal: bounded.signal }); if (!res.ok) throw new Error("memory unavailable"); const json = await res.json() as SystemMemory; - if (!cancelled) { - setData(json); - setUnavailable(false); + if (cancelled) return; + setData(json); + setUnavailable(false); + setSupportsRestart(typeof json.activeTurnCount === "number"); + if (json.isDraining && restartPhase === "idle") setRestartPhase("draining"); + // Fast recycle can finish between polls with no observed outage — detect pid change. + if ( + (restartPhase === "draining" || restartPhase === "reconnecting") + && restartFromPid != null + && typeof json.pid === "number" + && json.pid !== restartFromPid + && !json.isDraining + ) { + setRestartPhase("idle"); + setRestartFromPid(null); + setRestartError(null); } } catch { // Old servers (pre-#314) 404 this route; degrade to a quiet unavailable note. - if (!cancelled) setUnavailable(true); + // During drain/restart the proxy goes away — switch to reconnect polling. + if (cancelled) return; + if (restartPhase === "draining" || restartPhase === "reconnecting") { + setRestartPhase("reconnecting"); + } else { + setUnavailable(true); + } } finally { - if (timeoutId !== undefined) clearTimeout(timeoutId); - if (activeController === controller) activeController = null; + bounded.clear(); + if (active === bounded) active = null; inFlight = false; } }; @@ -174,12 +213,101 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } const interval = setInterval(() => void fetchMemory(), 5000); return () => { cancelled = true; - activeController?.abort(); + active?.controller.abort(); + active?.clear(); clearInterval(interval); }; - }, [apiBase]); + }, [apiBase, restartPhase, restartFromPid]); - if (unavailable && !data) { + useEffect(() => { + if (restartPhase !== "reconnecting") return; + let cancelled = false; + let inFlight = false; + let active: BoundedFetch | null = null; + const started = Date.now(); + const tick = () => { + if (inFlight || cancelled) return; + inFlight = true; + const bounded = createBoundedFetch(5_000); + active = bounded; + void fetch(`${apiBase}/healthz`, { cache: "no-store", signal: bounded.signal }) + .then(async (res) => { + if (cancelled) return; + if (!res.ok) { + if (Date.now() - started >= RECONNECT_GIVE_UP_MS) { + setRestartPhase("error"); + setRestartError(t("dash.mem.restartFailed")); + } + return; + } + let replaced = restartFromPid == null; + if (restartFromPid != null) { + try { + const health = await res.json() as { pid?: number }; + replaced = typeof health.pid === "number" && health.pid !== restartFromPid; + } catch { + replaced = true; + } + } + if (cancelled) return; + if (replaced) { + setRestartPhase("idle"); + setRestartFromPid(null); + setRestartError(null); + return; + } + if (Date.now() - started >= RECONNECT_GIVE_UP_MS) { + setRestartPhase("error"); + setRestartError(t("dash.mem.restartFailed")); + } + }) + .catch(() => { + if (cancelled) return; + if (Date.now() - started >= RECONNECT_GIVE_UP_MS) { + setRestartPhase("error"); + setRestartError(t("dash.mem.restartFailed")); + } + }) + .finally(() => { + bounded.clear(); + if (active === bounded) active = null; + inFlight = false; + }); + }; + tick(); + const interval = setInterval(tick, RECONNECT_POLL_MS); + return () => { + cancelled = true; + active?.controller.abort(); + active?.clear(); + clearInterval(interval); + }; + }, [apiBase, restartPhase, restartFromPid, t]); + + const confirmRestart = () => { + const count = data?.activeTurnCount ?? 0; + const lines = [ + t("dash.mem.restartConfirm", { count, seconds: DRAIN_TIMEOUT_S }), + ]; + if (noSupervisor) lines.push(t("dash.mem.restartNoSupervisor")); + if (!window.confirm(lines.join("\n\n"))) return; + void (async () => { + setRestartError(null); + setRestartFromPid(typeof data?.pid === "number" ? data.pid : null); + setRestartPhase("draining"); + try { + const res = await fetch(`${apiBase}/api/system/restart`, { method: "POST" }); + if (!res.ok) throw new Error("restart_failed"); + // Proxy will drain then exit; memory poll will trip reconnecting or pid change. + } catch { + setRestartPhase("error"); + setRestartFromPid(null); + setRestartError(t("dash.mem.restartFailed")); + } + })(); + }; + + if (unavailable && !data && restartPhase === "idle") { return (
@@ -196,6 +324,8 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } const observedBy = data ? observedMetric(data) : null; // Optional on purpose: a 200 from an older proxy may lack the responseState field. const responseState = data?.responseState; + const activeTurns = data?.activeTurnCount; + const busy = restartPhase === "draining" || restartPhase === "reconnecting"; return (
@@ -249,6 +379,37 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
)} + + {supportsRestart && ( +
+ + + {restartPhase === "draining" && ( + + {t("dash.mem.draining", { count: typeof activeTurns === "number" ? activeTurns : 0 })} + + )} + {restartPhase === "reconnecting" && ( + {t("dash.mem.reconnecting")} + )} + {restartPhase === "error" && restartError && ( + {restartError} + )} + {noSupervisor && restartPhase === "idle" && ( + {t("dash.mem.restartNoSupervisor")} + )} +
+ )}
); } diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index 89a3fbd8a..78cba31d3 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -143,6 +143,18 @@ export function AddProviderFormPane({ onFormChange({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> + {form.adapter === "anthropic" && form.authMode === "key" && ( + + + + )} )} {!isReservedForward && diff --git a/gui/src/components/add-provider-modal-reducer.ts b/gui/src/components/add-provider-modal-reducer.ts index db9775d62..97ee7cb17 100644 --- a/gui/src/components/add-provider-modal-reducer.ts +++ b/gui/src/components/add-provider-modal-reducer.ts @@ -56,7 +56,7 @@ export function createInitialAddProviderState( return { preset: initialCustom ? customPreset : null, form: initialCustom - ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "", allowPrivateNetwork: false } + ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", apiKeyTransport: undefined, defaultModel: "", allowPrivateNetwork: false } : null, saving: false, error: "", diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 478b0e8a2..605f3a458 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -1,5 +1,5 @@ import { useT } from "../i18n/shared"; -import { IconAlert, IconX } from "../icons"; +import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; import { displayAccountId } from "../lib/privacy"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; @@ -23,6 +23,9 @@ export function CodexAccountPoolCards({ threshold, onOpenReset, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onReauth, onEditAlias, onRemove, @@ -36,6 +39,9 @@ export function CodexAccountPoolCards({ threshold: number; onOpenReset: (account: CodexAccountEntry) => void; onSwitch: (account: CodexAccountEntry) => void; + onTogglePause: (account: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onReauth: (id: string) => void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; @@ -43,7 +49,7 @@ export function CodexAccountPoolCards({ doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); - const isNext = (id: string) => activeId === id; + const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; return ( <> @@ -54,24 +60,25 @@ export function CodexAccountPoolCards({ const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); return ( -
+
- + {a.alias ?? a.email} {a.plan && {a.plan}} + {a.paused && {t("codexAuth.paused")}} onOpenReset(a)} /> {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !showReauth && !inCooldown && ( + {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( @@ -86,6 +93,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(a.id))} )} + @@ -103,6 +119,7 @@ export function CodexAccountPoolCards({ {healthSummary && (
{healthSummary}
)} + {a.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx index 56c439b48..9c67d1ce8 100644 --- a/gui/src/components/codex-account-pool-helpers.tsx +++ b/gui/src/components/codex-account-pool-helpers.tsx @@ -1,10 +1,10 @@ -import type { TFn } from "../i18n/shared"; +import type { Locale, TFn } from "../i18n/shared"; import { IconTicket } from "../icons"; import type { CodexAccountEntry } from "./codex-account-pool-types"; -import { daysUntil, formatCreditDate } from "./codex-account-pool-utils"; +import { daysUntil, formatCreditDate, formatCreditDateTime } from "./codex-account-pool-utils"; -export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: { - index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn; +export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, locale, t }: { + index: number; grantedAt: string; expiresAt: string; isNext: boolean; locale: Locale; t: TFn; }) { const days = daysUntil(expiresAt); const urgent = days <= 7; @@ -18,8 +18,8 @@ export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: { {isNext && {t("codexAuth.creditNextBadge")}}
- {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })} - {t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })} + {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt, locale) })} + {t("codexAuth.creditExpires", { date: formatCreditDateTime(expiresAt, locale), days: String(days) })}
); diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da..97fd176e5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { IconLock, IconRefresh } from "../icons"; +import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons"; import QuotaBars from "./QuotaBars"; import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; @@ -23,6 +23,9 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, @@ -34,6 +37,9 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; @@ -45,6 +51,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -62,17 +69,20 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.paused && {t("codexAuth.paused")}} {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - + {!main?.paused && ( + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + )} - {!isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( @@ -82,12 +92,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))} )} + {main && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{healthSummary && (
{healthSummary}
)} + {main?.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} @@ -102,20 +124,43 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + pausingExhausted, + pauseBusy, onRefresh, + onPauseExhausted, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; onRefresh: () => void; + onPauseExhausted: () => void; }) { - if (embedded) return null; return ( -
-

{t("nav.codexAuth")}

- +
+ {!embedded &&

{t("nav.codexAuth")}

} +
+ + +
); } diff --git a/gui/src/components/codex-account-pool-utils.ts b/gui/src/components/codex-account-pool-utils.ts index 52d091eba..4462199cc 100644 --- a/gui/src/components/codex-account-pool-utils.ts +++ b/gui/src/components/codex-account-pool-utils.ts @@ -1,7 +1,14 @@ -import { formatCreditDate as formatCreditDateIntl } from "../intl-formatters"; +import { + formatCreditDate as formatCreditDateIntl, + formatCreditDateTime as formatCreditDateTimeIntl, +} from "../intl-formatters"; -export function formatCreditDate(iso: string): string { - return formatCreditDateIntl(iso); +export function formatCreditDate(iso: string, locale?: string): string { + return formatCreditDateIntl(iso, locale); +} + +export function formatCreditDateTime(iso: string, locale?: string): string { + return formatCreditDateTimeIntl(iso, locale); } export function daysUntil(iso: string): number { diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index 102c3e9ba..cc518029b 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { useT } from "../i18n/shared"; +import { useI18n } from "../i18n/shared"; import { IconAlert, IconTicket } from "../icons"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import { CodexCreditItem } from "./codex-account-pool-helpers"; @@ -26,7 +26,7 @@ export function CodexAccountResetModal({ onCancelConfirm: () => void; onRedeem: () => void; }) { - const t = useT(); + const { locale, t } = useI18n(); const dialogRef = useRef(null); useEffect(() => { @@ -61,7 +61,7 @@ export function CodexAccountResetModal({ {creditDetails && creditDetails.length > 0 && (
{creditDetails.map((c, i) => ( - + ))}
)} @@ -87,7 +87,7 @@ export function CodexAccountResetModal({

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

{creditDetails && creditDetails[0] && (

- {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })} + {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at, locale) })}

)}

{t("codexAuth.irreversible")}

diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx new file mode 100644 index 000000000..efa55d3f8 --- /dev/null +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -0,0 +1,274 @@ +/** + * Opt-in Anthropic OAuth account pool controls (#294). + * Experimental — shows a strong warning because the feature is not battle-tested. + */ +import { useCallback, useEffect, useState } from "react"; +import { useT } from "../../i18n/shared"; +import { + DEFAULT_ACCOUNT_POOL_STICKY_LIMIT, + DEFAULT_ACCOUNT_POOL_STRATEGY, + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + parseAccountPoolStickyLimitDraft, + type AccountPoolStrategy, +} from "../../account-pool-strategy"; +import AccountPoolStrategyControls from "../AccountPoolStrategyControls"; + +type PoolState = { + enabled: boolean; + threshold: number; + strategy: AccountPoolStrategy; + stickyLimit: number; +}; + +export default function AnthropicAccountPoolSettings({ + apiBase, + accountCount, +}: { + apiBase: string; + accountCount: number; +}) { + const t = useT(); + const [state, setState] = useState(null); + const [draft, setDraft] = useState("80"); + const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(false); + + useEffect(() => { + let cancelled = false; + const ac = new AbortController(); + const load = async () => { + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, { + signal: ac.signal, + }); + if (!res.ok) throw new Error("load"); + const json = await res.json() as { + enabled?: boolean; + autoSwitchThreshold?: number; + strategy?: unknown; + stickyLimit?: unknown; + }; + if (cancelled) return; + const nextEnabled = json.enabled === true; + const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80; + const nextStrategy = normalizeAccountPoolStrategy(json.strategy); + const nextSticky = normalizeAccountPoolStickyLimit(json.stickyLimit); + setState({ + enabled: nextEnabled, + threshold: nextThreshold, + strategy: nextStrategy, + stickyLimit: nextSticky, + }); + setDraft(String(nextThreshold)); + setStickyDraft(String(nextSticky)); + setLoadError(false); + } catch { + if (cancelled || ac.signal.aborted) return; + setLoadError(true); + } + }; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + cancelled = true; + ac.abort(); + window.clearTimeout(timer); + }; + }, [apiBase]); + + const save = useCallback(async (next: { + enabled: boolean; + threshold: number; + strategy: AccountPoolStrategy; + stickyLimit: number; + }) => { + const previousState = state; + setState({ + enabled: next.enabled, + threshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, + }); + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: next.enabled, + autoSwitchThreshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, + }), + }); + if (!res.ok) throw new Error("save"); + const json = await res.json().catch(() => null) as { + strategy?: unknown; + stickyLimit?: unknown; + } | null; + const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); + const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); + setState({ + enabled: next.enabled, + threshold: next.threshold, + strategy: savedStrategy, + stickyLimit: savedSticky, + }); + setDraft(String(next.threshold)); + setStickyDraft(String(savedSticky)); + } catch { + setError(t("anthropicPool.saveFailed")); + if (previousState) { + setState(previousState); + setDraft(String(previousState.threshold)); + setStickyDraft(String(previousState.stickyLimit)); + } + } finally { + setSaving(false); + } + }, [apiBase, state, t]); + + const enabled = state?.enabled === true; + const threshold = state?.threshold ?? 80; + const strategy = state?.strategy ?? DEFAULT_ACCOUNT_POOL_STRATEGY; + const stickyLimit = state?.stickyLimit ?? DEFAULT_ACCOUNT_POOL_STICKY_LIMIT; + const loading = state === null && !loadError; + // Always allow turning the pool off; only block enabling when fewer than 2 accounts. + const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2); + + return ( +
+
+
+ {t("anthropicPool.title")} +
+ {loadError + ? t("anthropicPool.loadFailed") + : loading + ? t("common.loading") + : enabled + ? t("anthropicPool.enabledDesc", { threshold }) + : t("anthropicPool.disabledDesc")} +
+
+ +
+ +
+ {t("anthropicPool.experimentalWarning")} +
+ + {accountCount < 2 && ( +
{t("anthropicPool.needTwoAccounts")}
+ )} + + {enabled && state && ( + <> + + + { + if (next === strategy) return; + void save({ + enabled: true, + threshold, + strategy: next, + stickyLimit, + }); + }} + onStickyDraftChange={setStickyDraft} + onStickyCommit={() => { + const parsed = parseAccountPoolStickyLimitDraft(stickyDraft); + if (parsed === null) { + setStickyDraft(String(stickyLimit)); + setError(t("accountPool.stickyLimitInvalid")); + return; + } + if (parsed === stickyLimit) { + setStickyDraft(String(parsed)); + return; + } + void save({ + enabled: true, + threshold, + strategy, + stickyLimit: parsed, + }); + }} + /> + + )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index efa61b33e..efa7bed39 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -19,7 +19,9 @@ import { oauthHealthShowsReauth, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; +import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginUrlBlock } from "../login-url-block"; +import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -104,6 +106,9 @@ export default function ProviderAuthPanel({
{isOauth && ( <> + {item.name === "anthropic" && ( + + )}