@kaydash9999/banker-plugins 0.25.0 → 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.
@@ -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.25.0"
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.25.0",
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,23 @@
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
+
3
21
  ## [0.25.0] - 2026-11-02
4
22
 
5
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.25.0",
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
  /**
@@ -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
  /**
@@ -29,7 +29,7 @@ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와
29
29
  |---|---|
30
30
  | `skillFile` | 설치된 원본 `SKILL.md` 경로. 없거나 꺼져 있으면 `null` |
31
31
  | `disabled` | 설치는 됐지만 꺼져 있음 |
32
- | `python.bin` | Playwright 를 쓸 Python |
32
+ | `python.bin` | Playwright 를 쓸 Python. 찾는 차례는 `MOTION_SITES_PYTHON`, 캐시 폴더의 가상 환경, PATH 의 `python3`, `python`(Windows 는 `py -3`, `python`). `MOTION_SITES_PYTHON` 이 있으면 그것과 캐시 폴더의 가상 환경만 보고, 지정한 것이 실행되지 않으면 다른 Python 으로 넘어가지 않는다 |
33
33
  | `python.playwright`, `python.chromium` | Playwright 와 Chromium 이 있는지 |
34
34
  | `ready` | 셋 다 있으면 `true`(종료 0) |
35
35
 
@@ -49,6 +49,14 @@ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와
49
49
  3. Codex 에서는 이 명령이 네트워크와 `~/.codex` 쓰기를 하므로 `sandbox_permissions: "require_escalated"` 로 승인을 요청한다.
50
50
  4. 출력의 `steps` 에 실패한 단계가 있으면 그 단계와 `why` 를 보여 주고 멈춘다. 설치되지 않은 것을 설치된 것처럼 말하지 않는다.
51
51
  - 네트워크가 없으면 여기서 멈춘다. 오프라인에서는 이 스킬을 쓸 수 없다.
52
+ - `python` 단계가 "Python 3 을 찾지 못함" 으로 실패하면, 설치한 Python 이 PATH 밖에 있는지 사용자에게 묻는다. Windows 에서 `py` 가 없고 PATH 의 `python` 이 Microsoft Store 연결 파일뿐이거나, Miniconda 를 PATH 에 넣지 않고 설치한 경우다.
53
+ - 있으면 그 python 실행 파일의 절대 경로를 환경 변수 `MOTION_SITES_PYTHON` 에 영구로 지정하라고 안내한다. `%USERPROFILE%`, `~` 같은 줄임은 쓰지 않는다. 셸에 따라 글자 그대로 저장되어 엔진이 찾지 못한다.
54
+ - Windows(cmd 나 PowerShell): `setx MOTION_SITES_PYTHON "C:\Users\<이름>\miniconda3\python.exe"`
55
+ - macOS, Linux: 셸 설정 파일(`~/.zshrc`, `~/.bashrc`)에 `export MOTION_SITES_PYTHON=/절대/경로/python3`
56
+ - 한 번만 주는 것으로는 안 된다. 다음 실행의 상태 확인도 그 Python 을 봐야 한다. 이미 떠 있는 프로그램에는 새 값이 반영되지 않으므로, Claude Code 나 Codex 를 완전히 끝내고 다시 시작한 뒤 2단계를 다시 한다.
57
+ - Playwright 는 지정한 Python 에 설치된다. 그 Python 을 바꾸고 싶지 않으면 `<그 python> -m venv <폴더>` 로 가상 환경을 만들고 그 안의 python 을 지정한다(Windows `<폴더>\Scripts\python.exe`, macOS 와 Linux `<폴더>/bin/python`). pip 이 시스템 Python 을 거부하는(externally managed) 경우는 엔진이 캐시 폴더에 가상 환경을 스스로 만든다.
58
+ - 실패 이유가 "MOTION_SITES_PYTHON 에 지정한 ... 가 실행되지 않음" 이면 지정한 경로가 틀린 것이다. 경로를 고치게 한다.
59
+ - Python 이 없으면 Python 3 설치를 안내한다. 직접 설치하지 않는다.
52
60
  - Linux 에서 Chromium 이 시스템 라이브러리 부족으로 뜨지 않으면 `<python.bin> -m playwright install-deps chromium` 을 안내한다. 관리자 권한이 필요하므로 직접 실행하지 않는다.
53
61
  5. 끝나면 `ready` 가 `true` 인지 확인하고 3단계로 간다.
54
62
 
@@ -215,9 +215,16 @@ export function lookPython(py, runner = run) {
215
215
  return { bin, cmd: py, playwright: pw, chromium: exe.ok && chrome !== "" && existsSync(chrome) };
216
216
  }
217
217
 
218
- /** The first Python with Playwright (and Chromium, when one has both), else the first that runs. */
218
+ /**
219
+ * The first Python with Playwright (and Chromium, when one has both), else the first that runs. A MOTION_SITES_PYTHON
220
+ * is the user's choice: only it and the cache's virtual environment (made from it when pip refuses it) are looked at,
221
+ * and when it does not run there is none, so Playwright never goes into a Python the user did not name.
222
+ */
219
223
  export function pickPython(env = process.env, runner = run) {
220
- const seen = pythons(env).map((p) => lookPython(p, runner)).filter(Boolean);
224
+ const all = pythons(env);
225
+ const named = env.MOTION_SITES_PYTHON ? lookPython(all[0], runner) : null;
226
+ if (env.MOTION_SITES_PYTHON && !named) return null;
227
+ const seen = (named ? [named, lookPython(all[1], runner)] : all.map((p) => lookPython(p, runner))).filter(Boolean);
221
228
  return seen.find((p) => p.playwright && p.chromium) ?? seen.find((p) => p.playwright) ?? seen[0] ?? null;
222
229
  }
223
230
 
@@ -265,10 +272,20 @@ function enablePlugin(runtime, runner) {
265
272
  return { step: "plugin enable", ok: r.ok, why: r.why };
266
273
  }
267
274
 
275
+ // No Python found: says where it looked and how to name one that is installed but not on PATH (Windows with only
276
+ // Miniconda, or only the Microsoft Store link, has no py and no working python).
277
+ export const NO_PYTHON = "Python 3 을 찾지 못함(python3, python, Windows 는 py). 설치한 Python 이 PATH 밖에 있으면 환경 변수 MOTION_SITES_PYTHON 에 그 python 실행 파일의 절대 경로를 영구로 지정한 뒤 다시 실행";
278
+
279
+ /** Why no Python was found; a MOTION_SITES_PYTHON that is set but does not run is named, so a typo is not hidden. */
280
+ export function noPython(env = process.env) {
281
+ if (!env.MOTION_SITES_PYTHON) return NO_PYTHON;
282
+ return `MOTION_SITES_PYTHON 에 지정한 ${pythons(env)[0][0]} 가 실행되지 않음. 경로를 고친 뒤 다시 실행. ${NO_PYTHON}`;
283
+ }
284
+
268
285
  /** The Python steps: Playwright, then Chromium. */
269
286
  function installBrowser(env, runner) {
270
287
  let py = pickPython(env, runner);
271
- if (!py) return [{ step: "python", ok: false, why: "Python 3 을 찾지 못함(python3, python, Windows 는 py)" }];
288
+ if (!py) return [{ step: "python", ok: false, why: noPython(env) }];
272
289
  const steps = [];
273
290
  let cmd = py.cmd;
274
291
  if (!py.playwright) {
@@ -252,8 +252,10 @@ function gitFetch(args) {
252
252
  // ── 1. 업데이트 ──────────────────────────────────────────────────────────────
253
253
  function ensureVersion() {
254
254
  const have = installedVersions();
255
- if (have.includes(TARGET_VERSION)) return useCached();
256
- if (MODE === "check") { bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
255
+ if (have.includes(TARGET_VERSION) && completeVersion(TARGET_VERSION)) return useCached();
256
+ // 풀다 멈춘 폴더(hooks/hooks.json 이나 scripts/ 가 없음)는 설치된 것으로 보지 않는다. 그대로 전환하면 빈 플러그인으로 바뀐다.
257
+ if (have.includes(TARGET_VERSION)) bad(`${TARGET_VERSION} 캐시 폴더가 불완전함 (hooks/hooks.json 이나 scripts/ 가 없음)${MODE === "check" ? "" : ". 다시 풉니다"}`);
258
+ if (MODE === "check") { if (!have.includes(TARGET_VERSION)) bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
257
259
  if (!canInstallTarget()) return false;
258
260
 
259
261
  log(` ${TARGET_VERSION} 설치 중...`);
@@ -265,6 +267,9 @@ function ensureVersion() {
265
267
  return switchActive(sha);
266
268
  }
267
269
 
270
+ // 판 폴더에 훅 목록과 스크립트 폴더가 있는가.
271
+ const completeVersion = (v) => existsSync(join(CACHE, v, "hooks", "hooks.json")) && existsSync(join(CACHE, v, "scripts"));
272
+
268
273
  // TARGET_VERSION 이 캐시에 있을 때. 이미 활성이면 끝, 아니면 마켓플레이스 태그의 커밋으로 전환한다.
269
274
  // 전환도 마켓플레이스와 git 이 있어야 하므로 먼저 확인한다(풀지 않으므로 tar 는 없어도 된다).
270
275
  function useCached() {
@@ -356,16 +361,27 @@ function targetSha() {
356
361
  }
357
362
 
358
363
  // git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
364
+ // 옆의 난수 이름 폴더에 다 푼 뒤에만 판 이름으로 옮긴다. 도중에 멈추면(tar 실패, 신호, 디스크 부족) 판 이름의 폴더는 생기지 않아,
365
+ // 다음 실행이 반쯤 찬 폴더를 설치된 판으로 보고 전환하지 않는다. 옆 폴더는 판 이름 꼴이 아니라 installedVersions 가 세지 않는다.
359
366
  function unpackTarget(dest) {
360
- if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
361
- mkdirSync(dest, { recursive: true });
362
- git(["archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")]);
363
- execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { cwd: homedir(), stdio: "ignore" });
364
- unlinkSync(join(dest, "_src.tar"));
367
+ const part = `${dest}.part-${randomBytes(6).toString("hex")}`;
368
+ mkdirSync(part, { recursive: true });
369
+ try {
370
+ git(["archive", `v${TARGET_VERSION}`, "-o", join(part, "_src.tar")]);
371
+ execFileSync("tar", ["-xf", join(part, "_src.tar"), "-C", part], { cwd: homedir(), stdio: "ignore" });
372
+ unlinkSync(join(part, "_src.tar"));
373
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
374
+ renameSync(part, dest);
375
+ } catch (e) {
376
+ rmSync(part, { recursive: true, force: true });
377
+ throw e;
378
+ }
365
379
  }
366
380
 
367
381
  // node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
368
382
  // (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
383
+ // 없어도 전환을 막지 않는다. Claude Code 가 직접 설치한 OMC 판에는 node_modules 가 없고, 실행에 쓰는 것은 dist/ 에
384
+ // 묶여 있다(2026-11-03 실측: 5.7.21~5.7.30 모두 없음, OMC 정상 동작). 막으면 보통 PC 에서 전환이 늘 실패한다.
369
385
  function linkNodeModules(dest, have) {
370
386
  const prev = have.find((v) => v !== TARGET_VERSION);
371
387
  const nm = join(dest, "node_modules");
@@ -699,16 +715,22 @@ function openParenOf(src, j) {
699
715
  // 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
700
716
  function siteInfo(src) {
701
717
  if (src.includes("__omcRaceImport")) {
702
- const why = helperDefect(scanFacts(src));
718
+ // 헬퍼가 있어도 적용이 감쌌어야 할 자리가 남았으면(패치 뒤 상류가 줄을 더함) 적용된 것이 아니다.
719
+ // 헬퍼 본문의 Promise.race 는 손으로 두른 것이 아니므로, 같은 길이의 공백으로 지운 글에서 찾는다(자리는 그대로).
720
+ const left = openSites(src.split(HELPER).join(" ".repeat(HELPER.length)));
721
+ const why = helperDefect(scanFacts(src)) ?? (left.length ? `감싸지 않은 최상위 await import 가 남음 :${left.map((p) => src.slice(0, p).split("\n").length).join(", :")}` : null);
703
722
  return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
704
723
  }
705
724
  const all = scanTopLevelAwaitImports(src);
706
725
  if (!all.length) return { state: "n/a", sites: [] };
707
- const open = all.filter((p) => !/Promise\.race/.test(src.slice(Math.max(0, p - 300), p)));
726
+ const open = openSites(src);
708
727
  if (!open.length) return { state: "guarded", sites: [] };
709
728
  return { state: "needs", sites: open };
710
729
  }
711
730
 
731
+ // 적용이 감싸는 자리: 최상위 await import 가운데 바로 앞(300자)에 손으로 두른 Promise.race 가 없는 것.
732
+ const openSites = (src) => scanTopLevelAwaitImports(src).filter((p) => !/Promise\.race/.test(src.slice(Math.max(0, p - 300), p)));
733
+
712
734
  // 헬퍼가 쓰일 수 없는 자리면 그 이유를, 제자리면 null 을 낸다.
713
735
  // - 선언이 블록 안(깊이 1 이상): 다른 블록의 자리에서 ReferenceError(옛 킷이 남긴 자리)
714
736
  // - 선언을 못 찾음: 이름만으로는 «패치됨» 이라 할 근거가 없다. 앞쪽 글을 토크나이저가 잘못
@@ -834,15 +856,33 @@ function patchHooks(root) {
834
856
  // 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
835
857
  // .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
836
858
  const names = MODE === "revert" ? scriptNames(root, r) : hookNames(root);
859
+ // 고칠 훅을 하나도 찾지 못했으면 «실패 0건» 이 아니다. 목록이 빈 hooks.json(`{}`, `[]`)은 고친 것도 확인한 것도 없다.
860
+ // hooks.json 이 없거나 읽히지 않으면 [3/4] 가 이름과 이유를 대고 센다. 여기서는 읽혔는데 훅이 없을 때만 센다.
861
+ if (MODE !== "revert" && !names.length) {
862
+ if (readableFile(join(root, "hooks", "hooks.json"))) notChecked(r, join(root, "hooks", "hooks.json"), "hooks.json 이 부르는 훅(scripts/*.mjs)을 하나도 찾지 못함");
863
+ return r;
864
+ }
837
865
  for (const n of names) {
838
866
  const f = join(root, "scripts", `${n}.mjs`);
839
- if (!existsSync(f)) continue;
840
- if (MODE === "revert") revertScript(root, n, f, r);
841
- else checkOrPatchScript(root, n, f, r);
867
+ if (MODE === "revert") { if (existsSync(f)) revertScript(root, n, f, r); continue; }
868
+ // hooks.json 이 부르는데 파일이 없으면 이름을 대고 센다. 그 밖에 볼 수 없으면(권한) readHook 이 이유를 대고 센다.
869
+ if (missingFile(f)) { notChecked(r, f, `hooks.json 이 부르는 훅 파일이 없음: ${shown(n)}`); continue; }
870
+ checkOrPatchScript(root, n, f, r);
842
871
  }
843
872
  return r;
844
873
  }
845
874
 
875
+ // 확인하지 못한 것: 이름과 이유를 대고 실패로 센다. check 는 «모두 적용» 이라 하지 않게 읽지 못한 것으로도 센다.
876
+ function notChecked(r, f, why) {
877
+ bad(why);
878
+ r.fail++;
879
+ r.failed.push(f);
880
+ if (MODE === "check") r.unread++;
881
+ }
882
+
883
+ const readableFile = (f) => { try { readRegular(f); return true; } catch { return false; } };
884
+ const missingFile = (f) => { try { lstatSync(f); return false; } catch (e) { return e.code === "ENOENT"; } };
885
+
846
886
  // revert 가 훑는 이름: scripts/ 의 .mjs 전부. scripts/ 가 없는 판 폴더는 되돌릴 훅이 없어 한 줄로 알리고 넘어간다.
847
887
  // 그 밖에 목록을 읽지 못하면(파일, 권한) 이름과 이유를 대고 실패로 센다 — 다음 판과 [3/4], [4/4] 는 그대로 간다.
848
888
  function scriptNames(root, r) {
@@ -927,12 +967,20 @@ function handRestoreHint(bak, f) {
927
967
 
928
968
  // 백업의 내용으로 파일을 바꾼다. 쓰지 못하면 그 이유, 백업이 읽으려는 순간 사라졌으면 GONE, 아니면 null.
929
969
  const GONE = "백업이 먼저 사라짐";
970
+ // 빈 백업이나 문법 검사를 통과하지 못하는 백업(잘린 복사)으로는 되돌리지 않는다. 쓴 뒤 검사에 걸리면 쓰기 전 글로 되돌린다.
930
971
  function restoreFrom(f, bak) {
931
972
  let data;
932
973
  try { data = readRegular(bak); }
933
974
  catch (e) { return e.code === "ENOENT" ? GONE : `읽지 못함: ${failWhy(e)}`; }
934
- try { writeAtomic(f, data); return null; }
975
+ if (!data.length) return "백업이 비어 있음";
976
+ let before;
977
+ try { before = readRegular(f); writeAtomic(f, data); }
935
978
  catch (e) { return `쓰지 못함: ${failWhy(e)}`; }
979
+ const err = syntaxError(f);
980
+ if (!err) return null;
981
+ try { writeAtomic(f, before); }
982
+ catch (e) { return `백업이 문법 검사를 통과하지 못했고 원래 글로도 되돌리지 못함 (${failWhy(e)}): ${oneLine(err)}`; }
983
+ return `백업이 문법 검사를 통과하지 못함: ${oneLine(err)}`;
936
984
  }
937
985
 
938
986
  // 되돌린 뒤 백업을 지운다. 다른 실행이 먼저 지웠으면(ENOENT) 된 것이다. 못 지우면 그 이유, 아니면 null.
@@ -948,6 +996,9 @@ function checkOrPatchScript(root, n, f, r) {
948
996
  // 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
949
997
  // 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
950
998
  if (info.state === "misplaced") { bad(`헬퍼 자리 결함: ${n} (${info.why})`); r.misplaced++; return; }
999
+ // 헬퍼 이름이 있어도 잘린 파일(복사가 끊김)은 돌지 않는다. 문법 검사를 통과해야 패치된 것으로 센다.
1000
+ const broken = info.state === "patched" ? syntaxError(f) : null;
1001
+ if (broken) { bad(`헬퍼 자리 결함: ${n} (패치된 파일이 문법 검사를 통과하지 못함: ${oneLine(broken)})`); r.misplaced++; return; }
951
1002
  if (info.state !== "needs") { r.skip++; return; }
952
1003
  const bak = `${f}.omcbak`;
953
1004
  const why = whyNotWrite(root, f, bak);
@@ -1079,6 +1130,7 @@ function revertHooksJson(root, hj) {
1079
1130
  const bak = `${hj}.omcbak`;
1080
1131
  const saved = readBackup(bak);
1081
1132
  if (saved !== null) {
1133
+ if (!isJson(saved)) { hjBad("hooks.json 을 되돌리지 않음 (백업이 비었거나 JSON 이 아님)"); return "unsafe"; }
1082
1134
  if (staleHooksJson(hj, saved)) { hjBad(`hooks.json 을 되돌리지 않음 (지금 파일에 PostToolUse 가 있고 백업과 글이 다름. 백업이 옛 원본일 수 있음)`); return "unsafe"; }
1083
1135
  writeAtomic(hj, saved);
1084
1136
  log(` 되돌림 ${hjTag}hooks.json`);
@@ -1102,9 +1154,14 @@ function revertHooksJson(root, hj) {
1102
1154
  return "reverted";
1103
1155
  }
1104
1156
  if (usable.length > 1) { hjBad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
1105
- return unsafe.length ? "linked" : "no-backup";
1157
+ if (unsafe.length) return "linked";
1158
+ // 깨진 hooks.json 에 되돌릴 백업도 없으면 «이 도구가 지운 적 없음» 이라 할 근거가 없다. 그대로 두고 실패로 센다.
1159
+ if (!isJson(readHooksJson(hj))) { hjBad("hooks.json 이 JSON 이 아니고 되돌릴 백업도 없음"); return "unsafe"; }
1160
+ return "no-backup";
1106
1161
  }
1107
1162
 
1163
+ const isJson = (text) => { try { JSON.parse(String(text)); return true; } catch { return false; } };
1164
+
1108
1165
  // 파일에서 읽은 값(파일 이름, PINNED 의 host)과 출력하는 경로를 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
1109
1166
  // SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
1110
1167
  // 바꾸는 문자: 제어 문자(Cc), 서식 문자(Cf: 폭 없는 공백, 방향 표시, 단어 결합자, 태그 문자), 줄과 문단 구분자,
@@ -1270,7 +1327,7 @@ function removePostToolUse(hj, why) {
1270
1327
  // 고정과 되돌리기가 하지 않은 일을 했다고 끝난다.
1271
1328
  function originUrl() {
1272
1329
  try { return git(["config", "--get", "remote.origin.url"]); }
1273
- catch (e) { return e.status === 1 ? null : { unreadable: gitLine(e) }; }
1330
+ catch (e) { return e.status === 1 && !String(e.stderr || "").trim() ? null : { unreadable: gitLine(e) }; }
1274
1331
  }
1275
1332
 
1276
1333
  // git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다), 정화하고
@@ -1688,7 +1745,8 @@ for (const v of targets) {
1688
1745
  else if (hj === "needs") hjNeedBy[v] = true;
1689
1746
  else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
1690
1747
  else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
1691
- else if (hj === "no-file") bad(`${tag}hooks.json 파일 자체가 없음`);
1748
+ // 판 폴더에 hooks.json 이 없으면 고친 것도 확인한 것도 없다. 실패로 센다.
1749
+ else if (hj === "no-file") { bad(`${tag}hooks.json 파일 자체가 없음`); r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
1692
1750
  // "failed" 는 고친 hooks.json 이 JSON 으로 읽히지 않아 되돌린 경우다. 고치지 못했으니 실패로 센다.
1693
1751
  else if (hj === "unsafe" || hj === "failed") { r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
1694
1752
  if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
@@ -408,6 +408,7 @@ node '<R>/gate-evidence.mjs' cleanup --dir '<g>'
408
408
 
409
409
  ### 3. 판정 수신
410
410
  각 좌석에서 4값 중 하나를 받는다. 외부 좌석은 **단독 줄 `VERDICT: <값>`** 이 필수이며, 부재·중복·절단·파싱실패·거부는 `ERROR` 다.
411
+ 코드펜스(```, ~~~) 안의 `VERDICT:` 줄은 인용한 글이라 세지 않는다. 판정 줄을 코드펜스로 감싼 답은 판정 줄이 없는 답이라 `ERROR` 다. send 출력의 `answered`, `failed` 는 답한 좌석과 실패한 좌석의 수다. `ok: true` 는 send 가 돌았다는 뜻일 뿐이고, 실패한 좌석의 `facts` 에는 `verdict` 가 없다.
411
412
 
412
413
  **나눠 보낸 페이로드.** 한 좌석에 나눠 보낸 부분들은 그 좌석의 한 판정이다. 모든 부분이 `APPROVE` 일 때만 `APPROVE`, 하나라도 `ERROR` 면 `ERROR`, 그 밖은 가장 나쁜 값(`REJECT` 가 `ITERATE` 보다 나쁘다)이다. 부분끼리는 서로의 내용을 모르므로 좌석 줄에 `분할 N` 을 적는다.
413
414
 
@@ -601,11 +601,24 @@ const readText = (file) => {
601
601
  }
602
602
  };
603
603
 
604
+ // The standalone verdict lines of an answer. A line inside a code fence (``` or ~~~) is quoted text, not the seat's verdict.
605
+ export function verdictLines(out) {
606
+ let fence = null;
607
+ const found = [];
608
+ for (const line of out.split(/\r?\n/)) {
609
+ const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
610
+ if (mark && (!fence || (mark[1][0] === fence[0] && mark[1].length >= fence.length))) { fence = fence ? null : mark[1]; continue; }
611
+ const m = !fence && VERDICT_LINE.exec(line);
612
+ if (m) found.push(m[1]);
613
+ }
614
+ return found;
615
+ }
616
+
604
617
  // What a seat's answer and errors say.
605
618
  function factsOf(cli, plan) {
606
619
  const out = readText(plan.answer);
607
620
  const err = readText(plan.stderr);
608
- const facts = { verdicts: out.split(/\r?\n/).map((l) => VERDICT_LINE.exec(l)).filter(Boolean).map((m) => m[1]), empty: !out.trim() };
621
+ const facts = { verdicts: verdictLines(out), empty: !out.trim() };
609
622
  if (cli === "codex") facts.ranModel = (/^model: (\S+)/m.exec(err.split("\n").slice(0, 40).join("\n")) || [])[1] || null;
610
623
  if (cli === "gemini") Object.assign(facts, { policyError: err.includes("Policy file error"), apiError: err.includes("Full report") });
611
624
  if (cli === "opencode") facts.fallback = err.includes("Falling back to default agent");
@@ -665,10 +678,12 @@ async function runParts(ctx, run, seat, g, parts, base) {
665
678
  }
666
679
  const last = done[done.length - 1];
667
680
  const secs = Math.round(done.reduce((t, x) => t + (x.r.secs || 0), 0) * 10) / 10;
681
+ // a failed seat has no verdict to count, so its facts carry none (a reader that skips `status` would count it)
682
+ const shownFacts = (x) => { const f = factsShown(x.f); if (x.why) delete f.verdict; return f; };
668
683
  const result = { ...base, status: last.why ? "failed" : "answered", why: last.why, exit: last.r.exit, signal: last.r.signal, secs,
669
- answer: last.plan.answer, errors: last.plan.stderr, facts: factsShown(last.f) };
684
+ answer: last.plan.answer, errors: last.plan.stderr, facts: shownFacts(last) };
670
685
  if (!last.why && parts.length > 1) result.facts.verdict = gathered(done.map((x) => x.f.verdicts[0]));
671
- if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: factsShown(x.f) }));
686
+ if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: shownFacts(x) }));
672
687
  return result;
673
688
  }
674
689
 
@@ -838,7 +853,9 @@ async function send(o, ctx) {
838
853
  if (thrown) throw thrown.reason;
839
854
  const seats = settled.map((x) => x.value);
840
855
  if (!a.dryRun) appendFileSync(join(d, "run.md"), seats.filter((x) => x.cliVersion).map((x) => `cli-${x.seat} ${x.cliVersion}\n`).join(""), { mode: 0o600 });
841
- const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), seats, declared: declared(run, payload.length, seats, d) };
856
+ // ok says the send ran; whether seats answered is in `answered`, `failed` and each seat's status
857
+ const answered = seats.filter((x) => x.status === "answered").length;
858
+ const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), answered, failed: seats.filter((x) => x.status === "failed").length, seats, declared: declared(run, payload.length, seats, d) };
842
859
  if (!a.dryRun) writeResult(d, "send", resultJson("send", result));
843
860
  return [result, signals.code(), a.dryRun ? null : d];
844
861
  } finally {
@@ -188,6 +188,8 @@ export const DECISION_REASONS = [
188
188
 
189
189
  export const CLI_DEFAULT = "@cli-default";
190
190
 
191
+ // How a CLI call ended, for a note.
192
+ const callWhy = (res) => (res?.why ? res.why : res?.status === 0 ? "exit 0 with output the probe could not use" : `exit ${res?.status ?? "?"}`);
191
193
  const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
192
194
  const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
193
195
  const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
@@ -382,6 +384,8 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
382
384
  // or the line that opened what is still open), and the strings of each top-level array
383
385
  // (`arrays`). Enough for those keys; not a TOML parser.
384
386
  export function readToml(text) {
387
+ // a file there that could not be read (permissions, a folder): nothing it says is known, so it reads as broken
388
+ if (text?.unreadable) return { values: {}, roots: [], arrays: {}, mcpServers: [], mcpUnread: true, modelKeys: false, broken: true, brokenAt: 0, unreadable: text.unreadable };
385
389
  const state = tomlState();
386
390
  const lines = String(text ?? "").split(/\r?\n/);
387
391
  const isOpen = () => state.open !== null || state.depth > 0;
@@ -457,6 +461,7 @@ export function stripJsonc(text) {
457
461
  // { value } for a readable JSONC text, { unreadable: true } for a broken one, {} when absent.
458
462
  function readJsonc(text) {
459
463
  if (text == null) return {};
464
+ if (text.unreadable) return { unreadable: true };
460
465
  try {
461
466
  const value = JSON.parse(stripJsonc(String(text)));
462
467
  return value && typeof value === "object" && !Array.isArray(value) ? { value } : { unreadable: true };
@@ -530,7 +535,7 @@ function codexConfig(world) {
530
535
  mcpUnread: layers.some((l) => l.mcpUnread),
531
536
  modelKeys: layers.some((l) => l.modelKeys),
532
537
  broken: layers.some((l) => l.broken),
533
- brokenAt: layers.map((l, k) => (l.broken ? `${files[k]} line ${l.brokenAt}` : null)).filter(Boolean),
538
+ brokenAt: layers.map((l, k) => (!l.broken ? null : l.unreadable ? `${files[k]} (not readable: ${l.unreadable})` : `${files[k]} line ${l.brokenAt}`)).filter(Boolean),
534
539
  };
535
540
  }
536
541
 
@@ -555,6 +560,8 @@ function mcpBlockedSeat(seat, conf, notes) {
555
560
  const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
556
561
  "sandbox_mode", "sandbox_workspace_write", "approval_policy"];
557
562
 
563
+ const unreadAt = (read) => (read.unreadable ? `could not be read (${read.unreadable})` : `could not be read to its end (line ${read.brokenAt})`);
564
+
558
565
  // The unseated seat when the managed file sets one of those keys, or could not be read to its
559
566
  // end (a line read short may set one); null otherwise.
560
567
  function managedBlockedSeat(seat, world, notes) {
@@ -562,7 +569,7 @@ function managedBlockedSeat(seat, world, notes) {
562
569
  const read = readToml(world.readFile(CODEX_MANAGED));
563
570
  const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
564
571
  if (!keys.length && !read.broken) return null;
565
- notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
572
+ notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
566
573
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
567
574
  }
568
575
 
@@ -583,7 +590,7 @@ function requirementsBlockedSeat(seat, world, notes) {
583
590
  const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
584
591
  const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
585
592
  if (!keys.length && !read.broken) return null;
586
- notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
593
+ notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
587
594
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
588
595
  }
589
596
 
@@ -653,10 +660,18 @@ function codexSeat(flag, { world, runtime, authorFamilies }) {
653
660
  const notes = [];
654
661
  const conf = codexConfig(world);
655
662
  const configured = vetConfigured(safeOnly([conf.cfg.model], "config.toml", notes)[0], authorFamilies, notes);
656
- const catalog = readCatalog(world.run("codex", ["debug", "models", "--bundled"]), notes) ?? [];
663
+ const listed = world.run("codex", ["debug", "models", "--bundled"]);
664
+ const read = readCatalog(listed, notes);
665
+ const catalog = read ?? [];
657
666
  const sorted = sortModels([...catalog, configured], authorFamilies);
658
- const version = firstLine(world.run("codex", ["--version"]));
667
+ const ran = world.run("codex", ["--version"]);
668
+ // a version line has a version number in it; an error text on stdout with exit 0 is not one
669
+ const version = /\d+\.\d+/.test(firstLine(ran) ?? "") ? firstLine(ran) : null;
670
+ if (!read) notes.push(`codex debug models --bundled gave no catalog (${callWhy(listed)})`);
671
+ if (!version) notes.push(`codex --version gave no version (${callWhy(ran)})`);
659
672
  const seat = { cli: "codex", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified, mcpServers: conf.mcpServers };
673
+ // both calls failed: this codex does not run here (not logged in, broken install, killed), so it is not a seat
674
+ if (!read && !version) return withNotes({ ...seat, decision: "unseated", reason: "cli-call-failed" }, notes);
660
675
  const refused = legacyProfileSeat(seat, conf.cfg, notes) ?? managedBlockedSeat(seat, world, notes)
661
676
  ?? requirementsBlockedSeat(seat, world, notes) ?? mcpBlockedSeat(seat, conf, notes);
662
677
  if (refused) return withNotes(refused, notes);
@@ -710,8 +725,10 @@ function geminiPoliciesDir(world) {
710
725
  // seat's read-only tools rest on that flag: such a seat is not seated.
711
726
  function geminiPolicySeat(seat, world, notes) {
712
727
  const dir = geminiPoliciesDir(world);
713
- if (!world.listDir(dir).some((f) => f.endsWith(".toml"))) return null;
714
- notes.push(`gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`);
728
+ const list = world.listDir(dir);
729
+ if (list && !list.some((f) => f.endsWith(".toml"))) return null;
730
+ notes.push(list ? `gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`
731
+ : `the probe could not list ${dir}: gemini ignores --admin-policy while it holds policies, so the seat cannot be shown to keep to its read-only tools`);
715
732
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
716
733
  }
717
734
 
@@ -835,7 +852,8 @@ const opencodeCache = (world) => join(world.env.XDG_CACHE_HOME || join(world.hom
835
852
  function catalogProviders(world) {
836
853
  const file = join(opencodeCache(world), "models.json");
837
854
  const read = readJsonc(world.readFile(file));
838
- return read.value ? new Set(Object.keys(read.value)) : null;
855
+ // an emptied copy ({}) lists no provider: it says nothing about which ones models.dev knows
856
+ return read.value && Object.keys(read.value).length ? new Set(Object.keys(read.value)) : null;
839
857
  }
840
858
 
841
859
  // The models the config lists are all opencode can run only when enabled_providers names
@@ -953,18 +971,20 @@ function realWorld() {
953
971
  platform: process.platform,
954
972
  env: process.env,
955
973
  which: onPath,
974
+ // null when the file is not there; { unreadable } when it is but cannot be read, which is not the same as absent
956
975
  readFile: (path) => {
957
976
  try {
958
977
  return readFileSync(path, "utf8");
959
- } catch {
960
- return null;
978
+ } catch (e) {
979
+ return e.code === "ENOENT" || e.code === "ENOTDIR" ? null : { unreadable: e.code || "error" };
961
980
  }
962
981
  },
982
+ // [] when the folder is not there; null when it is but cannot be listed
963
983
  listDir: (path) => {
964
984
  try {
965
985
  return readdirSync(path);
966
- } catch {
967
- return [];
986
+ } catch (e) {
987
+ return e.code === "ENOENT" || e.code === "ENOTDIR" ? [] : null;
968
988
  }
969
989
  },
970
990
  run: (cmd, args, env = {}) => {
@@ -978,11 +998,13 @@ function realWorld() {
978
998
  const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
979
999
  encoding: "utf8",
980
1000
  timeout: 15000,
1001
+ // a CLI that ignores SIGTERM would hold spawnSync past its time limit
1002
+ killSignal: "SIGKILL",
981
1003
  maxBuffer: 64 * 1024 * 1024,
982
1004
  shell,
983
1005
  env: { ...process.env, ...(viaCmd ? { NoDefaultCurrentDirectoryInExePath: "1" } : {}), ...env },
984
1006
  });
985
- return { status: r.status, stdout: r.stdout ?? "" };
1007
+ return { status: r.status, stdout: r.stdout ?? "", why: r.error ? (r.error.code === "ETIMEDOUT" ? "timed out" : r.error.code) : r.signal ? `killed by ${r.signal}` : null };
986
1008
  },
987
1009
  };
988
1010
  }
@@ -92,7 +92,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
92
92
  - `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
93
93
  - 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
94
94
 
95
- 박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
95
+ 박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 보통은 `/tmp` 아래이고, `runAs` 박스는 `/var/lib` 아래다. `runAs` 계정의 HOME 도 그 안에 있어서, 임시 폴더를 거부하는 도구(teamwork 등)의 시험도 그대로 돈다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
96
96
  알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
97
97
 
98
98
  ### 등록 파일
@@ -27,7 +27,11 @@ const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"
27
27
  const SECRETISH = /pass|secret|token|credential/i;
28
28
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
29
  const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
30
- const REMOTE_DIR = /^\/tmp\/banker-remains\.[A-Za-z0-9]+$/;
30
+ // Where a box's private folder goes. A runAs box gets one outside the temporary folders, so that the copy and the
31
+ // run account's HOME inside it are not under /tmp: tools that refuse temporary folders (teamwork's Codex home check)
32
+ // would refuse the tests' fixtures there. /var/lib is root's, so only a root login (runAs) makes one there.
33
+ const dirBase = (b) => (b.runAs ? "/var/lib" : "/tmp");
34
+ const remoteDir = (b) => new RegExp(`^${dirBase(b).replace(/\//g, "\\/")}\\/banker-remains\\.[A-Za-z0-9]+$`);
31
35
  // What ssh and scp print about the connection itself, as opposed to what the remote command prints.
32
36
  const SSH_ERR = /^(ssh|scp): |Connection (closed|refused|reset|timed out)|Host key verification failed|Permission denied \(|Could not resolve hostname|No route to host|Broken pipe/m;
33
37
  const UPLOAD_MS = 10 * 60 * 1000;
@@ -153,8 +157,8 @@ function winSteps(b, name) {
153
157
  };
154
158
  }
155
159
 
156
- // Linux and macOS work in the private folder `mktemp -d` made. A root login hands the run to runAs, with a
157
- // HOME of its own. Ending a run takes the process tree under the recorded id first, then the folder.
160
+ // Linux and macOS work in the private folder `mktemp -d` made (under /tmp, or /var/lib for runAs). A root login
161
+ // hands the run to runAs, with a HOME of its own inside that folder. Ending a run takes the process tree under the recorded id first, then the folder.
158
162
  function posixSteps(b, name, dir) {
159
163
  const go = b.runAs
160
164
  ? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
@@ -227,10 +231,10 @@ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255
227
231
 
228
232
  async function makeDir(b, exec) {
229
233
  if (b.os === "win32") return { dir: null };
230
- const r = await exec("ssh", [...sshBase(b), b.ssh, "mktemp -d /tmp/banker-remains.XXXXXXXX"], { timeoutMs: PROBE_MS });
234
+ const r = await exec("ssh", [...sshBase(b), b.ssh, `mktemp -d ${dirBase(b)}/banker-remains.XXXXXXXX`], { timeoutMs: PROBE_MS });
231
235
  const dir = String(r.stdout || "").trim();
232
236
  if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
233
- return REMOTE_DIR.test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
237
+ return remoteDir(b).test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
234
238
  }
235
239
 
236
240
  async function attempt(b, plan, exec, files, steps) {
@@ -265,22 +269,37 @@ export async function runOnBox(b, plan, exec) {
265
269
 
266
270
  // Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
267
271
  // inside the folder worked on never runs.
268
- function git(cwd, args) {
269
- const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
270
- if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.includes("=")).join(" ")} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
272
+ function git(cwd, args, env = process.env) {
273
+ const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
274
+ if (r.status !== 0) throw new Error(`git ${gitWords(args)} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
271
275
  return r.stdout;
272
276
  }
273
277
 
278
+ // The command words for a message: global options (-c key=value, --no-optional-locks) are left out.
279
+ export const gitWords = (args) => args.filter((a, i) => a !== "-c" && args[i - 1] !== "-c" && !a.startsWith("--no-optional-locks")).join(" ");
280
+
274
281
  // The commit to send. `worktree` is a commit object made from the tracked files as they are now
275
282
  // (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
276
- function resolveTree(repo, ref) {
277
- const status = git(repo, ["status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
283
+ // Neither step writes the repository's index: status takes no optional lock, and stash create works on a copy of
284
+ // the index (GIT_INDEX_FILE), so two runs at once, or a git command of the user's, do not meet on index.lock.
285
+ export function resolveTree(repo, ref) {
286
+ const status = git(repo, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
278
287
  const untracked = status.filter((l) => l.startsWith("??")).length;
279
288
  if (ref !== "worktree") {
280
289
  return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
281
290
  }
282
- const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"]).trim();
283
- return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
291
+ const tmp = join(tmpdir(), `banker-remains-index-${randomBytes(8).toString("hex")}`);
292
+ mkdirSync(tmp, { mode: 0o700 });
293
+ try {
294
+ const index = resolve(repo, git(repo, ["rev-parse", "--git-path", "index"]).trim());
295
+ const copy = join(tmp, "index");
296
+ if (existsSync(index)) copyFileSync(index, copy);
297
+ const env = { ...process.env, GIT_INDEX_FILE: copy };
298
+ const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"], env).trim();
299
+ return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
300
+ } finally {
301
+ rmSync(tmp, { recursive: true, force: true });
302
+ }
284
303
  }
285
304
 
286
305
  function testFiles(repo, sha) {