wkappbot-code 0.3.27 → 0.3.29

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.
@@ -41,7 +41,7 @@ import {
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";
44
- import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
44
+ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard, waitForArchive } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
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";
@@ -380,15 +380,16 @@ function installPlugin(o, kih, kihSource) {
380
380
  const side = join(dirname(target), "wkappbot-code.json");
381
381
  writeFileSync(side, JSON.stringify(buildConfig({}, it, tmpl), null, 2) + "\n");
382
382
  log(target + " is not plain JSON; left untouched, wrote " + side + " -- merge its plugin entry by hand");
383
- return;
383
+ return null;
384
384
  }
385
385
  const text = JSON.stringify(buildConfig(user, it, tmpl), null, 2) + "\n";
386
- let note = "";
386
+ let note = "", backup = null; // backup: the opencode.json copy THIS install takes; install records it, uninstall removes exactly that (0.3.29)
387
387
  if (raw && text === raw) note = " (unchanged)";
388
388
  else {
389
389
  if (raw) {
390
390
  const bak = target + ".bak-" + stamp();
391
391
  copyFileSync(target, bak);
392
+ backup = bak;
392
393
  note = " (merged; backup " + bak + ")";
393
394
  }
394
395
  for (const l of lineDiff(raw, text)) log("diff " + l);
@@ -397,14 +398,16 @@ function installPlugin(o, kih, kihSource) {
397
398
  }
398
399
  log("plugin: " + url);
399
400
  log("config: " + target + note);
401
+ return backup;
400
402
  }
401
403
 
402
404
  // The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
403
405
  // it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
404
406
  function shimCandidates() {
405
- return [join(linkDir(), "wkappbot"), join(linkDir(), "wkappbot-core"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // wkappbot-core: the harness install.sh links it beside wkappbot (SDK e2e nopwsh u1: local-bin= vs local-bin=wkappbot-core) // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
407
+ return [join(linkDir(), "wkappbot"), join(linkDir(), "wkappbot-core"), join(linkDir(), "wkappbot.cmd"), join(linkDir(), "wkappbot-core.cmd"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // wkappbot-core: the harness install.sh links it beside wkappbot (SDK e2e nopwsh u1: local-bin= vs local-bin=wkappbot-core) // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
406
408
  }
407
409
  const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
410
+ function settingsBackup() { const bak = SETTINGS() + ".bak-" + stamp(); copyFileSync(SETTINGS(), bak); log("settings: backup " + bak); return bak; }
408
411
  function recordPrior(prev) {
409
412
  if (prev && prev.prior) return prev.prior;
410
413
  // The user's settings.json as it was, byte for byte: uninstall gives these bytes back when nothing else changed.
@@ -433,6 +436,8 @@ function install(o) {
433
436
  let bad = 0;
434
437
  // 0.3.13 (owner 2026-11-09: Git Bash is a required tool too): bash, sh, curl, tar and git first; on Windows a Git that
435
438
  // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
439
+ // 0.3.29 (2026-11-12, spokesman order after SDK gate run 43227049219): uninstall waits (bounded 15 s) for a RECORDED archive pid before it removes the harness copy (lib/rmtree.js waitForArchive; the detached archive child starts from cwd / with the script fd closed), so no EPERM on .claude/hooks/wkharness-run.sh; install records st.opencodeBackups and uninstall leaves no opencode.json.bak-<stamp> of its own.
440
+ // 0.3.28 (owner 2026-11-12 via the spokesman): uninstall removes only the settings backup the install recorded (st.settingsBackup); the .cmd twins of the core links are shim candidates; the clean-baseline guard no longer reads --model of non-delegating commands; doctor sets process.exitCode.
436
441
  st.prereq = ensurePrereqs({ log });
437
442
  if (!st.prereq.ok) bad++;
438
443
  // 0.3.20 (owner 2026-11-09): a real Python before any step runs python -- the Store alias stub made install exit 49
@@ -466,6 +471,8 @@ function install(o) {
466
471
  const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
467
472
  // 0.3.13 (owner 2026-11-09): read the Claude settings first, then point every harness hook at the SAME harness the
468
473
  // plugin uses, on both roads (web harness or checkout); the user's own hooks stay; bad JSON is reported, not touched.
474
+ // the one settings.json copy install takes before it edits the file: uninstall removes this recorded copy and no other
475
+ st.settingsBackup = prev.settingsBackup || (o.harness && existsSync(SETTINGS()) ? settingsBackup() : null);
469
476
  st.hooks = o.harness ? wireClaudeHooks({ settings: SETTINGS(), kih, pwsh: !!ps, log }) : { skipped: true };
470
477
  // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
471
478
  st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
@@ -480,7 +487,8 @@ function install(o) {
480
487
  }
481
488
  st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
482
489
  if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
483
- installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout");
490
+ // 0.3.29: the opencode.json copy a merge takes is recorded (like st.settingsBackup); uninstall removes exactly the recorded copies, never a user's own .bak-*
491
+ { const ob = installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout"); st.opencodeBackups = [...(prev.opencodeBackups || []), ...(ob ? [ob] : [])]; }
484
492
  st.kihDir = kih;
485
493
  // The shims this install created (absent before, present now): uninstall removes exactly these.
486
494
  st.shims = [...new Set([...(prev.shims || []), ...shimCandidates().filter((p) => (existsSync(p) || isSymlink(p)) && !st.prior.shimsExisting.includes(p))])];
@@ -546,7 +554,6 @@ function uninstall(o) {
546
554
  }
547
555
  }
548
556
  if (removed || restored) {
549
- copyFileSync(sp, sp + ".bak-" + stamp());
550
557
  if (pr.settingsExisted === false && !Object.keys(d).length) { rmSync(sp); log("settings: removed " + sp + " (it did not exist before install)"); }
551
558
  else {
552
559
  const same = pre && JSON.stringify(sortKeys(pre)) === JSON.stringify(sortKeys(d));
@@ -557,6 +564,8 @@ function uninstall(o) {
557
564
  } else log("settings: no harness hook in " + sp);
558
565
  } catch (e) { rc = 1; log("settings: " + sp + " is not plain JSON, left untouched (" + e.message + ")"); }
559
566
  }
567
+ // the settings copy install recorded (only that one) goes with the uninstall; a user's own settings.json.bak-* stays
568
+ if (st.settingsBackup && existsSync(st.settingsBackup)) { rmSync(st.settingsBackup, { force: true }); log("settings: removed the backup install recorded, " + st.settingsBackup); }
560
569
  // 3. the shims install created
561
570
  for (const p of st.shims || []) {
562
571
  if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
@@ -595,6 +604,8 @@ function uninstall(o) {
595
604
  rc = 1; log((what || "remove") + ": could not remove " + p + " -- blocked by " + r.blockers.slice(0, 5).join("; ") + " -- delete it by hand");
596
605
  if (IS_WIN) { const d = spawnSync("tasklist.exe", ["/v"], { encoding: "utf8", timeout: 15000 }); log("remove diagnostics (process list, not file owners): " + String(d.stdout || d.stderr || "").slice(0, 4000)); }
597
606
  };
607
+ // (B) the detached per-turn archive child may still hold .claude/hooks/wkharness-run.sh (Windows EPERM): wait for its RECORDED pid first.
608
+ if (existsSync(HARNESS_DIR())) { const w = waitForArchive(HARNESS_DIR()); if (w.waited) log("harness: waited " + w.waited + " ms for the archive child" + (w.alive.length ? " (pid " + w.alive.join(",") + " still running)" : "")); }
598
609
  if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
599
610
  if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
600
611
  const cfgFile = join(configDir(o), "opencode.json");
@@ -602,12 +613,14 @@ function uninstall(o) {
602
613
  const c = JSON.parse(readFileSync(cfgFile, "utf8"));
603
614
  const keep = (c.plugin || []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
604
615
  if (keep.length !== (c.plugin || []).length) {
605
- copyFileSync(cfgFile, cfgFile + ".bak-" + stamp());
616
+ // 0.3.29 (spokesman ruling 2026-11-12): no backup of OUR OWN edit -- it left an opencode.json.bak-<stamp> behind. Only our entry goes.
606
617
  if (keep.length) c.plugin = keep; else delete c.plugin;
607
618
  writeFileSync(cfgFile, JSON.stringify(c, null, 2) + "\n");
608
619
  log("config: plugin entry removed from " + cfgFile);
609
620
  }
610
621
  } catch {}
622
+ // the opencode.json copies install recorded (and only those) go with the uninstall; a user's own opencode.json.bak-* stays
623
+ for (const b of (st.opencodeBackups || [])) { try { if (existsSync(b)) { rmSync(b, { force: true }); log("config: removed the backup install recorded, " + b); } } catch (e) { log("config: could not remove " + b + " (" + e.message + ")"); } }
611
624
  rmTree(STATE_HOME());
612
625
  rmTree(join(configDir(o), "wkappbot-code"));
613
626
  log("uninstall done." + (linkRemoved ? " Removed the link " + linkRemoved + "." : "") + " Kept: opencode-ai, the wkappbot core (" + core + ") and its skills, and this CLI (npm rm -g wkappbot-code)");
@@ -730,7 +743,7 @@ switch (o.cmd) {
730
743
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
731
744
  case "install": process.exit(install(o));
732
745
  case "uninstall": process.exit(uninstall(o));
733
- case "doctor": process.exit(doctor(o));
746
+ case "doctor": process.exitCode = doctor(o); break;
734
747
  case "update": process.exit(upd());
735
748
  case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
736
749
  default:
package/lib/fileurl.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // fileurl.js -- a file:// base that curl can open on every platform.
2
+ // VERIFIED 2026-11-12 (kih ROOT 3): selftest-rmtree.mjs case r4 (plainFileUrl decodes %7E to ~, an https base is untouched) PASS in RESULT 8/8.
2
3
  // pathToFileURL percent-encodes "~" ("RUNNER~1" -> "RUNNER%7E1") and Windows curl does not decode a file:// path, so a
3
4
  // Windows profile with a short name, a space or a non-ASCII letter failed to read the harness. plainFileUrl hands curl the plain path.
4
5
  import { fileURLToPath } from "node:url";
package/lib/rmtree.js CHANGED
@@ -1,10 +1,32 @@
1
1
  // rmtree.js -- delete a tree on a platform that releases a just-closed handle late (Windows: EBUSY/EPERM/ENOTEMPTY).
2
2
  // rmTreeHard(p, rm) -> { ok, blockers[] }: a long bounded retry first, then a per-entry pass that names what stays.
3
- import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
7
7
 
8
+ // waitForArchive(harnessDir, opts) -> { waited, alive[] }: the detached per-turn archive child (tools/wk-hippo-archive.sh) records its
9
+ // pid in <harnessDir>/.tmp/hippo-archive/archive-SID.pid and removes it on exit. A Windows uninstall that runs inside that child's
10
+ // lifetime hits EPERM on .claude/hooks/wkharness-run.sh, so uninstall waits (bounded) for every RECORDED pid to leave first.
11
+ // isAlive and sleep are injectable so the selftest fakes a held handle. A pid file whose pid is dead is stale and ignored.
12
+ const defaultAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
13
+ export function waitForArchive(harnessDir, opts = {}) {
14
+ const isAlive = opts.isAlive || defaultAlive, maxMs = opts.maxMs ?? 15000, gap = opts.gap || 250, nap = opts.sleep || sleepMs;
15
+ const dir = join(harnessDir, ".tmp", "hippo-archive");
16
+ const pids = () => {
17
+ let names = []; try { names = readdirSync(dir).filter((n) => /\.pid$/.test(n)); } catch { return []; }
18
+ const out = [];
19
+ for (const n of names) {
20
+ let pid = 0; try { pid = parseInt(String(readFileSync(join(dir, n), "utf8")).trim(), 10); } catch { /* unreadable */ }
21
+ if (pid > 0 && isAlive(pid)) out.push(pid);
22
+ }
23
+ return out;
24
+ };
25
+ let alive = pids(), waited = 0;
26
+ while (alive.length && waited < maxMs) { nap(gap); waited += gap; alive = pids(); }
27
+ return { waited, alive };
28
+ }
29
+
8
30
  // rm is injectable so the selftest can fake a held handle (default: fs.rmSync with Node's own retry)
9
31
  export function rmTreeHard(p, rm = (q, o) => rmSync(q, o), opts = {}) {
10
32
  const tries = opts.tries || 6, gap = opts.gap || 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
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": {