@link-assistant/hive-mind 2.9.0 → 2.9.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
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.9.2
4
+
5
+ ### Patch Changes
6
+
7
+ - a6d14db: Read the target repository's own agent instructions when deciding which Codex plugins and skills to provision. The capability preflight previously built its requirement corpus from the GitHub issue alone, so a repository that mandates a plugin in `AGENTS.md` — where the convention says to put it — provisioned nothing, and the model's own `request_plugin_install` attempt was rejected by Codex as an unrecognized `plugin_id`. Root and nested `AGENTS.md`, `CLAUDE.md` and `.codex/*.md` files under the checkout now feed the detector through a bounded walk that reports what it skipped, the zero-requirement path logs the sources it scanned instead of staying silent, a runtime plugin-install rejection is recognized and fails the run with a named diagnostic when the session produced no file changes, and `--require-codex-plugin` / `HIVE_MIND_CODEX_REQUIRED_PLUGINS` can state a requirement that no document spells out.
8
+
9
+ ## 2.9.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 3e48cb3: Resume Codex sessions after transient high-demand failures and preserve uncommitted work before uploading failure logs.
14
+
3
15
  ## 2.9.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
- const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
2
- const CLAUDE_MD_FILENAME = 'CLAUDE.md';
1
+ export const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
2
+ export const CLAUDE_MD_FILENAME = 'CLAUDE.md';
3
3
 
4
4
  const noopLog = async () => {};
5
5
  const fallbackFormatAligned = (_icon, label, value) => `${label} ${value}`;
@@ -14,16 +14,20 @@ import path from 'node:path';
14
14
  import { promisify } from 'node:util';
15
15
 
16
16
  import { CODEX_PLUGIN_CLI, buildPluginCachePath as buildAgentPluginCachePath, buildPluginPayloadRepairs, pluginIdParts, readMaterializedPluginSkills as readAgentMaterializedPluginSkills, repairPluginPayloads } from './agent-plugin-cache.lib.mjs';
17
+ import { AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME } from './agents-md-claude-support.lib.mjs';
17
18
 
18
19
  const execFileAsync = promisify(execFile);
19
- const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|use)\b/i;
20
+ const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|us(?:e|es|ing))\b/i;
20
21
  const NEGATED_REQUIREMENT = /\b(?:does\s+not\s+require|not\s+required|optional)\b/i;
21
22
  // `(?!\.[a-z])` keeps email addresses and hostnames (`ops@example.com`) out of
22
23
  // the plugin selector space.
23
24
  const PLUGIN_SELECTOR = /\b([a-z0-9][a-z0-9-]*@[a-z0-9][a-z0-9-]*(?:-remote)?)\b(?!\.[a-z])/gi;
24
25
  const NAMESPACED_SKILL = /\b([a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*)\b/gi;
25
26
  const EXPLICIT_BARE_SKILL = /\$([a-z0-9][a-z0-9-]*)|`([a-z0-9][a-z0-9-]*)`\s+(?:agent\s+)?skill/gi;
26
- const CAPABILITY_PREFIX = /\b(?:depend(?:s|ed)?\s+on|install|invoke|must\s+(?:install|invoke|use)|need(?:ed|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|require(?:d|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|use)\s+(?:(?:the|an?)\s+)?(?:(?:agent\s+)?skill\s+)?(?:named\s+)?[`$]?$/i;
27
+ // Issue #2102: `us(?:e|ing)` covers the imperative form (`Use \`ns:skill\``) and
28
+ // the participle a repository AGENTS.md uses for the same instruction
29
+ // (`Execute approved plans in an isolated worktree using \`ns:skill\``).
30
+ const CAPABILITY_PREFIX = /\b(?:depend(?:s|ed)?\s+on|install|invoke|must\s+(?:install|invoke|use)|need(?:ed|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|require(?:d|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|us(?:e|ing))\s+(?:(?:the|an?)\s+)?(?:(?:agent\s+)?skill\s+)?(?:named\s+)?[`$]?$/i;
27
31
  const CAPABILITY_SUFFIX = /^`?\s+(?:agent\s+)?(?:skill|capability)\b/i;
28
32
  const STRUCTURED_DATA_VALUES = new Set(['array', 'boolean', 'false', 'integer', 'null', 'number', 'object', 'string', 'true']);
29
33
 
@@ -49,6 +53,11 @@ const PROSE_TOKENS = new Set(['agent', 'caution', 'codex', 'default', 'error', '
49
53
 
50
54
  const isCapabilityToken = value => CAPABILITY_TOKEN.test(value) && !PROSE_TOKENS.has(value);
51
55
 
56
+ const skillParts = skill => {
57
+ const separator = skill.indexOf(':');
58
+ return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
59
+ };
60
+
52
61
  const hasExplicitCapabilityContext = (line, match) => {
53
62
  const before = line.slice(0, match.index);
54
63
  const after = line.slice(match.index + match[0].length);
@@ -89,7 +98,7 @@ export function normalizePluginSelector(selector) {
89
98
  .replace(/@openai-curated-remote$/u, '@openai-curated');
90
99
  }
91
100
 
92
- export function detectRequiredCodexCapabilities(text) {
101
+ export function detectRequiredCodexCapabilities(text, { source = null } = {}) {
93
102
  const plugins = new Set();
94
103
  const skills = new Set();
95
104
  // Issue #2088: a fully qualified reference — `plugin@marketplace` in plugin
@@ -106,12 +115,12 @@ export function detectRequiredCodexCapabilities(text) {
106
115
 
107
116
  const accept = (target, value, line, { qualified = false } = {}) => {
108
117
  if (!isCapabilityName(value)) {
109
- rejected.push({ capability: value, line });
118
+ rejected.push({ capability: value, line, source });
110
119
  return;
111
120
  }
112
121
  target.add(value);
113
122
  if (qualified) explicit.add(value);
114
- evidence.push({ capability: value, line, explicit: qualified });
123
+ evidence.push({ capability: value, line, explicit: qualified, source });
115
124
  };
116
125
 
117
126
  for (const rawLine of String(text || '').split(/\r?\n/u)) {
@@ -121,12 +130,22 @@ export function detectRequiredCodexCapabilities(text) {
121
130
  for (const match of line.matchAll(PLUGIN_SELECTOR)) {
122
131
  const selector = normalizePluginSelector(match[1]);
123
132
  if (hasExplicitPluginContext(line, match)) accept(plugins, selector, line, { qualified: true });
124
- else rejected.push({ capability: selector, line });
133
+ else rejected.push({ capability: selector, line, source });
125
134
  }
126
- for (const match of line.matchAll(NAMESPACED_SKILL)) {
127
- const skill = match[1].toLowerCase();
128
- if (hasExplicitCapabilityContext(line, match)) accept(skills, skill, line, { qualified: true });
129
- else rejected.push({ capability: skill, line });
135
+ // Issue #2102: a conjunction shares one requirement verb. `invoke `a:x` and
136
+ // `a:y`` only puts `a:x` in capability context, so a later reference to a
137
+ // namespace already qualified on the same line inherits the qualification
138
+ // instead of being dropped. Restricting inheritance to the same namespace,
139
+ // and still rejecting structured-data values, keeps issues #2077 and #2080
140
+ // from resurfacing: prose cannot produce a second `ns:name` token for a
141
+ // namespace that was just named in an explicit requirement.
142
+ const lineSkills = [...line.matchAll(NAMESPACED_SKILL)].map(match => ({ skill: match[1].toLowerCase(), qualified: hasExplicitCapabilityContext(line, match) }));
143
+ const qualifiedNamespaces = new Set(lineSkills.filter(entry => entry.qualified).map(entry => skillParts(entry.skill).namespace));
144
+ for (const entry of lineSkills) {
145
+ const { namespace, name } = skillParts(entry.skill);
146
+ const inherited = qualifiedNamespaces.has(namespace) && !STRUCTURED_DATA_VALUES.has(name);
147
+ if (entry.qualified || inherited) accept(skills, entry.skill, line, { qualified: true });
148
+ else rejected.push({ capability: entry.skill, line, source });
130
149
  }
131
150
  for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) accept(skills, (match[1] || match[2]).toLowerCase(), line);
132
151
  }
@@ -231,11 +250,6 @@ const verifyModelVisibleSkills = async ({ command, env, runCommand, log, require
231
250
  throw skillVisibilityError({ missing: outcome.missing, visible: outcome.visible, requirements });
232
251
  };
233
252
 
234
- const skillParts = skill => {
235
- const separator = skill.indexOf(':');
236
- return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
237
- };
238
-
239
253
  const pluginProvidesSkill = async (plugin, skill) => {
240
254
  const sourcePath = plugin?.source?.path;
241
255
  if (!sourcePath) return false;
@@ -316,14 +330,182 @@ export async function resolveRequiredPlugins(options) {
316
330
  return (await resolveRequiredCapabilities(options)).plugins;
317
331
  }
318
332
 
319
- const readIssueRequirementText = async ({ owner, repo, issueNumber, runCommand, env }) => {
333
+ const readIssueRequirementSegments = async ({ owner, repo, issueNumber, runCommand, env }) => {
320
334
  const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env });
321
335
  const issue = parseJsonCommand(issueResult, 'Codex capability issue discovery');
322
336
  const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env });
323
337
  const comments = parseJsonCommand(commentsResult, 'Codex capability comment discovery');
324
- return [issue?.title, issue?.body, ...(Array.isArray(comments) ? comments.map(comment => comment?.body) : [])].filter(Boolean).join('\n');
338
+ const bodies = (Array.isArray(comments) ? comments : []).map(comment => comment?.body).filter(Boolean);
339
+ return {
340
+ segments: [{ source: `issue #${issueNumber}`, text: [issue?.title, issue?.body].filter(Boolean).join('\n') }, ...bodies.map((text, index) => ({ source: `comment ${index + 1}`, text }))],
341
+ sources: [`issue #${issueNumber}`, ...(bodies.length > 0 ? [`${bodies.length} comment${bodies.length === 1 ? '' : 's'}`] : [])],
342
+ };
325
343
  };
326
344
 
345
+ // Issue #2102: the requirement corpus has to include the instructions the target
346
+ // repository gives the agent. CEHR2005/GCS-TS#5 delegated its entire mandatory
347
+ // workflow to `AGENTS.md` ("Follow the repository and nested engine `AGENTS.md`
348
+ // instructions"), so an issue-only corpus detected nothing, provisioning was
349
+ // skipped, and the model was left to call `request_plugin_install` — which
350
+ // `codex exec` can never satisfy.
351
+ //
352
+ // `CLAUDE.md` is scanned for parity with `--tool claude` (see
353
+ // `agents-md-claude-support.lib.mjs`, which presents `AGENTS.md` under that name)
354
+ // and `.codex/*.md` for repositories that keep Codex-specific rules there.
355
+ const AGENT_INSTRUCTION_FILENAMES = new Set([...AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME, 'claude.md']);
356
+ const CODEX_INSTRUCTION_DIRECTORY = '.codex';
357
+ // Vendored dependencies, build output and VCS internals carry instructions that
358
+ // belong to other projects; reading them would invent requirements the task
359
+ // never had.
360
+ const SKIPPED_INSTRUCTION_DIRECTORIES = new Set(['__pycache__', 'build', 'coverage', 'dist', 'node_modules', 'out', 'target', 'tmp', 'vendor', 'venv']);
361
+ const WALKED_HIDDEN_DIRECTORIES = new Set([CODEX_INSTRUCTION_DIRECTORY, '.github']);
362
+ const INSTRUCTION_WALK_LIMITS = Object.freeze({ maxDepth: 3, maxFiles: 24, maxBytes: 256 * 1024 });
363
+
364
+ const isInstructionFile = ({ name, directoryName }) => AGENT_INSTRUCTION_FILENAMES.has(name) || (directoryName === CODEX_INSTRUCTION_DIRECTORY && name.toLowerCase().endsWith('.md'));
365
+
366
+ /**
367
+ * Bounded walk over the checked-out repository's agent instruction files.
368
+ *
369
+ * Bounded on purpose: depth, file count and file size are all capped. A
370
+ * candidate dropped by the count or size cap is reported in `skipped`, so a
371
+ * truncated scan is visible in the log instead of looking like "nothing to
372
+ * find"; directories excluded by name or depth are not reported, because
373
+ * listing every skipped `node_modules` would bury that signal. Symlinked
374
+ * directories are not followed, which also makes the walk cycle-free.
375
+ */
376
+ export async function collectAgentInstructionFiles({ projectDir, maxDepth = INSTRUCTION_WALK_LIMITS.maxDepth, maxFiles = INSTRUCTION_WALK_LIMITS.maxFiles, maxBytes = INSTRUCTION_WALK_LIMITS.maxBytes } = {}) {
377
+ const files = [];
378
+ const skipped = [];
379
+ if (!projectDir) return { files, skipped };
380
+
381
+ const candidates = [];
382
+ const walk = async (directory, relative, depth) => {
383
+ let entries;
384
+ try {
385
+ entries = await fs.readdir(directory, { withFileTypes: true });
386
+ } catch {
387
+ return;
388
+ }
389
+ const directoryName = relative ? path.posix.basename(relative) : '';
390
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
391
+ const relativePath = relative ? `${relative}/${entry.name}` : entry.name;
392
+ if (entry.isDirectory()) {
393
+ if (depth >= maxDepth) continue;
394
+ if (SKIPPED_INSTRUCTION_DIRECTORIES.has(entry.name)) continue;
395
+ if (entry.name.startsWith('.') && !WALKED_HIDDEN_DIRECTORIES.has(entry.name)) continue;
396
+ await walk(path.join(directory, entry.name), relativePath, depth + 1);
397
+ } else if (entry.isFile() && isInstructionFile({ name: entry.name, directoryName })) {
398
+ candidates.push({ relativePath, absolutePath: path.join(directory, entry.name), depth });
399
+ }
400
+ }
401
+ };
402
+ await walk(projectDir, '', 0);
403
+
404
+ candidates.sort((left, right) => left.depth - right.depth || left.relativePath.localeCompare(right.relativePath));
405
+ for (const candidate of candidates) {
406
+ let stats;
407
+ try {
408
+ stats = await fs.stat(candidate.absolutePath);
409
+ } catch {
410
+ continue;
411
+ }
412
+ if (stats.size > maxBytes) {
413
+ skipped.push({ relativePath: candidate.relativePath, reason: 'too-large', bytes: stats.size });
414
+ continue;
415
+ }
416
+ if (files.length >= maxFiles) {
417
+ skipped.push({ relativePath: candidate.relativePath, reason: 'max-files' });
418
+ continue;
419
+ }
420
+ try {
421
+ files.push({ relativePath: candidate.relativePath, text: await fs.readFile(candidate.absolutePath, 'utf8'), bytes: stats.size });
422
+ } catch {
423
+ // An unreadable instruction file is not a requirement source.
424
+ }
425
+ }
426
+ return { files, skipped };
427
+ }
428
+
429
+ /**
430
+ * Issue #2102 escape hatch: `--require-codex-plugin` /
431
+ * `HIVE_MIND_CODEX_REQUIRED_PLUGINS` let an operator state a requirement that no
432
+ * text declares. A selector without a marketplace cannot be installed by
433
+ * `codex plugin add`, so it is reported rather than silently ignored.
434
+ */
435
+ export function parseRequiredCapabilityOverrides(value) {
436
+ const tokens = (Array.isArray(value) ? value : [value])
437
+ .flatMap(entry =>
438
+ String(entry ?? '')
439
+ .split(/[,;\s]+/u)
440
+ .map(token => token.trim())
441
+ )
442
+ .filter(Boolean);
443
+ const plugins = new Set();
444
+ const invalid = [];
445
+ for (const token of tokens) {
446
+ const selector = normalizePluginSelector(token);
447
+ if (selector.includes('@') && isCapabilityName(selector)) plugins.add(selector);
448
+ else invalid.push(token);
449
+ }
450
+ return { plugins: [...plugins].sort(), invalid };
451
+ }
452
+
453
+ /**
454
+ * Requirement corpus for the Codex capability preflight: the issue (title, body,
455
+ * comments), the checked-out repository's agent instruction files, and operator
456
+ * overrides. `sources` names everything that was scanned so a zero-requirement
457
+ * result is explainable from the log alone (issue #2102).
458
+ */
459
+ export async function collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand = defaultRunCommand, env = process.env, requiredPlugins, instructionLimits } = {}) {
460
+ const segments = [];
461
+ const sources = [];
462
+
463
+ if (owner && repo && issueNumber) {
464
+ const issue = await readIssueRequirementSegments({ owner, repo, issueNumber, runCommand, env });
465
+ segments.push(...issue.segments);
466
+ sources.push(...issue.sources);
467
+ }
468
+
469
+ const instructions = await collectAgentInstructionFiles({ projectDir, ...instructionLimits });
470
+ for (const file of instructions.files) {
471
+ segments.push({ source: file.relativePath, text: file.text });
472
+ sources.push(file.relativePath);
473
+ }
474
+
475
+ const plugins = new Set();
476
+ const skills = new Set();
477
+ const explicit = new Set();
478
+ const evidence = [];
479
+ const rejected = [];
480
+ for (const segment of segments) {
481
+ const detected = detectRequiredCodexCapabilities(segment.text, { source: segment.source });
482
+ for (const plugin of detected.plugins) plugins.add(plugin);
483
+ for (const skill of detected.skills) skills.add(skill);
484
+ for (const capability of detected.explicit) explicit.add(capability);
485
+ evidence.push(...detected.evidence);
486
+ rejected.push(...detected.rejected);
487
+ }
488
+
489
+ for (const { label, value } of [
490
+ { label: '--require-codex-plugin', value: requiredPlugins },
491
+ { label: 'HIVE_MIND_CODEX_REQUIRED_PLUGINS', value: env?.HIVE_MIND_CODEX_REQUIRED_PLUGINS },
492
+ ]) {
493
+ const overrides = parseRequiredCapabilityOverrides(value);
494
+ if (overrides.plugins.length === 0 && overrides.invalid.length === 0) continue;
495
+ sources.push(label);
496
+ for (const selector of overrides.plugins) {
497
+ plugins.add(selector);
498
+ // An operator override is as explicit as a requirement gets, so it fails
499
+ // closed like any other declared capability (issue #2088).
500
+ explicit.add(selector);
501
+ evidence.push({ capability: selector, line: `${label}=${selector}`, explicit: true, source: label });
502
+ }
503
+ for (const token of overrides.invalid) rejected.push({ capability: token, line: `${label}=${token} (expected plugin@marketplace)`, source: label });
504
+ }
505
+
506
+ return { plugins: [...plugins].sort(), skills: [...skills].sort(), explicit: [...explicit].sort(), evidence, rejected, sources, skippedInstructionFiles: instructions.skipped };
507
+ }
508
+
327
509
  const replaceWithRelativeSymlink = async ({ source, target }) => {
328
510
  try {
329
511
  const current = await fs.readlink(target);
@@ -586,22 +768,35 @@ export async function runCodexCapabilityPreflight(options = {}) {
586
768
  }
587
769
  }
588
770
 
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 () => {} } = {}) {
590
- if (!owner || !repo || !issueNumber) return { required: false, plugins: [], codexHome: null };
771
+ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, requiredPlugins, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
772
+ // Issue #2102: requirements no longer come from the issue alone, so a missing
773
+ // issue number (a pull-request continuation, for example) must not skip the
774
+ // repository's own AGENTS.md/CLAUDE.md files or an operator override. Owner and
775
+ // repo are still required: the scoped CODEX_HOME is keyed on them.
776
+ if (!owner || !repo) return { required: false, plugins: [], codexHome: null };
591
777
 
592
778
  // `executeToolWithBun` uses a shell expression for execution. Preflight uses
593
779
  // execFile and therefore selects the installed Codex binary directly.
594
780
  const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
595
- const requirementText = await readIssueRequirementText({ owner, repo, issueNumber, runCommand, env });
596
- const requirements = detectRequiredCodexCapabilities(requirementText);
597
- for (const { capability, line } of requirements.rejected || []) {
598
- await log(` ⏭️ Ignored non-capability token '${capability}' from: ${line.slice(0, 160)}`, { verbose: true });
781
+ const requirements = await collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand, env, requiredPlugins });
782
+ const scanned = `sources: ${requirements.sources.join(', ') || 'none'}`;
783
+ for (const { capability, line, source } of requirements.rejected || []) {
784
+ await log(` ⏭️ Ignored non-capability token '${capability}' from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
785
+ }
786
+ for (const { relativePath, reason, bytes } of requirements.skippedInstructionFiles || []) {
787
+ await log(` ⏭️ Skipped instruction file ${relativePath} (${reason}${reason === 'too-large' ? `: ${bytes} bytes` : ''})`, { verbose: true });
788
+ }
789
+ // Issue #2102: a silent early return made a missed requirement indistinguishable
790
+ // from a task that has none. Name what was scanned so the next report is
791
+ // diagnosable from the log alone.
792
+ if (requirements.plugins.length === 0 && requirements.skills.length === 0) {
793
+ await log(`🔌 Codex capability preflight: no plugin or skill requirements detected (${scanned})`, { verbose: true });
794
+ return { required: false, plugins: [], codexHome: null, sources: requirements.sources };
599
795
  }
600
- if (requirements.plugins.length === 0 && requirements.skills.length === 0) return { required: false, plugins: [], codexHome: null };
601
796
 
602
- await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s)`);
603
- for (const { capability, line } of requirements.evidence || []) {
604
- await log(` 🔎 '${capability}' detected from: ${line.slice(0, 160)}`, { verbose: true });
797
+ await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s) (${scanned})`);
798
+ for (const { capability, line, source } of requirements.evidence || []) {
799
+ await log(` 🔎 '${capability}' detected from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
605
800
  }
606
801
  // Every Codex-side preflight operation runs from the checkout that the
607
802
  // solver will enter. Besides matching `codex exec`, this keeps any future
@@ -677,9 +872,12 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
677
872
  export default {
678
873
  applyCodexCapabilityEnv,
679
874
  buildPluginCachePath,
875
+ collectAgentInstructionFiles,
876
+ collectCodexCapabilityRequirements,
680
877
  detectRequiredCodexCapabilities,
681
878
  isCapabilityName,
682
879
  isExplicitRequirement,
880
+ parseRequiredCapabilityOverrides,
683
881
  readMaterializedPluginSkills,
684
882
  repairScopedPluginPayloads,
685
883
  resolveRequiredCapabilities,
@@ -11,6 +11,7 @@
11
11
  // Both are re-exported from codex.lib.mjs for backward compatibility, so existing
12
12
  // importers (and tests) can keep importing them from either module.
13
13
 
14
+ import { normalizePluginSelector } from './codex-capability-preflight.lib.mjs';
14
15
  import { isENOSPC } from './lib.mjs';
15
16
 
16
17
  const unwrapCodexErrorMessage = value => {
@@ -115,6 +116,84 @@ export const getCodexErrorEventSummary = codexJsonState => {
115
116
  };
116
117
  };
117
118
 
119
+ // Issue #2102: `request_plugin_install` can never succeed under `codex exec`.
120
+ // The tool validates its `plugin_id` against the server-driven
121
+ // `<recommended_plugins>` list with an exact string comparison, and `codex exec`
122
+ // auto-cancels the elicitation it would raise, so a model that reaches for it is
123
+ // stuck in a loop it cannot exit. In the captured GCS-TS#5 runs that produced no
124
+ // work at all, the only trace was in codex's OTEL text stream (the tool is a
125
+ // builtin, so there is no NDJSON `mcp_tool_call` item to inspect):
126
+ //
127
+ // INFO codex_otel.log_only: event.name="codex.tool_result"
128
+ // tool_name=request_plugin_install call_id=… arguments={"plugin_id":"…"}
129
+ // … success=false output=plugin_id must match one of the entries in the
130
+ // <recommended_plugins> list
131
+ // ERROR codex_core::tools::router: error=plugin_id must match one of the
132
+ // entries in the <recommended_plugins> list
133
+ //
134
+ // Both patterns are anchored at the beginning of the line rather than matched
135
+ // anywhere in it: codex echoes the stdout of every command it runs back into its
136
+ // own stream (issue #1955), and this repository's own case-study logs contain
137
+ // these very lines. An echoed copy is always preceded by the emitting tool's own
138
+ // prefix (`tool_name=shell … output=…`), so requiring `request_plugin_install` to
139
+ // be the tool of the line's *own* event, and the router error to open the line,
140
+ // keeps replayed text from being read as a live rejection.
141
+ const PLUGIN_INSTALL_MESSAGE_TEXT = 'plugin_id must match one of the entries in the <recommended_plugins> list';
142
+ const PLUGIN_INSTALL_MESSAGE_PATTERN = /plugin_id must match one of the entries in the <recommended_plugins> list/;
143
+ const PLUGIN_INSTALL_TOOL_RESULT = /^(?:\S+\s+)?(?:TRACE|DEBUG|INFO|WARN|ERROR)\s+codex_otel\.log_only:\s+event\.name="codex\.tool_result"\s+tool_name=request_plugin_install\b/;
144
+ const PLUGIN_INSTALL_ROUTER_ERROR = /^(?:\S+\s+)?ERROR\s+codex_core::tools::router:\s+error=plugin_id must match one of the entries in the <recommended_plugins> list/;
145
+ const PLUGIN_INSTALL_CALL_ID = /\bcall_id=(\S+)/;
146
+ const PLUGIN_INSTALL_PLUGIN_ID = /"plugin_id"\s*:\s*"([^"]+)"/;
147
+ const PLUGIN_INSTALL_SUCCESS = /\bsuccess=(true|false)\b/;
148
+
149
+ export const matchCodexPluginInstallRejection = line => {
150
+ const text = String(line || '');
151
+ if (PLUGIN_INSTALL_ROUTER_ERROR.test(text)) return { source: 'router', callId: null, pluginId: null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
152
+ if (!PLUGIN_INSTALL_TOOL_RESULT.test(text)) return null;
153
+ if (!PLUGIN_INSTALL_MESSAGE_PATTERN.test(text)) return null;
154
+ if (PLUGIN_INSTALL_SUCCESS.exec(text)?.[1] === 'true') return null;
155
+ return { source: 'tool_result', callId: PLUGIN_INSTALL_CALL_ID.exec(text)?.[1] || null, pluginId: PLUGIN_INSTALL_PLUGIN_ID.exec(text)?.[1] || null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
156
+ };
157
+
158
+ // Issue #2102: a rejected runtime install means the capability preflight did not
159
+ // provision what the task needs, so the run cannot do the work it was asked to
160
+ // do. It is reported as a failure only when nothing was produced: a model may
161
+ // probe `request_plugin_install` and then complete the task without the plugin,
162
+ // and failing that run retroactively would discard real output.
163
+ export const getCodexPluginProvisioningHealth = (codexJsonState, { capabilityPreflight = null } = {}) => {
164
+ const rejections = codexJsonState?.pluginInstallRejections || [];
165
+ const requestedPlugins = [...new Set(rejections.map(entry => entry.pluginId).filter(Boolean))].sort();
166
+ const fileChanges = codexJsonState?.fileChanges || [];
167
+ const producedWork = fileChanges.length > 0;
168
+ const detected = rejections.length > 0;
169
+
170
+ const reasons = [];
171
+ const guidance = [];
172
+ if (detected) {
173
+ reasons.push(`Codex called request_plugin_install${requestedPlugins.length > 0 ? ` for ${requestedPlugins.join(', ')}` : ''} and codex rejected it: ${PLUGIN_INSTALL_MESSAGE_TEXT}. Under codex exec this tool can never install a plugin, so the model cannot recover on its own.`);
174
+ reasons.push(capabilityPreflight?.required ? `The Hive Mind Codex capability preflight ran for ${(capabilityPreflight.plugins || []).join(', ') || 'no plugins'}, so the plugin the model asked for was not among the requirements it discovered.` : 'The Hive Mind Codex capability preflight detected no requirements for this task, so nothing was provisioned before codex exec.');
175
+ for (const plugin of requestedPlugins.length > 0 ? requestedPlugins : ['<plugin>@<marketplace>']) {
176
+ // Issue #2102: the model asks for `@openai-curated-remote`, which is a
177
+ // synthesized namespace that `codex plugin add` cannot install; the
178
+ // preflight's normalization maps it onto the installable `@openai-curated`
179
+ // marketplace, so the guidance must quote the selector that actually works.
180
+ guidance.push(`Declare the requirement so the preflight provisions it: --require-codex-plugin ${normalizePluginSelector(plugin)} (or HIVE_MIND_CODEX_REQUIRED_PLUGINS).`);
181
+ }
182
+ guidance.push('Requirements declared in the target repository AGENTS.md / CLAUDE.md are discovered automatically; run with --verbose to see the sources the preflight scanned.');
183
+ }
184
+
185
+ return {
186
+ healthy: !detected || producedWork,
187
+ detected,
188
+ producedWork,
189
+ requestedPlugins,
190
+ rejections,
191
+ message: detected ? reasons[0] : null,
192
+ reasons,
193
+ guidance,
194
+ };
195
+ };
196
+
118
197
  // Issue #1990: A Codex run can exit 0 with no fatal `turn.failed`/error event yet
119
198
  // still be fundamentally broken. Under docker isolation two long-running
120
199
  // `solve --tool codex` tasks reported SUCCESS (Exit Code: 0) while their
@@ -189,3 +268,47 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
189
268
  reasons,
190
269
  };
191
270
  };
271
+
272
+ // Reporting helpers for the run gates in codex.lib.mjs. They live here with the
273
+ // analysis they narrate so codex.lib.mjs stays inside the 1500-line budget
274
+ // (issues #1730 / #1990).
275
+ export const logCodexResourceSnapshot = async ({ getResourceSnapshot, log }) => {
276
+ const resourcesAfter = await getResourceSnapshot();
277
+ await log('\n📈 System resources after execution:', { verbose: true });
278
+ await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
279
+ await log(` Load: ${resourcesAfter.load}`, { verbose: true });
280
+ };
281
+
282
+ export const reportCodexCompletionFailure = async ({ completionHealth, log, getResourceSnapshot }) => {
283
+ await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
284
+ for (const reason of completionHealth.reasons) {
285
+ await log(` • ${reason}`, { level: 'error' });
286
+ }
287
+ await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
288
+ if (completionHealth.diskPressureDetected) {
289
+ await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
290
+ for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
291
+ await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
292
+ }
293
+ await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
294
+ }
295
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
296
+ };
297
+
298
+ export const reportCodexPluginProvisioning = async ({ pluginProvisioning, log }) => {
299
+ if (!pluginProvisioning.detected) return;
300
+ if (!pluginProvisioning.healthy) {
301
+ await log('\n\n❌ Codex could not obtain a required plugin at runtime — treating as failure', { level: 'error' });
302
+ for (const reason of pluginProvisioning.reasons) {
303
+ await log(` • ${reason}`, { level: 'error' });
304
+ }
305
+ for (const hint of pluginProvisioning.guidance) {
306
+ await log(` 💡 ${hint}`, { level: 'error' });
307
+ }
308
+ return;
309
+ }
310
+ await log(`\n⚠️ Codex asked to install ${pluginProvisioning.requestedPlugins.join(', ') || 'a plugin'} at runtime and was rejected, but the run still produced changes`, { level: 'warning' });
311
+ for (const hint of pluginProvisioning.guidance) {
312
+ await log(` 💡 ${hint}`, { level: 'warning', verbose: true });
313
+ }
314
+ };
package/src/codex.lib.mjs CHANGED
@@ -17,8 +17,8 @@ const os = (await use('os')).default;
17
17
  import { log } from './lib.mjs';
18
18
  // Issues #1955 / #1990: run-health analysis lives in its own module to keep this
19
19
  // file under the max-lines budget. Re-exported below for backward compatibility.
20
- import { getCodexErrorEventSummary, getCodexCompletionHealth } from './codex-health.lib.mjs';
21
- export { getCodexErrorEventSummary, getCodexCompletionHealth };
20
+ import { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, logCodexResourceSnapshot, matchCodexPluginInstallRejection, reportCodexCompletionFailure, reportCodexPluginProvisioning } from './codex-health.lib.mjs';
21
+ export { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, matchCodexPluginInstallRejection };
22
22
  import { reportError } from './sentry.lib.mjs';
23
23
  import { timeouts, retryLimits } from './config.lib.mjs';
24
24
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -351,6 +351,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
351
351
  itemErrors: state.itemErrors || [],
352
352
  turnFailures: state.turnFailures || [],
353
353
  streamErrors: state.streamErrors || [],
354
+ pluginInstallRejections: state.pluginInstallRejections || [],
354
355
  observedUsageFieldSets: state.observedUsageFieldSets || [],
355
356
  observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
356
357
  };
@@ -371,6 +372,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
371
372
  try {
372
373
  data = sanitizeObjectStrings(JSON.parse(line));
373
374
  } catch {
375
+ // Issue #2102: `request_plugin_install` is a codex builtin, so its rejection
376
+ // never arrives as an NDJSON item — it only exists in the interleaved OTEL
377
+ // text stream, which is exactly the set of lines that fail to parse here.
378
+ const rejection = matchCodexPluginInstallRejection(line);
379
+ if (rejection) nextState.pluginInstallRejections.push(rejection);
374
380
  continue;
375
381
  }
376
382
 
@@ -745,7 +751,10 @@ export const executeCodex = async params => {
745
751
  // it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
746
752
  await deployHandoffSkill({ tempDir, argv, log, $ });
747
753
  const codexBaseEnv = getCodexExecEnv(argv.verbose);
748
- const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv });
754
+ // Issue #2102: the target repository's own agent instruction files are part of
755
+ // the requirement corpus, so the preflight needs the checkout; `--require-codex-plugin`
756
+ // is the explicit escape hatch for requirements no document states.
757
+ const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv, requiredPlugins: argv.requireCodexPlugin });
749
758
  // Execute the Codex command
750
759
  return await executeCodexCommand({
751
760
  tempDir,
@@ -961,6 +970,7 @@ export const executeCodexCommand = async params => {
961
970
  itemErrors: [],
962
971
  turnFailures: [],
963
972
  streamErrors: [],
973
+ pluginInstallRejections: [],
964
974
  observedUsageFieldSets: [],
965
975
  observedModelDiagnosticPaths: [],
966
976
  };
@@ -1127,13 +1137,25 @@ export const executeCodexCommand = async params => {
1127
1137
  await log(`⚠️ Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
1128
1138
  }
1129
1139
  const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
1140
+ // Every exit from this run reports the same accounting fields; only the
1141
+ // outcome-specific ones differ. `lastTextContent` is the result summary
1142
+ // captured from the JSON output stream (issue #1263).
1143
+ const buildRunResult = outcome => ({
1144
+ sessionId,
1145
+ limitReached,
1146
+ limitResetTime,
1147
+ pricingInfo,
1148
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1149
+ resultModelUsage,
1150
+ subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1151
+ codexJsonDetails: codexJsonState,
1152
+ resultSummary: lastTextContent || null,
1153
+ ...outcome,
1154
+ });
1130
1155
 
1131
1156
  // Check for authentication errors first - these should never be retried
1132
1157
  if (authError) {
1133
- const resourcesAfter = await getResourceSnapshot();
1134
- await log('\n📈 System resources after execution:', { verbose: true });
1135
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1136
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1158
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1137
1159
 
1138
1160
  // Throw an error to stop retries and propagate the auth failure
1139
1161
  const error = new Error('Codex authentication failed - 401 Unauthorized. Please run: codex login');
@@ -1199,25 +1221,9 @@ export const executeCodexCommand = async params => {
1199
1221
  await log(` Error events: item=${codexErrorSummary.counts.item}, turn=${codexErrorSummary.counts.turn}, stream=${codexErrorSummary.counts.stream}`, { level: 'error' });
1200
1222
  }
1201
1223
 
1202
- const resourcesAfter = await getResourceSnapshot();
1203
- await log('\n📈 System resources after execution:', { verbose: true });
1204
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1205
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1224
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1206
1225
 
1207
- return {
1208
- success: false,
1209
- sessionId,
1210
- limitReached,
1211
- limitResetTime,
1212
- pricingInfo,
1213
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1214
- resultModelUsage,
1215
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1216
- codexJsonDetails: codexJsonState,
1217
- errorInfo: codexErrorSummary,
1218
- result: codexErrorSummary.message,
1219
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1220
- };
1226
+ return buildRunResult({ success: false, errorInfo: codexErrorSummary, result: codexErrorSummary.message });
1221
1227
  }
1222
1228
 
1223
1229
  if (exitCode !== 0) {
@@ -1269,24 +1275,22 @@ export const executeCodexCommand = async params => {
1269
1275
  await log(`\n\n❌ Codex command failed with exit code ${exitCode}`, { level: 'error' });
1270
1276
  }
1271
1277
 
1272
- const resourcesAfter = await getResourceSnapshot();
1273
- await log('\n📈 System resources after execution:', { verbose: true });
1274
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1275
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1278
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1276
1279
 
1277
- return {
1278
- success: false,
1279
- sessionId,
1280
- limitReached,
1281
- limitResetTime,
1282
- pricingInfo,
1283
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1284
- resultModelUsage,
1285
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1286
- codexJsonDetails: codexJsonState,
1287
- errorInfo: getCodexErrorEventSummary(codexJsonState),
1288
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1289
- };
1280
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState) });
1281
+ }
1282
+
1283
+ // Issue #2102: a rejected `request_plugin_install` means codex asked for a
1284
+ // capability the preflight did not provision, and under `codex exec` that
1285
+ // request can never succeed — so a run that produced nothing is blocked,
1286
+ // not finished. Reporting it as a named failure (instead of an empty
1287
+ // success) is what makes the missing requirement visible.
1288
+ const pluginProvisioning = getCodexPluginProvisioningHealth(codexJsonState, { capabilityPreflight });
1289
+ await reportCodexPluginProvisioning({ pluginProvisioning, log });
1290
+ if (!pluginProvisioning.healthy) {
1291
+ if (sessionId && !argv.resume) argv.resume = sessionId;
1292
+
1293
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), pluginProvisioning, result: [pluginProvisioning.message, ...pluginProvisioning.guidance].join(' ') });
1290
1294
  }
1291
1295
 
1292
1296
  // Issue #1990: exit code 0 and the absence of a fatal codex error event are
@@ -1297,23 +1301,7 @@ export const executeCodexCommand = async params => {
1297
1301
  // container filesystem needed to inspect and retry the failure (#1990).
1298
1302
  const completionHealth = getCodexCompletionHealth(codexJsonState, { lastMessage });
1299
1303
  if (!completionHealth.healthy) {
1300
- await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
1301
- for (const reason of completionHealth.reasons) {
1302
- await log(` • ${reason}`, { level: 'error' });
1303
- }
1304
- await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
1305
- if (completionHealth.diskPressureDetected) {
1306
- await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
1307
- for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
1308
- await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
1309
- }
1310
- await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
1311
- }
1312
-
1313
- const resourcesAfter = await getResourceSnapshot();
1314
- await log('\n📈 System resources after execution:', { verbose: true });
1315
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1316
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1304
+ await reportCodexCompletionFailure({ completionHealth, log, getResourceSnapshot });
1317
1305
 
1318
1306
  // Issue #1990: preserve the codex session so an outer full restart can
1319
1307
  // resume with context (mirrors the transient-error retry above and the
@@ -1323,23 +1311,7 @@ export const executeCodexCommand = async params => {
1323
1311
  // restart at the orchestration level.
1324
1312
  if (sessionId && !argv.resume) argv.resume = sessionId;
1325
1313
 
1326
- return {
1327
- success: false,
1328
- sessionId,
1329
- limitReached,
1330
- limitResetTime,
1331
- pricingInfo,
1332
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1333
- resultModelUsage,
1334
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1335
- codexJsonDetails: codexJsonState,
1336
- errorInfo: getCodexErrorEventSummary(codexJsonState),
1337
- completionHealth,
1338
- incompleteSession: completionHealth.incompleteSession,
1339
- diskPressureDetected: completionHealth.diskPressureDetected,
1340
- result: completionHealth.reasons.join(' '),
1341
- resultSummary: lastTextContent || null,
1342
- };
1314
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), completionHealth, pluginProvisioning, incompleteSession: completionHealth.incompleteSession, diskPressureDetected: completionHealth.diskPressureDetected, result: completionHealth.reasons.join(' ') });
1343
1315
  }
1344
1316
 
1345
1317
  await log('\n\n✅ Codex command completed');
@@ -1351,18 +1323,7 @@ export const executeCodexCommand = async params => {
1351
1323
  await log('⚠️ No result summary captured from Codex output or last-message file', { level: 'warning', verbose: true });
1352
1324
  }
1353
1325
 
1354
- return {
1355
- success: true,
1356
- sessionId,
1357
- limitReached,
1358
- limitResetTime,
1359
- pricingInfo,
1360
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1361
- resultModelUsage,
1362
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1363
- codexJsonDetails: codexJsonState,
1364
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1365
- };
1326
+ return buildRunResult({ success: true, pluginProvisioning });
1366
1327
  } catch (error) {
1367
1328
  // Don't report auth errors to Sentry as they are user configuration issues
1368
1329
  if (!error.isAuthError) {
@@ -247,6 +247,7 @@ const KNOWN_OPTION_NAMES = [
247
247
  'keep-going-until-all-requirements-are-fully-done',
248
248
  'keep-working',
249
249
  'keep-going',
250
+ 'require-codex-plugin',
250
251
  ];
251
252
 
252
253
  /**
@@ -715,6 +715,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
715
715
  type: 'string',
716
716
  description: 'Comma-separated list of MCP server names that gemini-cli is allowed to call (passes --allowed-mcp-server-names to gemini-cli). Only used when --tool gemini.',
717
717
  },
718
+ // Issue #2102: the Codex capability preflight discovers requirements from the
719
+ // issue text and the target repository's agent instruction files (AGENTS.md,
720
+ // CLAUDE.md, .codex/). This is the escape hatch for a requirement no document
721
+ // states — codex's own `request_plugin_install` can never install anything
722
+ // under `codex exec`, so declaring it here is the only way in.
723
+ 'require-codex-plugin': {
724
+ type: 'string',
725
+ description: 'Comma-separated list of Codex plugins (plugin@marketplace) that must be installed into the scoped CODEX_HOME before codex exec starts, in addition to the ones discovered from the issue text and the repository AGENTS.md/CLAUDE.md files. Fails the run when a listed plugin is unavailable. Equivalent to HIVE_MIND_CODEX_REQUIRED_PLUGINS. Only used when --tool codex.',
726
+ },
718
727
  };
719
728
 
720
729
  function hasRawOption(rawArgs, optionName) {
package/src/solve.mjs CHANGED
@@ -1117,6 +1117,17 @@ try {
1117
1117
  await log('');
1118
1118
  }
1119
1119
 
1120
+ // Preserve work before remote diagnostics; issue #2101 ended during log upload.
1121
+ try {
1122
+ const { criticalErrorRecovery } = await import('./config.lib.mjs');
1123
+ if (criticalErrorRecovery.autoCommitUncommittedChanges) {
1124
+ const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
1125
+ await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
1126
+ }
1127
+ } catch (preserveError) {
1128
+ await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
1129
+ }
1130
+
1120
1131
  // Attach failure logs before exiting (Issues #1212, #1462: fall back to issue if no PR)
1121
1132
  const hasPR = global.createdPR && global.createdPR.number;
1122
1133
  const hasIssue = global.issueNumber;
@@ -1167,19 +1178,6 @@ try {
1167
1178
  }
1168
1179
  }
1169
1180
 
1170
- // Issue #1834 (PR #1835 feedback): "on all critical errors we auto commit uncommitted changes by
1171
- // default." A failed session exits here before the normal auto-commit chokepoint below, so commit
1172
- // + push any work first. On by default; disable via HIVE_MIND_AUTO_COMMIT_ON_CRITICAL_ERROR=false.
1173
- try {
1174
- const { criticalErrorRecovery } = await import('./config.lib.mjs');
1175
- if (criticalErrorRecovery.autoCommitUncommittedChanges) {
1176
- const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
1177
- await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
1178
- }
1179
- } catch (preserveError) {
1180
- await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
1181
- }
1182
-
1183
1181
  await safeExit(1, toolFailureMessage);
1184
1182
  }
1185
1183
 
@@ -47,7 +47,11 @@ export const classifyRetryableError = value => {
47
47
  // overloaded API anyway. Claude Code already exposes its own per-request fallback
48
48
  // via `--fallback-model` (wired in claude.lib.mjs), so we keep `--model` stable and
49
49
  // simply retry. Therefore isCapacity is false → retry with the same model.
50
- if (lower.includes('overloaded') || lower.includes('overloaded_error')) {
50
+ // Issue #2101: Codex may exhaust its internal WebSocket/HTTPS attempts and
51
+ // surface only "We're currently experiencing high demand, which may cause
52
+ // temporary errors." The terminal message omits both the preceding 503 and
53
+ // the backend's concurrency_limit code, so it must be recognized directly.
54
+ if (lower.includes('overloaded') || lower.includes('overloaded_error') || lower.includes('currently experiencing high demand') || lower.includes('too many concurrent requests') || lower.includes('concurrency_limit')) {
51
55
  return { message, isRetryable: true, isCapacity: false, label: 'API overload' };
52
56
  }
53
57