@link-assistant/hive-mind 2.8.9 → 2.8.11

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
@@ -1,5 +1,33 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.8.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 55a318d: Keep locally provisioned `openai-curated` plugins visible when Codex's authenticated remote plugin catalog is enabled. The repository-scoped `remote_plugin` override is written in place for every TOML spelling of the setting, so an operator config that uses a dotted key or an inline table cannot produce a duplicate key that Codex refuses to load.
8
+
9
+ ## 2.8.10
10
+
11
+ ### Patch Changes
12
+
13
+ - b93ed64: fix(2092): make every `use-m` call site self-healing
14
+
15
+ `/fix --ci-cd` crashed on `await use('command-stream')` — once on a truncated
16
+ global install, once on a failed `npm install -g`. The existing corrupt-install
17
+ recovery was wired into 3 of 100 `use(...)` call sites, so the ~40 top-level
18
+ `command-stream` loads were unprotected.
19
+
20
+ - `ensureUseM()` now returns a retry-wrapped `use`, so every call site inherits
21
+ the recovery (idempotent, no per-call-site edits).
22
+ - New retry mode for `Failed to install <pkg> globally into '<dir>'`, with
23
+ exponential backoff.
24
+ - Cleanup deletes the whole `<pkg>-v-<version>` alias directory instead of the
25
+ entry file's parent directory.
26
+ - Retries bust Node's ESM cache, which otherwise replays the original
27
+ `SyntaxError` even after a healthy reinstall.
28
+ - `formatFatalError` restores cause chains (and stacks under `HIVE_MIND_VERBOSE`)
29
+ in `fix.mjs`/`cleanup.mjs`; `HIVE_MIND_USE_M_DEBUG=1` logs each loader attempt.
30
+
3
31
  ## 2.8.9
4
32
 
5
33
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.9",
3
+ "version": "2.8.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/cleanup.mjs CHANGED
@@ -438,6 +438,8 @@ async function main() {
438
438
  }
439
439
 
440
440
  main().catch(async error => {
441
- await log(`❌ Error: ${error.message}`, { level: 'error' });
441
+ // Issue #2092: keep the cause chain so use-m load failures stay diagnosable.
442
+ const { formatFatalError } = await import('./error-formatting.lib.mjs');
443
+ await log(formatFatalError(error), { level: 'error' });
442
444
  process.exit(1);
443
445
  });
@@ -219,7 +219,7 @@ const checkModelVisibleSkills = async ({ command, env, runCommand, log, required
219
219
  return { status: missing.length === 0 ? 'satisfied' : 'missing', visible, missing };
220
220
  };
221
221
 
222
- const skillVisibilityError = ({ missing, visible, requirements, repairs = [] }) => new CodexCapabilityPreflightError(`Codex reports the required plugins as installed, but the model cannot see: ${missing.join(', ')}. ` + `Codex exposes a plugin's skills only while its payload is materialized under ` + `CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills. ` + `Visible skills were: ${visible ? [...visible].sort().join(', ') || 'none' : 'unknown'}.` + (repairs.length > 0 ? ` Attempted repairs: ${repairs.join(', ')}.` : ''), { missing, failClosed: missing.some(skill => isExplicitRequirement(requirements, skill)) });
222
+ const skillVisibilityError = ({ missing, visible, requirements, repairs = [] }) => new CodexCapabilityPreflightError(`Codex reports the required plugins as installed, but the model cannot see: ${missing.join(', ')}. ` + `A plugin must survive Codex loader reconciliation and have a valid payload under ` + `CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>/skills before its skills are exposed. ` + `Visible skills were: ${visible ? [...visible].sort().join(', ') || 'none' : 'unknown'}.` + (repairs.length > 0 ? ` Attempted repairs: ${repairs.join(', ')}.` : ''), { missing, failClosed: missing.some(skill => isExplicitRequirement(requirements, skill)) });
223
223
 
224
224
  const verifyModelVisibleSkills = async ({ command, env, runCommand, log, requiredSkills, requirements }) => {
225
225
  const outcome = await checkModelVisibleSkills({ command, env, runCommand, log, requiredSkills });
@@ -316,10 +316,10 @@ export async function resolveRequiredPlugins(options) {
316
316
  return (await resolveRequiredCapabilities(options)).plugins;
317
317
  }
318
318
 
319
- const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand }) => {
320
- const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env: process.env });
319
+ const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand, env }) => {
320
+ const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env });
321
321
  const issue = parseJsonCommand(issueResult, 'Codex capability issue discovery');
322
- const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env: process.env });
322
+ const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env });
323
323
  const comments = parseJsonCommand(commentsResult, 'Codex capability comment discovery');
324
324
  return [issue?.title, issue?.body, ...(Array.isArray(comments) ? comments.map(comment => comment?.body) : [])].filter(Boolean).join('\n');
325
325
  };
@@ -358,6 +358,128 @@ const readIfPresent = async filePath => {
358
358
  }
359
359
  };
360
360
 
361
+ const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
362
+
363
+ // A line inside a multi-line string is data, not structure: `[plugins."x"]`
364
+ // written inside `"""…"""` is a string, and treating it as a table header would
365
+ // splice a key into the operator's value.
366
+ const markStructuralLines = lines => {
367
+ const structural = [];
368
+ let openDelimiter = null;
369
+ for (const line of lines) {
370
+ structural.push(openDelimiter === null);
371
+ let rest = line;
372
+ for (;;) {
373
+ if (openDelimiter) {
374
+ const closeIndex = rest.indexOf(openDelimiter);
375
+ if (closeIndex === -1) break;
376
+ rest = rest.slice(closeIndex + openDelimiter.length);
377
+ openDelimiter = null;
378
+ continue;
379
+ }
380
+ const opened = /"""|'''/u.exec(rest);
381
+ if (!opened) break;
382
+ openDelimiter = opened[0];
383
+ rest = rest.slice(opened.index + opened[0].length);
384
+ }
385
+ }
386
+ return structural;
387
+ };
388
+
389
+ // TOML accepts the same setting as a header table, a dotted key, or an inline
390
+ // table. Appending `[features]` next to any of the other two is a duplicate-key
391
+ // document that Codex refuses to load, which would break every scoped
392
+ // invocation for the repository — so each spelling is edited in place.
393
+ export const setTomlTableBoolean = ({ config, table, key, value }) => {
394
+ const lines = String(config || '')
395
+ .replace(/\r\n/gu, '\n')
396
+ .split('\n');
397
+ const structural = markStructuralLines(lines);
398
+ const [escapedTable, escapedKey] = [escapeRegExp(table), escapeRegExp(key)];
399
+ const quoted = name => `(?:${name}|"${name}"|'${name}')`;
400
+ const tableHeaderPattern = new RegExp(`^\\s*\\[\\s*${quoted(escapedTable)}\\s*\\]\\s*(?:#.*)?$`, 'u');
401
+ const anyTableHeaderPattern = /^\s*\[\[?[^\]]*\]\]?\s*(?:#.*)?$/u;
402
+ // The value is everything before an optional trailing comment, so a comment
403
+ // an operator attached to the setting survives the rewrite.
404
+ const assignmentPattern = name => new RegExp(`^(\\s*${name}\\s*=\\s*)([^#]*?)(\\s*(?:#.*)?)$`, 'u');
405
+ const keyPattern = assignmentPattern(quoted(escapedKey));
406
+ const dottedPattern = assignmentPattern(`${quoted(escapedTable)}\\s*\\.\\s*${quoted(escapedKey)}`);
407
+ const inlineTablePattern = new RegExp(`^(\\s*${quoted(escapedTable)}\\s*=\\s*\\{)([^}]*)(\\}\\s*(?:#.*)?)$`, 'u');
408
+ const finish = () => `${lines.join('\n').trimEnd()}\n`;
409
+ const isStructural = index => structural[index];
410
+
411
+ const tableStart = lines.findIndex((line, index) => isStructural(index) && tableHeaderPattern.test(line));
412
+ if (tableStart !== -1) {
413
+ let tableEnd = lines.length;
414
+ for (let index = tableStart + 1; index < lines.length; index++) {
415
+ if (isStructural(index) && anyTableHeaderPattern.test(lines[index])) {
416
+ tableEnd = index;
417
+ break;
418
+ }
419
+ }
420
+ for (let index = tableStart + 1; index < tableEnd; index++) {
421
+ // Any existing value is replaced, not only a boolean literal: leaving a
422
+ // stale `remote_plugin = 1` behind and appending a second assignment is
423
+ // also a duplicate key.
424
+ if (isStructural(index) && keyPattern.test(lines[index])) {
425
+ lines[index] = lines[index].replace(keyPattern, `$1${value}$3`);
426
+ return finish();
427
+ }
428
+ }
429
+ // Insert after the table's last content line so the key is not separated
430
+ // from its own table by the blank lines that precede the next one.
431
+ let insertAt = tableEnd;
432
+ while (insertAt > tableStart + 1 && lines[insertAt - 1].trim() === '') insertAt--;
433
+ lines.splice(insertAt, 0, `${key} = ${value}`);
434
+ return finish();
435
+ }
436
+
437
+ // Outside any table header the operator may have used a dotted key or an
438
+ // inline table for the same setting.
439
+ const rootEnd = lines.findIndex((line, index) => isStructural(index) && anyTableHeaderPattern.test(line));
440
+ const rootLimit = rootEnd === -1 ? lines.length : rootEnd;
441
+ for (let index = 0; index < rootLimit; index++) {
442
+ if (!isStructural(index)) continue;
443
+ if (dottedPattern.test(lines[index])) {
444
+ lines[index] = lines[index].replace(dottedPattern, `$1${value}$3`);
445
+ return finish();
446
+ }
447
+ const inlineTable = inlineTablePattern.exec(lines[index]);
448
+ if (inlineTable) {
449
+ const [, prefix, contents, suffix] = inlineTable;
450
+ // The trailing separator is captured so the operator's spacing inside the
451
+ // braces survives the rewrite.
452
+ const inlineKeyPattern = new RegExp(`(^|,)(\\s*${quoted(escapedKey)}\\s*=\\s*)[^,]*?(\\s*)(,|$)`, 'u');
453
+ const nextContents = inlineKeyPattern.test(contents) ? contents.replace(inlineKeyPattern, `$1$2${value}$3$4`) : `${contents.trim() ? `${contents.trimEnd()}, ` : ' '}${key} = ${value} `;
454
+ lines[index] = `${prefix}${nextContents}${suffix}`;
455
+ return finish();
456
+ }
457
+ }
458
+
459
+ while (lines.length > 0 && lines.at(-1) === '') lines.pop();
460
+ if (lines.length > 0) lines.push('');
461
+ lines.push(`[${table}]`, `${key} = ${value}`, '');
462
+ return lines.join('\n');
463
+ };
464
+
465
+ // Codex's authenticated remote global catalog owns the reserved
466
+ // `openai-curated` marketplace. In codex-rs 0.144.6 the prompt loader removes
467
+ // every locally configured plugin from that marketplace before merging remote
468
+ // installations. Hive provisions the local marketplace payload, so the scoped
469
+ // home must select that local catalog or plugin list and prompt assembly report
470
+ // contradictory states (issue #2094). This override is deliberately scoped to
471
+ // runs that selected a local curated plugin; personal marketplaces retain the
472
+ // operator's remote catalog setting.
473
+ const configureScopedPluginLoader = async ({ codexHome, plugins, log }) => {
474
+ const localCurated = plugins.filter(plugin => pluginIdParts(plugin).marketplace === 'openai-curated');
475
+ if (localCurated.length === 0) return;
476
+ const configPath = path.join(codexHome, 'config.toml');
477
+ const config = await readIfPresent(configPath);
478
+ const nextConfig = setTomlTableBoolean({ config, table: 'features', key: 'remote_plugin', value: false });
479
+ if (nextConfig !== config) await fs.writeFile(configPath, nextConfig);
480
+ await log(` 🧭 Scoped Codex loader: remote_plugin=false for ${localCurated.join(', ')}; an authenticated remote catalog otherwise removes local @openai-curated entries before prompt assembly`, { verbose: true });
481
+ };
482
+
361
483
  // Runtime settings follow the operator config while plugin enablement remains
362
484
  // persistent and isolated to this repository.
363
485
  const syncScopedConfig = async ({ baseConfigPath, scopedConfigPath }) => {
@@ -464,13 +586,13 @@ export async function runCodexCapabilityPreflight(options = {}) {
464
586
  }
465
587
  }
466
588
 
467
- async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, baseCodexHome = process.env.HIVE_MIND_PARENT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
589
+ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
468
590
  if (!owner || !repo || !issueNumber) return { required: false, plugins: [], codexHome: null };
469
591
 
470
592
  // `executeToolWithBun` uses a shell expression for execution. Preflight uses
471
593
  // execFile and therefore selects the installed Codex binary directly.
472
594
  const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
473
- const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand });
595
+ const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand, env });
474
596
  const requirements = detectRequiredCodexCapabilities(requirementText);
475
597
  for (const { capability, line } of requirements.rejected || []) {
476
598
  await log(` ⏭️ Ignored non-capability token '${capability}' from: ${line.slice(0, 160)}`, { verbose: true });
@@ -481,8 +603,13 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
481
603
  for (const { capability, line } of requirements.evidence || []) {
482
604
  await log(` 🔎 '${capability}' detected from: ${line.slice(0, 160)}`, { verbose: true });
483
605
  }
484
- const baseEnv = { ...process.env, CODEX_HOME: baseCodexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
485
- const baseCatalogResult = await runCommand({ command, args: ['plugin', 'list', '--available', '--json'], env: baseEnv });
606
+ // Every Codex-side preflight operation runs from the checkout that the
607
+ // solver will enter. Besides matching `codex exec`, this keeps any future
608
+ // repository-local discovery deterministic without relying on Hive's launch
609
+ // directory (issue #2094).
610
+ const runCodexCommand = invocation => runCommand({ ...invocation, cwd: projectDir });
611
+ const baseEnv = { ...env, CODEX_HOME: baseCodexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
612
+ const baseCatalogResult = await runCodexCommand({ command, args: ['plugin', 'list', '--available', '--json'], env: baseEnv });
486
613
  const baseCatalog = parseJsonCommand(baseCatalogResult, 'Codex plugin catalog discovery');
487
614
  const skillDirectories = [path.join(os.homedir(), '.agents', 'skills'), projectDir && path.join(projectDir, '.agents', 'skills')].filter(Boolean);
488
615
  const { plugins, providers } = await resolveRequiredCapabilities({ requirements, catalog: baseCatalog, skillDirectories });
@@ -491,24 +618,25 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
491
618
  }
492
619
  if (plugins.length === 0) {
493
620
  await log(' ✅ Required Agent Skills are already available from standard skill directories');
494
- await verifyModelVisibleSkills({ command, env: baseEnv, runCommand, log, requiredSkills: requirements.skills, requirements });
621
+ await verifyModelVisibleSkills({ command, env: baseEnv, runCommand: runCodexCommand, log, requiredSkills: requirements.skills, requirements });
495
622
  return { required: true, plugins, skills: requirements.skills, codexHome: null, baseCodexHome };
496
623
  }
497
624
 
498
625
  const codexHome = buildCodexCapabilityStatePath({ baseCodexHome, owner, repo });
499
626
  await prepareScopedCodexHome({ baseCodexHome, codexHome });
500
- const scopedEnv = { ...process.env, CODEX_HOME: codexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
627
+ await configureScopedPluginLoader({ codexHome, plugins, log });
628
+ const scopedEnv = { ...env, CODEX_HOME: codexHome, HIVE_MIND_PARENT_CODEX_HOME: baseCodexHome };
501
629
 
502
630
  // Issue #2088: install *and repair*. Enablement recorded in the scoped
503
631
  // `config.toml` survives a container restart while the payload under
504
632
  // `plugins/cache` may not, and `codex plugin list` cannot tell those states
505
633
  // apart — so the payload itself is the thing that gets checked and rebuilt.
506
- const repair = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand, log, codexHome, baseCodexHome, plugins, providers });
634
+ const repair = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand: runCodexCommand, log, codexHome, baseCodexHome, plugins, providers });
507
635
  for (const entry of repair.report) {
508
636
  if (entry.healthy) await log(` ✅ Provisioned ${entry.pluginId} in repository-scoped Codex state`);
509
637
  }
510
638
 
511
- const verifyResult = await runCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
639
+ const verifyResult = await runCodexCommand({ command, args: ['plugin', 'list', '--json'], env: scopedEnv });
512
640
  const verifiedCatalog = parseJsonCommand(verifyResult, 'Codex capability verification');
513
641
  const verified = new Set((verifiedCatalog.installed || []).filter(plugin => plugin.installed && plugin.enabled).map(plugin => normalizePluginSelector(plugin.pluginId)));
514
642
  const unverified = plugins.filter(plugin => !verified.has(plugin));
@@ -525,14 +653,14 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
525
653
  // the model saw zero `superpowers:*` skills, so the run proceeded and then
526
654
  // stalled on the repository's mandatory preflight. Confirm the requirement
527
655
  // against the catalog the model actually receives.
528
- let visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand, log, requiredSkills: requirements.skills });
656
+ let visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand: runCodexCommand, log, requiredSkills: requirements.skills });
529
657
  if (visibility.status === 'missing') {
530
658
  // The payload looks materialized but the prompt disagrees: rebuild it from
531
659
  // scratch and re-probe before deciding (issue #2088).
532
660
  await log(` 🛠️ Model cannot see ${visibility.missing.join(', ')}; forcing a repository-scoped plugin payload rebuild`);
533
- const forced = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand, log, codexHome, baseCodexHome, plugins, providers, strategies: PLUGIN_PAYLOAD_REPAIRS.slice(1), force: true });
661
+ const forced = await repairScopedPluginPayloads({ command, env: scopedEnv, runCommand: runCodexCommand, log, codexHome, baseCodexHome, plugins, providers, strategies: PLUGIN_PAYLOAD_REPAIRS.slice(1), force: true });
534
662
  repair.applied.push(...forced.applied);
535
- visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand, log, requiredSkills: requirements.skills });
663
+ visibility = await checkModelVisibleSkills({ command, env: scopedEnv, runCommand: runCodexCommand, log, requiredSkills: requirements.skills });
536
664
  }
537
665
  if (visibility.status === 'missing') throw skillVisibilityError({ missing: visibility.missing, visible: visibility.visible, requirements, repairs: repair.applied });
538
666
  if (visibility.status === 'unknown' && repair.unhealthy.length > 0) {
@@ -556,4 +684,5 @@ export default {
556
684
  repairScopedPluginPayloads,
557
685
  resolveRequiredCapabilities,
558
686
  runCodexCapabilityPreflight,
687
+ setTomlTableBoolean,
559
688
  };
package/src/codex.lib.mjs CHANGED
@@ -744,8 +744,8 @@ export const executeCodex = async params => {
744
744
  // Issue #1877: deploy the experimental HANDOFF.md Agent Skill so Codex loads
745
745
  // it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
746
746
  await deployHandoffSkill({ tempDir, argv, log, $ });
747
-
748
- const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log });
747
+ const codexBaseEnv = getCodexExecEnv(argv.verbose);
748
+ const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv });
749
749
  // Execute the Codex command
750
750
  return await executeCodexCommand({
751
751
  tempDir,
@@ -763,7 +763,7 @@ export const executeCodex = async params => {
763
763
  owner,
764
764
  repo,
765
765
  prNumber,
766
- capabilityPreflight,
766
+ capabilityPreflight: { ...capabilityPreflight, codexBaseEnv },
767
767
  });
768
768
  };
769
769
 
@@ -809,7 +809,7 @@ export const executeCodexCommand = async params => {
809
809
  const mappedModel = mapModelToId(argv.model);
810
810
  const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
811
811
  const isResumeMode = !!argv.resume;
812
- const codexEnv = applyCodexCapabilityEnv(getCodexExecEnv(argv.verbose), {
812
+ const codexEnv = applyCodexCapabilityEnv(capabilityPreflight?.codexBaseEnv || getCodexExecEnv(argv.verbose), {
813
813
  codexHome: capabilityPreflight?.codexHome,
814
814
  baseCodexHome: capabilityPreflight?.baseCodexHome,
815
815
  });
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared fatal-error formatting (issue #2092).
5
+ *
6
+ * The failing `/fix --ci-cd` runs printed exactly one line:
7
+ *
8
+ * ❌ Failed to import module from '/home/box/.../command-stream-v-latest/src/$.mjs'.
9
+ *
10
+ * because the entry points did `console.error(\`❌ ${error.message}\`)`. Everything
11
+ * that would have identified the problem — the `SyntaxError` in `error.cause`,
12
+ * the stack showing which module triggered the load — was discarded, so the
13
+ * first investigation had to guess. This helper keeps the one-line summary but
14
+ * appends the cause chain, and the full stacks when verbose output is enabled.
15
+ */
16
+
17
+ const MAX_CAUSE_DEPTH = 5;
18
+
19
+ /**
20
+ * @param {unknown} error - the thrown value.
21
+ * @param {object} [options]
22
+ * @param {boolean} [options.verbose] - include stacks; defaults to the
23
+ * `HIVE_MIND_VERBOSE` / `VERBOSE` environment variables.
24
+ * @returns {string} a multi-line, human-readable rendering of the error.
25
+ */
26
+ export const formatFatalError = (error, options = {}) => {
27
+ const verbose = options.verbose ?? Boolean(process.env.HIVE_MIND_VERBOSE || process.env.VERBOSE);
28
+ const lines = [`❌ ${describe(error)}`];
29
+
30
+ let current = error?.cause;
31
+ for (let depth = 0; current && depth < MAX_CAUSE_DEPTH; depth++) {
32
+ lines.push(` Caused by: ${describe(current)}`);
33
+ if (verbose && typeof current?.stack === 'string') lines.push(indent(current.stack));
34
+ current = current?.cause;
35
+ }
36
+
37
+ if (verbose && typeof error?.stack === 'string') lines.push(indent(error.stack));
38
+ return lines.join('\n');
39
+ };
40
+
41
+ const describe = value => {
42
+ if (value === null || value === undefined) return String(value);
43
+ if (typeof value !== 'object') return String(value);
44
+ const name = value.name || value.constructor?.name || 'Error';
45
+ const message = typeof value.message === 'string' && value.message ? value.message : JSON.stringify(value);
46
+ const code = value.code ? ` (code: ${value.code})` : '';
47
+ return `${name}: ${message}${code}`;
48
+ };
49
+
50
+ const indent = text =>
51
+ String(text)
52
+ .split('\n')
53
+ .map(line => ` ${line}`)
54
+ .join('\n');
package/src/fix.mjs CHANGED
@@ -236,7 +236,10 @@ async function main() {
236
236
  });
237
237
  }
238
238
 
239
- main().catch(error => {
240
- console.error(`❌ ${error.message}`);
239
+ main().catch(async error => {
240
+ // Issue #2092: printing only error.message hid the SyntaxError cause of the
241
+ // use-m load failure, leaving the run log undiagnosable.
242
+ const { formatFatalError } = await import('./error-formatting.lib.mjs');
243
+ console.error(formatFatalError(error));
241
244
  process.exit(1);
242
245
  });
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { wrapUseWithRetry } from './use-with-retry.lib.mjs';
4
+
3
5
  export const USE_M_BOOTSTRAP_URL = 'https://unpkg.com/use-m/use.js';
4
6
  export const USE_M_BOOTSTRAP_FALLBACK_URL = 'https://unpkg.com/use-m@8.13.8/use.js';
5
7
 
@@ -41,12 +43,21 @@ const fallbackFetchUseMCode = () => fetchUseMCodeFromUrl(USE_M_BOOTSTRAP_FALLBAC
41
43
  export const ensureUseM = async (options = {}) => {
42
44
  const { fetchUseMCode = defaultFetchUseMCode, log = null } = options;
43
45
  if (typeof globalThis.use === 'undefined') {
46
+ let rawUse;
44
47
  try {
45
- globalThis.use = (await eval(await fetchUseMCode())).use;
48
+ rawUse = (await eval(await fetchUseMCode())).use;
46
49
  } catch (error) {
47
50
  if (typeof log === 'function') log(` use-m latest bootstrap failed (${error.message}); trying ${USE_M_BOOTSTRAP_FALLBACK_URL}`);
48
- globalThis.use = (await eval(await fallbackFetchUseMCode())).use;
51
+ rawUse = (await eval(await fallbackFetchUseMCode())).use;
49
52
  }
53
+ // Issue #2092: a truncated global `npm install -g <pkg>` makes use-m throw
54
+ // `Failed to import module from '<...>/command-stream-v-latest/src/$.mjs'.`
55
+ // Only a few call sites used useWithRetry explicitly; wrapping here means
56
+ // every `await use(...)` in the codebase recovers by deleting the corrupt
57
+ // install directory and re-fetching.
58
+ globalThis.use = wrapUseWithRetry(rawUse);
59
+ } else {
60
+ globalThis.use = wrapUseWithRetry(globalThis.use);
50
61
  }
51
62
  return globalThis.use;
52
63
  };
@@ -30,20 +30,57 @@
30
30
  * @param {number} [options.attempts=3] - total attempts including the first try.
31
31
  * @param {(path: string) => Promise<void>} [options.cleanup] - injectable cleanup
32
32
  * for the corrupted install directory (defaults to recursive `rm`).
33
+ * @param {(ms: number) => Promise<void>} [options.sleep] - injectable backoff used
34
+ * between attempts when the global `npm install -g` itself failed.
35
+ * @param {number} [options.backoffMs=1000] - base backoff, doubled per attempt.
36
+ * @param {(message: string) => void} [options.log] - diagnostics sink; defaults to
37
+ * `console.error` when `HIVE_MIND_USE_M_DEBUG` is set, otherwise silent.
33
38
  * @returns {Promise<unknown>} the module returned by use-m.
34
39
  */
35
40
  export const useWithRetry = async (use, specifier, options = {}) => {
36
41
  const attempts = options.attempts ?? 3;
37
42
  const cleanup = options.cleanup ?? defaultCleanup;
43
+ const sleep = options.sleep ?? defaultSleep;
44
+ const backoffMs = options.backoffMs ?? 1000;
45
+ const log = options.log ?? defaultLog;
46
+ const importModule = options.importModule ?? defaultImport;
47
+ const extraArgs = options.args ?? [];
38
48
  let lastError;
49
+ let cleanedImportPath = null;
39
50
  for (let attempt = 1; attempt <= attempts; attempt++) {
40
51
  try {
41
- return await use(specifier);
52
+ return await use(specifier, ...extraArgs);
42
53
  } catch (error) {
43
54
  lastError = error;
44
- if (attempt === attempts || !isCorruptInstallError(error)) {
55
+ // Node's ESM loader caches *failed* module evaluations by resolved URL.
56
+ // Once `<alias>/src/$.mjs` has thrown a SyntaxError, re-importing the very
57
+ // same path in this process replays that error even after the file on disk
58
+ // has been replaced by a healthy reinstall (verified against use-m@8.14.2 —
59
+ // see docs/case-studies/issue-2092). Deleting and reinstalling is therefore
60
+ // necessary but not sufficient: the retry must import through a
61
+ // cache-busting URL, which use-m has no way to do from the inside.
62
+ if (cleanedImportPath && extractCorruptedFilePath(error) === cleanedImportPath) {
63
+ try {
64
+ const recovered = await importModule(cleanedImportPath, attempt);
65
+ log(`use('${specifier}') recovered via a cache-busted import of ${cleanedImportPath}`);
66
+ return recovered;
67
+ } catch (reimportError) {
68
+ log(`cache-busted import of ${cleanedImportPath} also failed: ${reimportError?.message}`);
69
+ }
70
+ }
71
+ const retryable = isCorruptInstallError(error) || isTransientInstallError(error);
72
+ if (attempt === attempts || !retryable) {
73
+ log(`use('${specifier}') failed on attempt ${attempt}/${attempts} and will not be retried: ${error?.message}`);
45
74
  throw error;
46
75
  }
76
+ log(`use('${specifier}') failed on attempt ${attempt}/${attempts}: ${error?.message} — retrying`);
77
+ // Mode 4 (issue #2092): `npm install -g` itself failed (network blip,
78
+ // registry 5xx, DinD DNS not up yet). There is nothing to delete; just
79
+ // back off and let npm try again.
80
+ if (isTransientInstallError(error)) {
81
+ await sleep(backoffMs * 2 ** (attempt - 1));
82
+ continue;
83
+ }
47
84
  const corruptedPath = extractCorruptedFilePath(error);
48
85
  if (corruptedPath) {
49
86
  try {
@@ -53,9 +90,10 @@ export const useWithRetry = async (use, specifier, options = {}) => {
53
90
  // * "Failed to resolve the path to 'pkg' from '<dir>'" — corruptedPath
54
91
  // is the alias dir itself (e.g. /.../links-notation-v-latest).
55
92
  // For files, walk up to the alias dir; otherwise remove the dir as-is.
56
- const { dirname } = await import('node:path');
57
- const target = corruptedPath.endsWith('-v-latest') || /-v-\d/.test(corruptedPath) ? corruptedPath : dirname(corruptedPath);
58
- await cleanup(target);
93
+ await cleanup(resolveAliasDir(corruptedPath));
94
+ // Remember the file so the next attempt can bypass Node's poisoned
95
+ // module cache if use-m hands us the same path again.
96
+ cleanedImportPath = /Failed to import module from '/.test(error?.message ?? '') ? corruptedPath : null;
59
97
  } catch {
60
98
  // Best-effort cleanup; fall through to retry regardless.
61
99
  }
@@ -66,6 +104,20 @@ export const useWithRetry = async (use, specifier, options = {}) => {
66
104
  throw lastError;
67
105
  };
68
106
 
107
+ /**
108
+ * Mode 4 (issue #2092): use-m's own `npm install -g <pkg>` step failed, so no
109
+ * package tree exists yet — `Failed to install command-stream@latest globally
110
+ * into '/home/box/.nvm/.../node_modules'.` This is transient in Docker-in-Docker
111
+ * runs where the registry (or DNS) is briefly unreachable, so retry with backoff.
112
+ *
113
+ * @param {unknown} error
114
+ * @returns {boolean}
115
+ */
116
+ export const isTransientInstallError = error => {
117
+ const message = typeof error?.message === 'string' ? error.message : '';
118
+ return /^Failed to install .+ globally into /.test(message);
119
+ };
120
+
69
121
  export const isCorruptInstallError = error => {
70
122
  const cause = error?.cause;
71
123
  if (cause instanceof SyntaxError) return true;
@@ -101,7 +153,72 @@ export const extractCorruptedFilePath = error => {
101
153
  return invalidConfigMatch ? invalidConfigMatch[1] : null;
102
154
  };
103
155
 
156
+ /**
157
+ * Walk a corrupted path up to the use-m alias install directory.
158
+ *
159
+ * Issue #2092: the failing file can be nested several levels deep inside the
160
+ * package (`.../command-stream-v-latest/src/$.mjs`). Removing only its parent
161
+ * directory (`.../src`) leaves a half-package on disk whose package.json still
162
+ * resolves, so the retry re-imports the same broken tree. Walking up to the
163
+ * `<pkg>-v-<version>` alias segment removes the whole install instead.
164
+ *
165
+ * Falls back to the immediate parent directory when no alias segment is found.
166
+ *
167
+ * @param {string} corruptedPath - file or directory path from the error message.
168
+ * @returns {string} directory to delete before retrying.
169
+ */
170
+ export const resolveAliasDir = corruptedPath => {
171
+ const segments = corruptedPath.split('/');
172
+ const isAlias = segment => /-v-(latest|\d[^/]*)$/.test(segment);
173
+ for (let index = segments.length - 1; index >= 0; index--) {
174
+ if (isAlias(segments[index])) return segments.slice(0, index + 1).join('/');
175
+ }
176
+ return segments.slice(0, -1).join('/') || corruptedPath;
177
+ };
178
+
104
179
  const defaultCleanup = async path => {
105
180
  const { rm } = await import('node:fs/promises');
106
181
  await rm(path, { recursive: true, force: true });
107
182
  };
183
+
184
+ // Cache-busting import: a query string makes Node treat the URL as a distinct
185
+ // module, so the freshly reinstalled file is evaluated instead of the cached
186
+ // SyntaxError from the corrupt one.
187
+ const defaultImport = async (filePath, attempt) => {
188
+ const { pathToFileURL } = await import('node:url');
189
+ return import(`${pathToFileURL(filePath).href}?use-m-retry=${attempt}`);
190
+ };
191
+
192
+ const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
193
+
194
+ // Off by default so normal runs stay quiet; issue #2092 showed that when the
195
+ // loader dies there is no trace of which specifier or attempt failed.
196
+ const defaultLog = message => {
197
+ if (process.env.HIVE_MIND_USE_M_DEBUG) console.error(`[use-m] ${message}`);
198
+ };
199
+
200
+ const USE_RETRY_WRAPPED = Symbol.for('hive-mind.use-with-retry.wrapped');
201
+
202
+ /**
203
+ * Wrap a raw use-m `use` function so that *every* call site inherits the
204
+ * corrupt-install recovery above (issue #2092).
205
+ *
206
+ * Before this, only the handful of call sites that explicitly imported
207
+ * `useWithRetry` (config/queue-config/lino) were protected, while ~40 other
208
+ * modules called `await use('command-stream')` directly and crashed with
209
+ * `Failed to import module from '.../command-stream-v-latest/src/$.mjs'.`
210
+ * whenever the global npm install was truncated.
211
+ *
212
+ * The wrapper is idempotent: wrapping an already-wrapped function returns it
213
+ * unchanged, so repeated `ensureUseM()` calls don't nest retries.
214
+ *
215
+ * @param {Function} use - raw use-m loader.
216
+ * @param {object} [options] - forwarded to useWithRetry (attempts, cleanup).
217
+ * @returns {Function} retry-wrapped loader.
218
+ */
219
+ export const wrapUseWithRetry = (use, options = {}) => {
220
+ if (typeof use !== 'function' || use[USE_RETRY_WRAPPED]) return use;
221
+ const wrapped = (specifier, ...args) => useWithRetry(use, specifier, { ...options, args });
222
+ Object.defineProperty(wrapped, USE_RETRY_WRAPPED, { value: true });
223
+ return wrapped;
224
+ };