wkappbot-code 0.3.27 → 0.3.30
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.
- package/bin/wkappbot-code.js +23 -9
- package/lib/fileurl.js +1 -0
- package/lib/rmtree.js +52 -1
- package/package.json +1 -1
package/bin/wkappbot-code.js
CHANGED
|
@@ -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, holderReport } 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,9 @@ 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.30 (2026-11-12, spokesman order after gate runs 43246725941 and 43247534046): 0.3.29 left the EPERM on .claude/hooks/wkharness-run.sh in one of two win-x64-nopy runs. The detached shadow run (wkjs_shadow) and the skill-draft child of the hook script were still started inside the harness copy with the script fd open; both now start from cwd / with the fd closed (wk_detach_prep), like the archive child. A failing tree removal on Windows now prints lib/rmtree.js holderReport: every Win32_Process row naming the harness dir plus every shell, node and python process with pid, ppid and command line, so a run that still hits EPERM names the real holder.
|
|
440
|
+
// 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.
|
|
441
|
+
// 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
442
|
st.prereq = ensurePrereqs({ log });
|
|
437
443
|
if (!st.prereq.ok) bad++;
|
|
438
444
|
// 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 +472,8 @@ function install(o) {
|
|
|
466
472
|
const kih = (st.harness.mode === "web" && st.harness.ok) ? st.harness.dir : kihDir(o);
|
|
467
473
|
// 0.3.13 (owner 2026-11-09): read the Claude settings first, then point every harness hook at the SAME harness the
|
|
468
474
|
// plugin uses, on both roads (web harness or checkout); the user's own hooks stay; bad JSON is reported, not touched.
|
|
475
|
+
// the one settings.json copy install takes before it edits the file: uninstall removes this recorded copy and no other
|
|
476
|
+
st.settingsBackup = prev.settingsBackup || (o.harness && existsSync(SETTINGS()) ? settingsBackup() : null);
|
|
469
477
|
st.hooks = o.harness ? wireClaudeHooks({ settings: SETTINGS(), kih, pwsh: !!ps, log }) : { skipped: true };
|
|
470
478
|
// the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
|
|
471
479
|
st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
|
|
@@ -480,7 +488,8 @@ function install(o) {
|
|
|
480
488
|
}
|
|
481
489
|
st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
|
|
482
490
|
if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
|
|
483
|
-
|
|
491
|
+
// 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-*
|
|
492
|
+
{ const ob = installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout"); st.opencodeBackups = [...(prev.opencodeBackups || []), ...(ob ? [ob] : [])]; }
|
|
484
493
|
st.kihDir = kih;
|
|
485
494
|
// The shims this install created (absent before, present now): uninstall removes exactly these.
|
|
486
495
|
st.shims = [...new Set([...(prev.shims || []), ...shimCandidates().filter((p) => (existsSync(p) || isSymlink(p)) && !st.prior.shimsExisting.includes(p))])];
|
|
@@ -546,7 +555,6 @@ function uninstall(o) {
|
|
|
546
555
|
}
|
|
547
556
|
}
|
|
548
557
|
if (removed || restored) {
|
|
549
|
-
copyFileSync(sp, sp + ".bak-" + stamp());
|
|
550
558
|
if (pr.settingsExisted === false && !Object.keys(d).length) { rmSync(sp); log("settings: removed " + sp + " (it did not exist before install)"); }
|
|
551
559
|
else {
|
|
552
560
|
const same = pre && JSON.stringify(sortKeys(pre)) === JSON.stringify(sortKeys(d));
|
|
@@ -557,6 +565,8 @@ function uninstall(o) {
|
|
|
557
565
|
} else log("settings: no harness hook in " + sp);
|
|
558
566
|
} catch (e) { rc = 1; log("settings: " + sp + " is not plain JSON, left untouched (" + e.message + ")"); }
|
|
559
567
|
}
|
|
568
|
+
// the settings copy install recorded (only that one) goes with the uninstall; a user's own settings.json.bak-* stays
|
|
569
|
+
if (st.settingsBackup && existsSync(st.settingsBackup)) { rmSync(st.settingsBackup, { force: true }); log("settings: removed the backup install recorded, " + st.settingsBackup); }
|
|
560
570
|
// 3. the shims install created
|
|
561
571
|
for (const p of st.shims || []) {
|
|
562
572
|
if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
|
|
@@ -593,8 +603,10 @@ function uninstall(o) {
|
|
|
593
603
|
const rmTree = (p, what) => {
|
|
594
604
|
const r = rmTreeHard(p); if (r.ok) { if (what) log(what + ": removed " + p); return; }
|
|
595
605
|
rc = 1; log((what || "remove") + ": could not remove " + p + " -- blocked by " + r.blockers.slice(0, 5).join("; ") + " -- delete it by hand");
|
|
596
|
-
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,
|
|
606
|
+
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, 2000)); log("harness holders (rows naming " + p + ", then shell/node/python processes with parent pid and command line):\n" + holderReport(p)); }
|
|
597
607
|
};
|
|
608
|
+
// (B) the detached per-turn archive child may still hold .claude/hooks/wkharness-run.sh (Windows EPERM): wait for its RECORDED pid first.
|
|
609
|
+
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
610
|
if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
|
|
599
611
|
if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
|
|
600
612
|
const cfgFile = join(configDir(o), "opencode.json");
|
|
@@ -602,12 +614,14 @@ function uninstall(o) {
|
|
|
602
614
|
const c = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
603
615
|
const keep = (c.plugin || []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
|
|
604
616
|
if (keep.length !== (c.plugin || []).length) {
|
|
605
|
-
|
|
617
|
+
// 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
618
|
if (keep.length) c.plugin = keep; else delete c.plugin;
|
|
607
619
|
writeFileSync(cfgFile, JSON.stringify(c, null, 2) + "\n");
|
|
608
620
|
log("config: plugin entry removed from " + cfgFile);
|
|
609
621
|
}
|
|
610
622
|
} catch {}
|
|
623
|
+
// the opencode.json copies install recorded (and only those) go with the uninstall; a user's own opencode.json.bak-* stays
|
|
624
|
+
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
625
|
rmTree(STATE_HOME());
|
|
612
626
|
rmTree(join(configDir(o), "wkappbot-code"));
|
|
613
627
|
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 +744,7 @@ switch (o.cmd) {
|
|
|
730
744
|
console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
|
|
731
745
|
case "install": process.exit(install(o));
|
|
732
746
|
case "uninstall": process.exit(uninstall(o));
|
|
733
|
-
case "doctor": process.
|
|
747
|
+
case "doctor": process.exitCode = doctor(o); break;
|
|
734
748
|
case "update": process.exit(upd());
|
|
735
749
|
case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
|
|
736
750
|
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,61 @@
|
|
|
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
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
|
|
6
7
|
const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
|
|
7
8
|
|
|
9
|
+
// waitForArchive(harnessDir, opts) -> { waited, alive[] }: the detached per-turn archive child (tools/wk-hippo-archive.sh) records its
|
|
10
|
+
// pid in <harnessDir>/.tmp/hippo-archive/archive-SID.pid and removes it on exit. A Windows uninstall that runs inside that child's
|
|
11
|
+
// lifetime hits EPERM on .claude/hooks/wkharness-run.sh, so uninstall waits (bounded) for every RECORDED pid to leave first.
|
|
12
|
+
// isAlive and sleep are injectable so the selftest fakes a held handle. A pid file whose pid is dead is stale and ignored.
|
|
13
|
+
// holderReport(dir, opts) -> text: who could be holding the harness copy (kih ROOT 3 2026-11-12, spokesman order for 0.3.30). tasklist /v names no file owner, so on
|
|
14
|
+
// Windows this asks Win32_Process for every process whose command line or image path names the dir, plus every shell, node, python and setsid process with its
|
|
15
|
+
// parent pid and command line; the SDK e2e uninstall prints it when the tree removal fails, so a run that hits EPERM names the real holder. exec is injectable.
|
|
16
|
+
export function holderReport(dir, opts = {}) {
|
|
17
|
+
const exec = opts.exec || ((cmd, args) => { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 20000 }); return String(r.stdout || r.stderr || ""); });
|
|
18
|
+
const isWin = opts.win ?? (process.platform === "win32");
|
|
19
|
+
const needle = String(dir).replace(/\\/g, "/").toLowerCase();
|
|
20
|
+
try {
|
|
21
|
+
if (isWin) {
|
|
22
|
+
const ps = "Get-CimInstance Win32_Process | ForEach-Object { $c = [string]$_.CommandLine; $p = [string]$_.ExecutablePath; \"$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)`t$c`t$p\" }";
|
|
23
|
+
return filterHolderRows(exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]), needle);
|
|
24
|
+
}
|
|
25
|
+
return filterHolderRows(exec("ps", ["-eo", "pid=,ppid=,comm=,args="]).split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, "\t")).join("\n"), needle);
|
|
26
|
+
} catch (e) { return "holderReport failed: " + (e && e.message); }
|
|
27
|
+
}
|
|
28
|
+
const HOLDER_NAMES = /^(?:bash|sh|node|python3?|pwsh|powershell|setsid|flock|git|wk[\w.-]*)(?:\.exe)?$/i;
|
|
29
|
+
function filterHolderRows(out, needle) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const line of String(out).split(/\r?\n/)) {
|
|
32
|
+
const f = line.split("\t");
|
|
33
|
+
if (f.length < 3) continue;
|
|
34
|
+
const hay = f.slice(3).join(" ").replace(/\\/g, "/").toLowerCase();
|
|
35
|
+
const named = hay.includes(needle), shellish = HOLDER_NAMES.test(String(f[2]).trim());
|
|
36
|
+
if (named || shellish) rows.push((named ? "HOLDS-DIR " : "shell ") + "pid=" + f[0] + " ppid=" + f[1] + " " + f[2] + " " + f.slice(3).join(" ").slice(0, 300));
|
|
37
|
+
}
|
|
38
|
+
return rows.length ? rows.slice(0, 60).join("\n") : "no process names the harness dir and no shell, node or python process is running";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
|
|
42
|
+
export function waitForArchive(harnessDir, opts = {}) {
|
|
43
|
+
const isAlive = opts.isAlive || defaultAlive, maxMs = opts.maxMs ?? 15000, gap = opts.gap || 250, nap = opts.sleep || sleepMs;
|
|
44
|
+
const dir = join(harnessDir, ".tmp", "hippo-archive");
|
|
45
|
+
const pids = () => {
|
|
46
|
+
let names = []; try { names = readdirSync(dir).filter((n) => /\.pid$/.test(n)); } catch { return []; }
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const n of names) {
|
|
49
|
+
let pid = 0; try { pid = parseInt(String(readFileSync(join(dir, n), "utf8")).trim(), 10); } catch { /* unreadable */ }
|
|
50
|
+
if (pid > 0 && isAlive(pid)) out.push(pid);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
let alive = pids(), waited = 0;
|
|
55
|
+
while (alive.length && waited < maxMs) { nap(gap); waited += gap; alive = pids(); }
|
|
56
|
+
return { waited, alive };
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
// rm is injectable so the selftest can fake a held handle (default: fs.rmSync with Node's own retry)
|
|
9
60
|
export function rmTreeHard(p, rm = (q, o) => rmSync(q, o), opts = {}) {
|
|
10
61
|
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.
|
|
3
|
+
"version": "0.3.30",
|
|
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": {
|