kenaz 0.3.23 → 0.3.24

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.
@@ -491,7 +491,8 @@ function gcStaleWorktrees(repo, keepPath, opts) {
491
491
  // Windows recurses through junctions on delete and would empty the MAIN
492
492
  // checkout's node_modules (measured; see unlinkNodeModulesLinks). This
493
493
  // also covers pre-TASK_1844 worktrees where agents hand-built junctions.
494
- unlinkNodeModulesLinks(p);
494
+ const unl = unlinkNodeModulesLinks(p);
495
+ if (unl.failed.length) { diag(name + ' KEPT — cannot unlink: ' + unl.failed.slice(0, 3).join(', ')); continue; }
495
496
  // Lock-resilient removal: try git remove --force, fall back to fs.rmSync
496
497
  // with retries, then prune. If still locked -> log to stderr (observable)
497
498
  // but never throw (GC must remain non-blocking).
@@ -646,7 +647,7 @@ function ensureWorktreeIgnores(worktreePath, relPath) {
646
647
  }
647
648
 
648
649
  /**
649
- * Unlink node_modules junctions/symlinks inside `root` (a worktree about to be
650
+ * Unlink EVERY junction/symlink (any name, any depth) inside `root` (a worktree about to be
650
651
  * removed). MUST run before any `git worktree remove`: Git for Windows RECURSES
651
652
  * THROUGH junctions when deleting and EMPTIES THE LINK TARGET — measured
652
653
  * 2026-08-13 (TASK_1844 control test): a junction into the source repo's
@@ -657,27 +658,8 @@ function ensureWorktreeIgnores(worktreePath, relPath) {
657
658
  * left for the normal removal path. Best-effort: never throws.
658
659
  */
659
660
  function unlinkNodeModulesLinks(root) {
660
- const walk = (dir, depth) => {
661
- let entries;
662
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
663
- for (const ent of entries) {
664
- const p = path.join(dir, ent.name);
665
- if (ent.name === 'node_modules') {
666
- try {
667
- if (fs.lstatSync(p).isSymbolicLink()) {
668
- try { fs.unlinkSync(p); } catch { fs.rmdirSync(p); }
669
- }
670
- } catch { /* best-effort */ }
671
- continue;
672
- }
673
- let isDir = false;
674
- try { const st = fs.lstatSync(p); isDir = st.isDirectory() && !st.isSymbolicLink(); }
675
- catch { continue; }
676
- if (!isDir || NODE_MODULES_SCAN_PRUNE.has(ent.name)) continue;
677
- if (depth + 1 <= NODE_MODULES_SCAN_DEPTH) walk(p, depth + 1);
678
- }
679
- };
680
- walk(root, 0);
661
+ // TASK_2485: ONE implementation (lstat walk, every link, any name/depth) lives in worktree-reclaim.js.
662
+ return require('../../.formation/tools/worktree-reclaim.js').unlinkAllLinks(root); // {removed, failed}
681
663
  }
682
664
 
683
665
  /**
@@ -51,40 +51,14 @@ function gitTopLevel(dir) {
51
51
  // falsified by the control test above — keep this note so nobody re-litigates
52
52
  // it in either direction without re-measuring.)
53
53
  //
54
- // DUPLICATED from worktree-create.js ON PURPOSE — do not "deduplicate" via
55
- // require('./worktree-create.js'): this hook is wired as `node -e
56
- // "require(...)"`, so inside a required worktree-create.js
57
- // require.main === undefined, which its entry dispatch treats as "I am the
58
- // WorktreeCreate hook" → it would consume our stdin JSON and CREATE a spurious
59
- // worktree in the middle of a remove.
60
- const NM_SCAN_DEPTH = 3;
61
- const NM_SCAN_PRUNE = new Set([
62
- '.git', 'node_modules', '.next', 'target', 'dist', 'build', 'out',
63
- 'vendor', 'worktrees', '.kenaz-worktrees',
64
- ]);
65
-
54
+ // TASK_2485: the unlink walk is shared (worktree-reclaim.js unlinkAllLinks): every
55
+ // symlink/junction at any depth, because git recurses through ALL of them. Do not
56
+ // require('./worktree-create.js') here: this hook is wired as `node -e "require(...)"`,
57
+ // so inside a required worktree-create.js require.main === undefined, which its entry
58
+ // dispatch treats as "I am the WorktreeCreate hook" and would CREATE a worktree.
66
59
  function unlinkNodeModulesLinks(root) {
67
- const walk = (dir, depth) => {
68
- let entries;
69
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
70
- for (const ent of entries) {
71
- const p = path.join(dir, ent.name);
72
- if (ent.name === 'node_modules') {
73
- try {
74
- if (fs.lstatSync(p).isSymbolicLink()) {
75
- try { fs.unlinkSync(p); } catch { fs.rmdirSync(p); }
76
- }
77
- } catch { /* best-effort */ }
78
- continue;
79
- }
80
- let isDir = false;
81
- try { const st = fs.lstatSync(p); isDir = st.isDirectory() && !st.isSymbolicLink(); }
82
- catch { continue; }
83
- if (!isDir || NM_SCAN_PRUNE.has(ent.name)) continue;
84
- if (depth + 1 <= NM_SCAN_DEPTH) walk(p, depth + 1);
85
- }
86
- };
87
- try { walk(root, 0); } catch { /* best-effort */ }
60
+ try { return require('../../.formation/tools/worktree-reclaim.js').unlinkAllLinks(root); }
61
+ catch { return { removed: [], failed: ['(unlink helper unavailable)'] }; }
88
62
  }
89
63
 
90
64
  /** Find the branch checked out in `worktreePath` via `git worktree list --porcelain`. */
@@ -164,7 +138,7 @@ function main() {
164
138
  // TASK_1844: MUST unlink node_modules junctions before the git removal —
165
139
  // Git for Windows traverses junctions on delete (measured; see header
166
140
  // comment above unlinkNodeModulesLinks). fs.rmSync below is safe as-is.
167
- unlinkNodeModulesLinks(worktreePath);
141
+ if (unlinkNodeModulesLinks(worktreePath).failed.length) process.exit(0); // refuse: a surviving link would be traversed
168
142
  gitTry(repo, ['worktree', 'remove', '--force', worktreePath]);
169
143
  gitTry(repo, ['worktree', 'prune']);
170
144
  if (branch) gitTry(repo, ['branch', '-D', branch]);
@@ -15,7 +15,12 @@ waiting for a green build. The commit command (built by
15
15
  paths (`.kenaz/sessions`, `.kenaz/specialist`, `.formation/.dispatch-branch`,
16
16
  `.formation/analytics`) — never hand-type `git add -A` to replace it, and
17
17
  never delete the worktree yourself even after committing; the main thread
18
- owns worktree lifecycle.
18
+ owns worktree lifecycle. When the main thread (or an agent told to clean up)
19
+ must remove one, the only safe command is
20
+ `node <core>/.formation/tools/worktree-reclaim.js remove <path>`: dispatch
21
+ worktrees hold junctions into the main repo (node_modules, workspace links),
22
+ and on Windows `git worktree remove --force` follows every one of them and
23
+ empties the target (TASK_2485).
19
24
 
20
25
  ## Why "sending a message ends execution" is a hard rule
21
26
 
@@ -63,10 +63,9 @@ function linkNodeModulesBestEffort(projectRoot, worktreePath) {
63
63
  * target (the main checkout's node_modules). Never throws.
64
64
  */
65
65
  function unlinkNodeModulesBestEffort(worktreePath) {
66
- const hook = _loadWorktreeCreateHook();
67
- if (!hook || typeof hook.unlinkNodeModulesLinks !== 'function') return;
68
- try { hook.unlinkNodeModulesLinks(worktreePath); }
69
- catch (e) { console.warn(` ⚠️ node_modules unlink failed: ${(e && e.message) || 'unknown'}`); }
66
+ // TASK_2485: unlinks EVERY link (any name/depth); returns {removed, failed}. failed != [] => do not remove.
67
+ try { return require('./worktree-reclaim').unlinkAllLinks(worktreePath); }
68
+ catch (e) { console.warn(` ⚠️ link unlink failed: ${(e && e.message) || 'unknown'}`); return { removed: [], failed: ['(unlink helper unavailable)'] }; }
70
69
  }
71
70
 
72
71
  /**
@@ -848,7 +847,8 @@ function removeWorktree(projectRoot, worktreePath, maxRetries = 3) {
848
847
  // TASK_1844/TASK_2030: MUST run before any `git worktree remove` -- Git for
849
848
  // Windows recurses through a node_modules junction/symlink on removal and
850
849
  // empties the link target (the main checkout's node_modules).
851
- unlinkNodeModulesBestEffort(worktreePath);
850
+ const unl = unlinkNodeModulesBestEffort(worktreePath);
851
+ if (unl.failed.length) { console.warn(` ⚠️ not removing ${worktreePath}: link(s) survive unlink: ${unl.failed.slice(0, 3).join(', ')}`); return false; }
852
852
  for (let i = 0; i < maxRetries; i++) {
853
853
  try {
854
854
  execSync(`git worktree remove --force "${worktreePath}"`, {
@@ -73,6 +73,66 @@ function walkTree(root) {
73
73
  return { size, newest, links, managedLinks };
74
74
  }
75
75
 
76
+ /**
77
+ * TASK_2485: unlink EVERY symlink/junction under `root` (any name, any depth, also inside a real
78
+ * node_modules). lstat walk: a link is removed as a link and never entered. Measured on Windows:
79
+ * `git worktree remove --force` traverses every junction and empties its target (the main repo's
80
+ * node_modules, backend/.env, tracked files); fs.rmSync, Git Bash rm -rf and cmd rmdir /s do not.
81
+ * @returns {{removed:string[], failed:string[]}} paths relative to root
82
+ */
83
+ function unlinkAllLinks(root) {
84
+ const removed = []; const failed = [];
85
+ const stack = [root];
86
+ while (stack.length) {
87
+ const dir = stack.pop();
88
+ let names;
89
+ try { names = fs.readdirSync(dir); } catch { continue; }
90
+ for (const name of names) {
91
+ const p = path.join(dir, name);
92
+ let st;
93
+ try { st = fs.lstatSync(p); } catch { continue; }
94
+ const rel = path.relative(root, p).replace(/\\/g, '/');
95
+ if (st.isSymbolicLink()) {
96
+ try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { failed.push(rel); continue; } }
97
+ removed.push(rel);
98
+ } else if (st.isDirectory()) stack.push(p);
99
+ }
100
+ }
101
+ return { removed, failed };
102
+ }
103
+
104
+ /**
105
+ * The one safe removal: unlink all links, re-scan, then remove (git worktree remove --force when
106
+ * registered, fs.rmSync for orphan dirs). Refuses (ok:false) when a link survives or the path is
107
+ * not a worktree of this repo / under .formation/worktrees or .claude/worktrees.
108
+ * @returns {{ok:boolean, registered:boolean, unlinked:string[], reason?:string}}
109
+ */
110
+ function safeRemoveWorktree(repo, target) {
111
+ const abs = path.resolve(target);
112
+ const { main, worktrees } = listRegisteredWorktrees(repo);
113
+ const registered = worktrees.some((w) => norm(w.path) === norm(abs));
114
+ const inside = (...dir) => { const rel = path.relative(path.join(repo, ...dir), abs); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); };
115
+ const res = { ok: false, registered, unlinked: [] };
116
+ if (norm(abs) === norm(main.path)) return { ...res, reason: 'is the main checkout' };
117
+ if (!registered && !inside('.formation', 'worktrees') && !inside('.claude', 'worktrees')) return { ...res, reason: 'not a registered worktree and not under .formation/worktrees or .claude/worktrees' };
118
+ if (!fs.existsSync(abs) && !registered) return { ...res, ok: true, reason: 'already gone' };
119
+ const first = unlinkAllLinks(abs);
120
+ res.unlinked = first.removed;
121
+ const again = unlinkAllLinks(abs);
122
+ if (first.failed.length || again.removed.length || again.failed.length) {
123
+ return { ...res, reason: `cannot unlink: ${[...first.failed, ...again.removed, ...again.failed].slice(0, 5).join(', ')}` };
124
+ }
125
+ const rm = () => fs.rmSync(abs, { recursive: true, force: true, maxRetries: 3 });
126
+ try {
127
+ if (registered) {
128
+ try { git(main.path, ['worktree', 'remove', '--force', abs]); } catch { if (fs.existsSync(abs)) rm(); }
129
+ } else rm();
130
+ } catch (e) { return { ...res, reason: `remove failed: ${String(e.message).split('\n')[0]}` }; }
131
+ try { git(main.path, ['worktree', 'prune']); } catch { /* non-fatal */ }
132
+ const gone = !fs.existsSync(abs);
133
+ return { ...res, ok: gone, reason: gone ? undefined : 'directory still present (locked file?)' };
134
+ }
135
+
76
136
  /** Uncommitted content beyond telemetry. Compares content (CRLF-only changes do not count). */
77
137
  function dirtyContent(wt) {
78
138
  const dirty = [];
@@ -177,7 +237,7 @@ function removeWorktree(repo, w, opts = {}) {
177
237
  const { main } = listRegisteredWorktrees(repo);
178
238
  // Drop dispatch's own node_modules links first: `git worktree remove` would
179
239
  // traverse a junction and empty its target (the main checkout's node_modules).
180
- (opts.unlinkManaged || require('./utils').unlinkNodeModulesBestEffort)(w.path);
240
+ (opts.unlinkManaged || unlinkAllLinks)(w.path);
181
241
  const left = walkTree(w.path);
182
242
  if (left.links.length || left.managedLinks.length) {
183
243
  log(`KEPT ${w.path}: links still present after unlink: ${[...left.links, ...left.managedLinks].slice(0, 3).join(', ')}`);
@@ -256,4 +316,15 @@ function formatReport(evald) {
256
316
  ].join('\n')).join('\n') || '(no worktrees)';
257
317
  }
258
318
 
259
- module.exports = { evaluateWorktrees, ensureWorktreeSpace, reclaimAllEligible, listRegisteredWorktrees, formatReport, DEFAULT_MIN_IDLE_MS };
319
+ // CLI for agents: node worktree-reclaim.js remove <path> (never raw `git worktree remove` / rm -rf)
320
+ if (require.main === module) {
321
+ const target = process.argv[2] === 'remove' ? process.argv[3] : null;
322
+ if (!target) { process.stderr.write('usage: node worktree-reclaim.js remove <worktree-or-orphan-dir>\n'); process.exit(2); }
323
+ let repo;
324
+ try { repo = path.dirname(git(process.cwd(), ['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()); } catch { process.stderr.write('run from inside the repo\n'); process.exit(2); }
325
+ const r = safeRemoveWorktree(repo, target);
326
+ process.stdout.write(`${r.ok ? 'REMOVED' : 'REFUSED'} ${path.resolve(target)}${r.unlinked.length ? ` (unlinked ${r.unlinked.length} link(s): ${r.unlinked.slice(0, 5).join(', ')})` : ''}${r.reason ? ` - ${r.reason}` : ''}\n`);
327
+ process.exit(r.ok ? 0 : 1);
328
+ }
329
+
330
+ module.exports = { unlinkAllLinks, safeRemoveWorktree, evaluateWorktrees, ensureWorktreeSpace, reclaimAllEligible, listRegisteredWorktrees, formatReport, DEFAULT_MIN_IDLE_MS };
@@ -180,3 +180,116 @@ test('re-dispatch of a reclaimed card recreates the worktree from its branch wit
180
180
  assert.strictEqual(fs.readFileSync(path.join(wt, 'f8.txt'), 'utf8'), 'work 8\n');
181
181
  } finally { cleanup(root); }
182
182
  });
183
+
184
+ // ── TASK_2485: removal must never traverse a link into the main repo ─────────────────────────────
185
+ // Measured (Windows, Git for Windows): `git worktree remove --force` follows EVERY junction (any name,
186
+ // any depth, also inside a real node_modules) and empties its target; fs.rmSync, Git Bash rm -rf and
187
+ // cmd rmdir /s do not. Control = the same fixture removed with the raw git command.
188
+ const { safeRemoveWorktree, unlinkAllLinks } = require('./worktree-reclaim');
189
+ const IS_WIN = process.platform === 'win32';
190
+
191
+ function mainFixture() {
192
+ const { root, repo } = makeRepo();
193
+ fs.mkdirSync(path.join(repo, 'backend'));
194
+ fs.writeFileSync(path.join(repo, 'backend', 'a.js'), 'tracked');
195
+ fs.writeFileSync(path.join(repo, '.gitignore'), '.kenaz/tasks\n.formation/worktrees\nnode_modules\n.env\n');
196
+ g(repo, 'add', '-A'); g(repo, 'commit', '-qm', 'backend');
197
+ fs.writeFileSync(path.join(repo, 'backend', '.env'), 'SECRET'); // untracked, unrecoverable
198
+ fs.mkdirSync(path.join(repo, 'node_modules', '@gameark'), { recursive: true });
199
+ fs.mkdirSync(path.join(repo, 'node_modules', '.bin'));
200
+ fs.writeFileSync(path.join(repo, 'node_modules', '.bin', 'x'), 'bin');
201
+ fs.symlinkSync(path.join(repo, 'backend'), path.join(repo, 'node_modules', '@gameark', 'backend'), 'junction'); // workspace link
202
+ return { root, repo };
203
+ }
204
+ const lostFiles = (repo) => ['backend/.env', 'backend/a.js', 'node_modules/.bin/x'].filter((f) => !fs.existsSync(path.join(repo, f)));
205
+ // where the dangerous link sits inside the dispatch worktree (createWorktree already links node_modules)
206
+ const VARIANTS = {
207
+ 'node_modules junction (dispatch)': () => {},
208
+ 'link not named node_modules': (wt, repo) => fs.symlinkSync(path.join(repo, 'node_modules'), path.join(wt, 'shared'), 'junction'),
209
+ 'link deeper than NODE_MODULES_SCAN_DEPTH': (wt, repo) => { const d = path.join(wt, 'a', 'b', 'c', 'd', 'e'); fs.mkdirSync(d, { recursive: true }); fs.symlinkSync(path.join(repo, 'backend'), path.join(d, 'deep'), 'junction'); },
210
+ 'workspace junction inside a real node_modules': (wt, repo) => { fs.rmSync(path.join(wt, 'node_modules'), { recursive: true, force: true }); fs.mkdirSync(path.join(wt, 'node_modules', '@gameark'), { recursive: true }); fs.symlinkSync(path.join(repo, 'backend'), path.join(wt, 'node_modules', '@gameark', 'backend'), 'junction'); },
211
+ };
212
+ function dispatchWt(repo, name, variant) {
213
+ const wt = path.join(repo, '.formation', 'worktrees', name);
214
+ const q = quiet(); createWorktree(repo, wt, `task/${name}`); q();
215
+ VARIANTS[variant](wt, repo);
216
+ return wt;
217
+ }
218
+
219
+ for (const variant of Object.keys(VARIANTS)) {
220
+ test(`2485 ${variant}: raw git worktree remove loses main files (control), safe removal keeps them`, () => {
221
+ const ctl = mainFixture();
222
+ const t = mainFixture();
223
+ try {
224
+ const wtc = dispatchWt(ctl.repo, 'ctl', variant);
225
+ try { g(ctl.repo, 'worktree', 'remove', '--force', wtc); } catch { /* may stop on a locked file */ }
226
+ const lost = lostFiles(ctl.repo);
227
+ console.log(` control (${variant}): raw 'git worktree remove --force' lost in MAIN repo: ${lost.length ? lost.join(', ') : '(nothing)'}`);
228
+ if (IS_WIN) assert.ok(lost.length > 0, 'control must lose files, else the fixture proves nothing');
229
+
230
+ const wt = dispatchWt(t.repo, 'trt', variant);
231
+ const r = safeRemoveWorktree(t.repo, wt);
232
+ assert.ok(r.ok, JSON.stringify(r));
233
+ assert.ok(r.unlinked.length > 0);
234
+ assert.ok(!fs.existsSync(wt));
235
+ assert.deepStrictEqual(lostFiles(t.repo), []);
236
+ assert.strictEqual(fs.readFileSync(path.join(t.repo, 'backend', '.env'), 'utf8'), 'SECRET');
237
+ assert.ok(!g(t.repo, 'worktree', 'list').includes('trt'));
238
+ } finally { cleanup(ctl.root); cleanup(t.root); }
239
+ });
240
+ }
241
+
242
+ test('2485 orphan dir under .formation/worktrees (not registered) is removed safely, links unlinked first', () => {
243
+ const { root, repo } = mainFixture();
244
+ try {
245
+ const orphan = path.join(repo, '.formation', 'worktrees', 'orphan');
246
+ fs.mkdirSync(path.join(orphan, 'node_modules', '@gameark'), { recursive: true });
247
+ fs.symlinkSync(path.join(repo, 'backend'), path.join(orphan, 'node_modules', '@gameark', 'backend'), 'junction');
248
+ fs.symlinkSync(path.join(repo, 'node_modules'), path.join(orphan, 'node_modules2'), 'junction');
249
+ assert.ok(!g(repo, 'worktree', 'list').includes('orphan'));
250
+ const r = safeRemoveWorktree(repo, orphan);
251
+ assert.ok(r.ok && !r.registered, JSON.stringify(r));
252
+ assert.strictEqual(r.unlinked.length, 2);
253
+ assert.ok(!fs.existsSync(orphan));
254
+ assert.deepStrictEqual(lostFiles(repo), []);
255
+ } finally { cleanup(root); }
256
+ });
257
+
258
+ test('2485 safeRemoveWorktree refuses the main checkout and paths outside worktree dirs', () => {
259
+ const { root, repo } = mainFixture();
260
+ try {
261
+ const other = path.join(root, 'elsewhere'); fs.mkdirSync(other);
262
+ assert.strictEqual(safeRemoveWorktree(repo, repo).ok, false);
263
+ assert.strictEqual(safeRemoveWorktree(repo, other).ok, false);
264
+ assert.ok(fs.existsSync(other));
265
+ assert.deepStrictEqual(lostFiles(repo), []);
266
+ } finally { cleanup(root); }
267
+ });
268
+
269
+ test('2485 a link that cannot be unlinked is refused and named, nothing removed', () => {
270
+ const { root, repo } = mainFixture();
271
+ try {
272
+ const wt = dispatchWt(repo, 'stuck', 'link not named node_modules');
273
+ const realUnlink = fs.unlinkSync; const realRmdir = fs.rmdirSync;
274
+ fs.unlinkSync = () => { throw new Error('locked'); }; fs.rmdirSync = () => { throw new Error('locked'); };
275
+ let r;
276
+ try { r = safeRemoveWorktree(repo, wt); } finally { fs.unlinkSync = realUnlink; fs.rmdirSync = realRmdir; }
277
+ assert.strictEqual(r.ok, false);
278
+ assert.match(r.reason, /cannot unlink: /);
279
+ assert.ok(fs.existsSync(wt));
280
+ assert.deepStrictEqual(lostFiles(repo), []);
281
+ } finally { cleanup(root); }
282
+ });
283
+
284
+ test('2485 CLI: worktree-reclaim.js remove <path> works from inside the repo', () => {
285
+ const { root, repo } = mainFixture();
286
+ try {
287
+ const wt = dispatchWt(repo, 'cli', 'link not named node_modules');
288
+ const out = require('child_process').spawnSync(process.execPath, [path.join(__dirname, 'worktree-reclaim.js'), 'remove', wt], { cwd: repo, encoding: 'utf8' });
289
+ assert.strictEqual(out.status, 0, out.stdout + out.stderr);
290
+ assert.match(out.stdout, /REMOVED/);
291
+ assert.ok(!fs.existsSync(wt));
292
+ assert.deepStrictEqual(lostFiles(repo), []);
293
+ void unlinkAllLinks;
294
+ } finally { cleanup(root); }
295
+ });
@@ -80,7 +80,11 @@ the real defect BEFORE carding or dispatching.
80
80
  (ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
81
81
  In a `kenaz launch` session (KENAZ_PROVIDER set), Claude subagents also come as an executor block (adapter `claude`, they use the user's Claude quota).
82
82
  - Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
83
- via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
83
+ via `node <core>/.formation/tools/worktree-reclaim.js remove <path>` (registered worktrees and orphan dirs under
84
+ .formation/worktrees or .claude/worktrees). It unlinks EVERY junction/symlink (any name, any depth) first and
85
+ refuses if one survives. Never hand-run `git worktree remove --force`, `rm -rf`, `rmdir /s` or
86
+ `Remove-Item -Recurse` on a worktree: on Windows git follows junctions and empties the MAIN repo's
87
+ node_modules, backend/.env and tracked files (TASK_2485, field report 2026-10-20).
84
88
 
85
89
  ## Skill moments (TASK_2375)
86
90
  Call these with the Skill tool yourself (name may carry the `kenaz:` prefix in a plugin session); nobody types them. Transcript audit: dev/research/skill-routing/AUDIT.md.
@@ -1 +1 @@
1
- 0.3.23
1
+ 0.3.24
@@ -14,7 +14,7 @@ const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const http = require('http');
17
- const { spawnSync } = require('child_process');
17
+ const { spawnSync, spawn } = require('child_process');
18
18
 
19
19
  const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
20
20
  const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
@@ -22,6 +22,7 @@ const MIN_CLI = [2, 1, 287];
22
22
  const SEP = process.platform === 'win32' ? ';' : ':';
23
23
  const TASK_NAME = 'KenazServer';
24
24
  const LABEL = 'ai.kenaz.server';
25
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
25
26
  const BATTERY_PS = '$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName ' + TASK_NAME + ' -Settings $s';
26
27
  const linuxUser = () => { try { return os.userInfo().username; } catch (_) { return process.env.USER || ''; } };
27
28
 
@@ -115,9 +116,14 @@ function unregisterStatusline(file, dry) {
115
116
  // agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
116
117
  // once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
117
118
  const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
119
+ // Field report 2026-10-19: Kenaz never wrote hooks into the user's own ~/.claude/hooks, so a hook there is the user's.
120
+ const HOME_HOOKS = /(^|["'\s])(~|\$\{?HOME\}?|%USERPROFILE%)[\\/]\.claude[\\/]hooks[\\/]/i;
121
+ const slashLower = (s) => s.replace(/\\/g, '/').toLowerCase();
118
122
  /** @returns number of hook commands retired */
119
123
  function retireDashboardHooks(file, dry) {
120
124
  if (!fs.existsSync(file)) return 0;
125
+ const homeHooks = slashLower(path.join(path.dirname(file), 'hooks')) + '/';
126
+ const isDash = (c) => DASH_HOOK.test(c) && !HOME_HOOKS.test(c) && !slashLower(c).includes(homeHooks);
121
127
  const v = loadSettings(file);
122
128
  if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
123
129
  let n = 0;
@@ -126,7 +132,7 @@ function retireDashboardHooks(file, dry) {
126
132
  const groups = [];
127
133
  for (const g of v.hooks[ev]) {
128
134
  if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
129
- const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && DASH_HOOK.test(h.command)));
135
+ const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && isDash(h.command)));
130
136
  n += g.hooks.length - kept.length;
131
137
  if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
132
138
  }
@@ -146,12 +152,14 @@ function claudeVersion() {
146
152
  // ---- service definitions (pure; the OS calls are in service* below) ----
147
153
  function launcherPaths(home) {
148
154
  const d = path.join(home, '.kenaz');
149
- return { dir: d, vbs: path.join(d, 'kenaz-server.vbs'), log: path.join(d, 'kenaz-server.log') };
155
+ return { dir: d, log: path.join(d, 'kenaz-server.log') };
150
156
  }
151
157
  const q = (s) => '"' + s + '"';
152
- function winVbs(bin, core) { // hidden window, env for task tools; server binds loopback by default
153
- return 'Set sh = WScript.CreateObject("WScript.Shell")\r\nsh.Environment("Process")("KENAZ_SOURCE_DIR") = "' + core + '"\r\n' +
154
- 'sh.Run """' + bin + '""", 0, False\r\n';
158
+ // TASK_2478: kenaz-server is a no-console binary on Windows and takes the core dir as an argument (no launcher script).
159
+ function winRunCmd(bin, core) { return q(bin) + ' --source-dir ' + q(core); }
160
+ // TASK_2478 migration: the old script-host launchers (antivirus flagged them); removed on install and uninstall.
161
+ function legacyLaunchers(home, roaming) {
162
+ return [path.join(home, '.kenaz', 'kenaz-server.vbs'), path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs')]; // legacy-vbs-cleanup
155
163
  }
156
164
  // launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
157
165
  // nvm or /usr/local was not found and every MCP tool that runs a node script failed with
@@ -197,8 +205,7 @@ function servicePlan(home, bin, core, platform = process.platform, caps = { syst
197
205
  const L = launcherPaths(home);
198
206
  if (platform === 'win32') {
199
207
  const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
200
- const startup = path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs');
201
- return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
208
+ return { kind: 'windows', files: [], bin, core, runCmd: winRunCmd(bin, core), legacy: legacyLaunchers(home, roaming) };
202
209
  }
203
210
  if (platform === 'darwin') {
204
211
  const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
@@ -241,8 +248,7 @@ function restartHint(plan, uid = defaultUid()) {
241
248
  if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
242
249
  if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
243
250
  if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
244
- // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
245
- return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
251
+ return 'end kenaz-server.exe in Task Manager, then run: ' + plan.runCmd;
246
252
  }
247
253
  function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
248
254
  if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
@@ -297,12 +303,13 @@ async function verifyRuntime(plan, expected, log, opts = {}) {
297
303
  return 'unreachable';
298
304
  }
299
305
 
300
- function serviceInstall(plan, dry, log, run = sh, uid) {
306
+ function serviceInstall(plan, dry, log, run = sh, uid, spawnFn = spawn) {
301
307
  const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
302
308
  for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
303
309
  if (plan.kind === 'windows') {
304
- const tr = 'wscript.exe ' + q(plan.files[0][0]);
305
- log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + plan.fallbackFile + ')');
310
+ const tr = plan.runCmd;
311
+ log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + RUN_KEY + '\\' + TASK_NAME + ')');
312
+ removeLegacy(plan, dry, log);
306
313
  if (dry) return;
307
314
  const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
308
315
  if (r.status === 0) {
@@ -311,13 +318,13 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
311
318
  const b = sh('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', BATTERY_PS]);
312
319
  if (b.status !== 0) log('WARNING: could not set the task to run on battery (exit ' + b.status + '); on a laptop the server may not start unplugged. Run in PowerShell: ' + BATTERY_PS);
313
320
  sh('schtasks', ['/Run', '/TN', TASK_NAME]);
314
- } else { // ONLOGON tasks can need elevation: a Startup-folder launcher is the per-user equivalent
315
- fs.mkdirSync(path.dirname(plan.fallbackFile), { recursive: true });
316
- fs.copyFileSync(plan.files[0][0], plan.fallbackFile);
317
- sh('wscript.exe', [plan.files[0][0]]);
321
+ } else { // ONLOGON tasks can need elevation: an HKCU Run value is the per-user equivalent
322
+ sh('reg', ['add', RUN_KEY, '/v', TASK_NAME, '/t', 'REG_SZ', '/d', plan.runCmd, '/f']);
323
+ const c = spawnFn(plan.bin, ['--source-dir', plan.core], { detached: true, stdio: 'ignore', windowsHide: true });
324
+ c.on('error', () => {}); c.unref();
318
325
  // schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
319
- // missing admin rights for /SC ONLOGON. The Startup-folder launcher starts at logon the same way.
320
- log('schtasks needs administrator rights here (exit ' + r.status + '); installed the Startup-folder launcher instead (also starts at logon)');
326
+ // missing admin rights for /SC ONLOGON. The Run value starts the server at logon the same way.
327
+ log('schtasks needs administrator rights here (exit ' + r.status + '); registered the HKCU Run value instead (also starts at logon)');
321
328
  }
322
329
  } else if (plan.kind === 'launchd') {
323
330
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
@@ -340,11 +347,16 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
340
347
  serviceRestart(plan, dry, log, sh, uid);
341
348
  }
342
349
  }
350
+ function removeLegacy(plan, dry, log) {
351
+ for (const f of plan.legacy || []) if (fs.existsSync(f)) { log('remove ' + f + ' (old launcher)'); if (!dry) fs.rmSync(f, { force: true }); }
352
+ }
343
353
  function serviceUninstall(plan, dry, log, run = sh) {
344
354
  if (plan.kind === 'windows') {
345
355
  log('schtasks /End + /Delete /TN ' + TASK_NAME);
346
356
  if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
347
- if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
357
+ log('reg delete ' + RUN_KEY + ' /v ' + TASK_NAME);
358
+ if (!dry) run('reg', ['delete', RUN_KEY, '/v', TASK_NAME, '/f']); // not-found is fine
359
+ removeLegacy(plan, dry, log);
348
360
  } else if (plan.kind === 'launchd') {
349
361
  log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
350
362
  } else if (plan.kind === 'cron' || plan.kind === 'manual') {
@@ -358,7 +370,7 @@ function serviceUninstall(plan, dry, log, run = sh) {
358
370
  function serviceStatus(plan, run = sh) {
359
371
  if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
360
372
  if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
361
- if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
373
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0 ? 'Run key present' : 'not installed');
362
374
  if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
363
375
  return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
364
376
  }
@@ -366,10 +378,9 @@ function serviceStatus(plan, run = sh) {
366
378
  /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
367
379
  function detectServiceMode(home, core, run = sh, platform = process.platform) {
368
380
  if (platform === 'win32') {
369
- const plan = servicePlan(home, '', core, 'win32');
370
381
  if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
371
- if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
372
- return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
382
+ if (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0) return { mode: 'run key', detail: RUN_KEY + '\\' + TASK_NAME };
383
+ return { mode: 'manual', detail: 'no scheduled task or Run key; run `kenaz install`' };
373
384
  }
374
385
  if (platform === 'darwin') {
375
386
  const p = servicePlan(home, '', core, 'darwin').unit;
@@ -468,4 +479,4 @@ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) {
468
479
  }
469
480
 
470
481
  if (require.main === module) process.exit(main(process.argv.slice(2)));
471
- module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
482
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceUninstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winRunCmd, RUN_KEY, launchdPlist, systemdUnit, parseVersion, cmpVersion };
@@ -365,7 +365,8 @@ function main() {
365
365
  // checkout's node_modules). Reuse the hook's helper (never copy it). If it is
366
366
  // unavailable, refuse to remove rather than risk traversal.
367
367
  try {
368
- require(path.join(__dirname, '..', '..', '.claude', 'hooks', 'worktree-create.js')).unlinkNodeModulesLinks(wt.path);
368
+ const unl = require(path.join(__dirname, '..', '..', '.claude', 'hooks', 'worktree-create.js')).unlinkNodeModulesLinks(wt.path);
369
+ if (unl.failed.length) throw new Error('link(s) survive unlink: ' + unl.failed.slice(0, 3).join(', '));
369
370
  } catch (err) {
370
371
  console.log(` FAILED (node_modules unlink unavailable, not removing): ${err.message.trim()}`);
371
372
  failedCount++;
@@ -195,7 +195,9 @@ async function main() {
195
195
  const missing = Object.entries({ relay: BIN.relay, relayOld: BIN.relayOld, server: BIN.server, serverOldSrc: BIN.serverOldSrc }).filter(([, p]) => !fs.existsSync(p));
196
196
  if (missing.length) { console.error(`missing binaries: ${missing.map(([k, p]) => `${k}=${p}`).join(', ')}`); return 2; }
197
197
  for (const p of Object.values(PORT)) if (!(await portFree(p))) { console.error(`port ${p} busy; aborting`); return 2; }
198
- TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-peer-e2e-'));
198
+ // TASK_2480: not under os.tmpdir() - the server refuses to register (and prunes) projects in the OS temp dir
199
+ fs.mkdirSync(path.join(REPO, '.tmp'), { recursive: true });
200
+ TMP = fs.mkdtempSync(path.join(REPO, '.tmp', 'kenaz-peer-e2e-'));
199
201
  const runId = path.basename(TMP);
200
202
  console.log(`peer-identity-e2e: temp dir ${TMP}${CONTROL ? ' (control run: wrong fingerprint in scenario 3)' : ''}`);
201
203
  const gitBefore = gitState();
@@ -34,8 +34,9 @@ function walk(dir, exts, out = []) {
34
34
  return out;
35
35
  }
36
36
 
37
- // plugins/kenaz/hooks/core-resolve.js is generated (gitignored) from .claude/hooks/core-resolve.js
38
- const isGenerated = (rel) => rel === 'plugins/kenaz/hooks/core-resolve.js';
37
+ // generated (gitignored) copies of scanned sources: core-resolve.js from .claude/hooks, packages/kenaz/vendor/ from
38
+ // dashboard/scripts by packages/kenaz/scripts/stage.js at pack time (a stale local copy must not fail the guard)
39
+ const isGenerated = (rel) => rel === 'plugins/kenaz/hooks/core-resolve.js' || rel.startsWith('packages/kenaz/vendor/');
39
40
 
40
41
  function matchParen(src, open) {
41
42
  let depth = 0, q = null;
@@ -95,3 +96,17 @@ test('Rust: no raw Command::new in production code outside common/process.rs', (
95
96
  console.log(`rust violations: ${bad.length}`);
96
97
  assert.deepStrictEqual(bad, []);
97
98
  });
99
+
100
+ // TASK_2478: no script-host autostart (antivirus flags schtasks ONLOGON + wscript + .vbs). The only allowed mentions
101
+ // are the migration cleanup of the old launcher files, marked `legacy-vbs-cleanup` on the same line.
102
+ test('Node: shipped JS neither runs wscript nor writes a .vbs (outside the marked migration cleanup)', () => {
103
+ const files = ['dashboard/scripts', 'packages/kenaz', 'plugins'].flatMap((d) => walk(d, ['.js', '.cjs', '.mjs'])).filter((f) => !isGenerated(f));
104
+ const bad = [];
105
+ for (const f of files) {
106
+ fs.readFileSync(path.join(ROOT, f), 'utf8').split('\n').forEach((ln, i) => {
107
+ if (/wscript|cscript|\.vbs\b/i.test(ln) && !ln.includes('legacy-vbs-cleanup')) bad.push(`${f}:${i + 1}`);
108
+ });
109
+ }
110
+ console.log(`script-host violations: ${bad.length}`);
111
+ assert.deepStrictEqual(bad, []);
112
+ });
@@ -60,6 +60,15 @@
60
60
  }
61
61
  ]
62
62
  },
63
+ {
64
+ "matcher": "Bash",
65
+ "hooks": [
66
+ {
67
+ "type": "command",
68
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" worktree-guard"
69
+ }
70
+ ]
71
+ },
63
72
  {
64
73
  "matcher": "Agent|Task",
65
74
  "hooks": [
@@ -35,6 +35,7 @@ const HOOKS = {
35
35
  'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
36
36
  'vendor-agent-guard': { kind: 'script', file: 'vendor-agent-guard.js', call: 'main', minMode: 'assist' },
37
37
  'peer-session-fill': { kind: 'script', file: 'peer-session-fill.js', call: 'main', minMode: 'assist' },
38
+ 'worktree-guard': { kind: 'inline', file: 'worktree-guard.js', minMode: 'assist' }, // TASK_2485: runs from the plugin itself, needs no core
38
39
  'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
39
40
  };
40
41
 
@@ -59,14 +60,14 @@ function startServer(dir) {
59
60
  const meta = JSON.parse(fs.readFileSync(path.join(home, '.kenaz', 'install.json'), 'utf8'));
60
61
  const bin = meta && meta.serverBin;
61
62
  if (!bin || !fs.existsSync(bin)) return false;
62
- const vbs = path.join(home, '.kenaz', 'kenaz-server.vbs');
63
63
  const core = coreDir(dir);
64
64
  const env = Object.assign({}, process.env, core ? { KENAZ_SOURCE_DIR: core } : {});
65
65
  const opts = { env, detached: true, stdio: 'ignore', windowsHide: true };
66
66
  const viaNode = /\.js$/.test(bin); // a script (tests) runs under node; the real server is a binary
67
67
  let child;
68
68
  if (process.platform === 'win32') {
69
- child = fs.existsSync(vbs) ? spawn('wscript.exe', [vbs], opts) : viaNode ? spawn(process.execPath, [bin], opts) : spawn(bin, [], opts);
69
+ const args = core ? ['--source-dir', core] : []; // TASK_2478: no console, core dir as an argument
70
+ child = viaNode ? spawn(process.execPath, [bin, ...args], opts) : spawn(bin, args, opts);
70
71
  } else {
71
72
  const log = path.join(home, '.kenaz', 'kenaz-server.log');
72
73
  const sq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
@@ -140,6 +141,11 @@ function main() {
140
141
  if (spec.kind === 'session-start') {
141
142
  return post(JSON.stringify({ hook_event_name: 'SessionStart', session_id: process.env.SESSION_ID || 'unknown', project_path: dir }), () => {});
142
143
  }
144
+ if (spec.kind === 'inline') {
145
+ const out = require('./' + spec.file).decide(JSON.parse(stdin || '{}'));
146
+ if (out) process.stdout.write(JSON.stringify(out));
147
+ return;
148
+ }
143
149
  run(spec, stdin, dir);
144
150
  };
145
151
  if (!spec.backend) return go();
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ // PreToolUse(Bash): deny `git worktree remove`, `rm -r`, `rmdir /s` and `Remove-Item -Recurse` aimed at a
3
+ // directory that contains a symlink/junction (any name, any depth). On Windows `git worktree remove --force`
4
+ // walks THROUGH junctions and empties their targets: field report 2026-10-20 lost node_modules, backend/.env
5
+ // and tracked files of the MAIN repo. Directories without links pass. Fails open on any error or timeout.
6
+ // Safe path: node <core>/.formation/tools/worktree-reclaim.js remove <dir> (unlinks every link first).
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const MAX_MS = 2500;
11
+
12
+ // Quote-aware split into commands (on && || ; | newline), each a token list.
13
+ function commands(line) {
14
+ const out = []; let tok = ''; let cur = []; let q = null;
15
+ const endTok = () => { if (tok) cur.push(tok); tok = ''; };
16
+ const endCmd = () => { endTok(); if (cur.length) out.push(cur); cur = []; };
17
+ for (let i = 0; i < line.length; i++) {
18
+ const c = line[i];
19
+ if (q) { if (c === q) q = null; else tok += c; continue; }
20
+ if (c === '"' || c === "'") { q = c; continue; }
21
+ if (c === '\\' && /[ "']/.test(line[i + 1] || '') && process.platform !== 'win32') { tok += line[++i]; continue; }
22
+ if (/\s/.test(c) && c !== '\n') { endTok(); continue; }
23
+ if (c === '\n' || c === ';' || c === '|' || c === '&') { endCmd(); continue; }
24
+ tok += c;
25
+ }
26
+ endCmd();
27
+ return out;
28
+ }
29
+
30
+ // Directory arguments of a removal command, or [] when the command is not a recursive removal.
31
+ function removalTargets(t) {
32
+ const base = (s) => s.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
33
+ const i = t.findIndex((x) => ['git', 'rm', 'rmdir', 'rd', 'remove-item', 'ri', 'del'].includes(base(x)));
34
+ if (i < 0) return [];
35
+ const name = base(t[i]); const a = t.slice(i + 1);
36
+ if (name === 'git') {
37
+ const k = a.indexOf('worktree');
38
+ return k >= 0 && a[k + 1] === 'remove' ? a.slice(k + 2).filter((x) => !x.startsWith('-')) : [];
39
+ }
40
+ if (name === 'rm') return a.some((x) => /^-[a-z]*[rR]/.test(x) || x === '--recursive') ? a.filter((x) => !x.startsWith('-')) : [];
41
+ if (name === 'rmdir' || name === 'rd') return a.some((x) => /^\/s$/i.test(x)) ? a.filter((x) => !x.startsWith('/')) : [];
42
+ // PowerShell: Remove-Item / ri / del with -Recurse
43
+ if (!a.some((x) => /^-r(ecurse)?$/i.test(x))) return [];
44
+ const out = []; const valued = /^-(path|literalpath|include|exclude|filter|credential)$/i;
45
+ for (let j = 0; j < a.length; j++) {
46
+ if (/^-(path|literalpath)$/i.test(a[j]) && a[j + 1]) { out.push(a[++j]); continue; }
47
+ if (valued.test(a[j])) { j++; continue; }
48
+ if (!a[j].startsWith('-')) out.push(a[j]);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function resolveDir(arg, cwd) {
54
+ let p = arg;
55
+ if (/[*?]/.test(p)) return null;
56
+ const m = /^\/([a-zA-Z])\/(.*)$/.exec(p); // Git Bash /c/Users/...
57
+ if (m && process.platform === 'win32') p = m[1].toUpperCase() + ':/' + m[2];
58
+ return path.resolve(cwd || process.cwd(), p);
59
+ }
60
+
61
+ // First symlink/junction found under dir (lstat walk, never follows), or null.
62
+ function findLink(dir, deadline) {
63
+ const stack = [dir];
64
+ while (stack.length) {
65
+ if (Date.now() > deadline) return null; // too big to prove: fail open
66
+ const d = stack.pop();
67
+ let names; try { names = fs.readdirSync(d); } catch { continue; }
68
+ for (const n of names) {
69
+ const p = path.join(d, n);
70
+ let st; try { st = fs.lstatSync(p); } catch { continue; }
71
+ if (st.isSymbolicLink()) return p;
72
+ if (st.isDirectory()) stack.push(p);
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function coreHint(cwd) {
79
+ try { return require('./core-resolve').resolveCoreDir(cwd) || '<kenaz core>'; } catch (_) { return '<kenaz core>'; }
80
+ }
81
+
82
+ function decide(input) {
83
+ if (!input || input.tool_name !== 'Bash') return null;
84
+ const command = String((input.tool_input && input.tool_input.command) || '');
85
+ if (!/worktree|rm\b|rmdir|\brd\b|remove-item|\bri\b|\bdel\b/i.test(command)) return null;
86
+ const deadline = Date.now() + MAX_MS;
87
+ const cmds = commands(command);
88
+ // `powershell -Command "Remove-Item ..."` / `bash -c 'rm -rf ..'`: re-parse quoted tokens that hold a whole command
89
+ for (const t of [...cmds]) for (const tok of t) if (/\s/.test(tok)) cmds.push(...commands(tok));
90
+ for (const t of cmds) {
91
+ for (const arg of removalTargets(t)) {
92
+ const dir = resolveDir(arg, input.cwd);
93
+ if (!dir) continue;
94
+ let st; try { st = fs.lstatSync(dir); } catch { continue; }
95
+ if (!st.isDirectory()) continue; // a link itself, a file, or missing: removing it is safe
96
+ const link = findLink(dir, deadline);
97
+ if (!link) continue;
98
+ return {
99
+ hookSpecificOutput: {
100
+ hookEventName: 'PreToolUse',
101
+ permissionDecision: 'deny',
102
+ permissionDecisionReason: `Blocked: ${dir} contains a symlink/junction (${path.relative(dir, link)}). ` +
103
+ '`git worktree remove --force` follows junctions on Windows and empties their targets in the MAIN repo (node_modules, backend/.env, tracked files). ' +
104
+ 'Use the safe remover, which unlinks every link first and refuses if one survives: ' +
105
+ `node "${coreHint(input.cwd)}/.formation/tools/worktree-reclaim.js" remove "${dir}"`,
106
+ },
107
+ };
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function main() {
114
+ try {
115
+ const out = decide(JSON.parse(fs.readFileSync(0, 'utf8') || '{}'));
116
+ if (out) process.stdout.write(JSON.stringify(out));
117
+ } catch (_) { /* fail open */ }
118
+ }
119
+
120
+ if (require.main === module) main();
121
+ module.exports = { decide, main, commands, removalTargets };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ // TASK_2485: the guard denies recursive removal of a dir that holds a junction/symlink, allows link-free dirs.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+ const { decide } = require('./worktree-guard');
10
+
11
+ function fixture() {
12
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wtguard-')));
13
+ const target = path.join(root, 'main'); fs.mkdirSync(target);
14
+ const withLink = path.join(root, 'wt-link'); fs.mkdirSync(path.join(withLink, 'a', 'b'), { recursive: true });
15
+ fs.symlinkSync(target, path.join(withLink, 'a', 'b', 'anything'), 'junction'); // not named node_modules, nested
16
+ const plain = path.join(root, 'wt-plain'); fs.mkdirSync(path.join(plain, 'a'), { recursive: true });
17
+ fs.writeFileSync(path.join(plain, 'a', 'f.txt'), 'x');
18
+ return { root, withLink, plain, target };
19
+ }
20
+ const bash = (command, cwd) => ({ tool_name: 'Bash', cwd, tool_input: { command } });
21
+ const denied = (r) => r && r.hookSpecificOutput.permissionDecision === 'deny';
22
+
23
+ test('denies git worktree remove / rm -rf / rmdir /s / Remove-Item -Recurse on a dir holding a junction', () => {
24
+ const f = fixture();
25
+ try {
26
+ const d = f.withLink; const fwd = d.replace(/\\/g, '/');
27
+ for (const cmd of [
28
+ `git worktree remove --force "${d}"`,
29
+ `git -C "${f.root}" worktree remove -f ${fwd}`,
30
+ `rm -rf "${fwd}"`,
31
+ `rm -fr ${fwd} && echo done`,
32
+ `cd ${f.root} && rm -r wt-link`,
33
+ `rmdir /s /q "${d}"`,
34
+ `cmd /c rmdir /s /q "${d}"`,
35
+ `Remove-Item -Recurse -Force "${d}"`,
36
+ `powershell -Command "Remove-Item -Path '${d}' -Recurse -Force"`,
37
+ ]) {
38
+ const r = decide(bash(cmd, f.root));
39
+ assert.ok(denied(r), `should deny: ${cmd}`);
40
+ assert.match(r.hookSpecificOutput.permissionDecisionReason, /worktree-reclaim\.js/);
41
+ }
42
+ } finally { fs.rmSync(f.root, { recursive: true, force: true }); }
43
+ });
44
+
45
+ test('same commands on a link-free dir, a missing dir, a link itself and unrelated commands pass (control)', () => {
46
+ const f = fixture();
47
+ try {
48
+ const p = f.plain;
49
+ for (const cmd of [
50
+ `git worktree remove --force "${p}"`, `rm -rf "${p.replace(/\\/g, '/')}"`, `rmdir /s /q "${p}"`, `Remove-Item -Recurse -Force "${p}"`,
51
+ `rm -rf ${path.join(f.root, 'nope')}`, `rm -f ${path.join(f.withLink, 'a', 'b', 'anything')}`, // a plain link/file removal is safe
52
+ `rm -rf ${f.withLink}/a/b/*`, // glob: cannot resolve, passes
53
+ `ls ${f.withLink}`, `git status`, `git worktree list`,
54
+ ]) {
55
+ assert.strictEqual(decide(bash(cmd, f.root)), null, `should pass: ${cmd}`);
56
+ }
57
+ assert.strictEqual(decide({ tool_name: 'Read', tool_input: { command: `rm -rf ${f.withLink}` } }), null);
58
+ } finally { fs.rmSync(f.root, { recursive: true, force: true }); }
59
+ });
60
+
61
+ test('through the plugin entry point: deny output for a junction dir, nothing for a plain dir; mode off disables', () => {
62
+ const f = fixture();
63
+ try {
64
+ const hook = path.join(__dirname, 'kenaz-hook.js');
65
+ const run = (cmd, env = {}) => spawnSync(process.execPath, [hook, 'worktree-guard'], {
66
+ cwd: f.root, encoding: 'utf8', input: JSON.stringify(bash(cmd, f.root)),
67
+ env: { ...process.env, CLAUDE_PROJECT_DIR: f.root, KENAZ_MODE: 'full', CLAUDE_CODE_ENTRYPOINT: 'cli', ...env },
68
+ });
69
+ const blocked = run(`rm -rf "${f.withLink}"`);
70
+ assert.strictEqual(blocked.status, 0);
71
+ assert.strictEqual(JSON.parse(blocked.stdout).hookSpecificOutput.permissionDecision, 'deny');
72
+ assert.strictEqual(run(`rm -rf "${f.plain}"`).stdout, '');
73
+ assert.strictEqual(run(`rm -rf "${f.withLink}"`, { KENAZ_MODE: 'off' }).stdout, '');
74
+ } finally { fs.rmSync(f.root, { recursive: true, force: true }); }
75
+ });
@@ -17,6 +17,7 @@ function setup(on: any, rows: () => unknown[], marker?: () => string | null) {
17
17
  const url = String(e.url)
18
18
  calls.push(`${e.init?.method ?? 'GET'} ${url}`)
19
19
  const reply = (o: unknown) => ({ value: { status: 200, ok: true, headers: {}, text: JSON.stringify(o) } })
20
+ if (url.includes('/api/health')) return healthOk ? reply({ status: 'ok' }) : { value: { status: 500, ok: false, headers: {}, text: '' } }
20
21
  if (url.includes('/pending')) return reply({ messages: delivered ? [] : [msg] })
21
22
  if (url.includes('/ack')) { delivered = true; return reply({ acked: ['m1'], already_delivered: [], unknown: [] }) }
22
23
  return reply({ ok: true })
@@ -54,7 +55,7 @@ test('heartbeat precedes submit, ack carries the submitted id, second cycle is e
54
55
  expect(calls.filter(c => c.includes('/ack')).length).toBe(1)
55
56
  })
56
57
 
57
- function uiSetup(on: any, rows: unknown[], statusOk = true) {
58
+ function uiSetup(on: any, rows: unknown[], statusOk = true, healthOk = true) {
58
59
  const status: unknown[] = []
59
60
  const toasts: string[] = []
60
61
  on('session.start', async (_$: any, e: any) => ({ cwd: e.cwd }))
@@ -69,6 +70,7 @@ function uiSetup(on: any, rows: unknown[], statusOk = true) {
69
70
  if (url.includes('/pending')) return reply({ messages: delivered ? [] : [{ id: 'm1', framed: 'hi', from_node: 'node-b' }] })
70
71
  if (url.includes('/ack')) { delivered = true; return reply({ acked: ['m1'] }) }
71
72
  if (url.includes('/status')) return statusOk ? reply({ pending_count: 2, sessions: [{ delivery_mode: 'push' }, { delivery_mode: 'none' }] }) : { value: { status: 500, ok: false, headers: {}, text: '' } }
73
+ if (url.includes('/api/health')) return healthOk ? reply({ status: 'ok' }) : { value: { status: 503, ok: false, headers: {}, text: '' } }
72
74
  return reply({ ok: true })
73
75
  })
74
76
  on('prompt.submit', async (_$: any, e: any) => e)
@@ -90,13 +92,23 @@ test('Ansuz session: status line from /api/peer/status and a toast naming the se
90
92
 
91
93
  test('Ansuz session with the server down shows kenaz-server down', async ($, on) => {
92
94
  const clock = mock.clock(on)
93
- const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false)
95
+ const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false, false)
94
96
  await $.session.start(START)
95
97
  await clock.advance(5_000)
96
98
  await clock.advance(5_000)
97
99
  expect(status.some(s => String(s).includes('kenaz-server down'))).toBe(true)
98
100
  })
99
101
 
102
+ test('TASK_2480: /status failing while /api/health is up shows peer status slow, not down', async ($, on) => {
103
+ const clock = mock.clock(on)
104
+ const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false, true)
105
+ await $.session.start(START)
106
+ await clock.advance(5_000)
107
+ await clock.advance(5_000)
108
+ expect(status.some(s => String(s).includes('peer status slow'))).toBe(true)
109
+ expect(status.some(s => String(s).includes('kenaz-server down'))).toBe(false)
110
+ })
111
+
100
112
  test('control: non-Ansuz session gets no status and no toast', async ($, on) => {
101
113
  const clock = mock.clock(on)
102
114
  const { status, toasts } = uiSetup(on, [{ role: 'user', content: 'hello' }])
@@ -132,7 +132,12 @@ async function refreshStatus($: any): Promise<void> {
132
132
  try {
133
133
  const root = await project($)
134
134
  const st = await jget($, `${BACKEND}/api/peer/status?projectPath=${encodeURIComponent(root)}&limit=1`)
135
- if (!st || typeof st !== 'object') { await $.ui.status('Kenaz: kenaz-server down'); return }
135
+ if (!st || typeof st !== 'object') {
136
+ // TASK_2480: a failed /status is not "down": only a failed health probe says the server is down.
137
+ const alive = await jget($, `${BACKEND}/api/health`)
138
+ await $.ui.status(alive ? 'Kenaz: peer status slow' : 'Kenaz: kenaz-server down')
139
+ return
140
+ }
136
141
  const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || root
137
142
  const sessions = Array.isArray(st.sessions) ? st.sessions.filter((s: any) => s?.delivery_mode !== 'none') : []
138
143
  const pending = Number(st.pending_count) || 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
5
5
  "bin": {
6
6
  "kenaz": "bin/kenaz.js"
@@ -20,11 +20,11 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.23",
24
- "@kenazai/server-darwin-x64": "0.3.23",
25
- "@kenazai/server-linux-arm64": "0.3.23",
26
- "@kenazai/server-linux-x64": "0.3.23",
27
- "@kenazai/server-win32-x64": "0.3.23"
23
+ "@kenazai/server-darwin-arm64": "0.3.24",
24
+ "@kenazai/server-darwin-x64": "0.3.24",
25
+ "@kenazai/server-linux-arm64": "0.3.24",
26
+ "@kenazai/server-linux-x64": "0.3.24",
27
+ "@kenazai/server-win32-x64": "0.3.24"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {
@@ -14,7 +14,7 @@ const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const http = require('http');
17
- const { spawnSync } = require('child_process');
17
+ const { spawnSync, spawn } = require('child_process');
18
18
 
19
19
  const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
20
20
  const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
@@ -22,6 +22,7 @@ const MIN_CLI = [2, 1, 287];
22
22
  const SEP = process.platform === 'win32' ? ';' : ':';
23
23
  const TASK_NAME = 'KenazServer';
24
24
  const LABEL = 'ai.kenaz.server';
25
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
25
26
  const BATTERY_PS = '$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName ' + TASK_NAME + ' -Settings $s';
26
27
  const linuxUser = () => { try { return os.userInfo().username; } catch (_) { return process.env.USER || ''; } };
27
28
 
@@ -115,9 +116,14 @@ function unregisterStatusline(file, dry) {
115
116
  // agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
116
117
  // once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
117
118
  const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
119
+ // Field report 2026-10-19: Kenaz never wrote hooks into the user's own ~/.claude/hooks, so a hook there is the user's.
120
+ const HOME_HOOKS = /(^|["'\s])(~|\$\{?HOME\}?|%USERPROFILE%)[\\/]\.claude[\\/]hooks[\\/]/i;
121
+ const slashLower = (s) => s.replace(/\\/g, '/').toLowerCase();
118
122
  /** @returns number of hook commands retired */
119
123
  function retireDashboardHooks(file, dry) {
120
124
  if (!fs.existsSync(file)) return 0;
125
+ const homeHooks = slashLower(path.join(path.dirname(file), 'hooks')) + '/';
126
+ const isDash = (c) => DASH_HOOK.test(c) && !HOME_HOOKS.test(c) && !slashLower(c).includes(homeHooks);
121
127
  const v = loadSettings(file);
122
128
  if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
123
129
  let n = 0;
@@ -126,7 +132,7 @@ function retireDashboardHooks(file, dry) {
126
132
  const groups = [];
127
133
  for (const g of v.hooks[ev]) {
128
134
  if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
129
- const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && DASH_HOOK.test(h.command)));
135
+ const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && isDash(h.command)));
130
136
  n += g.hooks.length - kept.length;
131
137
  if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
132
138
  }
@@ -146,12 +152,14 @@ function claudeVersion() {
146
152
  // ---- service definitions (pure; the OS calls are in service* below) ----
147
153
  function launcherPaths(home) {
148
154
  const d = path.join(home, '.kenaz');
149
- return { dir: d, vbs: path.join(d, 'kenaz-server.vbs'), log: path.join(d, 'kenaz-server.log') };
155
+ return { dir: d, log: path.join(d, 'kenaz-server.log') };
150
156
  }
151
157
  const q = (s) => '"' + s + '"';
152
- function winVbs(bin, core) { // hidden window, env for task tools; server binds loopback by default
153
- return 'Set sh = WScript.CreateObject("WScript.Shell")\r\nsh.Environment("Process")("KENAZ_SOURCE_DIR") = "' + core + '"\r\n' +
154
- 'sh.Run """' + bin + '""", 0, False\r\n';
158
+ // TASK_2478: kenaz-server is a no-console binary on Windows and takes the core dir as an argument (no launcher script).
159
+ function winRunCmd(bin, core) { return q(bin) + ' --source-dir ' + q(core); }
160
+ // TASK_2478 migration: the old script-host launchers (antivirus flagged them); removed on install and uninstall.
161
+ function legacyLaunchers(home, roaming) {
162
+ return [path.join(home, '.kenaz', 'kenaz-server.vbs'), path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs')]; // legacy-vbs-cleanup
155
163
  }
156
164
  // launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
157
165
  // nvm or /usr/local was not found and every MCP tool that runs a node script failed with
@@ -197,8 +205,7 @@ function servicePlan(home, bin, core, platform = process.platform, caps = { syst
197
205
  const L = launcherPaths(home);
198
206
  if (platform === 'win32') {
199
207
  const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
200
- const startup = path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs');
201
- return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
208
+ return { kind: 'windows', files: [], bin, core, runCmd: winRunCmd(bin, core), legacy: legacyLaunchers(home, roaming) };
202
209
  }
203
210
  if (platform === 'darwin') {
204
211
  const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
@@ -241,8 +248,7 @@ function restartHint(plan, uid = defaultUid()) {
241
248
  if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
242
249
  if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
243
250
  if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
244
- // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
245
- return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
251
+ return 'end kenaz-server.exe in Task Manager, then run: ' + plan.runCmd;
246
252
  }
247
253
  function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
248
254
  if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
@@ -297,12 +303,13 @@ async function verifyRuntime(plan, expected, log, opts = {}) {
297
303
  return 'unreachable';
298
304
  }
299
305
 
300
- function serviceInstall(plan, dry, log, run = sh, uid) {
306
+ function serviceInstall(plan, dry, log, run = sh, uid, spawnFn = spawn) {
301
307
  const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
302
308
  for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
303
309
  if (plan.kind === 'windows') {
304
- const tr = 'wscript.exe ' + q(plan.files[0][0]);
305
- log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + plan.fallbackFile + ')');
310
+ const tr = plan.runCmd;
311
+ log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + RUN_KEY + '\\' + TASK_NAME + ')');
312
+ removeLegacy(plan, dry, log);
306
313
  if (dry) return;
307
314
  const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
308
315
  if (r.status === 0) {
@@ -311,13 +318,13 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
311
318
  const b = sh('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', BATTERY_PS]);
312
319
  if (b.status !== 0) log('WARNING: could not set the task to run on battery (exit ' + b.status + '); on a laptop the server may not start unplugged. Run in PowerShell: ' + BATTERY_PS);
313
320
  sh('schtasks', ['/Run', '/TN', TASK_NAME]);
314
- } else { // ONLOGON tasks can need elevation: a Startup-folder launcher is the per-user equivalent
315
- fs.mkdirSync(path.dirname(plan.fallbackFile), { recursive: true });
316
- fs.copyFileSync(plan.files[0][0], plan.fallbackFile);
317
- sh('wscript.exe', [plan.files[0][0]]);
321
+ } else { // ONLOGON tasks can need elevation: an HKCU Run value is the per-user equivalent
322
+ sh('reg', ['add', RUN_KEY, '/v', TASK_NAME, '/t', 'REG_SZ', '/d', plan.runCmd, '/f']);
323
+ const c = spawnFn(plan.bin, ['--source-dir', plan.core], { detached: true, stdio: 'ignore', windowsHide: true });
324
+ c.on('error', () => {}); c.unref();
318
325
  // schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
319
- // missing admin rights for /SC ONLOGON. The Startup-folder launcher starts at logon the same way.
320
- log('schtasks needs administrator rights here (exit ' + r.status + '); installed the Startup-folder launcher instead (also starts at logon)');
326
+ // missing admin rights for /SC ONLOGON. The Run value starts the server at logon the same way.
327
+ log('schtasks needs administrator rights here (exit ' + r.status + '); registered the HKCU Run value instead (also starts at logon)');
321
328
  }
322
329
  } else if (plan.kind === 'launchd') {
323
330
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
@@ -340,11 +347,16 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
340
347
  serviceRestart(plan, dry, log, sh, uid);
341
348
  }
342
349
  }
350
+ function removeLegacy(plan, dry, log) {
351
+ for (const f of plan.legacy || []) if (fs.existsSync(f)) { log('remove ' + f + ' (old launcher)'); if (!dry) fs.rmSync(f, { force: true }); }
352
+ }
343
353
  function serviceUninstall(plan, dry, log, run = sh) {
344
354
  if (plan.kind === 'windows') {
345
355
  log('schtasks /End + /Delete /TN ' + TASK_NAME);
346
356
  if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
347
- if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
357
+ log('reg delete ' + RUN_KEY + ' /v ' + TASK_NAME);
358
+ if (!dry) run('reg', ['delete', RUN_KEY, '/v', TASK_NAME, '/f']); // not-found is fine
359
+ removeLegacy(plan, dry, log);
348
360
  } else if (plan.kind === 'launchd') {
349
361
  log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
350
362
  } else if (plan.kind === 'cron' || plan.kind === 'manual') {
@@ -358,7 +370,7 @@ function serviceUninstall(plan, dry, log, run = sh) {
358
370
  function serviceStatus(plan, run = sh) {
359
371
  if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
360
372
  if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
361
- if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
373
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0 ? 'Run key present' : 'not installed');
362
374
  if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
363
375
  return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
364
376
  }
@@ -366,10 +378,9 @@ function serviceStatus(plan, run = sh) {
366
378
  /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
367
379
  function detectServiceMode(home, core, run = sh, platform = process.platform) {
368
380
  if (platform === 'win32') {
369
- const plan = servicePlan(home, '', core, 'win32');
370
381
  if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
371
- if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
372
- return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
382
+ if (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0) return { mode: 'run key', detail: RUN_KEY + '\\' + TASK_NAME };
383
+ return { mode: 'manual', detail: 'no scheduled task or Run key; run `kenaz install`' };
373
384
  }
374
385
  if (platform === 'darwin') {
375
386
  const p = servicePlan(home, '', core, 'darwin').unit;
@@ -468,4 +479,4 @@ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) {
468
479
  }
469
480
 
470
481
  if (require.main === module) process.exit(main(process.argv.slice(2)));
471
- module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
482
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceUninstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winRunCmd, RUN_KEY, launchdPlist, systemdUnit, parseVersion, cmpVersion };