novahiz 0.8.1 → 0.8.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
3
3
  All notable changes to Novahiz are documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.8.2] - 2026-10-10
7
+
8
+ ### Fixed
9
+
10
+ - **In-place `npm install -g novahiz` now keeps the new package instead of
11
+ silently restoring the previous one** (`install/install.mjs`): the
12
+ `Finalize` step spawned `npm outdated` / `npm update` from inside npm's own
13
+ `postinstall` to refresh dependencies. npm exports its config to lifecycle
14
+ scripts (`npm_config_*`, `envExport` default true), so the inherited
15
+ `npm_config_global=true` from the outer `npm install -g` turned that refresh
16
+ into a **second reify of the same global prefix while npm's transaction was
17
+ still open**. Its retire of `node_modules\novahiz` fails with `EBUSY` (the
18
+ installer's cwd *is* the package directory), the nested npm crashes, and
19
+ arborist's rollback runs `rm(new)` + `rename(.novahiz-OLD → novahiz)` —
20
+ the previous install comes back byte-for-byte with its original mtimes
21
+ while the outer npm reports exit 0 / "changed 1 package". The refresh is now
22
+ pinned to the novahiz home (`npm_config_global=false`, `--ignore-scripts`),
23
+ a directory the outer transaction never touches. This was the actual cause
24
+ of "the in-place install does not replace": it reproduces with **no OpenCode
25
+ restart at all** (proved end-to-end 2026-10-10, see tests below).
26
+
27
+ - **The npm postinstall no longer writes the opencode config while npm is
28
+ still mid-transaction** (`install/install.mjs`, `install/lib.mjs`): a second,
29
+ independent failure mode — running `npm install -g novahiz` from an
30
+ OpenCode-owned shell restarted the OpenCode server during `postinstall`, and
31
+ the restart killed the process tree with npm still inside its reify
32
+ transaction. Depending on where the kill landed, the update either left
33
+ orphaned `.novahiz-*` retire directories and shims behind or aborted the
34
+ configuration pass. The installer now splits in two: the synchronous pass
35
+ only touches `NOVAHIZ_HOME` (the only zone never observed as the restart
36
+ trigger across three reproduced kills), while everything that writes the
37
+ OpenCode config — skills, plugin, agent, commands, `opencode.jsonc`,
38
+ provider skill packs — runs in a detached process that waits for the
39
+ installer and npm to exit first (`--deferred-opencode`, log:
40
+ `<novahiz-home>/install-deferred.log`). That child is spawned with
41
+ `cwd=novahiz-home` so it can never hold the package directory open (a
42
+ process whose cwd sits in `node_modules\novahiz` is what makes any npm
43
+ retire of it fail with `EBUSY` on Windows). Direct runs (`novahiz-install`,
44
+ CLI bootstrap) keep the historical fully synchronous behaviour, and
45
+ `--sync` forces that behaviour back under npm as an escape hatch.
46
+ Regression tests: `tests/install-defer.test.ts`.
47
+
6
48
  ## [Unreleased]
7
49
 
8
50
  ## [0.8.1] - 2026-10-10
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { cpSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import {
7
8
  copyFileWithBackup,
8
9
  copyInto,
9
10
  defaultConfig,
11
+ deferOpencodePhase,
10
12
  detectedHarnesses,
13
+ findNpmAncestorPid,
11
14
  loadManifest,
12
15
  mergeBackups,
13
16
  mergeCreated,
@@ -21,6 +24,7 @@ import {
21
24
  saveManifest,
22
25
  skillNamesIn,
23
26
  spawnHost,
27
+ waitForDeferredPhase,
24
28
  which,
25
29
  writeJson
26
30
  } from "./lib.mjs";
@@ -65,6 +69,10 @@ const CORE_ITEMS = [
65
69
  async function main() {
66
70
  const flags = parseArgs(process.argv.slice(2));
67
71
  const dryRun = Boolean(flags["dry-run"]);
72
+ // Phase opencode rejetee hors du cycle de vie npm (voir lib.mjs) : sous
73
+ // postinstall, le passage synchronise n'ecrit plus que dans NOVAHIZ_HOME —
74
+ // jamais dans le dossier de config opencode que le serveur surveille.
75
+ const deferOpencode = deferOpencodePhase(flags);
68
76
  const force = Boolean(flags.force);
69
77
  const withSkills = !flags["no-skills"];
70
78
  const root = repoRoot(import.meta.url);
@@ -95,6 +103,42 @@ async function main() {
95
103
  }
96
104
  }
97
105
 
106
+ // Phase differee, spawn des le debut du postinstall : l'enfant attendra la
107
+ // mort de ce postinstall puis de npm avant d'ecrire quoi que ce soit dans
108
+ // la config opencode. Detache tot (detached + unref) : un redemarrage
109
+ // opencode qui tuerait npm ne l'atteint plus — la configuration rejoint
110
+ // alors la nouvelle version au lieu de rester bloquee a l'ancienne.
111
+ const deferLogPath = deferOpencode && !dryRun ? join(home, "install-deferred.log") : null;
112
+ if (deferLogPath) {
113
+ mkdirSync(home, { recursive: true });
114
+ const npmAncestor = findNpmAncestorPid();
115
+ const logFd = openSync(deferLogPath, "a");
116
+ // argv[1] du parent = ce meme script : l'enfant doit le recevoir, sinon
117
+ // node interprete --yes comme une option sienne ("bad option: --yes").
118
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2), "--deferred-opencode"], {
119
+ // cwd = home, NOT the inherited package dir: a process whose cwd sits
120
+ // inside node_modules\novahiz makes any npm retire of that directory
121
+ // fail with EBUSY on Windows (that is what let the nested-update
122
+ // rollback restore the old package). The child lives for minutes, so
123
+ // it must never hold the package dir. Consequence: with an explicit
124
+ // `--scope project`, configDir resolves against home instead of the
125
+ // caller's cwd (postinstall never passes --scope).
126
+ cwd: home,
127
+ detached: true,
128
+ stdio: ["ignore", logFd, logFd],
129
+ env: {
130
+ ...process.env,
131
+ NOVAHIZ_DEFERRED: "1",
132
+ NOVAHIZ_WAIT_PARENT_PID: String(process.pid),
133
+ NOVAHIZ_WAIT_PID: npmAncestor === null ? "" : String(npmAncestor),
134
+ },
135
+ windowsHide: true,
136
+ });
137
+ child.unref();
138
+ closeSync(logFd);
139
+ note(`Opencode config phase deferred (npm still running) - log: ${deferLogPath}`);
140
+ }
141
+
98
142
  const harnessDirs = { opencode: configDir };
99
143
  const harnessPackages = { opencode: "opencode-ai" };
100
144
  const detected = detectedHarnesses(harnessDirs);
@@ -184,7 +228,7 @@ async function main() {
184
228
  }
185
229
  }
186
230
 
187
- if (!dryRun) {
231
+ if (!dryRun && !deferOpencode) {
188
232
  for (const name of configured) {
189
233
  if (!existsSync(harnessDirs[name])) mkdirSync(harnessDirs[name], { recursive: true });
190
234
  }
@@ -222,10 +266,14 @@ async function main() {
222
266
  }
223
267
 
224
268
  ui.step("Skills");
225
- if (withSkills && !configured.includes("opencode")) {
269
+ if (deferOpencode) {
270
+ note("Opencode skills copy deferred to the post-npm phase.");
271
+ ui.finishStep("skip", "deferred");
272
+ }
273
+ if (!deferOpencode && withSkills && !configured.includes("opencode")) {
226
274
  note("Skipping opencode skills copy (opencode not selected).");
227
275
  }
228
- if (withSkills && configured.includes("opencode")) {
276
+ if (!deferOpencode && withSkills && configured.includes("opencode")) {
229
277
  const skillsSource = existsSync(join(home, "skills")) ? join(home, "skills") : join(root, "skills");
230
278
  if (existsSync(skillsSource)) {
231
279
  // Dedup only against roots the catalog actually scans (see
@@ -272,10 +320,14 @@ async function main() {
272
320
  // du modele de config, une entree fichier y declenchait l'avertissement
273
321
  // "configured plugin path must be a directory" a chaque demarrage.
274
322
  ui.step("Plugin, agent & commands");
323
+ if (deferOpencode) {
324
+ note("Plugin, agent & commands deferred to the post-npm phase.");
325
+ ui.finishStep("skip", "deferred");
326
+ }
275
327
  for (const pluginFile of ["novahiz-plugin.ts", "novahiz-token-economy.ts"]) {
276
328
  const pluginSource = join(home, "adapters", "opencode", pluginFile);
277
329
  const pluginTarget = join(pluginsDir, pluginFile);
278
- if (configured.includes("opencode") && existsSync(pluginSource)) {
330
+ if (!deferOpencode && configured.includes("opencode") && existsSync(pluginSource)) {
279
331
  note(`Installing opencode plugin in ${pluginTarget}`);
280
332
  if (!dryRun) {
281
333
  const result = copyFileWithBackup(pluginSource, pluginTarget, true);
@@ -289,7 +341,7 @@ async function main() {
289
341
  ? join(home, "adapters", "opencode", "agent", "novahiz.md")
290
342
  : join(root, "adapters", "opencode", "agent", "novahiz.md");
291
343
  const agentTarget = join(configDir, "agent", "novahiz.md");
292
- if (configured.includes("opencode") && existsSync(agentSource)) {
344
+ if (!deferOpencode && configured.includes("opencode") && existsSync(agentSource)) {
293
345
  note(`Installing Novahiz agent in ${agentTarget}`);
294
346
  if (!dryRun) {
295
347
  const result = copyFileWithBackup(agentSource, agentTarget, true);
@@ -302,7 +354,7 @@ async function main() {
302
354
  ? join(home, "adapters", "opencode", "commands")
303
355
  : join(root, "adapters", "opencode", "commands");
304
356
  const commandsTarget = join(configDir, "commands");
305
- if (configured.includes("opencode") && existsSync(commandsSource)) {
357
+ if (!deferOpencode && configured.includes("opencode") && existsSync(commandsSource)) {
306
358
  note(`Installing Novahiz commands in ${commandsTarget}`);
307
359
  if (!dryRun) {
308
360
  const result = copyInto(commandsSource, commandsTarget, true);
@@ -373,7 +425,9 @@ async function main() {
373
425
  env: { ...process.env, NOVAHIZ_HOME: home }
374
426
  });
375
427
  if (check.stdout) ui.raw(check.stdout);
376
- if (autoInstall) {
428
+ if (autoInstall && deferOpencode) {
429
+ note("Dependency/provider install deferred to the post-npm phase (skills and commands write into the opencode config).");
430
+ } else if (autoInstall) {
377
431
  note("Installing dependencies and providers (MCP, skills, commands)");
378
432
  const result = spawnSync(process.execPath, [cli, "deps", "--install", "--yes"], {
379
433
  encoding: "utf8",
@@ -492,7 +546,9 @@ async function main() {
492
546
  { id: "flutter-skills", repo: "flutter/agent-plugins" },
493
547
  { id: "dart-skills", repo: "dart-lang/skills" },
494
548
  ];
495
- if (!dryRun && flags["flutter-skills"]) {
549
+ if (!dryRun && flags["flutter-skills"] && deferOpencode) {
550
+ note("\nFlutter/Dart skill packs deferred to the post-npm phase.");
551
+ } else if (!dryRun && flags["flutter-skills"]) {
496
552
  note("\nInstalling official Flutter/Dart skill packs...");
497
553
  for (const pack of skillPacks) {
498
554
  note(` ${pack.repo}...`);
@@ -540,7 +596,10 @@ async function main() {
540
596
  if (!configured.includes("opencode")) {
541
597
  note("Skipping opencode.jsonc (opencode not selected).");
542
598
  }
543
- if (!dryRun && configured.includes("opencode")) {
599
+ if (!dryRun && deferOpencode && configured.includes("opencode")) {
600
+ note("\nopencode.jsonc generation deferred to the post-npm phase.");
601
+ }
602
+ if (!dryRun && !deferOpencode && configured.includes("opencode")) {
544
603
  const configPath = join(configDir, "opencode.jsonc");
545
604
  if (!existsSync(configPath)) {
546
605
  note(`\nCreating ${configPath}`);
@@ -682,19 +741,35 @@ async function main() {
682
741
 
683
742
  ui.step("Finalize");
684
743
  if (!dryRun) {
685
- // Auto-update dependencies
744
+ // Auto-update dependencies (scoped to novahiz home, NEVER the global prefix).
745
+ //
746
+ // npm exports its config to lifecycle scripts (npm_config_*, envExport
747
+ // default true), so running under `npm install -g` leaks
748
+ // npm_config_global=true into this process. Without the explicit override
749
+ // below, `npm update` here silently becomes a SECOND reify of the global
750
+ // prefix while npm's own transaction is still open: its retire of
751
+ // node_modules\novahiz gets EBUSY (this process's cwd IS the package dir),
752
+ // the nested npm crashes, and arborist's rollback does
753
+ // rm(new) + rename(.novahiz-OLD -> novahiz) — the previous install comes
754
+ // back byte-for-byte with its original mtimes while the outer npm reports
755
+ // exit 0 / "changed 1 package". That is the "in-place install does not
756
+ // replace" bug. Forcing npm_config_global=false keeps this refresh in
757
+ // home (home/package.json), which shares no directory with the outer
758
+ // transaction. --ignore-scripts so nothing in home can re-enter the
759
+ // installer (home/package.json carries the postinstall script too).
686
760
  note("Checking for dependency updates...");
687
761
  const pkgPath = join(home, "package.json");
688
762
  if (existsSync(pkgPath)) {
763
+ const homeEnv = { ...process.env, NOVAHIZ_HOME: home, npm_config_global: "false" };
689
764
  const npmCheck = spawnHost("npm", ["outdated", "--json"], {
690
765
  cwd: home,
691
- env: { ...process.env, NOVAHIZ_HOME: home }
766
+ env: homeEnv
692
767
  });
693
768
  if (npmCheck.stdout && npmCheck.stdout.trim().length > 2) {
694
769
  note("Updates available, installing...");
695
- const npmUpdate = spawnHost("npm", ["update"], {
770
+ const npmUpdate = spawnHost("npm", ["update", "--ignore-scripts"], {
696
771
  cwd: home,
697
- env: { ...process.env, NOVAHIZ_HOME: home }
772
+ env: homeEnv
698
773
  });
699
774
  if (npmUpdate.stdout) ui.raw(npmUpdate.stdout);
700
775
  if (npmUpdate.status !== 0 && npmUpdate.stderr) process.stderr.write(npmUpdate.stderr);
@@ -704,6 +779,7 @@ async function main() {
704
779
  }
705
780
  }
706
781
  const summary = [`Novahiz installed in ${home}.`];
782
+ if (deferLogPath) summary.push(`opencode config phase: deferred - see ${deferLogPath} (runs once npm exits).`);
707
783
  if (configured.length > 0) summary.push("Restart opencode to activate the plugin and MCP server.");
708
784
  summary.push("Gate can be disabled with the NOVAHIZ_GATE=off environment variable.");
709
785
  summary.push("Update anytime: `novahiz upgrade` (npm) or `/novahiz-upgrade` inside OpenCode.");
@@ -716,6 +792,14 @@ async function main() {
716
792
  }
717
793
  }
718
794
 
795
+ // Entree de la phase differee (NOVAHIZ_DEFERRED=1) : on attend la mort du
796
+ // postinstall puis de npm avant le moindre passage complet — pendant ce
797
+ // temps, npm finalise sa transaction reify sans risque d'etre tue par un
798
+ // redemarrage opencode.
799
+ if (process.env.NOVAHIZ_DEFERRED === "1") {
800
+ await waitForDeferredPhase();
801
+ }
802
+
719
803
  main().catch((error) => {
720
804
  process.stderr.write(`${String(error)}\n`);
721
805
  // postinstall (`npm install novahiz`) runs --dry-run: an unexpected crash
@@ -0,0 +1,90 @@
1
+ // Declarations pour install/lib.mjs (module JS pur, consomme aussi par les
2
+ // tests TypeScript). Garde le contrat en phase avec lib.mjs — toute change de
3
+ // signature se reflete ici (typecheck = preuve que les deux coincident).
4
+ import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process";
5
+
6
+ export interface BackupEntry {
7
+ path: string;
8
+ backup: string;
9
+ }
10
+
11
+ export interface CopyFileResult {
12
+ created: string | null;
13
+ backup: BackupEntry | null;
14
+ }
15
+
16
+ export interface CopyIntoResult {
17
+ created: string[];
18
+ backups: BackupEntry[];
19
+ total: number;
20
+ }
21
+
22
+ export interface NovahizManifest {
23
+ version?: string;
24
+ installedAt?: string;
25
+ harness?: string;
26
+ harnesses?: string[];
27
+ configDir?: string;
28
+ home?: string;
29
+ coreCopied?: boolean;
30
+ configCreated?: boolean;
31
+ created?: string[];
32
+ backups?: BackupEntry[];
33
+ }
34
+
35
+ export interface NovahizConfig {
36
+ dbPath: string;
37
+ skillRoots: string[];
38
+ gate: { enabled: boolean; mode: string; envEscape: string; tools: string[] };
39
+ classify: { minScore: number; maxCategories: number; fallbackCategory: string };
40
+ providers: { autoRegister: boolean; autoInstall: boolean; disabled: string[] };
41
+ }
42
+
43
+ export interface DeferredWaitOptions {
44
+ /** Cadence de sonde entre deux verifications de vie (defaut 2000 ms). */
45
+ pollMs?: number;
46
+ /** Plafond inclusif par pid (defaut 10 min) : la fonction rend toujours la main. */
47
+ timeoutMs?: number;
48
+ /** Patience fixe quand npm n'a pas pu etre identifie (defaut 30000 ms). */
49
+ graceMs?: number;
50
+ /** Sonde de vie injectable pour les tests (defaut pidAlive). */
51
+ isAlive?: (pid: number) => boolean;
52
+ log?: (message: string) => void;
53
+ sleep?: (ms: number) => Promise<void>;
54
+ }
55
+
56
+ export declare function parseArgs(argv: string[]): Record<string, string | boolean>;
57
+ export declare function expandHome(value: string): string;
58
+ export declare function repoRoot(metaUrl: string): string;
59
+ export declare function opencodeConfigDir(env?: NodeJS.ProcessEnv): string;
60
+ export declare function NovahizHome(flags?: Record<string, unknown>, env?: NodeJS.ProcessEnv): string;
61
+ export declare function listFiles(root: string): string[];
62
+ export declare function skillNamesIn(roots: string[]): Set<string>;
63
+ export declare function copyFileWithBackup(srcPath: string, destPath: string, useBackup?: boolean): CopyFileResult;
64
+ export declare function copyInto(srcDir: string, destDir: string, useBackup?: boolean): CopyIntoResult;
65
+ export declare function readJson(path: string): unknown;
66
+ export declare function readJson<T>(path: string, fallback: T): T;
67
+ export declare function writeJson(path: string, value: unknown): void;
68
+ export declare function playwrightBrowserFlag(platform?: string): string;
69
+ export declare function defaultConfig(): NovahizConfig;
70
+ export declare function loadManifest(home: string): NovahizManifest;
71
+ export declare function unsafeHostToken(tokens: string[]): string | null;
72
+ export declare function spawnHost(cmd: string, args: string[], opts?: SpawnSyncOptions): SpawnSyncReturns<string>;
73
+ export declare function saveManifest(home: string, manifest: NovahizManifest): void;
74
+ export declare function mergeCreated(previous?: string[], next?: string[]): string[];
75
+ export declare function mergeBackups(previous?: BackupEntry[], next?: BackupEntry[]): BackupEntry[];
76
+ export declare function pruneEmptyDirs(paths: string[], stops?: string[]): void;
77
+ export declare function detectedHarnesses(dirs: Record<string, string>, whichFn?: (cmd: string) => boolean): string[];
78
+ export declare function which(cmd: string): boolean;
79
+ export declare function nodeVersionOk(minimum?: [number, number, number]): boolean;
80
+
81
+ // --- Phase opencode differee (postinstall npm) ------------------------------
82
+ export declare function deferOpencodePhase(flags?: Record<string, unknown>, env?: NodeJS.ProcessEnv): boolean;
83
+ export declare function parsePid(value: unknown): number | null;
84
+ export declare function pidAlive(pid: number): boolean;
85
+ export declare function findNpmAncestorPid(startPid?: number): number | null;
86
+ export declare function waitForPids(
87
+ pids: ReadonlyArray<number | null | undefined>,
88
+ opts?: DeferredWaitOptions,
89
+ ): Promise<void>;
90
+ export declare function waitForDeferredPhase(env?: NodeJS.ProcessEnv, opts?: DeferredWaitOptions): Promise<void>;
package/install/lib.mjs CHANGED
@@ -319,3 +319,150 @@ export function nodeVersionOk(minimum = [22, 18, 0]) {
319
319
  }
320
320
  return true;
321
321
  }
322
+
323
+ // --- Phase opencode differee (postinstall npm) ------------------------------
324
+ //
325
+ // Bug reproduit le 2026-10-10 (enquete npm, memoire slot-005) : quand
326
+ // install.mjs tourne comme script postinstall, ses ecritures dans le dossier
327
+ // de config opencode (skills, plugin, agent, commands, opencode.jsonc) font
328
+ // redemarrer le serveur opencode, qui tue alors son arbre de processus —
329
+ // npm compris, encore en pleine transaction reify. Le remplacement en place
330
+ // est interrompu : rollback vers l'ancien paquet (version et mtimes
331
+ // preserves a la milliseconde) ou dossiers .novahiz-* orphelins.
332
+ //
333
+ // Le correctif se pose sur deux piliers :
334
+ // 1. la phase opencode part en processus detache (detached + unref) qui
335
+ // attend la mort du postinstall puis de npm avant la moindre ecriture —
336
+ // un redemarrage opencode arrive alors que la transaction est finie ;
337
+ // 2. le passage synchronise ne touche plus qu'au NOVAHIZ_HOME, seule zone
338
+ // jamais observee comme declencheur lors des trois kills reproduits.
339
+
340
+ const deferDefaultLog = (message) => process.stdout.write(`${message}\n`);
341
+ const deferSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
342
+
343
+ /**
344
+ * La phase opencode doit-elle sortir du cycle de vie npm ?
345
+ *
346
+ * - `npm_lifecycle_event` present (postinstall) -> oui, sauf :
347
+ * - `--deferred-opencode` / `NOVAHIZ_DEFERRED=1` (le passage detache
348
+ * lui-meme) -> non : la phase deferree ne se defererait pas a l'infini ;
349
+ * - `--sync` (repli d'urgence vers l'ancien comportement) -> non ;
350
+ * - hors npm (novahiz-install direct, bootstrap CLI) -> non : le
351
+ * comportement historique entierement synchrone est conserve.
352
+ */
353
+ export function deferOpencodePhase(flags = {}, env = process.env) {
354
+ if (flags.sync === true) return false;
355
+ if (flags["deferred-opencode"] === true) return false;
356
+ if (env.NOVAHIZ_DEFERRED === "1") return false;
357
+ return Boolean(env.npm_lifecycle_event);
358
+ }
359
+
360
+ /** pid utilisable : entier strictement positif, sinon null. */
361
+ export function parsePid(value) {
362
+ const pid = Number.parseInt(String(value ?? ""), 10);
363
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
364
+ }
365
+
366
+ /** Le processus existe-t-il encore ? EPERM = oui (proprietaire different). */
367
+ export function pidAlive(pid) {
368
+ if (!Number.isInteger(pid) || pid <= 0) return false;
369
+ try {
370
+ process.kill(pid, 0);
371
+ return true;
372
+ } catch (error) {
373
+ return Boolean(error) && error.code === "EPERM";
374
+ }
375
+ }
376
+
377
+ /**
378
+ * PID du processus npm de l'installation en cours : on remonte la chaine des
379
+ * ParentProcessId jusqu'au premier ancetre dont la ligne de commande
380
+ * reference `npm-cli.js`. Le chemin de notre propre processus contient bien
381
+ * `\npm\` (paquet installe globalement dans Roaming\npm), jamais
382
+ * `npm-cli.js` : le filtre ne peut pas se retenir lui-meme.
383
+ * null = pas de npm au-dessus (ou plateforme non interrogeable) — l'appelant
384
+ * bascule alors sur une patience fixe.
385
+ */
386
+ export function findNpmAncestorPid(startPid = process.pid) {
387
+ if (process.platform === "win32") {
388
+ const script =
389
+ `$cur = Get-CimInstance Win32_Process -Filter "ProcessId=${startPid}"; ` +
390
+ "while ($cur) { " +
391
+ "if ($cur.CommandLine -and $cur.CommandLine -match 'npm-cli\\.js') { Write-Output $cur.ProcessId; break }; " +
392
+ "if (-not $cur.ParentProcessId -or $cur.ParentProcessId -eq $cur.ProcessId) { break }; " +
393
+ "$cur = Get-CimInstance Win32_Process -Filter \"ProcessId=$($cur.ParentProcessId)\" -ErrorAction SilentlyContinue; " +
394
+ "}";
395
+ const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8" });
396
+ if (result.status === 0) {
397
+ const pid = parsePid((result.stdout ?? "").trim());
398
+ if (pid !== null && pid !== startPid) return pid;
399
+ }
400
+ return null;
401
+ }
402
+ const result = spawnSync("ps", ["-o", "pid=", "-o", "ppid=", "-o", "command=", "-ax"], { encoding: "utf8" });
403
+ if (result.status !== 0) return null;
404
+ const rows = new Map();
405
+ for (const line of String(result.stdout ?? "").split("\n")) {
406
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+([\s\S]*)$/);
407
+ if (match) rows.set(Number.parseInt(match[1], 10), { ppid: Number.parseInt(match[2], 10), cmd: match[3] });
408
+ }
409
+ let current = startPid;
410
+ const seen = new Set();
411
+ while (current && rows.has(current) && !seen.has(current)) {
412
+ seen.add(current);
413
+ const row = rows.get(current);
414
+ if (current !== startPid && /npm-cli\.js/.test(row.cmd)) return current;
415
+ current = row.ppid;
416
+ }
417
+ return null;
418
+ }
419
+
420
+ /**
421
+ * Attend la mort de chaque pid (cadence pollMs, plafond timeoutMs par pid).
422
+ * Le plafond est inclusif : la fonction rend TOUJOURS la main — un pid
423
+ * rebelle ne doit jamais bloquer la phase differee indefiniment.
424
+ */
425
+ export async function waitForPids(pids, opts = {}) {
426
+ const pollMs = opts.pollMs ?? 2000;
427
+ const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
428
+ const isAlive = opts.isAlive ?? pidAlive;
429
+ const log = opts.log ?? deferDefaultLog;
430
+ const wait = opts.sleep ?? deferSleep;
431
+ for (const pid of pids) {
432
+ if (!Number.isInteger(pid) || pid <= 0) continue;
433
+ if (!isAlive(pid)) {
434
+ log(`novahiz defer: pid ${pid} already exited`);
435
+ continue;
436
+ }
437
+ const deadline = Date.now() + timeoutMs;
438
+ log(`novahiz defer: waiting for pid ${pid} to exit...`);
439
+ while (isAlive(pid)) {
440
+ if (Date.now() >= deadline) {
441
+ log(`novahiz defer: pid ${pid} still alive after ${timeoutMs}ms - proceeding anyway`);
442
+ break;
443
+ }
444
+ await wait(pollMs);
445
+ }
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Entree de la phase differee : attend que le postinstall (notre createur)
451
+ * ET npm soient sortis avant d'ecrire la moindre chose, puis rend la main.
452
+ * npm non identifie (chaine d'ancetres atypique, pnpm...) -> patience fixe :
453
+ * npm sort quelques secondes apres la fin du script de lifecycle.
454
+ */
455
+ export async function waitForDeferredPhase(env = process.env, opts = {}) {
456
+ const log = opts.log ?? deferDefaultLog;
457
+ const wait = opts.sleep ?? deferSleep;
458
+ const parentPid = parsePid(env.NOVAHIZ_WAIT_PARENT_PID);
459
+ const npmPid = parsePid(env.NOVAHIZ_WAIT_PID);
460
+ log(`novahiz defer: opencode phase detached - parent ${parentPid ?? "(none)"}, npm ${npmPid ?? "(unknown)"}`);
461
+ await waitForPids(parentPid === null ? [] : [parentPid], opts);
462
+ if (npmPid !== null) {
463
+ await waitForPids([npmPid], opts);
464
+ } else {
465
+ await wait(opts.graceMs ?? 30000);
466
+ }
467
+ log("novahiz defer: npm exited - running the full pass (opencode config included)");
468
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",