@link-assistant/hive-mind 2.0.13 → 2.0.15

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.0.15
4
+
5
+ ### Patch Changes
6
+
7
+ - 0d2f2bb: Fix "Cannot read properties of null (reading 'type')" crash that aborted Codex (and other agent) runs when the tool echoed a stream line that parsed to a bare `null` or non-object JSON primitive. All NDJSON stream parsers (Codex, Claude, Agent, OpenCode) now ignore non-object lines instead of dereferencing them.
8
+
9
+ ## 2.0.14
10
+
11
+ ### Patch Changes
12
+
13
+ - 03954a9: Show fuzzy suggestions for mistyped CLI and Telegram command options, including the closest match plus up to four additional alternatives.
14
+
3
15
  ## 2.0.13
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
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/agent.lib.mjs CHANGED
@@ -542,6 +542,9 @@ export const executeAgentCommand = async params => {
542
542
  if (!line.trim()) continue;
543
543
  try {
544
544
  const data = sanitizeObjectStrings(JSON.parse(line));
545
+ // Issue #1968: a bare `null`/primitive NDJSON line must not abort
546
+ // event processing (any data.X access would throw on null).
547
+ if (data === null || typeof data !== 'object') continue;
545
548
  // Output formatted JSON
546
549
  await log(JSON.stringify(data, null, 2));
547
550
  // Capture session ID from the first message
@@ -616,6 +619,8 @@ export const executeAgentCommand = async params => {
616
619
  if (!stderrLine.trim()) continue;
617
620
  try {
618
621
  const stderrData = sanitizeObjectStrings(JSON.parse(stderrLine));
622
+ // Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
623
+ if (stderrData === null || typeof stderrData !== 'object') continue;
619
624
  // Output formatted JSON (same formatting as stdout)
620
625
  await log(JSON.stringify(stderrData, null, 2));
621
626
  // Capture session ID from stderr too (agent sends it via stderr)
@@ -695,6 +700,9 @@ export const executeAgentCommand = async params => {
695
700
  try {
696
701
  const msg = sanitizeObjectStrings(JSON.parse(line));
697
702
 
703
+ // Issue #1968: ignore bare `null`/primitive lines (msg.type would throw on null).
704
+ if (msg === null || typeof msg !== 'object') continue;
705
+
698
706
  // Check for explicit error message types from agent
699
707
  if (msg.type === 'error' || msg.type === 'step_error') {
700
708
  return { detected: true, type: 'AgentError', match: msg.message || msg.error || line.substring(0, 100) };
@@ -872,6 +872,7 @@ export const executeClaudeCommand = async params => {
872
872
  if (!line.trim()) continue;
873
873
  try {
874
874
  const data = sanitizeObjectStrings(JSON.parse(line));
875
+ if (data === null || typeof data !== 'object') continue; // Issue #1968: skip bare null/primitive NDJSON lines
875
876
  // Issue #1510: Track last event time for all modes (not just interactive)
876
877
  // so activity timeout can report accurate idle duration
877
878
  lastEventTime = Date.now();
@@ -1110,11 +1111,10 @@ export const executeClaudeCommand = async params => {
1110
1111
  try {
1111
1112
  const data = sanitizeObjectStrings(JSON.parse(stdoutLineBuffer));
1112
1113
  await log(JSON.stringify(data, null, 2));
1113
- if (data.type === 'result' && data.subtype === 'success' && data.total_cost_usd != null) {
1114
+ if (data?.type === 'result' && data.subtype === 'success' && data.total_cost_usd != null) {
1114
1115
  anthropicTotalCostUSD = data.total_cost_usd;
1115
- } else if (data.type === 'result' && data.total_cost_usd != null) {
1116
- // Issue #1886: keep a non-success terminal result's cost as a fallback
1117
- // for accumulation (see the streaming branch above).
1116
+ } else if (data?.type === 'result' && data.total_cost_usd != null) {
1117
+ // Issue #1886: keep a non-success terminal result's cost as a fallback (see streaming branch above).
1118
1118
  anthropicCostFromAnyResult = data.total_cost_usd;
1119
1119
  }
1120
1120
  // Issue #1472: Forward remaining buffer event to interactive handler (was previously missed)
@@ -1,5 +1,7 @@
1
1
  import { getenv, makeConfig, yargs as linoYargs } from 'lino-arguments';
2
2
 
3
+ import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
4
+
3
5
  export { getenv };
4
6
 
5
7
  export const hideBin = argv => argv.slice(2);
@@ -52,17 +54,24 @@ export function addCliCompatibilityAliases(parsed, { positionalAliases = [] } =
52
54
 
53
55
  export function parseCliArgumentsWithLino({ argv = process.argv, commandName = 'cli', createYargsConfig, positionalAliases = [], lenv = { enabled: true }, env = { enabled: false }, getenv: getenvOptions = { enabled: true } } = {}) {
54
56
  const fullArgv = ensureFullArgv(argv, commandName);
55
- const parsed = makeConfig({
56
- argv: fullArgv,
57
- lenv,
58
- env,
59
- getenv: getenvOptions,
60
- yargs: ({ yargs, getenv: getenvHelper }) => {
61
- const parser = yargs.exitProcess(false);
62
- const configured = createYargsConfig ? createYargsConfig(parser, getenvHelper) : parser;
63
- return configured.exitProcess(false);
64
- },
65
- });
57
+ let configuredParser = null;
58
+ let parsed;
59
+
60
+ try {
61
+ parsed = makeConfig({
62
+ argv: fullArgv,
63
+ lenv,
64
+ env,
65
+ getenv: getenvOptions,
66
+ yargs: ({ yargs, getenv: getenvHelper }) => {
67
+ const parser = yargs.exitProcess(false);
68
+ configuredParser = createYargsConfig ? createYargsConfig(parser, getenvHelper) : parser;
69
+ return configuredParser.exitProcess(false);
70
+ },
71
+ });
72
+ } catch (error) {
73
+ throw enhanceUnknownArgumentError(error, configuredParser);
74
+ }
66
75
 
67
76
  return addCliCompatibilityAliases(parsed, { positionalAliases });
68
77
  }
package/src/codex.lib.mjs CHANGED
@@ -470,6 +470,16 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
470
470
  continue;
471
471
  }
472
472
 
473
+ // Issue #1968: a stream line that parses to a bare `null` (or any non-object
474
+ // JSON primitive such as a number/string/boolean) must not crash the parser.
475
+ // Codex echoes the stdout of every command it runs back into its own NDJSON
476
+ // stream (see issue #1955), so a target repo that prints a standalone `null`
477
+ // line surfaces here as `JSON.parse('null') === null`. Accessing `data.type`
478
+ // on that null threw "Cannot read properties of null (reading 'type')" and
479
+ // aborted the entire solve. Real Codex events are always JSON objects, so any
480
+ // non-object line is safely ignored.
481
+ if (data === null || typeof data !== 'object') continue;
482
+
473
483
  const eventType = typeof data.type === 'string' ? data.type : 'unknown';
474
484
  nextState.eventCounts[eventType] = (nextState.eventCounts[eventType] || 0) + 1;
475
485
 
@@ -1044,6 +1054,9 @@ export const executeCodexCommand = async params => {
1044
1054
  if (!line) continue;
1045
1055
  try {
1046
1056
  const data = sanitizeObjectStrings(JSON.parse(line));
1057
+ // Issue #1968: skip bare `null`/primitive lines so the handlers
1058
+ // below never receive a non-object event (see parseCodexExecJsonOutput).
1059
+ if (data === null || typeof data !== 'object') continue;
1047
1060
  if (interactiveHandler) await interactiveHandler.processEvent(data);
1048
1061
  if (progressMonitor) await progressMonitor.processStreamEvent(data);
1049
1062
  } catch {
@@ -342,6 +342,9 @@ export const executeOpenCodeCommand = async params => {
342
342
  for (const line of lines) {
343
343
  if (!line.trim()) continue;
344
344
  const data = sanitizeObjectStrings(JSON.parse(line));
345
+ // Issue #1968: a bare `null`/primitive NDJSON line must not abort the
346
+ // rest of the chunk (data.type access would throw on null).
347
+ if (data === null || typeof data !== 'object') continue;
345
348
  accumulateAgentStepFinishUsage(streamingTokenUsage, data);
346
349
  // Track text content for result summary
347
350
  // OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
@@ -385,6 +388,8 @@ export const executeOpenCodeCommand = async params => {
385
388
  for (const line of lines) {
386
389
  if (!line.trim()) continue;
387
390
  const data = sanitizeObjectStrings(JSON.parse(line));
391
+ // Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
392
+ if (data === null || typeof data !== 'object') continue;
388
393
  accumulateAgentStepFinishUsage(streamingTokenUsage, data);
389
394
  if (data.type === 'text' && data.text) {
390
395
  lastTextContent = data.text;
@@ -44,79 +44,97 @@ export function calculateLevenshteinDistance(a, b) {
44
44
  return matrix[b.length][a.length];
45
45
  }
46
46
 
47
- /**
48
- * Find similar option names based on Levenshtein distance
49
- *
50
- * @param {string} unknownOption - The option name that was not recognized (e.g., "branch")
51
- * @param {Object} yargsInstance - The yargs instance with defined options
52
- * @param {number} maxSuggestions - Maximum number of suggestions to return (default: 3)
53
- * @param {number} distanceThreshold - Maximum distance to consider for suggestions (default: 5)
54
- * @returns {string[]} - Array of suggested option names, sorted by similarity
55
- */
56
- export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 3, distanceThreshold = 5) {
57
- // Remove leading dashes from the unknown option
58
- const cleanUnknown = unknownOption.replace(/^-+/, '');
47
+ const normalizeOptionName = option =>
48
+ String(option || '')
49
+ .trim()
50
+ .replace(/^-+/, '')
51
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
52
+ .replace(/[_\s]+/g, '-')
53
+ .toLowerCase();
59
54
 
60
- // Get all available options from yargs
55
+ const compactOptionName = option => normalizeOptionName(option).replace(/-/g, '');
56
+
57
+ function getAvailableOptionNames(yargsInstance, includeShortAliases) {
61
58
  const availableOptions = yargsInstance.getOptions();
62
59
  const allOptions = new Set();
60
+ const rawHiddenOptions = availableOptions.hiddenOptions || [];
61
+ const hiddenOptionNames = Array.isArray(rawHiddenOptions) ? rawHiddenOptions : Object.keys(rawHiddenOptions);
62
+ const hiddenOptions = new Set(hiddenOptionNames.map(normalizeOptionName));
63
+
64
+ const addOption = option => {
65
+ const normalized = normalizeOptionName(option);
66
+ if (!normalized || hiddenOptions.has(normalized)) return;
67
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) return;
68
+ if (!includeShortAliases && normalized.length === 1) return;
69
+ allOptions.add(normalized);
70
+ };
63
71
 
64
- // Collect all option names (both long form and aliases)
65
72
  if (availableOptions.key) {
66
- // Ensure it's an array before iterating
67
73
  const keys = Array.isArray(availableOptions.key) ? availableOptions.key : Object.keys(availableOptions.key || {});
68
- keys.forEach(opt => {
69
- allOptions.add(opt);
70
- });
74
+ keys.forEach(addOption);
71
75
  }
72
76
 
73
- // Collect aliases
74
77
  if (availableOptions.alias) {
75
78
  Object.entries(availableOptions.alias).forEach(([key, aliases]) => {
76
- allOptions.add(key);
79
+ addOption(key);
77
80
  if (Array.isArray(aliases)) {
78
- aliases.forEach(alias => allOptions.add(alias));
81
+ aliases.forEach(addOption);
79
82
  } else if (aliases) {
80
- // If it's not an array but exists, add it as a single alias
81
- allOptions.add(String(aliases));
83
+ addOption(aliases);
82
84
  }
83
85
  });
84
86
  }
85
87
 
86
- // Calculate distance for each option
88
+ return allOptions;
89
+ }
90
+
91
+ /**
92
+ * Find similar option names based on Levenshtein distance
93
+ *
94
+ * @param {string} unknownOption - The option name that was not recognized (e.g., "branch")
95
+ * @param {Object} yargsInstance - The yargs instance with defined options
96
+ * @param {number} maxSuggestions - Maximum number of suggestions to return (default: 5)
97
+ * @param {number} distanceThreshold - Maximum distance to consider for suggestions (default: 5)
98
+ * @returns {string[]} - Array of suggested option names, sorted by similarity
99
+ */
100
+ export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 5, distanceThreshold = 5) {
101
+ const cleanUnknown = normalizeOptionName(unknownOption);
102
+ const compactUnknown = compactOptionName(unknownOption);
103
+ const includeShortAliases = cleanUnknown.length === 1;
104
+ const allOptions = getAvailableOptionNames(yargsInstance, includeShortAliases);
105
+
87
106
  const distances = [];
88
107
  allOptions.forEach(option => {
89
- const distance = calculateLevenshteinDistance(cleanUnknown, option);
90
- if (distance <= distanceThreshold) {
91
- // Calculate bonus score for substring matches
92
- // If the unknown option is a substring of the valid option, it's likely what the user meant
93
- // Check both directions: is unknown a substring of option, or is option a substring of unknown
94
- const unknownInOption = option.includes(cleanUnknown);
95
- const optionInUnknown = cleanUnknown.includes(option);
96
-
97
- // Strong bonus for when user typed a word that appears in the option name
98
- // e.g., "branch" appears in "base-branch"
99
- const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
108
+ const distance = Math.min(calculateLevenshteinDistance(cleanUnknown, option), calculateLevenshteinDistance(compactUnknown, compactOptionName(option)));
109
+ const unknownInOption = option.includes(cleanUnknown) || compactOptionName(option).includes(compactUnknown);
110
+ const optionInUnknown = cleanUnknown.includes(option) || compactUnknown.includes(compactOptionName(option));
100
111
 
101
- // Also prioritize options with similar length (user likely tried to type the full name)
112
+ if (distance <= distanceThreshold || unknownInOption || optionInUnknown) {
113
+ const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
102
114
  const lengthDiff = Math.abs(option.length - cleanUnknown.length);
103
115
  const lengthBonus = lengthDiff < 3 ? -1 : 0;
104
116
 
105
117
  distances.push({
106
118
  option,
107
119
  distance,
120
+ lengthDiff,
108
121
  effectiveDistance: distance + substringBonus + lengthBonus,
109
122
  });
110
123
  }
111
124
  });
112
125
 
113
- // Sort by effective distance (closest first), then by actual distance
114
126
  return distances
115
127
  .sort((a, b) => {
116
128
  if (a.effectiveDistance !== b.effectiveDistance) {
117
129
  return a.effectiveDistance - b.effectiveDistance;
118
130
  }
119
- return a.distance - b.distance;
131
+ if (a.distance !== b.distance) {
132
+ return a.distance - b.distance;
133
+ }
134
+ if (a.lengthDiff !== b.lengthDiff) {
135
+ return a.lengthDiff - b.lengthDiff;
136
+ }
137
+ return a.option.localeCompare(b.option);
120
138
  })
121
139
  .slice(0, maxSuggestions)
122
140
  .map(item => item.option);
@@ -133,17 +151,17 @@ export function formatSuggestions(suggestions) {
133
151
  return '';
134
152
  }
135
153
 
136
- if (suggestions.length === 1) {
137
- return `\n\nDid you mean --${suggestions[0]}?`;
138
- }
139
-
140
- // For multiple suggestions, format them nicely
141
154
  const formattedOptions = suggestions.map(opt => {
142
155
  // If it's a single character, show as -x, otherwise --option-name
143
156
  return opt.length === 1 ? `-${opt}` : `--${opt}`;
144
157
  });
158
+ const [primarySuggestion, ...alternatives] = formattedOptions;
145
159
 
146
- return `\n\nDid you mean one of these?\n${formattedOptions.map(opt => ` • ${opt}`).join('\n')}`;
160
+ if (alternatives.length === 0) {
161
+ return `\n\nDid you mean \`${primarySuggestion}\` option?`;
162
+ }
163
+
164
+ return `\n\nDid you mean \`${primarySuggestion}\` option?\n\nOther close matches:\n${alternatives.map(opt => ` • \`${opt}\``).join('\n')}`;
147
165
  }
148
166
 
149
167
  /**
@@ -300,9 +318,13 @@ export function detectMalformedFlags(args) {
300
318
  * @returns {string} - Enhanced error message with suggestions
301
319
  */
302
320
  export function enhanceErrorMessage(originalError, yargsInstance) {
321
+ if (/Did you mean/i.test(originalError)) {
322
+ return originalError;
323
+ }
324
+
303
325
  // Extract the unknown option name from the error message
304
326
  // Typical format: "Unknown argument: branch" or "Unknown arguments: branch, test"
305
- const unknownMatch = originalError.match(/Unknown arguments?:\s*(.+?)(?:\s|$)/i);
327
+ const unknownMatch = originalError.match(/Unknown arguments?:\s*([^\n]+)/i);
306
328
 
307
329
  if (!unknownMatch) {
308
330
  return originalError;
@@ -324,3 +346,23 @@ export function enhanceErrorMessage(originalError, yargsInstance) {
324
346
 
325
347
  return enhancedMessage;
326
348
  }
349
+
350
+ export function enhanceUnknownArgumentError(error, yargsInstance) {
351
+ if (!error || error._enhanced || !yargsInstance || !/Unknown arguments?/i.test(error.message || '')) {
352
+ return error;
353
+ }
354
+
355
+ const enhancedMessage = enhanceErrorMessage(error.message, yargsInstance);
356
+ if (enhancedMessage === error.message) {
357
+ return error;
358
+ }
359
+
360
+ const enhancedError = new Error(enhancedMessage);
361
+ enhancedError.name = error.name;
362
+ enhancedError.cause = error;
363
+ for (const key of Object.keys(error)) {
364
+ enhancedError[key] = error[key];
365
+ }
366
+ enhancedError._enhanced = true;
367
+ return enhancedError;
368
+ }
@@ -25,6 +25,7 @@ await loadLenvConfig({ override: true, quiet: true });
25
25
  const yargs = getLinoYargsFactory();
26
26
  const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
27
27
  const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
28
+ const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
28
29
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
29
30
  const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
30
31
 
@@ -229,7 +230,8 @@ if (solveEnabled && solveOverrides.length > 0) {
229
230
  process.stderr.write = originalStderrWrite;
230
231
  }
231
232
  } catch (error) {
232
- console.error(`❌ Invalid solve-overrides: ${error.message || String(error)}`);
233
+ const enhancedError = enhanceUnknownArgumentError(error, createSolveYargsConfig(yargs()));
234
+ console.error(`❌ Invalid solve-overrides: ${enhancedError.message || String(enhancedError)}`);
233
235
  console.error(` Overrides: ${solveOverrides.join(' ')}`);
234
236
  process.exit(1);
235
237
  }
@@ -275,7 +277,8 @@ if (hiveEnabled && hiveOverrides.length > 0) {
275
277
  process.stderr.write = originalStderrWrite;
276
278
  }
277
279
  } catch (error) {
278
- console.error(`❌ Invalid hive-overrides: ${error.message || String(error)}`);
280
+ const enhancedError = enhanceUnknownArgumentError(error, createHiveYargsConfig(yargs()));
281
+ console.error(`❌ Invalid hive-overrides: ${enhancedError.message || String(enhancedError)}`);
279
282
  console.error(` Overrides: ${hiveOverrides.join(' ')}`);
280
283
  process.exit(1);
281
284
  }
@@ -8,6 +8,8 @@
8
8
  * @see https://github.com/link-assistant/hive-mind/issues/1618
9
9
  */
10
10
 
11
+ import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
12
+
11
13
  export const TOOL_SOLVE_COMMAND_ALIASES = Object.freeze({
12
14
  claude: 'claude',
13
15
  codex: 'codex',
@@ -89,8 +91,9 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
89
91
  else if (typeof callback === 'function') callback();
90
92
  return true;
91
93
  };
94
+ let parser = null;
92
95
  try {
93
- const parser = createYargsConfig(yargsFactory());
96
+ parser = createYargsConfig(yargsFactory());
94
97
  parser
95
98
  .exitProcess(false)
96
99
  .showHelpOnFail(false)
@@ -98,6 +101,8 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
98
101
  throw err || new Error(msg || 'Invalid arguments');
99
102
  });
100
103
  return await parser.parse(args);
104
+ } catch (error) {
105
+ throw enhanceUnknownArgumentError(error, parser);
101
106
  } finally {
102
107
  process.stderr.write = originalStderrWrite;
103
108
  }
@@ -1,10 +1,12 @@
1
1
  import { buildUserMention } from './buildUserMention.lib.mjs';
2
2
  import { validateModelName } from './models/index.mjs';
3
+ import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
4
+ import { createYargsConfig as createTaskYargsConfig } from './task.config.lib.mjs';
3
5
  import { createTaskIssue, parseTaskIssueCreationInput, resolveTaskIssueCreationInput } from './task.issue-creation.lib.mjs';
4
6
  import { parseTaskIssueUrl } from './task.split.lib.mjs';
5
7
  import { escapeMarkdown } from './telegram-markdown.lib.mjs';
6
8
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
7
- import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
9
+ import { moveArgumentToFront, parseArgsWithYargs, parseCommandArgs } from './telegram-solve-command.lib.mjs';
8
10
  import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
9
11
 
10
12
  export const TASK_COMMAND_NAMES = Object.freeze(['task', 'split']);
@@ -187,6 +189,13 @@ export function registerTaskCommands(bot, options) {
187
189
  return;
188
190
  }
189
191
 
192
+ try {
193
+ await parseArgsWithYargs(filteredArgs, getLinoYargsFactory(), createTaskYargsConfig);
194
+ } catch (error) {
195
+ await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, { reply_to_message_id: ctx.message.message_id });
196
+ return;
197
+ }
198
+
190
199
  const modelError = validateTaskModel(filteredArgs);
191
200
  if (modelError) {
192
201
  await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });