@kaydash9999/banker-plugins 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/3d-intro-build/SKILL.md +1 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
  14. package/skills/3d-intro-build/references/video-pool.mjs +11 -6
  15. package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
  16. package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
  17. package/skills/anti-drm/SKILL.md +2 -2
  18. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  19. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  20. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  21. package/skills/build-testbox/scripts/containers.mjs +8 -3
  22. package/skills/build-testbox/scripts/proc.mjs +32 -8
  23. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  24. package/skills/git-init/scripts/git-init.mjs +53 -5
  25. package/skills/git-init/scripts/marker-block.mjs +131 -19
  26. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  27. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  28. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  29. package/skills/lineage/SKILL.md +1 -1
  30. package/skills/lineage/lineage.py +11 -11
  31. package/skills/motion-sites/SKILL.md +87 -0
  32. package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
  33. package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
  34. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  35. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  36. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  37. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  38. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  39. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  40. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  41. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  42. package/skills/ralph-qa/SKILL.md +1 -0
  43. package/skills/ralph-qa/references/seat-run.mjs +21 -4
  44. package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
  45. package/skills/remains/SKILL.md +31 -4
  46. package/skills/remains/scripts/boxes.mjs +44 -15
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.24.1"
9
+ "version": "0.25.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.1] - 2026-11-03
4
+
5
+ ### Fixed
6
+ - `motion-sites`: Python 을 찾지 못해 첫 실행 구성이 멈출 때, 실패 이유와 `SKILL.md` 가 `MOTION_SITES_PYTHON` 으로 Python 을 지정하는 방법을 알린다. 전에는 PATH 밖의 Python(Windows 의 Miniconda, `py` 없이 Store 연결 파일만 있는 PC)을 쓸 방법이 안내되지 않았다. Windows 박스 실제 설치 시험에서 찾음.
7
+ - `motion-sites`: `MOTION_SITES_PYTHON` 으로 지정한 Python 이 실행되지 않으면 그 사실을 알리고 멈춘다. 전에는 PATH 의 다른 Python 이 있으면 알리지 않고 그쪽에 Playwright 를 설치했다. 지정한 Python 으로 만든 캐시 폴더 가상 환경은 그대로 찾는다.
8
+ - `3d-intro-build`, `3d-intro-setup`: 셸의 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이면 `resolveFfmpeg()` 가 그 까닭으로 멈춘다. 전에는 알리지 않고 설정 파일, PATH, `ffmpeg-static` 의 다른 ffmpeg 를 썼다. 설정 파일의 값은 예전처럼 쓸 수 없으면 다음 후보로 넘어간다.
9
+ - `remains`: 박스 시험 실행기(`boxes.mjs`)가 저장소의 index 를 쓰지 않는다. `--ref worktree` 의 `git stash create` 는 index 사본으로, `git status` 는 선택 잠금 없이 돈다. 두 실행을 동시에 하거나 사용자의 git 명령이 index 를 잠그고 있으면 "could not write index" 로 멈추던 것을 고친다. 실패 문구가 `git -c -c stash create` 처럼 깨져 보이던 것도 고친다.
10
+ - `remains`: root 로 접속해 `runAs` 계정으로 돌리는 Linux 박스는 개인 폴더를 `/tmp` 대신 `/var/lib` 아래에 만든다. 저장소 사본과 그 계정의 HOME 이 임시 폴더 아래에 있어, 임시 폴더를 거부하는 teamwork 의 설치 흐름 시험을 확인할 수 없던 것을 고친다.
11
+ - 오류 주입 시험으로 찾은 "실패를 성공으로 보고" 를 고친다(omc-patch, ralph-qa 의 seat-run, verifier-probe, 3d-intro 의 azure-adapter, video-pool).
12
+ - `omc-patch`: 풀다 멈춘 판 폴더로 전환하던 것(옆 폴더에 다 푼 뒤에만 판 이름으로 옮기고, hooks.json 이나 scripts/ 가 없는 폴더는 설치된 판으로 보지 않음), hooks.json 이 없거나 훅을 하나도 부르지 않거나 부르는 훅 파일이 없을 때 "실패 0건" 이던 것, 빈 백업이나 잘린 백업으로 되돌리고 "되돌리기 완료" 이던 것, 깨진 hooks.json 을 "이 도구가 지운 적 없음" 으로 넘기던 것, 잘린 패치 파일과 패치 뒤 새로 생긴 감싸지 않은 import 를 `--check` 가 "모두 적용" 으로 보던 것, 오류 문구가 있는 git 종료 1 을 "origin 없음" 으로 보던 것
13
+ - `verifier-probe`: 두 호출이 모두 실패한 codex 를 앉힐 수 있는 좌석으로 보고하던 것(이제 `cli-call-failed` 와 호출이 끝난 사정), 읽지 못한 설정 파일(권한, 폴더)을 없는 파일로 보고 모델을 채택하던 것, 빈 models.dev 사본(`{}`)으로 채택하던 것, SIGTERM 을 무시하는 CLI 에 끝없이 멈추던 것(SIGKILL), 오류 글을 판으로 적던 것
14
+ - `seat-run`: 코드펜스 안의 `VERDICT:` 줄을 좌석의 판정으로 세던 것, 실패한 좌석의 `facts` 에 판정이 남던 것. send 출력에 `answered`, `failed` 를 더한다
15
+ - `3d-intro`: ffmpeg 가 실패하거나 끝나지 않거나 아무것도 쓰지 않아도 앞서 있던 파일이나 반쯤 쓴 파일을 결과로 내던 것(이제 출력부터 지우고 종료 0 과 빈 파일 아님을 요구, 10분 시간 제한), 길이를 읽지 못하면 0초에서 겹치던 crossfade, 200 으로 온 HTML, JSON 오류 페이지를 영상으로 받던 것, 이미지가 아닌 답을 PNG 로 내던 것, 빈 시드 프레임으로 유료 작업을 보내던 것
16
+
17
+ ### Notes
18
+ - teamwork 시험은 저장소 사본이 임시 폴더 아래에 있으면(remains 박스 실행기 등) 설치 흐름 고정물을 홈 아래 난수 폴더에 두고 끝나면 지운다. 홈도 임시 폴더 아래면 설치 흐름 시험만 까닭을 대고 건너뛴다. 전에는 macOS, Rocky8 박스에서 74, 75건이 거짓 실패했다.
19
+ - `scripts/class-guards.test.mjs` 는 git 저장소가 아닌 사본(박스에 보내는 추적 파일 묶음)에서도 파일을 걸어 돈다.
20
+
21
+ ## [0.25.0] - 2026-11-02
22
+
23
+ ### Added
24
+ - 새 스킬 `motion-sites`: MotionSites 의 `motionsites-web-builder` 스킬(https://github.com/Motionsites-AI/claude-skill)을 짧은 이름으로 부른다. 원본은 banker 에 복사하지 않는다(원본의 예시와 미디어는 재배포 금지). 첫 실행 때 사용자의 Claude Code 나 Codex 가 MotionSites 마켓플레이스에서 원본 플러그인을 설치하고, `pip install playwright` 와 `python -m playwright install chromium` 으로 스크린숏 검사를 갖춘 뒤 프롬프트를 그대로 넘긴다. 그다음부터는 넘기기만 한다.
25
+ - 받기 전에 원천(github.com, PyPI, Playwright 내려받기 서버, 환경 변수나 git, pip 설정의 미러)에 닿는지 본다. 닿지 않으면 "오프라인이라 설치하지 않음" 으로 끝난다.
26
+ - pip 이 시스템 Python 을 거부하면(externally managed) `~/.cache/banker/motion-sites/venv` 에 설치한다.
27
+ - 원본의 유료 상품 안내 블록은 넣지 않고 결과 끝에 출처 한 줄을 남긴다.
28
+ - `remains`: "같은 종류 전체 찾기" 단계(2-1). 지적마다 원인을 한 문장으로 정하고, 같은 원인의 코드 꼴을 배포 코드 전체(사본 포함)에서 찾아 자리마다 판정한다. CHANGELOG, 위키, 메모리에 기록된 예전에 고친 결함도 종류로 삼는다. 지적 하나를 그 자리에서만 고쳐 다음 검토에서 같은 종류가 또 나오던 것을 막는다.
29
+
30
+ ### Fixed
31
+ - Windows 에서 추측할 수 있는 이름에 새 파일을 만들 때(`wx`, `O_EXCL`), 그 자리에 심어 둔 끊어진 링크를 따라가 프로젝트 밖에 파일을 만들던 자리를 전부 고친다. 임시 이름은 난수를 붙이고, 고정 이름(잠금, 정리 잠금, 백업, 처음 만드는 설정)은 열기 전후로 이름을 확인한다.
32
+ - 고친 스킬: `init-dir` 틀의 `service.mjs`, `progress-mon`, `smart-compact`, `setup-bypass-permissions`, `setup-omc-hud`, `payload-mon`, `omc-patch`, `vertical-pptx`, `lineage`, 사용량 기록 훅
33
+ - Codex 설정 백업(`config.toml` 의 `.bak`)은 백업 자리의 링크를 모든 OS 에서 따라가 덮어쓰던 것도 함께 고친다.
34
+ - 대소문자, Windows 8.3 짧은 이름, 링크인 부모 폴더, macOS `/var` 처럼 철자만 다른 같은 폴더를 다른 폴더로 보던 판정을 고친다.
35
+ - `init-dir`, `git-init`: `--root` 를 HOME 의 다른 철자(예: `c:\users\me`)로 주면 HOME 거부를 통과하던 것
36
+ - 훅 명령을 쓸 때 임시 폴더의 node 를 거부하는 판정(`smart-compact`, Codex 훅 설치)
37
+ - `progress-mon`: 자료 폴더 링크 검사, git 제외 목록, Codex 지시 합계
38
+ - `tune-tmux`: 같은 설정 파일을 다른 파일로 보아 내던 거짓 경고
39
+ - 표지 블록 모듈(`init-dir`, `git-init`, `tune-tmux`): 아직 없는 파일을 두 철자로 받으면 예외로 끝나던 것
40
+ - Windows 에서 이름만 준 프로그램(`git`, `ssh`, `scp`, `psmux`, `claude`, `codex`, `python`)을 일하는 폴더에서 먼저 찾아, 그 폴더에 심은 같은 이름의 프로그램이 실행되던 자리를 고친다. 이제 홈 폴더에서 띄우고 대상 폴더는 `git -C` 로 준다(`git-init`, `progress-mon`, `ultra-chain`, `remains`, `build-testbox`, `tune-tmux`, `motion-sites`).
41
+ - `build-testbox`: 시간 제한이 직접 자식만 끝내고 손자가 쥔 파이프를 기다려 멈추던 것. 이제 프로세스 그룹(Windows 는 트리)을 끝내고 곧 돌아온다. 링크가 낀 경로로 부르면 아무것도 하지 않고 종료 0 으로 끝나던 것도 고친다.
42
+ - `git-init`(Windows): `git commit` 이 시간 제한에 걸리면 래퍼만 끝나고 실제 git 이 남아, 실패로 보고한 뒤 커밋이 생길 수 있던 것.
43
+ - 잠금과 백업을 새로 만들 때 연 파일과 이름을 대조한 뒤에만 쓴다. 이름이 사라졌거나 다른 것이면 쓰지 않고, 쓰기가 실패하면 빈 파일을 지운다(빈 백업이 첫 원본처럼 남지 않게). 확인할 수 없을 때(ESTALE 등)는 예전처럼 쓴다.
44
+ - 백업 자리에 링크나 폴더가 있으면 원본을 바꾸지 않고 거부한다(`init-dir`, `git-init`, `tune-tmux` 는 계획 단계에서, `progress-mon` 규칙 줄, `anti-drm` 설정). 전에는 백업을 건너뛰고 원본을 바꿔 첫 원본을 잃었다.
45
+ - `anti-drm`: `anti-drm.json.bak` 자리에 일반 파일이 아닌 것이 있으면 설정을 저장하지 않고 거부한다.
46
+
47
+ ### Notes
48
+ - 같은 종류가 다시 들어오지 않게 저장소 시험 `scripts/class-guards.test.mjs` 가 배포 코드에서 pid 나 시각만으로 만든 임시, 잠금 이름을 잡는다.
49
+ - 사용량 기록 훅(`hooks/telemetry-count-codex.mjs`)이 바뀌어 Codex 플러그인 사용자는 다음 세션에서 `/hooks` 신뢰를 다시 묻는 화면을 본다.
50
+ - `lineage` 3.1.2.
51
+ - 실측: 테스트박스 3대에서 d4a1242 를 시험했다(통과/실패/건너뜀). Rocky Linux 8 2856/0/39, macOS 2852/0/43, Windows 2543/2/345. Windows 의 2건은 절대 경로를 다시 푸는 시험의 가정이었고 211ae51 에서 고쳐 그 두 시험 파일이 Windows 에서 56/56 통과했다.
52
+
3
53
  ## [0.24.1] - 2026-11-02
4
54
 
5
55
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [빠른 시작](#빠른-시작) · [워크플로 예시](#워크플로-사용-예시) · [구성](#구성) · [설치 상세](#설치-상세-npm--codex) · [설정 변경 지점](#설정-변경-지점-claude-code--codex) · [요구사항](#요구사항) · [업데이트 / 제거](#업데이트--제거) · [업데이트 확인](#업데이트-확인-및-사용량-카운팅) · [오프라인](#오프라인에서-쓰기) · [라이선스 / 서드파티](#라이선스--서드파티)
10
10
 
11
11
  □ banker는 다음을 포함하는 Claude Code 및 Codex CLI 플러그인입니다.
12
- - QA·보안 감사·문서·아키텍처·위키·미디어 작업과 의존성·개발환경(OS별) 설치를 아우르는 **스킬 70개 + 커맨드 2개**(총 72개 구성요소. Codex 는 Claude Code 전용 `ultraqa` 를 뺀 스킬 69개와 Codex 전용 스킬 3개)
12
+ - QA·보안 감사·문서·아키텍처·위키·미디어 작업과 의존성·개발환경(OS별) 설치를 아우르는 **스킬 71개 + 커맨드 2개**(총 73개 구성요소. Codex 는 Claude Code 전용 `ultraqa` 를 뺀 스킬 70개와 Codex 전용 스킬 3개)
13
13
  - Claude Code 즉시 명령 `/graceful-pause`, `/progress`
14
14
 
15
15
  □ 설치하면 스킬과 커맨드가 `/banker:<이름>` 네임스페이스로 노출됩니다.
@@ -180,6 +180,7 @@ Claude Code 2.1.296 은 Claude 5 모델(Opus 5.5 등)에 작업 목록 도구(Ta
180
180
  | 스킬 | 설명 |
181
181
  |---|---|
182
182
  | `motion-graphic-make` | 10초 내외 내레이션 없는 무료 모션 그래픽 제작 (hyperframes 위임). 계획 승인, 스냅숏과 렌더 검토 페이지, 독립 검증, 사이트 배치는 선택 |
183
+ | `motion-sites` | MotionSites 의 `motionsites-web-builder` 스킬을 짧은 이름으로 부름. 첫 실행 때 원본 플러그인을 MotionSites 마켓플레이스에서 설치하고 Playwright 와 Chromium 을 갖춘 뒤 프롬프트를 그대로 넘김. 원본은 banker 에 복사하지 않음(예시와 미디어 재배포 금지), 첫 실행에 네트워크 필요 |
183
184
  | `3d-intro-build` | 스크롤-스크럽 3D 인트로 사이트 제작 (Azure gpt-image 스틸 + WAN 키 풀 영상, 소진 시 Sora-2 폴백, 유료). 단계별 검토 페이지, 엄격한 CSP 에서 도는 페이지, 실제 브라우저 독립 검증. 영상 모드는 chain, dive 나가기 없음(기본), dive 나가기 있음 가운데 고름. 장면 단위 이동(장면 시작점에 멈춤, Home, End, 장면 점은 바로 이동)은 기본 켬이고 끌 수 있음, 기존 사이트 통합은 선택 |
184
185
 
185
186
  ### 스킬: 개발환경 setup (OS별 · 런타임별)
@@ -231,14 +232,14 @@ banker uninstall # 제거
231
232
 
232
233
  - `--scope project` 로 프로젝트 로컬(`./.codex`)에 설치하고, `--dry-run` 으로 미리 볼 수 있습니다.
233
234
  - non-root 전용입니다(전역 sudo 설치 시 root 소유 파일을 방지). postinstall이 없으므로 `banker setup` 을 직접 실행합니다.
234
- - Codex에는 스킬 69개와 Codex 전용 스킬 3개(`graceful-pause`, `progress`, `teamwork`)가 `~/.codex/skills/banker-<name>/` 에, 커맨드 2개가 `~/.codex/prompts/banker-<name>.md` 에 설치됩니다(`codex/manifest.json`). \
235
+ - Codex에는 스킬 70개와 Codex 전용 스킬 3개(`graceful-pause`, `progress`, `teamwork`)가 `~/.codex/skills/banker-<name>/` 에, 커맨드 2개가 `~/.codex/prompts/banker-<name>.md` 에 설치됩니다(`codex/manifest.json`). \
235
236
  `CODEX_HOME` 을 설정했으면 `~/.codex` 대신 그 폴더에 설치합니다(Codex 가 읽는 곳). \
236
237
  `lineage`, `progress`, 사용량 카운팅은 Codex 의 대화 기록(rollout)을 읽습니다. Codex 는 이 형식을 공개 약속으로 내놓지 않아 업데이트로 바뀔 수 있습니다. \
237
238
  Codex 를 업데이트한 뒤에는 `banker doctor` 로 형식을 점검합니다. 읽지 못하면 lineage 는 빈 페이지 대신 오류로 멈추고, progress 는 경고 줄을 보이며, 카운팅은 다음 세션 시작 때 경고를 한 번 보입니다. \
238
239
  Codex 0.144 이상에는 사용자 프롬프트가 없으므로 커맨드 2개는 스킬로도 설치되어 `$banker-front-qa`, `$banker-setup` 으로 부릅니다. \
239
240
  디렉터리명과 일치하도록 프론트매터 `name:` 이 `banker-<name>` 으로 재작성되어 Codex가 `banker-<name>` 으로 인식합니다.
240
241
  - Codex 플러그인 설치(`codex plugin add banker@banker-plugins`)는 저장소를 `~/.codex/plugins/cache/banker-plugins/banker/<판>/` 에 두고 `.codex-plugin/plugin.json` 을 읽습니다. \
241
- 스킬 69개, Codex 전용 스킬 3개, 커맨드 2개(스킬로)가 `$banker:<name>` 으로 보이고, 업데이트 알림과 사용량 카운팅 훅은 `codex/hooks.json` 에서 옵니다. \
242
+ 스킬 70개, Codex 전용 스킬 3개, 커맨드 2개(스킬로)가 `$banker:<name>` 으로 보이고, 업데이트 알림과 사용량 카운팅 훅은 `codex/hooks.json` 에서 옵니다. \
242
243
  Codex 는 세션을 시작할 때 마켓플레이스를 스스로 올려 새 판을 설치합니다. 훅 명령 앞에는 훅이 실행하는 파일의 해시가 붙어 있어, 훅 코드가 바뀐 판에서는 `/hooks` 가 다시 신뢰를 묻고 신뢰하기 전에는 훅이 돌지 않습니다. 훅 코드가 그대로인 판은 신뢰도 그대로입니다. 스킬 파일은 신뢰 확인 없이 새 판으로 바뀝니다. \
243
244
  obsidizer 훅은 npm 설치와 같이 `obsidizer --enable` 할 때만 사용자 범위에 설치됩니다(셸 호출마다 node 를 띄우므로). \
244
245
  훅은 Codex 샌드박스 밖에서 돌므로, PATH 의 node 가 임시 폴더나 작업 폴더 안에 있으면(샌드박스가 바꿀 수 있는 곳) 아무것도 돌리지 않고 그 이유를 세션 시작 알림으로 보입니다. 작업 폴더가 홈 폴더 자체이면 홈 아래의 node(nvm 등)는 그대로 씁니다. 훅 스크립트, 플러그인 폴더, node 가 `config.toml` 의 `[sandbox_workspace_write] writable_roots` 아래(그 항목 맨 위 `.codex` 제외)면 훅은 다른 일을 하지 않고 그 이유를 한 줄로 알립니다. 이 검사는 훅 파일 안에서 하므로, 샌드박스가 이미 그 파일을 고친 뒤에는 막지 못합니다. writable_roots 에 Codex 홈이나 node 가 든 폴더를 넣지 마십시오. \
@@ -449,7 +450,7 @@ banker setup
449
450
  - 세션 시작 훅의 업데이트 조회와 체크인은 따로 돌아 세션을 기다리게 하지 않습니다. 실패하면 24시간 동안 다시 시도하지 않고, 아예 시도하지 않게 하려면 `BANKER_NO_UPDATE_CHECK=1` 을 둡니다.
450
451
  - 훅은 `HTTPS_PROXY`, `HTTP_PROXY` 를 따르지 않습니다(Node 가 `NODE_USE_ENV_PROXY=1` 일 때만 따름). 프록시를 거쳐야만 밖에 닿는 망에서는 훅의 확인이 실패로 기록됩니다(`banker telemetry status` 의 "마지막 시도").
451
452
  - 네트워크가 필요한 스킬과 네트워크 없이 끝나는 모습
452
- - 내려받는 설치 스킬(`setup-omc-hud`, `setup-bypass-permissions` 를 뺀 `setup-*`, `docs-setup`, `vertical-pptx-setup`, `motion-graphic-setup`, `3d-intro-setup`): 웹 원천(npm, PyPI, GitHub 등)은 먼저 닿는지 보고, 닿지 못하면 받지 않고 "오프라인이라 설치하지 않음" 으로 알립니다. OS 패키지 관리자(dnf, apt, brew, winget)의 단계는 그 관리자의 미러 설정을 따르므로 미리 판정하지 않고 시도합니다. 단 `setup-pwsh` 는 winget 이 유일한 원천이라 그 원천을 먼저 봅니다.
453
+ - 내려받는 설치 스킬(`setup-omc-hud`, `setup-bypass-permissions` 를 뺀 `setup-*`, `docs-setup`, `vertical-pptx-setup`, `motion-graphic-setup`, `3d-intro-setup`, `motion-sites` 의 첫 실행 구성): 웹 원천(npm, PyPI, GitHub 등)은 먼저 닿는지 보고, 닿지 못하면 받지 않고 "오프라인이라 설치하지 않음" 으로 알립니다. OS 패키지 관리자(dnf, apt, brew, winget)의 단계는 그 관리자의 미러 설정을 따르므로 미리 판정하지 않고 시도합니다. 단 `setup-pwsh` 는 winget 이 유일한 원천이라 그 원천을 먼저 봅니다.
453
454
  - `update-banker`: 레지스트리에 닿지 못하면 어느 채널도 바꾸지 않고 멈춥니다.
454
455
  - `audit-security` 의 의존성 취약점 조회: 결과로 읽을 수 없는 도구는 0건이 아니라 "미커버" 로 적습니다.
455
456
  - `deep-research`, `lfm`, 이를 부르는 `ultra-chain` 1단계, `ultra-interview` 와 `rfp-author` 의 조사: 웹 도구가 모두 실패하면 조사하지 못했다고 알립니다. `lfm` 은 모델이 아는 관행으로 표를 채우지 않고 "웹 조사 불가" 로 끝납니다.
@@ -119,7 +119,11 @@ export function safeNode(node, { win = process.platform === 'win32', cwd = proce
119
119
  // absolute home names one (HOME=. would make every folder the home); path.relative compares as the OS does (case on Windows).
120
120
  const isHome = P.isAbsolute(home) && P.relative(real(home), real(cwd)) === '';
121
121
  const inWork = codex && !isHome;
122
- return !places.some((f) => under(f, os.tmpdir()) || (inWork && under(f, cwd)));
122
+ // Both sides as given and as the file system spells them (as hookGuard): node comes from process.execPath, which is
123
+ // the real path (/private/var on macOS, the long name on Windows), while TMPDIR or TEMP may be a link or an 8.3 name.
124
+ const temps = [os.tmpdir(), ...(win ? [] : ['/tmp'])].flatMap((d) => [d, real(d)]);
125
+ const works = [cwd, real(cwd)];
126
+ return !places.some((f) => temps.some((d) => under(f, d)) || (inWork && works.some((d) => under(f, d))));
123
127
  }
124
128
 
125
129
  // 실제 경로. 아직 없는 절대 경로는 있는 조상까지만 풀고 나머지를 붙인다(macOS 의 /var 는 /private/var 로 가는 링크라,
package/codex/hooks.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": ": banker-hooks-rev 7fad90560d65; x() { m=$(printf '%s' \"banker hooks skipped: $1\" | command -p tr -d '\\000-\\037\"\\\\'); printf '{\"systemMessage\":\"%s\"}\\n' \"$m\"; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; pr=$(cd -P \"$PLUGIN_ROOT\" 2>/dev/null && pwd -P) || x \"cannot enter the plugin folder\"; w \"$pr\" && x \"the plugin folder is in a temp folder or the session folder ($pr)\"; A='function dec(s, o, i, c) { o = \"\"; for (i = 1; i <= length(s); i++) { c = substr(s, i, 1); if (c == \"\\\\\" && substr(s, i + 1, 3) ~ /^[0-7][0-7][0-7]$/) { o = o sprintf(\"%c\", substr(s, i + 1, 1) * 64 + substr(s, i + 2, 1) * 8 + substr(s, i + 3, 1)); i += 3 } else o = o c }; return o } mac { i = index($0, \" on \"); j = 0; for (k = length($0); k > i; k--) if (substr($0, k, 2) == \" (\") { j = k; break }; if (!i || !j) next; m = substr($0, i + 4, j - i - 4); t = substr($0, j + 2); sub(/[,)].*/, \"\", t) } !mac { m = dec($2); t = $3 } (t ~ /^(nfs|nfs4|cifs|smbfs|smb3|afpfs|webdav|davfs|autofs|ceph|glusterfs|lustre|9p)$/ || t ~ /^fuse\\./) && m !~ /:/ { printf \"%s:\", m }'; if [ -r '/proc/self/mounts' ]; then M=$(LC_ALL=C command -p awk \"$A\" '/proc/self/mounts' 2>/dev/null); else M=$('/sbin/mount' 2>/dev/null | LC_ALL=C command -p awk -v mac=1 \"$A\" 2>/dev/null); fi; nm() { for z in $M; do case \"$1/\" in \"$z\"/*) return 0;; esac; done; return 1; }; hm() { for z in \"$h\" \"$HOME\"; do case \"$z\" in /?*) case \"$1/\" in \"$z\"/*) return 0;; esac;; esac; done; return 1; }; set -f; o=$IFS; IFS=:; a=; q=; for v in $PATH; do case \"$v\" in /*) ;; *) a=$a${a:+:}$v; continue;; esac; nm \"$v\" && ! hm \"$v\" && continue; a=$a${a:+:}$v; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; n=; [ -n \"$a\" ] && n=$(PATH=$a; command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do nm \"$d\" && continue; [ -x \"$d/node\" ] && n=$d/node && break; done; IFS=$o; set +f; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$pr\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$pr/hooks/run.cjs\" \"$pr/hooks/update-notify.mjs\" --codex --plugin",
10
- "commandWindows": "$null = 'banker-hooks-rev 7fad90560d65'; function x($m) { Write-Output (ConvertTo-Json -Compress @{ systemMessage = \"banker hooks skipped: $m\" }); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $pr = L $env:PLUGIN_ROOT; if (u $pr) { x \"the plugin folder is in a temp folder or the session folder ($pr)\" }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $pr -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$pr\\hooks\\run.cjs\" \"$pr\\hooks\\update-notify.mjs\" --codex --plugin",
9
+ "command": ": banker-hooks-rev 59265eebca3c; x() { m=$(printf '%s' \"banker hooks skipped: $1\" | command -p tr -d '\\000-\\037\"\\\\'); printf '{\"systemMessage\":\"%s\"}\\n' \"$m\"; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; pr=$(cd -P \"$PLUGIN_ROOT\" 2>/dev/null && pwd -P) || x \"cannot enter the plugin folder\"; w \"$pr\" && x \"the plugin folder is in a temp folder or the session folder ($pr)\"; A='function dec(s, o, i, c) { o = \"\"; for (i = 1; i <= length(s); i++) { c = substr(s, i, 1); if (c == \"\\\\\" && substr(s, i + 1, 3) ~ /^[0-7][0-7][0-7]$/) { o = o sprintf(\"%c\", substr(s, i + 1, 1) * 64 + substr(s, i + 2, 1) * 8 + substr(s, i + 3, 1)); i += 3 } else o = o c }; return o } mac { i = index($0, \" on \"); j = 0; for (k = length($0); k > i; k--) if (substr($0, k, 2) == \" (\") { j = k; break }; if (!i || !j) next; m = substr($0, i + 4, j - i - 4); t = substr($0, j + 2); sub(/[,)].*/, \"\", t) } !mac { m = dec($2); t = $3 } (t ~ /^(nfs|nfs4|cifs|smbfs|smb3|afpfs|webdav|davfs|autofs|ceph|glusterfs|lustre|9p)$/ || t ~ /^fuse\\./) && m !~ /:/ { printf \"%s:\", m }'; if [ -r '/proc/self/mounts' ]; then M=$(LC_ALL=C command -p awk \"$A\" '/proc/self/mounts' 2>/dev/null); else M=$('/sbin/mount' 2>/dev/null | LC_ALL=C command -p awk -v mac=1 \"$A\" 2>/dev/null); fi; nm() { for z in $M; do case \"$1/\" in \"$z\"/*) return 0;; esac; done; return 1; }; hm() { for z in \"$h\" \"$HOME\"; do case \"$z\" in /?*) case \"$1/\" in \"$z\"/*) return 0;; esac;; esac; done; return 1; }; set -f; o=$IFS; IFS=:; a=; q=; for v in $PATH; do case \"$v\" in /*) ;; *) a=$a${a:+:}$v; continue;; esac; nm \"$v\" && ! hm \"$v\" && continue; a=$a${a:+:}$v; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; n=; [ -n \"$a\" ] && n=$(PATH=$a; command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do nm \"$d\" && continue; [ -x \"$d/node\" ] && n=$d/node && break; done; IFS=$o; set +f; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$pr\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$pr/hooks/run.cjs\" \"$pr/hooks/update-notify.mjs\" --codex --plugin",
10
+ "commandWindows": "$null = 'banker-hooks-rev 59265eebca3c'; function x($m) { Write-Output (ConvertTo-Json -Compress @{ systemMessage = \"banker hooks skipped: $m\" }); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $pr = L $env:PLUGIN_ROOT; if (u $pr) { x \"the plugin folder is in a temp folder or the session folder ($pr)\" }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $pr -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$pr\\hooks\\run.cjs\" \"$pr\\hooks\\update-notify.mjs\" --codex --plugin",
11
11
  "timeout": 5
12
12
  }
13
13
  ]
@@ -18,8 +18,8 @@
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": ": banker-hooks-rev 7fad90560d65; x() { printf '%s\\n' \"banker hooks skipped: $1\" >&2; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; pr=$(cd -P \"$PLUGIN_ROOT\" 2>/dev/null && pwd -P) || x \"cannot enter the plugin folder\"; w \"$pr\" && x \"the plugin folder is in a temp folder or the session folder ($pr)\"; A='function dec(s, o, i, c) { o = \"\"; for (i = 1; i <= length(s); i++) { c = substr(s, i, 1); if (c == \"\\\\\" && substr(s, i + 1, 3) ~ /^[0-7][0-7][0-7]$/) { o = o sprintf(\"%c\", substr(s, i + 1, 1) * 64 + substr(s, i + 2, 1) * 8 + substr(s, i + 3, 1)); i += 3 } else o = o c }; return o } mac { i = index($0, \" on \"); j = 0; for (k = length($0); k > i; k--) if (substr($0, k, 2) == \" (\") { j = k; break }; if (!i || !j) next; m = substr($0, i + 4, j - i - 4); t = substr($0, j + 2); sub(/[,)].*/, \"\", t) } !mac { m = dec($2); t = $3 } (t ~ /^(nfs|nfs4|cifs|smbfs|smb3|afpfs|webdav|davfs|autofs|ceph|glusterfs|lustre|9p)$/ || t ~ /^fuse\\./) && m !~ /:/ { printf \"%s:\", m }'; if [ -r '/proc/self/mounts' ]; then M=$(LC_ALL=C command -p awk \"$A\" '/proc/self/mounts' 2>/dev/null); else M=$('/sbin/mount' 2>/dev/null | LC_ALL=C command -p awk -v mac=1 \"$A\" 2>/dev/null); fi; nm() { for z in $M; do case \"$1/\" in \"$z\"/*) return 0;; esac; done; return 1; }; hm() { for z in \"$h\" \"$HOME\"; do case \"$z\" in /?*) case \"$1/\" in \"$z\"/*) return 0;; esac;; esac; done; return 1; }; set -f; o=$IFS; IFS=:; a=; q=; for v in $PATH; do case \"$v\" in /*) ;; *) a=$a${a:+:}$v; continue;; esac; nm \"$v\" && ! hm \"$v\" && continue; a=$a${a:+:}$v; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; n=; [ -n \"$a\" ] && n=$(PATH=$a; command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do nm \"$d\" && continue; [ -x \"$d/node\" ] && n=$d/node && break; done; IFS=$o; set +f; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$pr\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$pr/hooks/run.cjs\" \"$pr/hooks/telemetry-count-codex.mjs\"",
22
- "commandWindows": "$null = 'banker-hooks-rev 7fad90560d65'; function x($m) { [Console]::Error.WriteLine(\"banker hooks skipped: $m\"); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $pr = L $env:PLUGIN_ROOT; if (u $pr) { x \"the plugin folder is in a temp folder or the session folder ($pr)\" }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $pr -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$pr\\hooks\\run.cjs\" \"$pr\\hooks\\telemetry-count-codex.mjs\"",
21
+ "command": ": banker-hooks-rev 59265eebca3c; x() { printf '%s\\n' \"banker hooks skipped: $1\" >&2; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; pr=$(cd -P \"$PLUGIN_ROOT\" 2>/dev/null && pwd -P) || x \"cannot enter the plugin folder\"; w \"$pr\" && x \"the plugin folder is in a temp folder or the session folder ($pr)\"; A='function dec(s, o, i, c) { o = \"\"; for (i = 1; i <= length(s); i++) { c = substr(s, i, 1); if (c == \"\\\\\" && substr(s, i + 1, 3) ~ /^[0-7][0-7][0-7]$/) { o = o sprintf(\"%c\", substr(s, i + 1, 1) * 64 + substr(s, i + 2, 1) * 8 + substr(s, i + 3, 1)); i += 3 } else o = o c }; return o } mac { i = index($0, \" on \"); j = 0; for (k = length($0); k > i; k--) if (substr($0, k, 2) == \" (\") { j = k; break }; if (!i || !j) next; m = substr($0, i + 4, j - i - 4); t = substr($0, j + 2); sub(/[,)].*/, \"\", t) } !mac { m = dec($2); t = $3 } (t ~ /^(nfs|nfs4|cifs|smbfs|smb3|afpfs|webdav|davfs|autofs|ceph|glusterfs|lustre|9p)$/ || t ~ /^fuse\\./) && m !~ /:/ { printf \"%s:\", m }'; if [ -r '/proc/self/mounts' ]; then M=$(LC_ALL=C command -p awk \"$A\" '/proc/self/mounts' 2>/dev/null); else M=$('/sbin/mount' 2>/dev/null | LC_ALL=C command -p awk -v mac=1 \"$A\" 2>/dev/null); fi; nm() { for z in $M; do case \"$1/\" in \"$z\"/*) return 0;; esac; done; return 1; }; hm() { for z in \"$h\" \"$HOME\"; do case \"$z\" in /?*) case \"$1/\" in \"$z\"/*) return 0;; esac;; esac; done; return 1; }; set -f; o=$IFS; IFS=:; a=; q=; for v in $PATH; do case \"$v\" in /*) ;; *) a=$a${a:+:}$v; continue;; esac; nm \"$v\" && ! hm \"$v\" && continue; a=$a${a:+:}$v; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; n=; [ -n \"$a\" ] && n=$(PATH=$a; command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do nm \"$d\" && continue; [ -x \"$d/node\" ] && n=$d/node && break; done; IFS=$o; set +f; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$pr\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$pr/hooks/run.cjs\" \"$pr/hooks/telemetry-count-codex.mjs\"",
22
+ "commandWindows": "$null = 'banker-hooks-rev 59265eebca3c'; function x($m) { [Console]::Error.WriteLine(\"banker hooks skipped: $m\"); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $pr = L $env:PLUGIN_ROOT; if (u $pr) { x \"the plugin folder is in a temp folder or the session folder ($pr)\" }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $pr -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$pr\\hooks\\run.cjs\" \"$pr\\hooks\\telemetry-count-codex.mjs\"",
23
23
  "timeout": 5
24
24
  }
25
25
  ]
@@ -63,6 +63,7 @@
63
63
  { "name": "cleansing-memory", "type": "skill", "target": "both", "reason": "keeps agent memory files inside their documented thresholds by de-duping to latest, condensing losslessly, and converting append-style records into replace-style", "supportingFiles": [], "caveat": "only MEMORY.md has a documented hard gate (200 lines or 25KB, whichever comes first); CLAUDE.md has NO documented size limit, so asserting a byte cap for it would be fabrication. Codex's 32768-byte project_doc_max_bytes covers project-scope AGENTS.md only, not the global one, and truncates raw bytes before a lossy UTF-8 decode, so cutting to an exact byte count corrupts the boundary character. Codebase-derivable CLAUDE.md trimming is left to Claude's own /doctor." },
64
64
 
65
65
  { "name": "motion-graphic-setup", "type": "skill", "target": "both", "reason": "installs hyperframes prereqs (Node22+ffmpeg per-OS) then npx hyperframes init/skills update/doctor; ffmpeg never bundled; runtime-agnostic installer", "supportingFiles": [], "caveat": "needs Node+ffmpeg; hyperframes npx-installed (Apache-2.0)" },
66
+ { "name": "motion-sites", "type": "skill", "target": "both", "reason": "a short name for MotionSites' own motionsites-web-builder skill (Motionsites-AI/claude-skill): the first run installs that plugin from MotionSites' marketplace in the runtime itself (claude plugin marketplace add and install, or codex plugin marketplace add and add), then pip install playwright and python -m playwright install chromium, and hands the user's prompt over unchanged; later runs only hand it over; nothing of the original is copied into banker, since its examples and media may not be redistributed", "supportingFiles": ["scripts/motion-sites.mjs"], "caveat": "needs the network on the first run (the plugin, Playwright and Chromium); pip refusing an externally managed Python puts Playwright in a virtual environment under ~/.cache/banker/motion-sites; on Codex setup asks for sandbox_permissions require_escalated; a plugin installed in this session shows in the skill list from the next session, so until then the original SKILL.md is read and followed; the original's sponsored upgrade block is left out and one line of credit kept" },
66
67
  { "name": "motion-graphic-make", "type": "skill", "target": "both", "reason": "thin wrapper delegating to hyperframes /motion-graphics (free form categories); owns no render pipeline, only the stage gates (plan approval, snapshot and render review, independent check) and the review page and preview server it shares with 3d-intro-build; runtime-agnostic", "supportingFiles": ["references/preview-lib.mjs", "references/serve.mjs", "references/curate.mjs"], "caveat": "needs hyperframes via motion-graphic-setup (npx, Apache-2.0); the three references are byte-identical copies of 3d-intro-build's (scripts/sync-adapter.js)" },
67
68
  { "name": "3d-intro-setup", "type": "skill", "target": "both", "reason": "collects Azure creds (direct or console-sample parse) to untracked env + FREE preflight; installs Node/ffmpeg; runtime-agnostic", "supportingFiles": ["references/azure-adapter.mjs", "references/video-pool.mjs"] },
68
69
  { "name": "3d-intro-build", "type": "skill", "target": "both", "reason": "scroll-scrub 3D intro: gpt-image stills, WAN key pool video with Sora-2 fallback (dive per scene by default, forward-chaining, or the optional hold-and-flight mode), a review page for every paid stage, a page with no inline script or style (strict CSP), scene-by-scene navigation on by default (can be turned off), an optional glass panel, and a Playwright measurement for the independent check; vendored scroll-world engine (MIT) left unmodified; cross-platform Node", "supportingFiles": ["references/azure-adapter.mjs", "references/video-pool.mjs", "references/scrub-engine.js", "references/index-template.html", "references/prompts.md", "references/LICENSE", "references/NOTICE.md", "references/preview-lib.mjs", "references/serve.mjs", "references/assemble.mjs", "references/curate.mjs", "references/intro-fixes.css", "references/panel-glass.css", "references/step-nav.js", "references/step-nav.css", "references/verify-intro.mjs", "references/verify.md", "references/clip-post.mjs", "references/dive-mode.mjs"], "caveat": "the independent check (verify-intro.mjs) needs Node Playwright with Chromium (setup-playwright); the hold-and-flight mode needs WAN keys and is not yet confirmed by a paid smoke" },
@@ -86,7 +86,11 @@ function err(s) { process.stderr.write(s.endsWith('\n') ? s : s + '\n'); }
86
86
  // POSIX sh 용 따옴표. 홑따옴표 안에서는 확장이 일어나지 않는다
87
87
  function shq(s) { return /^[A-Za-z0-9_./:@%+=-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`; }
88
88
 
89
- function realOr(p) { try { return fs.realpathSync(p); } catch { return p; } }
89
+ // 실제 경로. native 먼저: macOS 에서 디스크의 대소문자로 돌려줘, 세션 폴더나 writable_roots 를 다른 대소문자로 적어도
90
+ // 같은 폴더로 판정한다(JS 판은 받은 철자를 그대로 둔다).
91
+ function realOr(p) {
92
+ try { return fs.realpathSync.native(p); } catch { try { return fs.realpathSync(p); } catch { return p; } }
93
+ }
90
94
 
91
95
  function isExec(f) { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } }
92
96
 
@@ -99,12 +103,18 @@ const verOf = (s) => String(s).split('.').map(Number);
99
103
 
100
104
  // ---------- 샌드박스가 쓸 수 있는 곳 ----------
101
105
 
102
- function under(p, dir) {
106
+ function under(p, dir, fold = (x) => x) {
103
107
  if (!dir) return false;
104
- const r = path.relative(dir, p);
108
+ const r = path.relative(fold(dir), fold(p));
105
109
  return r === '' || (r.split(path.sep)[0] !== '..' && !path.isAbsolute(r));
106
110
  }
107
111
 
112
+ // 위험한 곳 판정에만 쓰는 접기: macOS 는 대소문자를 가리지 않는 볼륨이 기본이라, 아직 없어 실제 철자로 못 푼 경로도
113
+ // 같은 폴더로 본다. 대소문자를 가리는 볼륨에서는 다른 폴더를 위험한 곳으로 볼 수 있지만 그쪽은 거부일 뿐이다.
114
+ // 파일 보내기 같은 허용 판정(아래 roots)은 접지 않는다.
115
+ const foldMac = (p) => (process.platform === 'darwin' ? p.toLowerCase() : p);
116
+ const unsafeUnder = (p, dir) => under(p, dir, foldMac);
117
+
108
118
  // p 가 샌드박스 안 세션이 쓸 수 있는 곳이면 그 까닭, 아니면 ''. 순수 함수: 경로는 부르는 쪽이 실제 경로로 푼다.
109
119
  // 세션 폴더(와 그 git 최상위)가 홈 폴더 자체나 파일 시스템 루트면 그 규칙은 쓰지 않는다
110
120
  const NO_CTX = { cwd: '', gitTop: '', tmpRoots: [], writableRoots: [], home: '' };
@@ -114,10 +124,10 @@ export function unsafePath(p, ctx = {}) {
114
124
  if (typeof p !== 'string' || !path.isAbsolute(p)) return `절대 경로가 아님(${p})`;
115
125
  const c = { ...NO_CTX, ...ctx };
116
126
  const hit = [
117
- [c.tmpRoots.some((d) => under(p, d)), '임시 폴더 아래'],
118
- [isWork(c.cwd, c.home) && under(p, c.cwd), '세션 폴더 아래'],
119
- [isWork(c.gitTop, c.home) && under(p, c.gitTop), '세션 폴더의 git 저장소 아래'],
120
- [c.writableRoots.some((d) => under(p, d)), '샌드박스 쓰기 허용 폴더(writable_roots) 아래'],
127
+ [c.tmpRoots.some((d) => unsafeUnder(p, d)), '임시 폴더 아래'],
128
+ [isWork(c.cwd, c.home) && unsafeUnder(p, c.cwd), '세션 폴더 아래'],
129
+ [isWork(c.gitTop, c.home) && unsafeUnder(p, c.gitTop), '세션 폴더의 git 저장소 아래'],
130
+ [c.writableRoots.some((d) => unsafeUnder(p, d)), '샌드박스 쓰기 허용 폴더(writable_roots) 아래'],
121
131
  ].find(([yes]) => yes);
122
132
  return hit ? `${hit[1]}(${p})` : '';
123
133
  }
@@ -922,7 +932,7 @@ function readChecked(real, roots) {
922
932
  // 래퍼는 샌드박스 밖에서 돈다. 이 세션의 작업 폴더와 임시 폴더 안의 파일만 보낸다
923
933
  function readFileBody(file) {
924
934
  let real;
925
- try { real = fs.realpathSync(path.resolve(file)); } catch (e) { usageExit(`파일을 읽지 못했습니다: ${e.message}`); }
935
+ try { real = fs.realpathSync.native(path.resolve(file)); } catch (e) { usageExit(`파일을 읽지 못했습니다: ${e.message}`); }
926
936
  const base = sessionCwd();
927
937
  const roots = [base, os.tmpdir()].filter(Boolean).map(realOr);
928
938
  if (!roots.some((r) => under(real, r))) {
@@ -52,6 +52,7 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
52
52
  - `theme-selection` — the same procedure in both runtimes; the engine is `scripts/themes.mjs` (Codex: `~/.codex/skills/banker-theme-selection/scripts/themes.mjs`), which reads `assets/palettes.json` beside it. Pure Node with no network; it writes only the file `--out` names (refused when that file itself is a symbolic link), and the skill asks before it overwrites one that exists.
53
53
  - `progress-mon` — the same procedure in both runtimes; the engine is `scripts/progress-mon.mjs` (Codex npm install: `~/.codex/skills/banker-progress-mon/scripts/progress-mon.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/progress-mon/scripts/progress-mon.mjs`), every command with `--runtime claude|codex`. The data folder is `.omc/progress-mon/` for Claude Code and `.omx/progress-mon/` for Codex. Pure Node with no network; the server binds 127.0.0.1 by default (`--host` takes a private IPv4 only). On Codex `start`, `stop`, `status` and `init` each ask for `sandbox_permissions: "require_escalated"` (`init` writes the git exclude list), and since closing the session may stop the server, a long-lived screen runs from the user's terminal (`serve`). `init --memory agents-md` writes its rule to `AGENTS.override.md` when that file has content of its own, as Codex then reads it instead of `AGENTS.md`. The fifth tab, ADR, reads `adr.json` only (the agent cites `docs/adr/` and `docs/evidence/` from it); a project set up before the ADR tab gets, on the next bare call and after one confirmation (Claude Code AskUserQuestion, Codex request_user_input), a rerun of `init` with the memory kind from `state.json` (auto-memory passes the folder of its `MEMORY.md` as `--memory-dir`) that replaces the old rule line in place with a `.progress-mon.bak` backup, then the ADR step. Not the same as `/progress` (the session's own task panel).
54
54
  - `init-dir` — the same procedure in both runtimes; the engine is `scripts/init-dir.mjs` (Codex npm install: `~/.codex/skills/banker-init-dir/scripts/init-dir.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/init-dir/scripts/init-dir.mjs`), run with `--runtime claude|codex`. It writes one marked block (`<!-- banker:init-dir:start -->` to `<!-- banker:init-dir:end -->`) into whichever of `AGENTS.md` and `CLAUDE.md` exist (Codex with neither: `AGENTS.md`), the `AGENTS.md` block inside its own `USER:OMX:POLICY` pair; `AGENTS.override.md` is never written, only warned about when it has content of its own. The block editor `scripts/marker-block.mjs` is the source of truth for its future copies (`scripts/sync-adapter.js`). Pure Node with no network. On Codex a `--root` outside the workspace, or starting a generated service that must outlive the session, asks for `sandbox_permissions: "require_escalated"`.
55
+ - `motion-sites` — the same procedure in both runtimes; the engine is `scripts/motion-sites.mjs` (Codex npm install: `~/.codex/skills/banker-motion-sites/scripts/motion-sites.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/motion-sites/scripts/motion-sites.mjs`). `status --runtime codex` finds the original plugin through `config.toml` (`[plugins."motionsites-web-builder@motionsites"]`) and `plugins/cache/motionsites/`; `setup --runtime codex` runs `codex plugin marketplace add Motionsites-AI/claude-skill` and `codex plugin add motionsites-web-builder@motionsites` where Claude Code runs `claude plugin marketplace add` and `claude plugin install`. The hand-over is `$motionsites-web-builder:motionsites-web-builder` once the plugin shows in the session, else the original `SKILL.md` read and followed. `setup` needs the network and asks for `sandbox_permissions: "require_escalated"`.
55
56
  - `git-init` — the same procedure in both runtimes; the engine is `scripts/git-init.mjs` (Codex npm install: `~/.codex/skills/banker-git-init/scripts/git-init.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/git-init/scripts/git-init.mjs`). `check` (the default) writes nothing; `run` writes the `.gitignore` block (`# banker:git-init:start` to `# banker:git-init:end`, through `scripts/marker-block.mjs`, a byte-identical copy of init-dir's), scans the files to track with `scripts/payload-scan.mjs` (a byte-identical copy of ralph-qa's; both kept by `scripts/sync-adapter.js`) and only then runs `git init`, `git add .` and `git commit -m "init"`. Needs `git`; no network, no remote, no push. On Codex `run` asks for `sandbox_permissions: "require_escalated"`, as the default sandbox may block writes to `.git`.
56
57
  - `build-testbox` — the same procedure in both runtimes; the engine is `scripts/testbox.mjs` (Codex npm install: `~/.codex/skills/banker-build-testbox/scripts/testbox.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/build-testbox/scripts/testbox.mjs`) with `detect`, `run` and `setup`; `scripts/browser-cell.mjs` is copied into the Playwright image and runs there alone for `--browser-in-container`. Playwright and docker or podman are optional: a missing one leaves its cells `unverified` with the reason. `run` downloads nothing; `setup` without `--yes` only lists what it would fetch, and the skill confirms with the user (Claude Code `AskUserQuestion`, Codex `request_user_input`) before running it again with `--yes`. macOS runs only as a Tart VM on an Apple Silicon host; other hosts get `unsupported(apple-sla)`. On Codex `detect` and `run` ask for `sandbox_permissions: "require_escalated"` (container engine, headless browsers, copies under the OS temp folder), `setup --yes` and container commands that use the network ask for network access, and the server under test is the user's own on a 127.0.0.1 port; a cell stopped by a permission error is `unverified(sandbox)`.
57
58
  - `tune-tmux` — the same procedure in both runtimes; the engine is `scripts/tune-tmux.mjs` (Codex npm install: `~/.codex/skills/banker-tune-tmux/scripts/tune-tmux.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/tune-tmux/scripts/tune-tmux.mjs`) with `status` (the default), `on` and `off`. It writes one marked block (`# banker:tune-tmux:start` to `# banker:tune-tmux:end`) through `scripts/marker-block.mjs`, a byte-identical copy of init-dir's kept by `scripts/sync-adapter.js`, into the user's tmux config (`~/.tmux.conf` or the XDG `tmux.conf`) or psmux config (`~/.psmux.conf` and the rest of psmux's order); psmux gets zero lines today, as `scripts/psmux-caps.json` verifies nothing beyond its defaults. The conditional `#{||:}` is probed on a private `tmux -L` server, never the user's. A symbolic-link config is refused unless `--follow-symlink`. No network. On Codex `on`, `off` and `--reload` ask for `sandbox_permissions: "require_escalated"`, as the config file and the tmux socket are outside the workspace; Claude Code and Codex settings are never changed, and Codex Fullscreen history cannot be scrolled by tmux copy-mode (only the user's `--no-alt-screen` or `tui.alternate_screen = "never"` changes that).
@@ -253,7 +253,8 @@ function readTurns() {
253
253
  }
254
254
  }
255
255
 
256
- // 링크나 일반 파일이 아닌 자리에는 쓰지 않는다. 새 파일을 만들어 바꿔 넣는다.
256
+ // 링크나 일반 파일이 아닌 자리에는 쓰지 않는다. 새 파일을 만들어 바꿔 넣는다. 임시 이름에 난수를 붙인다: Windows 의
257
+ // wx 는 끊어진 링크를 따라가 그 너머에 파일을 만들므로, 이름을 미리 알 수 없어야 링크를 심어 둘 수 없다.
257
258
  function writeTurns(lines) {
258
259
  const file = TURNS_FILE();
259
260
  try {
@@ -261,7 +262,7 @@ function writeTurns(lines) {
261
262
  } catch (e) {
262
263
  if (e?.code !== 'ENOENT') return;
263
264
  }
264
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
265
+ const tmp = `${file}.${process.pid}.${randomHex()}.tmp`;
265
266
  try {
266
267
  writeFileSync(tmp, `${lines.slice(-KEEP_TURNS).join('\n')}\n`, { flag: 'wx', mode: 0o600 });
267
268
  renameSync(tmp, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -63,6 +63,7 @@ if (creds._tracked) throw new Error(`프로젝트 키 파일을 git 이 추적
63
63
  - `creds._ignored` 가 있으면 프로젝트 키 파일의 `FFMPEG_PATH` 를 쓰지 않은 것이다. 저장소와 함께 온 파일이 실행할 프로그램을 정하지 못하게 막은 것이다. 값은 출력하지 않으니 사람이 파일을 열어 확인하고, 그 경로가 필요하면 `3d-intro-setup` §2 로 홈 설정 파일에 남긴다.
64
64
  Claude Code: `/banker:setup` 에서 3d-intro-setup / Codex: `banker-3d-intro-setup`.
65
65
  - `resolveFfmpeg()` 가 throw 하면 ffmpeg 가 없다. 같은 `3d-intro-setup` 으로 보낸다.
66
+ - 오류가 `FFMPEG_PATH from the shell is not usable` 이면 셸에서 지정한 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이다. 다른 ffmpeg 로 넘어가지 않는다. 그 값을 고치거나 지우게 한다.
66
67
  - 둘 다 통과해야 다음 단계로 간다.
67
68
  - 영상 provider 상태를 무료로 확인해 둔다.
68
69
  `poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)와 마지막 줄의 Sora 상태(`label: 'AZURE_SORA'`, `ok`, `retired`, `unset`, `malformed`)를 키 원문 없이 돌려준다. WAN 줄은 `provider: 'wan'`, Sora 줄은 `provider: 'sora'` 다.
@@ -642,12 +642,26 @@ export async function generateImage({
642
642
  const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
643
643
  const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
644
644
 
645
- // The PNG an image call answered with, or an error that starts with `failed`.
645
+ // The PNG an image call answered with, or an error that starts with `failed`. Bytes that are not an image (a URL or an
646
+ // error text in the image field, base64 that decodes to nothing) are a failure, not a picture.
646
647
  function pngFrom(r, b64, failed) {
647
648
  if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
648
- return Buffer.from(b64, 'base64');
649
+ const buf = Buffer.from(String(b64), 'base64');
650
+ if (!isImage(buf)) throw new Error(`${failed}: the answer is not a PNG, JPEG or WebP image (${buf.length} bytes)`);
651
+ return buf;
649
652
  }
650
653
 
654
+ /** Whether bytes start as a PNG, JPEG or WebP image. */
655
+ export const isImage = (b) => Buffer.isBuffer(b) && b.length >= 12 && (b.readUInt32BE(0) === 0x89504e47
656
+ || (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) || (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'));
657
+
658
+ /**
659
+ * Whether bytes start as an ISO media file (mp4, mov): the first box is one such a file starts with (ftyp, or styp,
660
+ * free, skip, wide, moov, mdat in fragmented and QuickTime files). An HTML or JSON error page is not.
661
+ */
662
+ const MEDIA_BOXES = new Set(['ftyp', 'styp', 'free', 'skip', 'wide', 'moov', 'mdat']);
663
+ export const isMp4 = (b) => Buffer.isBuffer(b) && b.length >= 12 && MEDIA_BOXES.has(b.toString('latin1', 4, 8));
664
+
651
665
  /** Edit an image (optional mask) via the v1 images/edits multipart path. Returns a PNG Buffer. */
652
666
  export async function editImage({
653
667
  endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
@@ -781,6 +795,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
781
795
  key, method: 'GET', binary: true,
782
796
  });
783
797
  if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
798
+ if (!isMp4(r.body)) throw Object.assign(new Error(`downloadVideo failed: HTTP 200 but the body is not an mp4 (${r.ct || 'no content type'}, ${r.body.length} bytes)`), { status: r.status });
784
799
  return r.body;
785
800
  }
786
801
 
@@ -919,7 +934,7 @@ function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
919
934
  * 3) the optional ffmpeg-static package (dynamic import — may be absent)
920
935
  * A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
921
936
  * skipped: either would let the files of a project, a cloned repository say, choose what runs.
922
- * Throws an actionable error if none is found.
937
+ * Throws an actionable error if none is found, or if $FFMPEG_PATH is set but not usable.
923
938
  */
924
939
  export async function resolveFfmpeg() {
925
940
  const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
@@ -928,10 +943,17 @@ export async function resolveFfmpeg() {
928
943
  }
929
944
 
930
945
  // The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
931
- // folder, the project. The files are read only when the shell names nothing usable.
946
+ // folder, the project. A $FFMPEG_PATH from the shell is the user's choice for this run: when it is
947
+ // relative or names no file, this stops with why instead of quietly taking another ffmpeg. The files
948
+ // are read only when the shell names nothing.
932
949
  function namedFfmpeg() {
933
950
  const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
934
- if (usable(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
951
+ const shell = process.env.FFMPEG_PATH;
952
+ if (shell) {
953
+ if (usable(shell)) return shell;
954
+ const why = path.isAbsolute(shell) ? 'no such file' : 'not an absolute path';
955
+ throw new Error(`FFMPEG_PATH from the shell is not usable (${why}): ${shell}; fix it or unset it`);
956
+ }
935
957
  return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
936
958
  }
937
959
 
@@ -953,17 +975,30 @@ async function staticFfmpeg() {
953
975
  }
954
976
 
955
977
  // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
978
+ // ffmpeg has a time limit: a stuck one (a FIFO input, a blocked disk) would hold the whole run. SIGKILL, as a stuck
979
+ // ffmpeg may not end on SIGTERM.
980
+ export const FF_TIMEOUT_MS = 10 * 60 * 1000;
956
981
  function runFf(bin, args, cwd) {
957
- const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
958
- return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', args };
982
+ const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28, timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
983
+ return { code: r.status, signal: r.signal || null, error: r.error || null, stdout: r.stdout || '', stderr: r.stderr || '', args };
984
+ }
985
+
986
+ // One ffmpeg run that must make `out`: an earlier file there is removed first, and the run counts only when ffmpeg
987
+ // ended by itself with 0 and `out` is a file with something in it. A killed or failed ffmpeg's partial file is not a result.
988
+ function ffOut(name, bin, args, out) {
989
+ fs.rmSync(out, { force: true });
990
+ const r = runFf(bin, args);
991
+ const size = (() => { try { return fs.statSync(out).size; } catch { return 0; } })();
992
+ if (r.code === 0 && size > 0) return out;
993
+ const how = r.error ? (r.error.code === 'ETIMEDOUT' ? `timed out after ${FF_TIMEOUT_MS / 1000} s` : r.error.code || r.error.message)
994
+ : r.signal ? `killed by ${r.signal}` : r.code !== 0 ? `exit ${r.code}: ${tail(r.stderr)}` : 'ffmpeg wrote no output';
995
+ throw new Error(`${name} failed: ${how}`);
959
996
  }
960
997
 
961
998
  /** Extract the exact LAST frame of an mp4 to a PNG (reverse + first-frame trick). Returns out. */
962
999
  export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
963
1000
  const bin = ffmpeg || await resolveFfmpeg();
964
- const r = runFf(bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out]);
965
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractLastFrame failed: ${tail(r.stderr)}`);
966
- return out;
1001
+ return ffOut('extractLastFrame', bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out], out);
967
1002
  }
968
1003
 
969
1004
  /**
@@ -974,20 +1009,15 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
974
1009
  */
975
1010
  export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
976
1011
  const bin = ffmpeg || await resolveFfmpeg();
977
- fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
978
1012
  const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
979
- const r = runFf(bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
980
- '-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
981
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
982
- return out;
1013
+ return ffOut('stillToClip', bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
1014
+ '-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out], out);
983
1015
  }
984
1016
 
985
1017
  /** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
986
1018
  export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
987
1019
  const bin = ffmpeg || await resolveFfmpeg();
988
- const r = runFf(bin, ['-y', '-i', mp4, '-frames:v', '1', out]);
989
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractFirstFrame failed: ${tail(r.stderr)}`);
990
- return out;
1020
+ return ffOut('extractFirstFrame', bin, ['-y', '-i', mp4, '-frames:v', '1', out], out);
991
1021
  }
992
1022
 
993
1023
  /**
@@ -1000,13 +1030,12 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
1000
1030
  let off = offset;
1001
1031
  if (off == null) {
1002
1032
  const dur = probeDurationSec(bin, aMp4);
1003
- off = dur != null ? Math.max(0, dur - sec) : 0;
1033
+ // with A's length unknown, an offset of 0 would cut A to the fade: a wrong clip, not a result
1034
+ if (dur == null) throw new Error(`crossfade failed: could not read the duration of ${path.basename(aMp4)}; pass { offset }`);
1035
+ off = Math.max(0, dur - sec);
1004
1036
  }
1005
1037
  const filter = `[0:v][1:v]xfade=transition=fade:duration=${sec}:offset=${off}`;
1006
- const args = ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out];
1007
- const r = runFf(bin, args);
1008
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`crossfade failed: ${tail(r.stderr)}`);
1009
- return out;
1038
+ return ffOut('crossfade', bin, ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out], out);
1010
1039
  }
1011
1040
 
1012
1041
  /**
@@ -1015,9 +1044,7 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
1015
1044
  */
1016
1045
  export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
1017
1046
  const bin = ffmpeg || await resolveFfmpeg();
1018
- const r = runFf(bin, concatArgs(clipList(mp4List), out, { fps, width, height }));
1019
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
1020
- return out;
1047
+ return ffOut('concatClips', bin, concatArgs(clipList(mp4List), out, { fps, width, height }), out);
1021
1048
  }
1022
1049
 
1023
1050
  function clipList(mp4List) {
@@ -1038,7 +1065,7 @@ function concatArgs(list, out, { fps, width, height }) {
1038
1065
 
1039
1066
  // Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
1040
1067
  function probeDurationSec(bin, mp4) {
1041
- const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8' });
1068
+ const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
1042
1069
  const text = `${r.stderr || ''}${r.stdout || ''}`;
1043
1070
  const m = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
1044
1071
  if (!m) return null;
@@ -43,7 +43,7 @@ import crypto from 'node:crypto';
43
43
  import cp from 'node:child_process';
44
44
  import {
45
45
  createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack, probeSoraDeployment,
46
- API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
46
+ API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft, isMp4, isImage, FF_TIMEOUT_MS,
47
47
  } from './azure-adapter.mjs';
48
48
 
49
49
  export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
@@ -399,9 +399,11 @@ async function stripAlpha(buf) {
399
399
  const src = path.join(dir, 'in.png');
400
400
  const out = path.join(dir, 'out.png');
401
401
  fs.writeFileSync(src, buf);
402
- const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8' });
403
- if (!fs.existsSync(out)) throw new Error(`stripAlpha failed: ${String(r.stderr || '').slice(-400)}`);
404
- return fs.readFileSync(out);
402
+ const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
403
+ // only a run that ended by itself with 0: a killed ffmpeg's partial PNG would go to WAN as the frame
404
+ const png = r.status === 0 && fs.existsSync(out) ? fs.readFileSync(out) : null;
405
+ if (!png || !isImage(png)) throw new Error(`stripAlpha failed: ${r.error ? r.error.code : r.signal ? `killed by ${r.signal}` : String(r.stderr || '').slice(-400)}`);
406
+ return png;
405
407
  } finally {
406
408
  fs.rmSync(dir, { recursive: true, force: true });
407
409
  }
@@ -410,6 +412,8 @@ async function stripAlpha(buf) {
410
412
  /** Encode a PNG/JPEG frame as the data URI WAN accepts, dropping any PNG alpha channel first. */
411
413
  export async function frameDataUri(buf) {
412
414
  const b = Buffer.isBuffer(buf) ? buf : fs.readFileSync(buf);
415
+ // an empty or non-image seed would be sent with a paid task and answered with a clip that does not start from it
416
+ if (!isImage(b)) throw new Error(`frameDataUri: the frame is not a PNG, JPEG or WebP image (${b.length} bytes)`);
413
417
  if (b[0] === 0xff && b[1] === 0xd8) return `data:image/jpeg;base64,${b.toString('base64')}`;
414
418
  const png = pngHasAlpha(b) ? await stripAlpha(b) : b;
415
419
  return `data:image/png;base64,${png.toString('base64')}`;
@@ -514,10 +518,11 @@ export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {
514
518
  let last;
515
519
  for (let i = 0; i < tries; i++) {
516
520
  last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
517
- if (last.status === 200 && last.body?.length) return last.body;
521
+ // a 200 that is an HTML or JSON page (an expired or refused signed URL) is not the video: try again, then fail
522
+ if (last.status === 200 && isMp4(last.body)) return last.body;
518
523
  if (i < tries - 1) await sleep(2000 * (i + 1));
519
524
  }
520
- throw new Error(`WAN video download failed: HTTP ${last?.status}`);
525
+ throw new Error(`WAN video download failed: HTTP ${last?.status}${last?.status === 200 ? ` but the body is not an mp4 (${last.body?.length ?? 0} bytes)` : ''}`);
521
526
  }
522
527
 
523
528
  /**