@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 +28 -0
- package/package.json +1 -1
- package/src/cleanup.mjs +3 -1
- package/src/codex-capability-preflight.lib.mjs +144 -15
- package/src/codex.lib.mjs +4 -4
- package/src/error-formatting.lib.mjs +54 -0
- package/src/fix.mjs +5 -2
- package/src/use-m-bootstrap.lib.mjs +13 -2
- package/src/use-with-retry.lib.mjs +122 -5
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
package/src/cleanup.mjs
CHANGED
|
@@ -438,6 +438,8 @@ async function main() {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
main().catch(async error => {
|
|
441
|
-
|
|
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(', ')}. ` + `
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
};
|