@kaydash9999/banker-plugins 0.20.1 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +195 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +27 -4
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +5 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +137 -30
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +13 -5
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +381 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -40,9 +40,10 @@ node "<이 스킬 디렉터리 절대경로>/scripts/payload-mon.mjs" <on|off|st
40
40
 
41
41
  - `on`: Claude Code 설정 폴더(`$CLAUDE_CONFIG_DIR`, 없으면 `~/.claude`)의 `hud/omc-hud-custom.mjs` 에서, ctx 를 넣는 줄 바로 뒤에 `// >>> payload-mon >>>` 블록 하나를 넣는다.
42
42
  블록이 불러오는 추정 모듈은 래퍼 옆 `hud/payload-mon/payload-size.mjs` 에 사본으로 둔다. 플러그인 설치 경로는 버전마다 바뀌어 업데이트 뒤에 사라지기 때문이다.
43
+ 래퍼 파일이 다른 곳을 가리키는 링크면 사본은 그 파일 옆에 둔다. 래퍼 v4 가 그 자리에서 사본을 먼저 찾기 때문이다.
43
44
  이미 켜져 있으면 래퍼를 바꾸지 않고, 모듈 사본이 이 버전과 다를 때만 사본을 맞춘다. 예전 방식(블록 2개)으로 켜져 있었다면 새 방식으로 바꾼다.
44
45
  바꾸기 전 내용은 `omc-hud-custom.mjs.payload-mon.bak` 에 남는다.
45
- - `off`: 그 블록만 빼서 래퍼를 켜기 전 상태로 되돌리고, 모듈 사본과 추정치 캐시를 지운다. 래퍼 v4 에서는 모듈 사본이 없어져 `PL:` 칸도 사라진다.
46
+ - `off`: 그 블록만 빼서 래퍼를 켜기 전 상태로 되돌리고, 모듈 사본과 추정치 캐시를 지운다. 래퍼 v4 에서는 모듈 사본이 없어져 `PL:` 칸도 사라진다. 사본 폴더가 링크면 사본을 지우지 않고 그렇다고 알린다(`PL:` 칸이 남는다).
46
47
  - `status`: 켜짐·꺼짐, 모듈 사본이 이 버전과 같은지, statusLine 이 이 래퍼를 쓰는지, 현재 세션의 추정치를 보여 준다.
47
48
  - 같은 래퍼에 자기 표시 블록을 넣는 다른 도구가 있어도 서로의 블록을 건드리지 않는다.
48
49
 
@@ -59,7 +60,9 @@ node "<이 스킬 디렉터리 절대경로>/scripts/payload-mon.mjs" <on|off|st
59
60
  플러그인 파일을 손으로 고치지 말고, banker 를 최신으로 올린 뒤(`update-banker`) 다시 시도한다. 그래도 안 되면 banker 이슈로 알린다.
60
61
  - "node --check 실패": 블록을 넣은 결과가 문법 검사를 통과하지 못했다(예: 래퍼가 ctx 를 넣는 줄을 비동기가 아닌 함수 안으로 옮김). 파일은 그대로다.
61
62
  - "래퍼에 쓸 권한이 없습니다", 또는 `status` 의 "HUD 래퍼를 읽을 수 없음": 래퍼 파일의 권한을 확인한다. 파일은 그대로다.
62
- - "모듈 사본을 쓰지 못했습니다": 래퍼 옆 `hud/payload-mon/` 에 쓸 수 없다(권한, 같은 이름의 파일, 디스크 공간). 래퍼는 그대로다.
63
+ - "모듈 사본을 쓰지 못했습니다": 래퍼 옆(래퍼 파일이 링크면 링크가 가리키는 파일 옆) `payload-mon/` 에 쓸 수 없다(권한, 같은 이름의 파일, 디스크 공간). 래퍼는 그대로다.
64
+ `사본 폴더가 링크입니다` 면 그 폴더가 다른 곳을 가리키는 링크다. 이 도구는 링크 너머에 쓰거나 지우지 않는다. 링크를 일반 폴더로 바꾼 뒤 다시 켠다.
65
+ `사본 폴더가 쓰는 동안 바뀌었습니다` 면 쓰는 사이 그 폴더가 다른 것(링크, 다른 폴더)으로 바뀌었거나, 바뀌었다 돌아와 사본이 폴더에 없다. 바뀐 자리의 사본은 이 도구가 쓴 그 파일이고 바이트도 같을 때만 거뒀다(원래 거기 있던 다른 래퍼의 사본은 둔다). 바뀌었다 돌아온 사이 다른 곳에 간 사본은 거두지 못한다. 폴더를 확인한 뒤 다시 켠다.
63
66
  - "표시(>>> / <<<)의 짝이 맞지 않습니다": 래퍼에 payload-mon 표시 줄이 한쪽만 남았다. `on`·`off` 모두 거부하므로, 손으로 고치지 말라는 원칙의 유일한 예외다.
64
67
  `setup-omc-hud` 로 HUD 를 재설치하거나, 래퍼에서 짝 없이 남은 `// >>> payload-mon >>>` 또는 `// <<< payload-mon <<<` 줄을 지운 뒤 다시 실행한다.
65
68
  - HUD 를 재설치한 뒤 payload 가 안 보이면 `on` 을 다시 실행한다. `setup-omc-hud` 는 설치할 때마다 이 스킬을 늘 켠다(래퍼 v4 의 `PL:` 칸이 모듈 사본을 씀).
@@ -25,6 +25,14 @@
25
25
  * install lives under a versioned path that the next update retires, and an
26
26
  * import pointing there would go dark without a word. `on` writes the copy and
27
27
  * refreshes it when this version's module differs; `status` says when it does.
28
+ * When the wrapper file itself is a symlink, the copy goes beside the file it
29
+ * points at, where wrapper v4 looks first. A copy folder (payload-mon/) that is
30
+ * itself a link is neither written nor emptied through: `on` refuses it, and
31
+ * also refuses (taking its copy back) when the folder changes while it writes.
32
+ *
33
+ * The block takes the ctx line's line ending, so a CRLF wrapper stays CRLF, and
34
+ * a block whose line endings an editor rewrote still counts as on. A wrapper
35
+ * that starts with a BOM is taken as it is.
28
36
  *
29
37
  * `on` converges on the current block and is a no-op when it is in place. It
30
38
  * writes only into the OMC custom HUD (signature-gated); `off` removes only
@@ -39,6 +47,7 @@ import {
39
47
  accessSync,
40
48
  constants,
41
49
  existsSync,
50
+ lstatSync,
42
51
  mkdirSync,
43
52
  mkdtempSync,
44
53
  readdirSync,
@@ -56,7 +65,10 @@ import { CACHE_DIR, clearCache, estimatePayloadBytes, REQUEST_LIMIT_BYTES, SHOW_
56
65
 
57
66
  const OPEN = "// >>> payload-mon >>>";
58
67
  const CLOSE = "// <<< payload-mon <<<";
59
- const BLOCKS = /^[ \t]*\/\/ >>> payload-mon >>>[\s\S]*?\/\/ <<< payload-mon <<<[ \t]*\r?\n/gm;
68
+ export const BLOCKS = /^[ \t]*\/\/ >>> payload-mon >>>[\s\S]*?\/\/ <<< payload-mon <<<[ \t]*\r?\n/gm;
69
+ // The closing line. No block reaches past the last one, so the pattern runs only on the text before it:
70
+ // otherwise every stray opening line scans to the end again (a 2MB file of them took 28s).
71
+ export const CLOSE_LINE = /\/\/ <<< payload-mon <<<[ \t]*\r?\n/g;
60
72
  // The block goes right after the line that adds ctx.
61
73
  const CTX_LINE = /^ {2}if \(f\.ctx\) colored\.push\(colorCtx\(f\.ctx\)\);\r?\n/m;
62
74
  // The statusline JSON the block reads, in either layout.
@@ -86,13 +98,29 @@ const DEFAULTS = {
86
98
  sessionId: process.env.CLAUDE_CODE_SESSION_ID,
87
99
  };
88
100
 
89
- // Where the block's module lives: beside the wrapper, outside any versioned path.
90
- const moduleCopy = (hudFile) => join(dirname(hudFile), "payload-mon", "payload-size.mjs");
101
+ // The file a wrapper that is itself a symlink (dotfiles) points at; any other path as it is. A wrapper
102
+ // reached through a linked folder keeps the path as configured.
103
+ function linkTarget(hudFile) {
104
+ try {
105
+ return lstatSync(hudFile).isSymbolicLink() ? realpathSync(hudFile) : hudFile;
106
+ } catch {
107
+ return hudFile;
108
+ }
109
+ }
110
+ // Where the block's module lives: beside the wrapper, outside any versioned path. For a wrapper file
111
+ // that is a link, beside the file it points at: wrapper v4 resolves its own path through the link and
112
+ // looks there for its PL segment.
113
+ const moduleCopy = (hudFile) => join(dirname(linkTarget(hudFile)), "payload-mon", "payload-size.mjs");
114
+ // Where 0.20.1 put the copy for such a link; `off` clears it too.
115
+ const besideLink = (hudFile) => join(dirname(hudFile), "payload-mon", "payload-size.mjs");
91
116
  const moduleUrl = (hudFile) => pathToFileURL(moduleCopy(hudFile)).href;
92
117
 
118
+ // The ctx line's line ending, which the block takes so that a CRLF wrapper stays CRLF.
119
+ const eolOf = (src) => (CTX_LINE.exec(src)?.[0].endsWith("\r\n") ? "\r\n" : "\n");
120
+
93
121
  // Right after ctx, colored like the 5h/wk rates: % on the ctx scale, MB in cyan.
94
122
  // Its names are block-scoped, so none can clash with the wrapper's own.
95
- const placeBlock = (url) =>
123
+ const placeBlock = (url, eol = "\n") =>
96
124
  [
97
125
  " " + OPEN,
98
126
  " // payload-mon: estimated API request payload against the 32MB request limit, shown",
@@ -110,12 +138,19 @@ const placeBlock = (url) =>
110
138
  " }",
111
139
  " " + CLOSE,
112
140
  "",
113
- ].join("\n");
141
+ ].join(eol);
114
142
 
115
- const insertBlock = (base, url) => base.replace(CTX_LINE, (line) => line + placeBlock(url));
143
+ const insertBlock = (base, url) => base.replace(CTX_LINE, (line) => line + placeBlock(url, eolOf(line)));
116
144
 
117
145
  const count = (text, needle) => text.split(needle).length - 1;
118
146
 
147
+ // The text without its blocks, the pattern run only up to the last closing line (see CLOSE_LINE).
148
+ function withoutBlocks(src) {
149
+ let end = -1;
150
+ for (const m of src.matchAll(CLOSE_LINE)) end = m.index + m[0].length;
151
+ return end < 0 ? src : src.slice(0, end).replace(BLOCKS, "") + src.slice(end);
152
+ }
153
+
119
154
  // The wrapper's text, null when there is no such file. Any other read failure
120
155
  // (permissions, say) is not "missing" and propagates.
121
156
  const readText = (file) => {
@@ -137,18 +172,21 @@ const readBytes = (file) => {
137
172
 
138
173
  const refusal = (why, hudFile) => ({ ok: false, message: `${why}. 래퍼를 그대로 두었습니다: ${hudFile}` });
139
174
 
140
- // The block exactly as `on` writes it for this URL, once, right after the ctx line.
141
- function inPlace(src, url) {
142
- if (count(src, OPEN) !== 1 || count(src, CLOSE) !== 1) return false;
175
+ // The line ending ("\n" or "\r\n") of the block that sits, exactly as `on` writes it for this URL,
176
+ // once, right after the ctx line; "" when it does not. Either ending counts as in place, so a block
177
+ // whose line endings an editor rewrote is still on.
178
+ function blockEol(src, url) {
179
+ if (count(src, OPEN) !== 1 || count(src, CLOSE) !== 1) return "";
143
180
  const ctx = CTX_LINE.exec(src);
144
- return ctx !== null && src.startsWith(placeBlock(url), ctx.index + ctx[0].length);
181
+ if (ctx === null) return "";
182
+ return ["\n", "\r\n"].find((eol) => src.startsWith(placeBlock(url, eol), ctx.index + ctx[0].length)) ?? "";
145
183
  }
146
184
 
147
185
  // Why the block cannot go into this wrapper, or "" when it can. Markers that do
148
186
  // not pair up would let the block pattern swallow the code between them.
149
187
  function misfit(src) {
150
188
  if (count(src, OPEN) !== count(src, CLOSE)) return "payload-mon 표시(>>> / <<<)의 짝이 맞지 않습니다";
151
- const base = src.replace(BLOCKS, "");
189
+ const base = withoutBlocks(src);
152
190
  if (!base.includes("omc-hud.mjs") || !base.includes("const colorRate")) return "OMC 커스텀 HUD 래퍼가 아닙니다";
153
191
  if (!INPUT_DECL.test(base)) return "삽입 위치를 찾지 못했습니다(statusline 입력을 담는 input 변수가 없음)";
154
192
  if (!CTX_LINE.test(base)) return "삽입 위치를 찾지 못했습니다(ctx를 넣는 줄이 없음)";
@@ -163,6 +201,16 @@ function copyState(o) {
163
201
  return source !== null && copy.equals(source) ? "current" : "stale";
164
202
  }
165
203
 
204
+ // Whether the folder holding `file` is a link. The copy folder is this tool's, and a link there
205
+ // would take the copy, or its removal, to wherever it points.
206
+ function linkedFolder(file) {
207
+ try {
208
+ return lstatSync(dirname(file)).isSymbolicLink();
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
166
214
  // Replaces file through a fresh temp file ("wx") and a rename: a link planted at
167
215
  // either name is replaced rather than written through, and a write that fails
168
216
  // leaves the old file as it was.
@@ -171,24 +219,62 @@ function writeFresh(file, data, mode) {
171
219
  rmSync(tmp, { force: true });
172
220
  try {
173
221
  writeFileSync(tmp, data, { flag: "wx", mode });
222
+ const id = fileId(tmp);
174
223
  renameSync(tmp, file);
224
+ return id;
175
225
  } catch (e) {
176
226
  rmSync(tmp, { force: true });
177
227
  throw e;
178
228
  }
179
229
  }
180
230
 
231
+ // A file's identity, exact: NTFS file IDs pass 2^53, where a Number loses the low bits.
232
+ const fileId = (p) => {
233
+ const s = lstatSync(p, { bigint: true });
234
+ return `${s.dev}:${s.ino}`;
235
+ };
236
+ // The copy folder's identity, to tell afterwards whether it is still the folder that was written into.
237
+ const folderStamp = (dir) => {
238
+ const s = lstatSync(dir, { bigint: true });
239
+ return `${s.dev}:${s.ino}:${s.isSymbolicLink()}`;
240
+ };
241
+ // After the write: the copy folder must still be the one stamped before, holding these bytes. Another program
242
+ // may have swapped it (for a link, say), so the copy went wherever it led, or put it back after the write, so
243
+ // the copy is not in it. Then the write is refused, and the copy is taken back from where the folder now leads
244
+ // when the file there is the one written (`written`, from writeFresh) and still ours byte for byte: a copy that
245
+ // was there before, or one another program wrote over, stays.
246
+ // Node has no openat, so a copy that went elsewhere through a swap that was put back stays where it went, and
247
+ // a swap after this check goes unseen.
248
+ function checkFolder(copy, stamp, data, written) {
249
+ const dir = dirname(copy);
250
+ if (folderStamp(dir) === stamp && readBytes(copy)?.equals(data)) return;
251
+ try {
252
+ const landed = join(realpathSync(dir), basename(copy));
253
+ if (fileId(landed) === written && readFileSync(landed).equals(data)) rmSync(landed, { force: true });
254
+ } catch {
255
+ /* nothing there to take back */
256
+ }
257
+ throw new Error(`사본 폴더가 쓰는 동안 바뀌었습니다: ${dir}`);
258
+ }
259
+
181
260
  // Brings the copy up to this version's module; true when it had to write. The
182
261
  // statusline imports it on every refresh, so no other account may change it
183
262
  // (reading it is harmless). When the write fails, a folder made here is taken
184
263
  // away again: mkdirSync names a folder only when it created it, and rmdir
185
264
  // leaves one that something else has meanwhile put a file in.
186
265
  function syncModule(o) {
187
- if (copyState(o) === "current") return false;
188
266
  const copy = moduleCopy(o.hudFile);
267
+ if (linkedFolder(copy)) throw new Error(`사본 폴더가 링크입니다: ${dirname(copy)}`);
268
+ if (copyState(o) === "current") return false;
269
+ // Read first: a module that cannot be read leaves no folder behind.
270
+ const data = readFileSync(o.moduleSource);
189
271
  const made = mkdirSync(dirname(copy), { recursive: true, mode: 0o755 }) !== undefined;
190
272
  try {
191
- writeFresh(copy, readFileSync(o.moduleSource), 0o644);
273
+ const stamp = folderStamp(dirname(copy));
274
+ // From the same lstat: a folder that became a link since the check above is refused before the write.
275
+ if (stamp.endsWith(":true")) throw new Error(`사본 폴더가 링크입니다: ${dirname(copy)}`);
276
+ const written = writeFresh(copy, data, 0o644);
277
+ checkFolder(copy, stamp, data, written);
192
278
  } catch (e) {
193
279
  if (made) {
194
280
  try {
@@ -202,12 +288,14 @@ function syncModule(o) {
202
288
  return true;
203
289
  }
204
290
 
205
- // The first lines of node's complaint about `text` as an ES module, "" if it parses.
291
+ // The first lines of node's complaint about `text` as an ES module, "" if it parses. A leading BOM
292
+ // is left out: node runs a wrapper that starts with one, but `node --check` rejects a BOM before a
293
+ // shebang (Node 20, 24).
206
294
  function syntaxError(text) {
207
295
  const dir = mkdtempSync(join(tmpdir(), "payload-mon-"));
208
296
  try {
209
297
  const file = join(dir, "check.mjs");
210
- writeFileSync(file, text);
298
+ writeFileSync(file, text.replace(/^\uFEFF/, ""));
211
299
  const r = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
212
300
  return r.status === 0 ? "" : String(r.stderr || r.error).trim().split("\n").slice(0, 3).join(" | ");
213
301
  } finally {
@@ -257,7 +345,7 @@ const showsPL = (src) => src !== null && PL_SEGMENT.test(src);
257
345
  // Where the size shows, named only while it does: the block in place and a
258
346
  // module copy to import. "" otherwise, so status never promises a segment.
259
347
  function displayWhere(o, src) {
260
- if (src === null || !inPlace(src, moduleUrl(o.hudFile)) || copyState(o) === "missing") return "";
348
+ if (src === null || blockEol(src, moduleUrl(o.hudFile)) === "" || copyState(o) === "missing") return "";
261
349
  return showsPL(src) ? "PL 칸에 늘 표시" : `${SHOW_FROM_BYTES / MB}MB부터 표시`;
262
350
  }
263
351
 
@@ -265,7 +353,8 @@ export function turnOn(opts = {}) {
265
353
  const o = { ...DEFAULTS, ...opts };
266
354
  const src = readText(o.hudFile);
267
355
  if (src === null) return { ok: false, message: `HUD 래퍼가 없습니다: ${o.hudFile}` };
268
- if (inPlace(src, moduleUrl(o.hudFile))) {
356
+ // In place with the wrapper's own line ending; a block whose ending differs is written again.
357
+ if (blockEol(src, moduleUrl(o.hudFile)) === eolOf(src)) {
269
358
  try {
270
359
  const refreshed = syncModule(o);
271
360
  return { ok: true, message: (refreshed ? "모듈 사본을 이 버전으로 맞췄습니다." : "이미 켜져 있습니다 (변경 없음).") + notShownNote(o) };
@@ -278,43 +367,52 @@ export function turnOn(opts = {}) {
278
367
  const done = showsPL(src)
279
368
  ? "켰습니다. 다음 statusline 갱신부터 PL 칸에 요청 크기가 늘 표시됩니다."
280
369
  : "켰습니다. 다음 statusline 갱신부터 8MB 이상이면 ctx 옆에 payload가 표시됩니다.";
281
- const after = insertBlock(src.replace(BLOCKS, ""), moduleUrl(o.hudFile));
370
+ const after = insertBlock(withoutBlocks(src), moduleUrl(o.hudFile));
282
371
  return commit(o.hudFile, src, after, done + notShownNote(o), () => syncModule(o));
283
372
  }
284
373
 
285
374
  function removeBlocks(o, src) {
286
375
  if (src === null) return { ok: true, message: `HUD 래퍼가 없어 끌 것이 없습니다: ${o.hudFile}` };
287
376
  if (count(src, OPEN) !== count(src, CLOSE)) return refusal("payload-mon 표시(>>> / <<<)의 짝이 맞지 않습니다", o.hudFile);
288
- const out = src.replace(BLOCKS, "");
377
+ const out = withoutBlocks(src);
289
378
  if (out === src) return { ok: true, message: "이미 꺼져 있습니다 (래퍼 변경 없음)." };
290
379
  return commit(o.hudFile, src, out, "껐습니다. 다음 statusline 갱신부터 payload가 표시되지 않습니다.");
291
380
  }
292
381
 
293
382
  // What off leaves behind otherwise: the cache (when this account controls its
294
383
  // folder), and the module copy with its folder (only when nothing else is in
295
- // it). Neither failing undoes off.
384
+ // it), also the one 0.20.1 left beside a linked wrapper, but nothing through a copy folder that is a link.
385
+ // None failing undoes off.
386
+ // Returns the copy folders it left alone because they are links.
296
387
  function removeLeftovers(o) {
297
- const copy = moduleCopy(o.hudFile);
298
- for (const remove of [() => clearCache(o.cacheDir), () => rmSync(copy, { force: true })]) {
388
+ const all = [...new Set([moduleCopy(o.hudFile), besideLink(o.hudFile)])];
389
+ const copies = all.filter((copy) => !linkedFolder(copy));
390
+ for (const remove of [() => clearCache(o.cacheDir), ...copies.map((copy) => () => rmSync(copy, { force: true }))]) {
299
391
  try {
300
392
  remove();
301
393
  } catch {
302
394
  /* stale numbers or an unused copy; off stands */
303
395
  }
304
396
  }
305
- try {
306
- rmdirSync(dirname(copy));
307
- } catch {
308
- /* absent, or holds something that is not ours */
397
+ for (const copy of copies) {
398
+ try {
399
+ rmdirSync(dirname(copy));
400
+ } catch {
401
+ /* absent, or holds something that is not ours */
402
+ }
309
403
  }
404
+ return all.filter((copy) => !copies.includes(copy)).map((copy) => dirname(copy));
310
405
  }
311
406
 
312
407
  export function turnOff(opts = {}) {
313
408
  const o = { ...DEFAULTS, ...opts };
314
409
  const res = removeBlocks(o, readText(o.hudFile));
315
- if (res.ok) removeLeftovers(o);
316
- return res;
410
+ if (!res.ok) return res;
411
+ // A copy kept in a linked folder still feeds wrapper v4's PL segment, so off says so.
412
+ const kept = removeLeftovers(o);
413
+ return kept.length ? { ...res, message: `${res.message} ${KEPT_NOTE}: ${kept.join(", ")}` } : res;
317
414
  }
415
+ const KEPT_NOTE = "사본 폴더가 링크라 모듈 사본은 지우지 않았습니다(래퍼 v4 의 PL 칸이 계속 보일 수 있음)";
318
416
 
319
417
  // The estimate for the session that runs this, "" if unknown, ending with where
320
418
  // it shows when `where` names it:
@@ -346,8 +444,17 @@ function stateOf(src, o) {
346
444
  if (count(src, OPEN) + count(src, CLOSE) === 0) return "꺼짐";
347
445
  // on and off both refuse this, so pointing at on would be a dead end.
348
446
  if (count(src, OPEN) !== count(src, CLOSE)) return "표시(>>> / <<<)의 짝이 맞지 않습니다 (on·off 모두 거부: HUD 재설치 또는 남은 표시 줄 정리)";
349
- if (!inPlace(src, moduleUrl(o.hudFile))) return "켜짐이 불완전하거나 오래됨 (on 으로 복구)";
350
- return "켜짐" + COPY_NOTE[copyState(o)];
447
+ if (blockEol(src, moduleUrl(o.hudFile)) === "") return "켜짐이 불완전하거나 오래됨 (on 으로 복구)";
448
+ return "켜짐" + copyNote(o);
449
+ }
450
+
451
+ // What status adds about the module copy. In a copy folder that is a link, on refuses to write it, so
452
+ // pointing at on would be a dead end.
453
+ function copyNote(o) {
454
+ const state = copyState(o);
455
+ const copy = moduleCopy(o.hudFile);
456
+ if (state !== "current" && linkedFolder(copy)) return `, 단 사본 폴더가 링크라 모듈 사본을 쓰지 않음 (링크를 일반 폴더로 바꾼 뒤 on): ${dirname(copy)}`;
457
+ return COPY_NOTE[state];
351
458
  }
352
459
 
353
460
  // What status says about the wrapper. A wrapper it cannot read is reported, not
@@ -211,7 +211,10 @@ export function clearCache(dir = CACHE_DIR) {
211
211
  export function estimatePayloadBytes(transcriptPath, cacheDir = CACHE_DIR) {
212
212
  const fd = openSync(transcriptPath, "r");
213
213
  try {
214
- const { size, ino } = fstatSync(fd);
214
+ // The file number as a string: NTFS file IDs pass 2^53, where a Number loses the low bits, and JSON has no BigInt.
215
+ const st = fstatSync(fd, { bigint: true });
216
+ const size = Number(st.size);
217
+ const ino = String(st.ino);
215
218
  const file = cacheFile(cacheDir, transcriptPath);
216
219
  const c = loadCache(file);
217
220
  // Resume only a cache of these rules, for this file, which has not shrunk since.
@@ -35,7 +35,7 @@ Target: **long edge = 2400 px** (just under cap, leaves headroom).
35
35
 
36
36
  ```bash
37
37
  ls -lh <pdf-path>
38
- python3 -c "import fitz; d=fitz.open('<pdf-path>'); print('pages=', d.page_count)"
38
+ (cd ~ && python3 -c "import fitz; d=fitz.open('<absolute-pdf-path>'); print('pages=', d.page_count)") # from home: -c, -m and a stdin script import from the current folder first
39
39
  ```
40
40
 
41
41
  ### 2. Choose output directory
@@ -49,7 +49,7 @@ Convention: place PNGs **next to** existing design assets, in a subfolder named
49
49
  PyMuPDF is the most reliable single-dependency path (no Poppler, no ImageMagick).
50
50
 
51
51
  ```bash
52
- python3 - <<'PY'
52
+ (cd ~ && python3 - <<'PY'
53
53
  import fitz, os
54
54
  SRC = "<absolute-pdf-path>"
55
55
  OUT = "<absolute-output-dir>"
@@ -63,9 +63,12 @@ for i, page in enumerate(doc, start=1):
63
63
  pix.save(f"{OUT}/page-{i:03d}.png")
64
64
  print(f"[{i}/{doc.page_count}] page-{i:03d}.png {pix.width}x{pix.height}")
65
65
  PY
66
+ )
66
67
  ```
67
68
 
68
- Install if missing: `python3 -m pip install --user pymupdf`
69
+ Install if missing: `(cd ~ && python3 -m pip install --user pymupdf)`
70
+
71
+ Offline (no network): do not install; look for the fallback tools of section 4 (`pdftoppm`, ImageMagick) instead and say "오프라인이라 설치하지 않음(필요한 것: pymupdf)". pip retries 5 times with a 15 s timeout by default before it fails.
69
72
 
70
73
  ### 4. Fallback toolchain
71
74
 
@@ -83,7 +86,7 @@ If Python/PyMuPDF unavailable:
83
86
 
84
87
  ```bash
85
88
  ls <OUT> | wc -l # page count matches PDF
86
- python3 -c "from PIL import Image; im=Image.open('<OUT>/page-001.png'); print(im.size)"
89
+ (cd ~ && python3 -c "from PIL import Image; im=Image.open('<absolute-output-dir>/page-001.png'); print(im.size)")
87
90
  ```
88
91
 
89
92
  Expect long edge ≈ 2400 px.
@@ -110,10 +113,12 @@ For multiple pages in one turn, batch multiple `Read` calls in parallel.
110
113
  For a hybrid pipeline (visual + searchable text), also extract markdown:
111
114
 
112
115
  ```bash
113
- python3 -m pip install --user pymupdf4llm
114
- python3 -c "import pymupdf4llm; print(pymupdf4llm.to_markdown('<pdf>'))" > <OUT>/text.md
116
+ (cd ~ && python3 -m pip install --user pymupdf4llm)
117
+ (cd ~ && python3 -c "import pymupdf4llm; print(pymupdf4llm.to_markdown('<absolute-pdf-path>'))") > <OUT>/text.md
115
118
  ```
116
119
 
120
+ The install needs the network too; offline, leave the markdown companion out and say so.
121
+
117
122
  Then `grep` the `.md` for labels and cross-reference with the PNGs.
118
123
 
119
124
  ## Example invocation history