wkappbot-code 0.3.33 → 0.3.39

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.
@@ -23,7 +23,7 @@ import { homedir } from "node:os";
23
23
  import { appendFileSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
- import { buildDone, files, globalBinDir, markRunning, parseDoctor, platformOf, selfTest, withPathFirst, writeDone } from "../lib/setup-run.js";
26
+ import { buildDone, files, globalBinDir, parseDoctor, platformOf, selfTest, startWorker, withPathFirst, writeDone } from "../lib/setup-run.js";
27
27
 
28
28
  const HERE = dirname(fileURLToPath(import.meta.url));
29
29
  const CLI = join(HERE, "wkappbot-code.js");
@@ -52,11 +52,15 @@ async function runWorker() {
52
52
  const env = withPathFirst({ ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" }, globalBinDir(process.env));
53
53
  stamp("worker start: node " + args.join(" "));
54
54
  const out = openSync(LOG, "a");
55
- const installRc = await new Promise((res) => {
55
+ const runInstall = () => new Promise((res) => {
56
56
  const c = spawn(process.execPath, args, { stdio: ["ignore", out, out], env });
57
57
  c.on("error", () => res(-1));
58
58
  c.on("close", (code) => res(code ?? -1));
59
59
  });
60
+ let installRc = await runInstall();
61
+ // a permanent file lock makes install exit 75 (lib/lockwait.js EXIT_LOCKED): retry three times, then say what to do and go on (the worker exits 0)
62
+ for (let i = 1; i <= 3 && installRc === 75; i++) { stamp("install rc 75 (a file is locked); retry " + i + " of 3"); installRc = await runInstall(); }
63
+ if (installRc === 75) stamp("install is still locked: close the program holding the file, then run wkappbot-code install again (run wkappbot-code install again)");
60
64
  stamp("install rc " + installRc + "; doctor");
61
65
  const dr = spawnSync(process.execPath, [CLI, "doctor"], { encoding: "utf8", env, timeout: 300000 });
62
66
  const dtext = (dr.stdout || "") + (dr.stderr || "");
@@ -79,10 +83,8 @@ function main() {
79
83
  if (d.version === version() && (d.installRc ?? d.rc) === 0) { console.log("[wkappbot-code] already set up by " + d.version + " (" + DONE + ")"); return; }
80
84
  } catch {}
81
85
  mkdirSync(STATE, { recursive: true });
82
- markRunning(homedir(), version());
83
- const out = openSync(LOG, "a");
84
- const c = spawn(process.execPath, [fileURLToPath(import.meta.url), "--run"], { detached: true, stdio: ["ignore", out, out], env: process.env, windowsHide: true });
85
- c.unref();
86
+ // 0.3.39: startWorker records the WORKER's pid in postinstall.running (this parent exits at once)
87
+ startWorker({ script: fileURLToPath(import.meta.url), home: homedir(), version: version(), env: process.env });
86
88
  console.log("[wkappbot-code] " + (cloud ? "cloud VM detected: wiring the Claude Code harness in the background" : "setting up in the background (install, doctor, self-test)") +
87
89
  " (log: " + LOG + ", result: " + DONE + "); the first `wkappbot-code` run prints the summary");
88
90
  }
package/bin/prepack.js CHANGED
@@ -12,6 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFile
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { PLUGIN_LIBS } from "../lib/plugin-files.js";
15
16
 
16
17
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
18
  const KIH = resolve(PKG_DIR, "..");
@@ -20,6 +21,15 @@ const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
20
21
  const die = (m) => { console.error("[wkappbot-code prepack] " + m); process.exit(1); };
21
22
  const sh = (p) => process.platform === "win32" ? p.replace(/\\/g, "/") : p;
22
23
 
24
+ // Every relative import of a file install copies beside the plugin must be copied too (0.3.34: wk-hook.js imported ./rmguard.js, install copied wk-hook.js alone,
25
+ // ERR_MODULE_NOT_FOUND, every guard dead). Fail BEFORE a tarball exists. Spec: wkappbot skill read agent-harness-landscape-2026-howto
26
+ {
27
+ const flat = new Set(["wisdom-kernel.js", ...PLUGIN_LIBS]);
28
+ for (const [d, f] of [["plugin", "wisdom-kernel.js"], ...PLUGIN_LIBS.map((n) => ["lib", n])]) {
29
+ const src = readFileSync(join(PKG_DIR, d, f), "utf8");
30
+ for (const m of src.matchAll(/(?:\bfrom\s+|\bimport\s*\(\s*)["'](\.\/[^"']+)["']/g)) if (!flat.has(m[1].slice(2))) die(d + "/" + f + " imports " + m[1] + ", which install does not copy beside the plugin (add it to lib/plugin-files.js)");
31
+ }
32
+ }
23
33
  let stage = process.env.WKAPPBOT_CODE_HARNESS_STAGE || "";
24
34
  let tmp = "";
25
35
  const copy = join(KIH, "tools", "wk-harness-min-copy.sh");
@@ -37,7 +37,7 @@ import { createHash } from "node:crypto";
37
37
  import {
38
38
  copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync,
39
39
  realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync,
40
- } from "node:fs";
40
+ } from "../lib/fslocked.js";
41
41
  import { homedir, tmpdir } from "node:os";
42
42
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
43
43
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -45,12 +45,13 @@ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rm
45
45
  import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
46
46
  import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
47
47
  import { providerCli, providerRow } from "../lib/providers.js";
48
+ import { EXIT_LOCKED, isLockError } from "../lib/lockwait.js"; import { PLUGIN_LIBS } from "../lib/plugin-files.js"; import { installLockMd, removeLockMd } from "../lib/lock-md.js";
48
49
  import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
49
50
  import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
50
51
  import { addAllows, removeAllows } from "../lib/allow-compact.js";
51
52
  import { ensurePrereqs } from "../lib/prereq.js";
52
53
  import { ensurePython } from "../lib/python-acquire.js";
53
- import { files as setupFiles, launchOpenCode, printBanner } from "../lib/setup-run.js";
54
+ import { ensureOpencodeBin, files as setupFiles, findOpenCode, globalBinDir, launchOpenCode, printBanner, resumeIfWorkerDead, startWorker } from "../lib/setup-run.js";
54
55
 
55
56
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
56
57
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
@@ -361,8 +362,12 @@ function installPlugin(o, kih, kihSource) {
361
362
  const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
362
363
  copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
363
364
  // The plugin file exports ONLY the plugin (OpenCode calls every named export as one); its helpers ride beside it.
364
- copyFileSync(join(PKG_DIR, "lib", "wk-hook.js"), join(cfg, "wkappbot-code", "wk-hook.js"));
365
+ for (const f of PLUGIN_LIBS) copyFileSync(join(PKG_DIR, "lib", f), join(cfg, "wkappbot-code", f));
365
366
  writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
367
+ // 0.3.41: the session arm (plugin/wisdom-kernel.js) resumes a dead postinstall worker from this package root, which a
368
+ // global npm install does not place beside the plugin (lib/setup-run.js postinstallScriptIn).
369
+ mkdirSync(join(homedir(), ".wkappbot-code"), { recursive: true });
370
+ writeFileSync(join(homedir(), ".wkappbot-code", "package-dir"), PKG_DIR + "\n");
366
371
  const url = pathToFileURL(dest).href;
367
372
  const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
368
373
  const target = o.project ? join(process.cwd(), "opencode.json") : join(cfg, "opencode.json");
@@ -429,6 +434,14 @@ function waitForSetupWorker() {
429
434
  log(alive() ? "background setup still running after 10 min; installing anyway" : "background setup finished; continuing");
430
435
  }
431
436
 
437
+ // npm install -g opencode-ai at the pinned version; returns the rc (-1 when npm cannot start). A failure is logged, never silent.
438
+ function npmInstallOpencode(log) {
439
+ const r = spawnSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { shell: process.platform === "win32", stdio: "inherit" });
440
+ const rc = r.error ? -1 : (r.status ?? -1);
441
+ if (rc !== 0) log("opencode: npm install FAILED (rc " + rc + (r.error ? ", " + String(r.error.message).split("\n")[0] : "") + ") -- run: npm install -g opencode-ai@" + OPENCODE_VERSION);
442
+ return rc;
443
+ }
444
+
432
445
  function install(o) {
433
446
  waitForSetupWorker();
434
447
  const prev = readState(o);
@@ -457,9 +470,10 @@ function install(o) {
457
470
  else {
458
471
  log("npm install -g opencode-ai@" + OPENCODE_VERSION + " (was: " + (have || "none") + ")");
459
472
  // 0.3.12: a failed OpenCode install (offline, no npm rights) must not abort the harness, plugin and config parts below.
460
- try { execFileSync("npm", ["install", "-g", "opencode-ai@" + OPENCODE_VERSION, "--no-audit", "--no-fund"], { shell: process.platform === "win32", stdio: "inherit" }); }
461
- catch (e) { bad++; log("opencode: npm install FAILED (" + String(e.message).split("\n")[0] + ") -- run: npm install -g opencode-ai@" + OPENCODE_VERSION); }
473
+ if (npmInstallOpencode(log) !== 0) bad++;
462
474
  }
475
+ // 0.3.39 (2026-11-14): the bin must be LINKED, not the 479-byte placeholder; one retry, every rc logged (lib/setup-run.js)
476
+ if (!ensureOpencodeBin({ bin: findOpenCode(process.env, globalBinDir(process.env)), reinstall: () => npmInstallOpencode(log), log }).ok) bad++;
463
477
  }
464
478
  // 0.3.13 (owner 2026-11-08): the famous AI CLIs come along, so a user who knows nothing about LLMs still has them.
465
479
  st.aiClis = (o.npm && o.aiClis) ? installAiClis(log, process.env, undefined, (prev.aiClis && prev.aiClis.installed) || [], (prev.aiClis && prev.aiClis.prefix) || "")
@@ -481,7 +495,8 @@ function install(o) {
481
495
  // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
482
496
  st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
483
497
  // the standard global rules for a fresh machine (lib/starter-md.js): only where the user has no ~/.claude/CLAUDE.md
484
- st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true }; // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
498
+ st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true };
499
+ st.lockMd = o.harness ? installLockMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.lockMd, log }) : { skipped: true }; // the study-lock rules of the installed sources, in ~/.claude/REFLEX.md (lib/lock-md.js) // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
485
500
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
486
501
  st.pushGate = { skipped: true };
487
502
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
@@ -576,7 +591,7 @@ function uninstall(o) {
576
591
  }
577
592
  // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
578
593
  const core = (st.bin && st.bin.dest) || binDest(o);
579
- removeStarterMd(st.starterMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
594
+ removeStarterMd(st.starterMd, log); removeLockMd(st.lockMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
580
595
  let linkRemoved = "";
581
596
  for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
582
597
  if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
@@ -660,7 +675,7 @@ function doctor(o) {
660
675
  const v = opencodeVersion();
661
676
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
662
677
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
663
- add("plugin", existsSync(pdest) && existsSync(join(cfg, "wkappbot-code", "wk-hook.js")), pdest + " + wk-hook.js", true);
678
+ add("plugin", existsSync(pdest) && PLUGIN_LIBS.every((f) => existsSync(join(cfg, "wkappbot-code", f))), pdest + " + " + PLUGIN_LIBS.join(" + "), true);
664
679
  let entry = null;
665
680
  try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
666
681
  add("config", !!entry, join(cfg, "opencode.json"), true);
@@ -678,7 +693,11 @@ function doctor(o) {
678
693
  core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
679
694
  add("bin", !!core, core ? dest + " " + core + (st.bin && st.bin.link ? " (PATH link " + st.bin.link + ")" : "") : dest + " does not run", !!(st.bin && !st.bin.skipped));
680
695
  }
681
- const pr = checkPath({ home: homedir(), env: process.env, platform: process.platform }); add("path", pr.ok, pr.text, true);
696
+ const pr = checkPath({ home: homedir(), env: process.env, platform: process.platform });
697
+ // --no-bin: this install wrote no bare-name link, so the path row is a skip whatever core file sits at dest, as the bin row is
698
+ // (a core at dest that is not on PATH is only a FAIL when the install owns the links, i.e. bin is not skipped)
699
+ if (!pr.ok && st.bin && st.bin.skipped) add("path", false, pr.text, false, true);
700
+ else add("path", pr.ok, pr.text, true);
682
701
  // kih-hook: the harness the plugin calls and its verdict road; no pwsh and no lite = NO verdict = FAIL whatever the install mode.
683
702
  const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
684
703
  const hk = hasHook(k);
@@ -740,13 +759,21 @@ if (["install", "doctor", "config"].includes(o.cmd) && !o.update) {
740
759
  if (nv && (o.autoUpdate || process.env.WKAPPBOT_CODE_AUTO_UPDATE === "1")) upd();
741
760
  }
742
761
  // The one-line result of the automatic post-install setup (lib/setup-run.js), once; not for the worker's own children.
762
+ // 0.3.39 self-heal (2026-11-14): a background worker that died before postinstall.done is started again here, on every run
763
+ // (doctor and doctor --fix included). The worker's own children and the opt-out switch never arm it.
764
+ if (process.env.WKAPPBOT_CODE_SETUP_WORKER !== "1" && process.env.WKAPPBOT_CODE_NO_POSTINSTALL !== "1" && !["--version", "-v", "version"].includes(o.cmd)) {
765
+ try {
766
+ const rs = resumeIfWorkerDead({ home: homedir(), runSetup: () => startWorker({ script: join(PKG_DIR, "bin", "postinstall.js"), home: homedir(), version: PKG.version }) });
767
+ if (rs.resumed) log("background setup did not finish (" + rs.reason + "); started it again -- log: " + setupFiles(homedir()).log);
768
+ } catch {}
769
+ }
743
770
  if (!["--version", "-v", "version", "uninstall"].includes(o.cmd) && process.env.WKAPPBOT_CODE_SETUP_WORKER !== "1") { try { printBanner(); } catch {} }
744
771
  // Bare `wkappbot-code`: open OpenCode (its start screen); no opencode on PATH falls through to the usage below.
745
772
  if (process.argv.length <= 2 && o.cmd === "help") { const rc = launchOpenCode(); if (rc !== null) process.exit(rc); }
746
773
  switch (o.cmd) {
747
774
  case "--version": case "-v": case "version":
748
775
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
749
- case "install": process.exit(install(o));
776
+ case "install": { let rc; try { rc = install(o); } catch (e) { if (e && (e.code === "ELOCKBUDGET" || isLockError(e))) { console.error(e.message + "\nwkappbot-code: a file is held by another program; close it and run wkappbot-code install again"); rc = EXIT_LOCKED; } else throw e; } process.exit(rc); }
750
777
  case "uninstall": process.exit(uninstall(o));
751
778
  case "doctor": process.exitCode = doctor(o); break;
752
779
  case "update": process.exit(upd());
@@ -4,7 +4,7 @@
4
4
  // are far too wide for a stranger's machine. A fresh install gets ONLY these five read-or-own-store prefixes. Every call
5
5
  // still meets the harness PreToolUse hook first, and a hook refusal beats an allow rule, so an entry here skips the
6
6
  // permission prompt, never the harness. Uninstall removes exactly the entries this install added.
7
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync } from "./fslocked.js";
8
8
  import { dirname } from "node:path";
9
9
  import { readSettings } from "./hook-wire.js";
10
10
 
@@ -13,6 +13,9 @@ export const COMPACT_ALLOWS = [
13
13
  "Bash(wkappbot file read *)", // read-only file view
14
14
  "Bash(wk-verify)", // live hook check, no arguments
15
15
  "Bash(wkappbot-code doctor)", // read-only health table
16
+ "Bash(wkappbot-code install)", // what postinstall and the user run to set up (owner 2026-11-13: refused or prompted every time)
17
+ "Bash(wkappbot-code install *)", // the same with flags
18
+ "Bash(node */wkappbot-code/bin/postinstall.js*)", // the postinstall entry npm runs
16
19
  ];
17
20
 
18
21
  // prev: the state recorded by the last install ({ added, madeAllow, madePerms }); a reinstall keeps owning what it added.
@@ -4,7 +4,7 @@
4
4
  // Windows: a new cmd.exe whose PATH is machine PATH + user PATH read from the registry.
5
5
  // PASS only when each bare-name run prints "wkappbot v". Any other outcome is FAIL for that name.
6
6
  import { execFileSync } from "node:child_process";
7
- import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "./fslocked.js";
8
8
  import { dirname, join, resolve } from "node:path";
9
9
 
10
10
  const OK_MARK = "wkappbot v";
@@ -40,6 +40,13 @@ export function loginShell(h, env, bin) {
40
40
  }
41
41
  }
42
42
 
43
+ // The environment a fresh cmd.exe is started with on win32. It carries the registry PATH only, plus PATHEXT, so the
44
+ // .cmd shims the installer writes always resolve by bare name (PATHEXT is never left to cmd's built-in default).
45
+ function winShellEnv(h, env, winPath) {
46
+ // profile-mark: winShellEnv
47
+ return { SystemRoot: env.SystemRoot || "C:\\Windows", USERPROFILE: h, HOME: h, PATHEXT: env.PATHEXT || ".COM;.EXE;.BAT;.CMD", PATH: winPath };
48
+ }
49
+
43
50
  // checkPath({home, env, platform, readRegPath, runShell}) -> {ok, text}
44
51
  // home HOME (or USERPROFILE on Windows) the fresh shell starts in
45
52
  // env carries the OS persistent PATH (PATH key; default process.env)
@@ -61,7 +68,7 @@ export function checkPath({ home, env = process.env, platform = process.platform
61
68
  try {
62
69
  out = execFileSync("cmd.exe", ["/d", "/s", "/c", `${bin} --version`], {
63
70
  encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
64
- env: { SystemRoot: env.SystemRoot || "C:\\Windows", USERPROFILE: h, HOME: h, PATH: winPath },
71
+ env: winShellEnv(h, env, winPath),
65
72
  });
66
73
  } catch (e) {
67
74
  out = typeof e.stdout === "string" ? e.stdout : "";
@@ -0,0 +1,18 @@
1
+ // fslocked.js -- node:fs for the install road: every name of node:fs, with the writers wrapped in lockRetry, so a file another
2
+ // process holds (EBUSY, EPERM, EACCES, ETXTBSY) is waited for, then reported as ELOCKBUDGET, instead of failing at once.
3
+ // The modules an install runs import from here instead of "node:fs" (owner 2026-11-13). Spec: wkappbot skill read harness-installer-owns-all-setup
4
+ import * as real from "node:fs";
5
+ import { lockRetry } from "./lockwait.js";
6
+
7
+ export * from "node:fs";
8
+
9
+ const wrap = (name, labelAt = 0) => (...a) => lockRetry(() => real[name](...a), { label: String(a[labelAt]) });
10
+ export const writeFileSync = wrap("writeFileSync");
11
+ export const appendFileSync = wrap("appendFileSync");
12
+ export const mkdirSync = wrap("mkdirSync");
13
+ export const copyFileSync = wrap("copyFileSync", 1);
14
+ export const renameSync = wrap("renameSync", 1);
15
+ export const symlinkSync = wrap("symlinkSync", 1);
16
+ export const unlinkSync = wrap("unlinkSync");
17
+ export const rmSync = wrap("rmSync");
18
+ export const chmodSync = wrap("chmodSync");
package/lib/hook-wire.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // stays verbatim, and every lite arm is present once. The web installer calls it through the CLI form below when it has
11
11
  // node but no python3: node hook-wire.js wire SETTINGS LITE
12
12
  import { spawnSync } from "node:child_process";
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "./fslocked.js";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { pickPython, stubWarning } from "./python-pick.js";
package/lib/lock-md.js ADDED
@@ -0,0 +1,56 @@
1
+ // lock-md.js -- the study-lock rules for the installed harness sources, laid where the installed hooks read them (owner 2026-11-13
2
+ // via the spokesman: "the md files that protect the harness sources are not installed, not in the global md either").
3
+ // The JS study lock reads ~/.claude/CLAUDE.md and ~/.claude/REFLEX.md besides the repo's own; this install writes the rules of
4
+ // templates/harness-locks.md into ~/.claude/REFLEX.md between two markers. A REFLEX.md the user already has keeps every byte
5
+ // of its own text: the block is appended once and refreshed in place. Uninstall removes exactly the block, and the file when
6
+ // the install made it and nothing else is left. Spec: wkappbot skill read harness-installer-owns-all-setup
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export const BEGIN = "<!-- wk-harness-locks:begin (managed by wkappbot-code install; edit outside this block) -->";
11
+ export const END = "<!-- wk-harness-locks:end -->";
12
+
13
+ const block = (tpl) => BEGIN + "\n" + tpl.replace(/\s+$/, "") + "\n" + END + "\n";
14
+ const cut = (text) => {
15
+ const a = text.indexOf(BEGIN), b = text.indexOf(END);
16
+ return a >= 0 && b > a ? { before: text.slice(0, a), after: text.slice(b + END.length).replace(/^\r?\n/, ""), has: true } : { before: text, after: "", has: false };
17
+ };
18
+
19
+ // o: { claudeDir, pkgDir, prev ({ path, created }), log } -> { path, created, action }
20
+ export function installLockMd(o) {
21
+ const log = o.log || (() => {});
22
+ const path = join(o.claudeDir, "REFLEX.md");
23
+ const tplPath = join(o.pkgDir, "templates", "harness-locks.md");
24
+ if (!existsSync(tplPath)) { log("lock-md: WARN " + tplPath + " is missing from this package"); return { path, action: "missing-template" }; }
25
+ const mine = block(readFileSync(tplPath, "utf8"));
26
+ if (!existsSync(path)) {
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, mine);
29
+ log("lock-md: wrote the harness study-lock rules to " + path);
30
+ return { path, created: true, action: "written" };
31
+ }
32
+ const cur = readFileSync(path, "utf8"), c = cut(cur);
33
+ const created = !!(o.prev && o.prev.created);
34
+ if (c.has) {
35
+ const next = c.before + mine + c.after;
36
+ if (next === cur) return { path, created, action: "current" };
37
+ writeFileSync(path, next);
38
+ log("lock-md: refreshed the harness study-lock block in " + path);
39
+ return { path, created, action: "refreshed" };
40
+ }
41
+ writeFileSync(path, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + (cur === "" ? "" : "\n") + mine);
42
+ log("lock-md: appended the harness study-lock block to your " + path + " (your text is untouched)");
43
+ return { path, created: false, action: "appended" };
44
+ }
45
+
46
+ // Removes the block; a file this install made is deleted when nothing but the block was in it.
47
+ export function removeLockMd(rec, log = () => {}) {
48
+ if (!rec || !rec.path || !existsSync(rec.path)) return false;
49
+ const cur = readFileSync(rec.path, "utf8"), c = cut(cur);
50
+ if (!c.has) return false;
51
+ const rest = (c.before.replace(/\s+$/, "") + (c.before.trim() && c.after.trim() ? "\n\n" : "") + c.after.replace(/^\s+/, ""));
52
+ if (rec.created && !rest.trim()) { rmSync(rec.path, { force: true }); log("lock-md: removed " + rec.path + " (it held only the harness block)"); return true; }
53
+ writeFileSync(rec.path, c.before.trim() ? (c.before.replace(/\s+$/, "") + (c.after.trim() ? "\n\n" + c.after.replace(/^\s+/, "") : "\n")) : c.after.replace(/^\s+/, ""));
54
+ log("lock-md: removed the harness study-lock block from " + rec.path + " (your text kept)");
55
+ return true;
56
+ }
@@ -0,0 +1,36 @@
1
+ // lockwait.js -- run a file operation, and when another process holds the file (EBUSY, EPERM, EACCES, ETXTBSY) wait for it to
2
+ // leave instead of failing at once (owner 2026-11-13 via the spokesman: install and postinstall failed on a locked file every time).
3
+ // lockRetry(fn, { label, holderDir, budgetMs = 30000, firstMs = 250 })
4
+ // - the wait starts at 250 ms and doubles, through holders.js waitForHolders when holderDir names the harness folder
5
+ // - ONE untruncated "LOCKED <label>: <code> ..." line goes to the log, however many retries follow
6
+ // - past the budget the error has code ELOCKBUDGET (the message holds the whole label and the last OS code)
7
+ // - any other error is thrown at once
8
+ // lib/fslocked.js puts this under every file write an install makes; bin/wkappbot-code.js turns ELOCKBUDGET into exit 75.
9
+ // Spec: wkappbot skill read harness-installer-owns-all-setup
10
+ import { waitForHolders } from "./holders.js";
11
+
12
+ export const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES", "ETXTBSY"]);
13
+ export const EXIT_LOCKED = 75;
14
+ export const isLockError = (e) => !!e && LOCK_CODES.has(e.code);
15
+ const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
16
+ const envBudget = () => { const n = Number(process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS); return Number.isFinite(n) && n >= 0 && process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS !== undefined ? n : 30000; };
17
+
18
+ export function lockRetry(fn, opts = {}) {
19
+ const budget = opts.budgetMs ?? envBudget(), nap = opts.sleep || sleepMs, log = opts.log || ((s) => console.error(s));
20
+ const label = String(opts.label || "file"), wait = opts.waitForHolders || waitForHolders;
21
+ let delay = opts.firstMs ?? 250, waited = 0, said = false, last = null;
22
+ for (;;) {
23
+ try { return fn(); } catch (e) {
24
+ if (!isLockError(e)) throw e;
25
+ last = e;
26
+ if (waited >= budget) break;
27
+ if (!said) { log(`LOCKED ${label}: ${e.code} -- another process holds it; waiting up to ${Math.round(budget / 1000)} s`); said = true; }
28
+ const step = Math.min(delay, budget - waited);
29
+ if (opts.holderDir) { wait(opts.holderDir, { maxMs: step, gap: Math.min(50, step), sleep: nap }); } else nap(step);
30
+ waited += step; delay *= 2;
31
+ }
32
+ }
33
+ const err = new Error(`LOCKED ${label}: ${last.code} still held after ${waited} ms (budget ${budget} ms)`);
34
+ err.code = "ELOCKBUDGET"; err.cause = last; err.lockedLabel = label;
35
+ throw err;
36
+ }
@@ -0,0 +1,8 @@
1
+ // plugin-files.js -- the helper files that ride BESIDE the OpenCode plugin in <config>/wkappbot-code/ (install copies each of
2
+ // lib/<name> there). wisdom-kernel.js imports ./wk-hook.js, and wk-hook.js imports ./rmguard.js, so EVERY relative import of a
3
+ // copied file must be in this list: a missing one made the whole plugin fail to load (0.3.34, SDK run 43333160662:
4
+ // ERR_MODULE_NOT_FOUND, every guard dead). bin/prepack.js refuses to pack when an import is not listed, and
5
+ // selftest-pluginlayout.mjs imports the plugin from an installed layout. Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
+ // 0.3.40: the session arm (wisdom-kernel.js session.created) resumes a dead postinstall worker through lib/setup-run.js,
7
+ // whose chain is setup-run -> fslocked -> lockwait -> holders; each rides beside the plugin like the others.
8
+ export const PLUGIN_LIBS = ["wk-hook.js", "rmguard.js", "setup-run.js", "fslocked.js", "lockwait.js", "holders.js"];
package/lib/providers.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // ("{env:VAR}"); a key or a URL typed where a name belongs is refused and never echoed, and no env var value is read
4
4
  // except by the doctor row, which only checks that a variable is set and prints its name.
5
5
  // Pure build/merge/remove functions first, then the CLI body. Spec: wkappbot skill read agent-harness-landscape-2026
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "./fslocked.js";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
9
  export const NPM = "@ai-sdk/openai-compatible";
@@ -10,7 +10,7 @@
10
10
  // Spec: wkappbot skill read harness-installer-owns-all-setup
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "./fslocked.js";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { pickPython, stubWarning } from "./python-pick.js";
package/lib/rmguard.js ADDED
@@ -0,0 +1,128 @@
1
+ // wkappbot-code/lib/rm-guard.js -- the rm rule every harness family shares (owner 2026-11-12: rm was not blocked; block
2
+ // wildcards and folders, make each file be named, check each file's protection state). JS home of the rule; the sh twin
3
+ // is tools/wkharness-lite.sh rk_eval/rk_rm, and both run the one case table tools/wkjs/rm-guard.cases.txt
4
+ // (tools/wkjs/rm-guard.selftest.js). Spec: wkappbot skill read harness-rm-guard-howto
5
+ // REFUSED: a recursive delete (rm -r, rmdir -r, find -delete, Remove-Item -Recurse, del /s); a glob character in an rm
6
+ // target; an expansion ($, backtick) or xargs rm; a named file that is git-tracked with uncommitted changes, on the
7
+ // harness protected list, or read-only/immutable. EXEMPT: scratch (repo .tmp/, /tmp, TMPDIR, node_modules/ dist/ build/
8
+ // .bak/ inside the repo). A clean tracked file may go (git restores it).
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import { execFileSync } from "node:child_process";
12
+
13
+ const REMEDY = "name each file explicitly, or move scratch work under .tmp/";
14
+ const SCRATCH_SEG = new Set([".tmp", "node_modules", "dist", "build", ".bak"]);
15
+ const PROTECTED_BASE = /^(CLAUDE\.md|REFLEX\.md|HANDOFF\.md|settings(\.local)?\.json|[^/]*\.skill\.json)$/i;
16
+ const PROTECTED_DIR = /(^|\/)(\.claude\/hooks|\.githooks)(\/|$)/i;
17
+
18
+ // Slash form, ".." and "." folded away; relative names resolve against cwd. Returns "" for an escape past the root.
19
+ function norm(p, cwd) {
20
+ let s = String(p).replace(/\\+/g, "/");
21
+ if (!/^([A-Za-z]:)?\//.test(s)) s = String(cwd || ".").replace(/\\+/g, "/").replace(/\/+$/, "") + "/" + s;
22
+ const m = /^([A-Za-z]:)?(\/)?/.exec(s);
23
+ const head = m[0];
24
+ const out = [];
25
+ for (const seg of s.slice(head.length).split("/")) {
26
+ if (seg === "" || seg === ".") continue;
27
+ if (seg === "..") { if (!out.length) return ""; out.pop(); continue; }
28
+ out.push(seg);
29
+ }
30
+ return head + out.join("/");
31
+ }
32
+
33
+ function tmpRoots(env) {
34
+ const r = ["/tmp", "/var/tmp"];
35
+ for (const t of [env && env.TMPDIR, process.env.TMPDIR, os.tmpdir()]) if (t) r.push(String(t).replace(/\\+/g, "/").replace(/\/+$/, ""));
36
+ return r.filter((x) => x && x !== "/");
37
+ }
38
+
39
+ // True when target sits under a scratch area. The scratch root itself is NOT scratch (rm -rf /tmp is refused).
40
+ export function isScratch(target, cwd, env) {
41
+ const t = String(target).replace(/^\$\{?TMPDIR\}?(?=\/|\\|$)/, (env && env.TMPDIR) || process.env.TMPDIR || os.tmpdir());
42
+ if (/[$`]/.test(t)) return false;
43
+ const n = norm(t, cwd);
44
+ if (!n) return false;
45
+ const nd = n.replace(/^[A-Za-z]:/, ""); // C:/r/build and an MSYS-style cwd /r name the same place
46
+ const lower = nd.toLowerCase();
47
+ for (const r of tmpRoots(env)) { const rl = r.toLowerCase(); if (lower.startsWith(rl + "/") && lower.length > rl.length + 1) return true; }
48
+ const c = norm(cwd || ".", "/").replace(/^[A-Za-z]:/, "");
49
+ if (c && lower.startsWith(c.toLowerCase() + "/")) {
50
+ const rel = nd.slice(c.length + 1).split("/");
51
+ if (rel.some((seg) => SCRATCH_SEG.has(seg))) return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ function gitDirty(file, cwd) {
57
+ try {
58
+ const dir = fs.statSync(file).isDirectory() ? file : file.replace(/[\\/][^\\/]*$/, "") || ".";
59
+ const out = execFileSync("git", ["-C", dir, "status", "--porcelain", "--", file], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
60
+ return out.split("\n").some((l) => l.length > 3 && !l.startsWith("??") && !l.startsWith("!!"));
61
+ } catch { return false; }
62
+ }
63
+
64
+ function immutable(file) {
65
+ if (process.platform !== "linux") return false;
66
+ try { return /^\S*i\S*\s/.test(execFileSync("lsattr", ["-d", file], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })); } catch { return false; }
67
+ }
68
+
69
+ // A named file target: why it must not be removed, or "".
70
+ export function fileProtection(target, cwd) {
71
+ const abs = norm(target, cwd);
72
+ if (!abs) return "";
73
+ if (PROTECTED_BASE.test(abs.replace(/^.*\//, "")) || PROTECTED_DIR.test(abs)) return "protected harness file";
74
+ let st; try { st = fs.lstatSync(abs); } catch { return ""; }
75
+ if (st.isDirectory()) return "";
76
+ if ((st.mode & 0o222) === 0) return "read-only file";
77
+ if (immutable(abs)) return "immutable file";
78
+ if (gitDirty(abs, cwd)) return "git-tracked with uncommitted changes";
79
+ return "";
80
+ }
81
+
82
+ const isRecFlag = (x) => /^-[A-Za-z]*[rR][A-Za-z]*$/.test(x) || x === "--recursive";
83
+ const PS_RM = /^(remove-item|ri|rm|del|erase|rd|rmdir|rmdir\.exe)$/i;
84
+
85
+ // One simple command's words (quotes already removed by shellSegments). Returns a refusal label or "".
86
+ export function rmRisk(words, cwd, env) {
87
+ const w = words || [];
88
+ if (!w.length) return "";
89
+ const low = w.map((x) => String(x).toLowerCase());
90
+ // find ... -delete / -exec rm: refused unless every search root is scratch
91
+ const fi = low.indexOf("find");
92
+ if (fi >= 0 && fi === low.findIndex((x) => /^(find|gfind)$/.test(x)) && (low.includes("-delete") || /-exec(dir)?/.test(low.join(" ")) && low.includes("rm"))) {
93
+ const roots = w.slice(fi + 1).filter((x, i, a) => !x.startsWith("-") && (i === 0 || !a[i - 1].startsWith("-") || /^-[HLP]$/.test(a[i - 1])));
94
+ if (!roots.length || !roots.every((r) => isScratch(r, cwd, env))) return `find -delete (recursive delete): ${REMEDY}`;
95
+ }
96
+ const xi = low.indexOf("xargs");
97
+ if (xi >= 0 && low.slice(xi + 1).some((x) => /^(rm|rmdir|unlink|shred)$/.test(x))) return `xargs rm (targets unknown): ${REMEDY}`;
98
+ // Remove-Item / del / rd with a recursive switch
99
+ const pi = w.findIndex((x, i) => PS_RM.test(x) && (i === 0 || /^(sudo|cmd|powershell|pwsh|\/c|-command|-c)$/i.test(w[i - 1])));
100
+ const ri = w.indexOf("rm");
101
+ if (ri > 0 && w[ri - 1] === "git") return ""; // git rm stages a removal; git restores it
102
+ const i = ri >= 0 && (ri === 0 || /^(sudo|doas|command|exec|env|time|nice|nohup)$/.test(w[ri - 1])) ? ri : pi;
103
+ if (i < 0 && !/^(unlink|shred)$/.test(low[0])) return "";
104
+ const a = w.slice(i < 0 ? 1 : i + 1);
105
+ const rec = a.some((x) => isRecFlag(x) || /^-recurse$/i.test(x) || /^\/s$/i.test(x));
106
+ const tg = [];
107
+ let dd = false;
108
+ for (const x of a) {
109
+ if (dd) { tg.push(x); continue; }
110
+ if (x === "--") { dd = true; continue; }
111
+ if (x.startsWith("--") || (/^-[A-Za-z]/.test(x) && !/[\\/.]/.test(x)) || /^\/[a-z]$/i.test(x)) continue;
112
+ if (/^-(path|literalpath|include|exclude|filter)$/i.test(x)) continue;
113
+ tg.push(x);
114
+ }
115
+ if (!tg.length) return "";
116
+ for (const t of tg) {
117
+ if (/[$`]/.test(t) && !/^\$\{?TMPDIR\}?[\\/]/.test(t)) return `rm with a shell expansion in the target: ${REMEDY}`;
118
+ }
119
+ const scratch = tg.map((t) => isScratch(t, cwd, env));
120
+ if (rec && !scratch.every(Boolean)) return `rm -r of a folder or tree (recursive delete): ${REMEDY}`;
121
+ if (tg.some((t, k) => /[*?[\]]/.test(t) && !scratch[k])) return `rm with a wildcard in the target: ${REMEDY}`;
122
+ for (let k = 0; k < tg.length; k++) {
123
+ if (scratch[k]) continue;
124
+ const why = fileProtection(tg[k], cwd);
125
+ if (why) return `rm of ${tg[k]} refused, ${why}: ${REMEDY}`;
126
+ }
127
+ return "";
128
+ }
package/lib/setup-run.js CHANGED
@@ -8,8 +8,9 @@
8
8
  // Files in ~/.wkappbot-code: postinstall.log, postinstall.running (worker alive), postinstall.done, postinstall.seen.
9
9
  // A user's own settings.json entries are the installer's job to keep (wkappbot-code.js install); this module only reads.
10
10
  // Spec: wkappbot skill read agent-harness-landscape-2026-howto
11
- import { spawnSync } from "node:child_process";
12
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { closeSync, openSync, statSync, writeSync } from "node:fs";
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "./fslocked.js";
13
14
  import { homedir, tmpdir } from "node:os";
14
15
  import { delimiter, dirname, join } from "node:path";
15
16
 
@@ -88,6 +89,125 @@ export function writeDone(home, rec) {
88
89
  renameSync(tmp, f.done); // the done file appears whole or not at all (the selftest polls for it)
89
90
  rmSync(f.running, { force: true });
90
91
  }
92
+ // Starts the detached worker (bin/postinstall.js --run) and records ITS pid in postinstall.running. The parent used to record
93
+ // its own pid, which exits at once, so a live worker looked dead. (0.3.39, 2026-11-14)
94
+ export function startWorker({ script, home = homedir(), version, env = process.env, spawnFn = spawn }) {
95
+ const f = files(home);
96
+ mkdirSync(f.dir, { recursive: true });
97
+ rmSync(f.done, { force: true }); // the old verdict belongs to the old install
98
+ const out = openSync(f.log, "a");
99
+ const c = spawnFn(process.execPath, [script, "--run"], { detached: true, stdio: ["ignore", out, out], env, windowsHide: true });
100
+ if (c && c.unref) c.unref();
101
+ writeFileSync(f.running, JSON.stringify({ version, at: new Date().toISOString(), pid: (c && c.pid) || 0 }) + "\n");
102
+ return c;
103
+ }
104
+ // A pid that answers signal 0 is alive (EPERM = alive under another user). 0 or junk is never alive.
105
+ export function pidAlive(pid) {
106
+ const n = Number(pid);
107
+ if (!Number.isInteger(n) || n <= 0) return false;
108
+ try { process.kill(n, 0); return true; } catch (e) { return e.code === "EPERM"; }
109
+ }
110
+ // Self-heal (0.3.39, 2026-11-14): a worker that died before postinstall.done leaves postinstall.running naming a dead pid,
111
+ // and nothing ever retried. Every wkappbot-code run (doctor included) calls this: a dead pid with no done file runs setup
112
+ // again through runSetup (production: startWorker). Returns {resumed, pid, reason}.
113
+ // ONE SHARED LOCK (0.3.42, 2026-11-15): the shell arm pi_resume in tools/wkharness-lite.sh takes the same file,
114
+ // ~/.wkappbot-code/resume.lock, with noclobber. Whoever holds it re-reads postinstall.running UNDER the lock, so a second
115
+ // starter sees the first worker's live pid and does nothing. A lock whose holder pid is dead is reclaimed.
116
+ export const resumeLockFile = (home = homedir()) => join(stateDir(home), "resume.lock");
117
+ const RESUME_LOCK_STALE_MS = 60 * 1000; // an empty lock file (writer died before its pid) is reclaimed after this
118
+ function createResumeLock(p) {
119
+ try {
120
+ const fd = openSync(p, "wx");
121
+ try { writeSync(fd, String(process.pid) + "\n"); } finally { closeSync(fd); }
122
+ return true;
123
+ } catch (e) { if (e.code === "EEXIST") return false; throw e; }
124
+ }
125
+ export function takeResumeLock(home = homedir(), alive = pidAlive) {
126
+ const p = resumeLockFile(home);
127
+ try { mkdirSync(stateDir(home), { recursive: true }); } catch {}
128
+ try { if (createResumeLock(p)) return true; } catch { return false; }
129
+ let holder = "";
130
+ try { holder = String(readFileSync(p, "utf8")).trim(); } catch { return false; }
131
+ if (/^[0-9]+$/.test(holder)) { if (alive(Number(holder))) return false; }
132
+ else if (Date.now() - statSync(p).mtimeMs < RESUME_LOCK_STALE_MS) return false;
133
+ rmSync(p, { force: true });
134
+ try { return createResumeLock(p); } catch { return false; }
135
+ }
136
+ export function releaseResumeLock(home = homedir()) { try { rmSync(resumeLockFile(home), { force: true }); } catch {} }
137
+ export function resumeIfWorkerDead({ home = homedir(), runSetup, alive = pidAlive } = {}) {
138
+ const f = files(home);
139
+ if (!takeResumeLock(home, alive)) return { resumed: false, pid: 0, reason: "resume lock held by another starter" };
140
+ try {
141
+ const running = readJson(f.running); // read AFTER taking the lock: the lock holder may have just written the new pid
142
+ if (!running) return { resumed: false, pid: 0, reason: "no postinstall.running" };
143
+ if (existsSync(f.done)) return { resumed: false, pid: 0, reason: "postinstall.done exists" };
144
+ const pid = Number(running.pid);
145
+ if (!(pid > 0)) return { resumed: false, pid: 0, reason: "postinstall.running names no pid" };
146
+ if (alive(pid)) return { resumed: false, pid, reason: "worker pid " + pid + " is alive" };
147
+ runSetup(); // startWorker writes the new worker's pid into postinstall.running before the lock is released
148
+ return { resumed: true, pid, reason: "worker pid " + pid + " is dead and postinstall.done is absent" };
149
+ } finally { releaseResumeLock(home); }
150
+ }
151
+ // Session arm (0.3.40, 2026-11-14): called once per OpenCode session start by plugin/wisdom-kernel.js (event session.created).
152
+ // The same self-heal as the CLI dispatch, but fail-open: it never throws, and the worker is detached (startWorker), so the
153
+ // session start waits for nothing. The CLI's skip flags apply: WKAPPBOT_CODE_SETUP_WORKER=1 (a worker's own child) and
154
+ // WKAPPBOT_CODE_NO_POSTINSTALL=1 (the opt-out). runSetup is injectable for selftest-resume.mjs (s5a-s5e).
155
+ export function sessionStartResume({ home = homedir(), env = process.env, script = "", version = "", runSetup, spawnFn } = {}) {
156
+ try {
157
+ if (env.WKAPPBOT_CODE_SETUP_WORKER === "1" || env.WKAPPBOT_CODE_NO_POSTINSTALL === "1") return { resumed: false, pid: 0, reason: "skip flag set" };
158
+ const ver = version || (script && (readJson(join(dirname(script), "..", "package.json")) || {}).version) || "";
159
+ const run = runSetup || (script ? () => startWorker({ script, home, version: ver, env, spawnFn }) : null);
160
+ if (!run) return { resumed: false, pid: 0, reason: "no postinstall script" };
161
+ return resumeIfWorkerDead({ home, runSetup: run });
162
+ } catch (e) { return { resumed: false, pid: 0, reason: "error: " + String(e && e.message).slice(0, 120) }; }
163
+ }
164
+ // The worker script: <kih>/wkappbot-code/bin/postinstall.js when the harness checkout carries it, else the package root the
165
+ // installer recorded in ~/.wkappbot-code/package-dir (0.3.41: a normal npm global install has no checkout). "" when neither exists.
166
+ export function postinstallScriptIn(kihDir, home = homedir()) {
167
+ const inKih = kihDir ? join(kihDir, "wkappbot-code", "bin", "postinstall.js") : "";
168
+ if (inKih && existsSync(inKih)) return inKih;
169
+ let pkg = "";
170
+ try { pkg = String(readFileSync(join(stateDir(home), "package-dir"), "utf8")).trim(); } catch {}
171
+ const p = pkg ? join(pkg, "bin", "postinstall.js") : "";
172
+ return p && existsSync(p) ? p : "";
173
+ }
174
+
175
+ // The opencode-ai npm package links a 479-byte placeholder bin that its own postinstall replaces with the real binary.
176
+ // The step is LINKED when the file is over OPENCODE_MIN_BYTES or `--version` exits 0. Otherwise npm install runs ONCE more
177
+ // (reinstall) and every size and rc goes to log, never silently. Returns {ok, retries, size, rc}. (0.3.39, 2026-11-14)
178
+ export const OPENCODE_MIN_BYTES = 4096;
179
+ // A Windows npm bin is a .cmd shim that node cannot spawn without a shell, so the probe returned rc 1
180
+ // (SDK run 43689775714, 2026-11-15). The shell flag is set only for .cmd/.bat on win32.
181
+ export function versionSpawnPlan(bin, isWin = IS_WIN) {
182
+ const shell = isWin && /\.(?:cmd|bat)$/i.test(bin);
183
+ return { file: shell ? '"' + bin + '"' : bin, shell };
184
+ }
185
+ function defaultVersionRc(bin) {
186
+ const plan = versionSpawnPlan(bin);
187
+ const r = spawnSync(plan.file, ["--version"], { encoding: "utf8", timeout: 30000, shell: plan.shell, stdio: ["ignore", "pipe", "ignore"] });
188
+ return r.status ?? 1;
189
+ }
190
+ export function ensureOpencodeBin({ bin, reinstall, log = () => {}, versionRc = defaultVersionRc, minBytes = OPENCODE_MIN_BYTES }) {
191
+ const check = () => {
192
+ const size = bin && existsSync(bin) ? statSync(bin).size : -1;
193
+ const rc = size >= 0 ? versionRc(bin) : -1;
194
+ return { ok: size > minBytes || rc === 0, size, rc };
195
+ };
196
+ const describe = (s) => "bin " + (bin || "(none)") + " size " + s.size + ", --version rc " + s.rc + (s.ok ? " -- linked" : " -- NOT linked (placeholder or missing)");
197
+ let s = check();
198
+ log("opencode " + describe(s));
199
+ let retries = 0;
200
+ if (!s.ok) {
201
+ retries = 1;
202
+ const rc = reinstall();
203
+ log("opencode: retry 1 of 1: npm install rc " + rc);
204
+ s = check();
205
+ log("opencode after retry: " + describe(s));
206
+ }
207
+ if (!s.ok) log("opencode: FAIL -- the opencode bin is still not linked after the retry; run: npm install -g opencode-ai@<pinned version>");
208
+ return { ok: s.ok, retries, size: s.size, rc: s.rc };
209
+ }
210
+
91
211
  export function buildDone({ version, installRc, doctor, selftest, env = process.env }) {
92
212
  return { version, installRc, rc: installRc, doctor: { pass: doctor.pass, warn: doctor.warn, fail: doctor.fail, skip: doctor.skip },
93
213
  selftest: { n: selftest.n, m: selftest.m }, platform: platformOf(env), at: new Date().toISOString(),
@@ -2,7 +2,7 @@
2
2
  // (owner 2026-11-11: the fake wkskill is deleted; spokesman 2026-11-12: SDK e2e "after: local-bin=wkskill", RESULT 20/21 on every leg).
3
3
  // A link or .cmd twin is removed ONLY while it still points into a harness (its target or body names wkharness or wk-harness);
4
4
  // a wkskill file or link of the user's own stays. Spec: wkappbot skill read harness-installer-owns-all-setup
5
- import { lstatSync, readFileSync, readlinkSync, rmSync } from "node:fs";
5
+ import { lstatSync, readFileSync, readlinkSync, rmSync } from "./fslocked.js";
6
6
  import { join } from "node:path";
7
7
 
8
8
  const isHarness = (s) => /wkharness|wk-harness/.test(String(s || ""));
package/lib/starter-md.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // NO global CLAUDE.md; refreshed only while it still equals what this install wrote (sha256 kept in install-state.json);
6
6
  // a file the user changed or wrote is never touched. Uninstall removes it only when it is still unchanged.
7
7
  import { createHash } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  const sha = (s) => createHash("sha256").update(s).digest("hex");
package/lib/update.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns the quiet check off. Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "./fslocked.js";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
package/lib/wk-hook.js CHANGED
@@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { rmRisk } from "./rmguard.js";
12
13
 
13
14
  // OpenCode tool id -> Claude tool name the kih guards already know.
14
15
  export const TOOL_MAP = {
@@ -271,6 +272,8 @@ export function riskClass(tool, input, cwd) {
271
272
  if (tg.some((x) => GIT_SEG.test(x))) return "write under .git";
272
273
  }
273
274
  }
275
+ const rr = rmRisk(w, cwd, process.env); // owner 2026-11-12: recursive, wildcard and unprotected-file deletes (lib/rmguard.js)
276
+ if (rr) return rr;
274
277
  if (w.some((x) => GIT_SEG.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
275
278
  }
276
279
  return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.33",
3
+ "version": "0.3.39",
4
4
  "description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -82,6 +82,14 @@ export const WisdomKernel = async (ctx = {}, options = {}) => {
82
82
  if (!event) return;
83
83
  if (event.type === "session.created") {
84
84
  const sid = event.properties && event.properties.info && event.properties.info.id;
85
+ // 0.3.40 session arm: a postinstall worker that died before postinstall.done is started again, detached and fail-open
86
+ // (lib/setup-run.js sessionStartResume; the CLI dispatch does the same on every run).
87
+ try {
88
+ let S;
89
+ try { S = await import("./setup-run.js"); } catch { S = await import("../lib/setup-run.js"); }
90
+ const os = await import("node:os");
91
+ S.sessionStartResume({ home: os.homedir(), env: process.env, script: S.postinstallScriptIn(k) });
92
+ } catch {}
85
93
  await H.runHook("session", { hook_event_name: "SessionStart", session_id: sid, cwd, source: "startup", agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
86
94
  } else if (event.type === "session.idle") {
87
95
  const sid = event.properties && event.properties.sessionID;
@@ -0,0 +1,6 @@
1
+ ## Study locks for the installed harness sources (wkappbot-code install, owner 2026-11-13)
2
+ ## An Edit or Write of an installed harness source is refused until the skills below are read, even after an emergency-edit grant.
3
+ ## harness:skill (?i)[/\\]\.wk-harness[/\\](?:tools|\.githooks|\.claude[/\\]hooks|wkappbot-code[/\\]lib)[/\\]
4
+ wkappbot skill read harness-installer-owns-all-setup
5
+ wkappbot skill read agent-harness-landscape-2026-howto
6
+ wkappbot skill read verify-tool-or-guard-synthetic-live-static-howto