@link-assistant/hive-mind 1.36.1 → 1.37.1

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,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.37.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8df5a3d: Treat ENOSPC as immediate failure at all stages (issues #1212, #1211)
8
+
9
+ When disk space runs out during any stage — including git clone, execution, and log
10
+ upload — ENOSPC is now treated as a hard failure (not partial success). Added ENOSPC
11
+ detection to git clone error classification so disk-full clone failures are not
12
+ retried. The isENOSPC utility now detects git-specific patterns like "unable to write
13
+ file" and "cannot create directory". Actionable disk cleanup guidance is provided.
14
+
15
+ ## 1.37.0
16
+
17
+ ### Minor Changes
18
+
19
+ - f02c1fc: fix synthetic model appearing in PR comments by filtering internal Claude CLI router entries (Issue #1486)
20
+ - dd87b23: Add opusplan model support and --plan-model option for flexible plan/execution model pairing
21
+
3
22
  ## 1.36.1
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -859,6 +859,12 @@ ps axjf
859
859
  pkill -f gh-issue-solver-1773073065743
860
860
  ```
861
861
 
862
+ ### Kill all headless browsers spawned by ms-playwright
863
+
864
+ ```bash
865
+ pkill -f ms-playwright/chromium_headless_shell-1200
866
+ ```
867
+
862
868
  That can be done, but not recommended as reboot have better effect.
863
869
 
864
870
  ## 📄 License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.36.1",
3
+ "version": "1.37.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -6,7 +6,7 @@ if (typeof globalThis.use === 'undefined') {
6
6
  const { $ } = await use('command-stream');
7
7
  const fs = (await use('fs')).promises;
8
8
  const path = (await use('path')).default;
9
- import { log } from './lib.mjs';
9
+ import { log, isENOSPC } from './lib.mjs';
10
10
  import { reportError } from './sentry.lib.mjs';
11
11
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
12
12
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -586,13 +586,13 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
586
586
  // Read the entire file
587
587
  const fileContent = await fs.readFile(sessionFile, 'utf8');
588
588
  const lines = fileContent.trim().split('\n');
589
- // Parse each line and accumulate token counts per model
590
589
  for (const line of lines) {
591
590
  if (!line.trim()) continue;
592
591
  try {
593
592
  const entry = JSON.parse(line);
594
593
  if (entry.message && entry.message.usage && entry.message.model) {
595
594
  const model = entry.message.model;
595
+ if (model.startsWith('<') && model.endsWith('>')) continue; // Issue #1486: skip <synthetic> etc.
596
596
  const usage = entry.message.usage;
597
597
  // Initialize model entry if it doesn't exist
598
598
  if (!modelUsage[model]) {
@@ -808,12 +808,10 @@ export const executeClaudeCommand = async params => {
808
808
  await log(' Feedback info included: No', { verbose: true });
809
809
  }
810
810
  }
811
- // Take resource snapshot before execution
812
811
  const resourcesBefore = await getResourceSnapshot();
813
812
  await log('📈 System resources before execution:', { verbose: true });
814
813
  await log(` Memory: ${resourcesBefore.memory.split('\n')[1]}`, { verbose: true });
815
814
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
816
- // Use command-stream's async iteration for real-time streaming with file logging
817
815
  let commandFailed = false;
818
816
  let sessionId = null;
819
817
  let limitReached = false;
@@ -842,11 +840,12 @@ export const executeClaudeCommand = async params => {
842
840
  } else if (argv.interactiveMode) {
843
841
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
844
842
  }
845
- // Build claude command with optional resume flag
846
843
  let execCommand;
847
844
  const mappedModel = mapModelToId(argv.model);
848
- // Build claude command arguments
849
- let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel}`;
845
+ const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
846
+ const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
847
+ const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
848
+ let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
850
849
  if (argv.resume) {
851
850
  await log(`🔄 Resuming from session: ${argv.resume}`);
852
851
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
@@ -862,22 +861,22 @@ export const executeClaudeCommand = async params => {
862
861
  }
863
862
  try {
864
863
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
865
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: mappedModel, thinkLevel, maxBudget });
864
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel });
866
865
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
867
- const modelMaxOutputTokens = getMaxOutputTokensForModel(mappedModel);
866
+ const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
868
867
  if (argv.verbose) {
869
868
  await log(`📊 CLAUDE_CODE_MAX_OUTPUT_TOKENS: ${modelMaxOutputTokens}, MCP_TIMEOUT: ${claudeCode.mcpTimeout}ms, MCP_TOOL_TIMEOUT: ${claudeCode.mcpToolTimeout}ms, ANTHROPIC_LOG: debug`, { verbose: true });
869
+ if (resolvedPlanModel) await log(`📊 opusplan: plan=${resolvedPlanModel}, exec=${resolvedExecutionModel}`, { verbose: true });
870
870
  if (resolvedThinkingBudget !== undefined) await log(`📊 MAX_THINKING_TOKENS: ${resolvedThinkingBudget}`, { verbose: true });
871
871
  if (claudeEnv.CLAUDE_CODE_EFFORT_LEVEL) await log(`📊 CLAUDE_CODE_EFFORT_LEVEL: ${claudeEnv.CLAUDE_CODE_EFFORT_LEVEL}`, { verbose: true });
872
872
  if (!isNewVersion && thinkLevel) await log(`📊 Thinking level (via keywords): ${thinkLevel}`, { verbose: true });
873
873
  }
874
+ const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
874
875
  if (argv.resume) {
875
876
  const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
876
- const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
877
- execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
877
+ execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
878
878
  } else {
879
- const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
880
- execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} --append-system-prompt "${simpleEscapedSystem}"`;
879
+ execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} --append-system-prompt "${simpleEscapedSystem}"`;
881
880
  }
882
881
  await log(`${formatAligned('📋', 'Command details:', '')}`);
883
882
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
@@ -1026,7 +1025,12 @@ export const executeClaudeCommand = async params => {
1026
1025
  const subtype = data.subtype || 'unknown';
1027
1026
  if (subtype === 'error_during_execution') {
1028
1027
  errorDuringExecution = true;
1029
- await log(`⚠️ Error during execution (subtype: ${subtype}) - work may be completed`, { verbose: true });
1028
+ if ((data.errors || []).some(e => isENOSPC(e))) {
1029
+ commandFailed = true;
1030
+ await log('❌ ENOSPC: No space left on device. Free disk space (check ~/.claude/debug).');
1031
+ } else {
1032
+ await log(`⚠️ Error during execution (subtype: ${subtype}) - work may be completed`, { verbose: true });
1033
+ }
1030
1034
  } else {
1031
1035
  commandFailed = true;
1032
1036
  await log(`⚠️ Detected error from Claude CLI (subtype: ${subtype})`, { verbose: true });
@@ -1038,8 +1042,8 @@ export const executeClaudeCommand = async params => {
1038
1042
  if (lastMessage.includes('Internal server error') && !lastMessage.includes('Overloaded')) {
1039
1043
  isInternalServerError = true;
1040
1044
  }
1041
- // Issue #1353: Detect "Request timed out" Claude CLI emits {type:"result",is_error:true,result:"Request timed out"} after exhausting retries
1042
- if (lastMessage === 'Request timed out' || lastMessage.includes('Request timed out')) {
1045
+ // Issue #1353: Detect "Request timed out" from Claude CLI
1046
+ if (lastMessage.includes('Request timed out')) {
1043
1047
  isRequestTimeout = true;
1044
1048
  await log('⏱️ Detected request timeout from Claude CLI (will retry with --resume)', { verbose: true });
1045
1049
  }
@@ -1262,7 +1266,6 @@ export const executeClaudeCommand = async params => {
1262
1266
  sessionId,
1263
1267
  resumeCommand: argv.url ? `${process.argv[0]} ${process.argv[1]} --auto-continue ${argv.url}` : null,
1264
1268
  });
1265
-
1266
1269
  for (const line of messageLines) {
1267
1270
  await log(line, { level: 'warning' });
1268
1271
  }
@@ -1277,8 +1280,7 @@ export const executeClaudeCommand = async params => {
1277
1280
  }
1278
1281
  }
1279
1282
  }
1280
- // Issue #1354: Detect silent failures (no messages + stderr errors, e.g. "kill EPERM" with exit 0).
1281
- // Skip if result event confirmed success (definitive proof regardless of messageCount).
1283
+ // Issue #1354: Detect silent failures (no messages + stderr errors, skip if result confirmed success)
1282
1284
  if (!commandFailed && !resultSuccessReceived && stderrErrors.length > 0 && messageCount === 0 && toolUseCount === 0) {
1283
1285
  commandFailed = true;
1284
1286
  const errorsPreview = stderrErrors
@@ -1307,9 +1309,7 @@ export const executeClaudeCommand = async params => {
1307
1309
  resultSummary, // Issue #1263: Include result summary
1308
1310
  };
1309
1311
  }
1310
- // Issue #1088: If error_during_execution occurred but command didn't fail,
1311
- // log it as "Finished with errors" instead of pure success
1312
- // Issue #1351: Distinguish interrupted sessions (exit code 130) from normal completion
1312
+ // Issue #1088/#1351: Log execution result status
1313
1313
  if (exitCode === 130) {
1314
1314
  await log('\n\n⚠️ Claude command interrupted (CTRL+C)');
1315
1315
  } else if (errorDuringExecution) {
@@ -174,9 +174,10 @@ export const DEFAULT_MAX_THINKING_BUDGET_OPUS_46 = parseIntWithDefault('HIVE_MIN
174
174
  export const isOpus46OrLater = model => {
175
175
  if (!model) return false;
176
176
  const normalizedModel = model.toLowerCase();
177
- // Check for explicit opus-4-6 or later versions
177
+ // Check for explicit opus-4-6 or later versions, or opusplan (Issue #1223)
178
178
  // Note: The 'opus' alias now maps to Opus 4.6 (Issue #1433), so we also check for the alias directly
179
- return normalizedModel === 'opus' || normalizedModel.includes('opus-4-6') || normalizedModel.includes('opus-4-7') || normalizedModel.includes('opus-5');
179
+ // opusplan uses Opus for planning, so it should get Opus-level settings
180
+ return normalizedModel === 'opus' || normalizedModel === 'opusplan' || normalizedModel.includes('opus-4-6') || normalizedModel.includes('opus-4-7') || normalizedModel.includes('opus-5');
180
181
  };
181
182
 
182
183
  /**
@@ -318,6 +319,10 @@ export const supportsThinkingBudget = (version, minVersion = '2.1.12') => {
318
319
  // Also sets MCP_TIMEOUT and MCP_TOOL_TIMEOUT for MCP tool execution (see issue #1066)
319
320
  // Supports model-specific max output tokens for Opus 4.6 (Issue #1221)
320
321
  // Sets CLAUDE_CODE_EFFORT_LEVEL for Opus 4.6 models (Issue #1238)
322
+ // Supports planModel/executionModel for opusplan mode (Issue #1223)
323
+ // See: https://code.claude.com/docs/en/model-config
324
+ // ANTHROPIC_DEFAULT_OPUS_MODEL → model used in plan mode (and for 'opus' alias)
325
+ // ANTHROPIC_DEFAULT_SONNET_MODEL → model used in execution mode (and for 'sonnet' alias)
321
326
  export const getClaudeEnv = (options = {}) => {
322
327
  // Get max output tokens based on model (Issue #1221)
323
328
  const maxOutputTokens = options.model ? getMaxOutputTokensForModel(options.model) : claudeCode.maxOutputTokens;
@@ -327,8 +332,6 @@ export const getClaudeEnv = (options = {}) => {
327
332
  CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxOutputTokens),
328
333
  // MCP timeout configurations to prevent tool calls from hanging indefinitely
329
334
  // See: https://github.com/link-assistant/hive-mind/issues/1066
330
- // MCP_TIMEOUT: Timeout for MCP server startup
331
- // MCP_TOOL_TIMEOUT: Timeout for MCP tool execution (the one that prevents stuck tools)
332
335
  MCP_TIMEOUT: String(claudeCode.mcpTimeout),
333
336
  MCP_TOOL_TIMEOUT: String(claudeCode.mcpToolTimeout),
334
337
  };
@@ -354,6 +357,17 @@ export const getClaudeEnv = (options = {}) => {
354
357
  env.CLAUDE_CODE_EFFORT_LEVEL = effortLevel;
355
358
  }
356
359
  }
360
+ // Set ANTHROPIC_DEFAULT_OPUS_MODEL when planModel is specified (Issue #1223)
361
+ // This tells Claude Code which model to use during plan mode in opusplan
362
+ if (options.planModel) {
363
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(options.planModel);
364
+ }
365
+ // Set ANTHROPIC_DEFAULT_SONNET_MODEL when executionModel is specified (Issue #1223)
366
+ // This tells Claude Code which model to use during execution mode in opusplan
367
+ // Enables combinations like --plan-model opus --model haiku
368
+ if (options.executionModel) {
369
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(options.executionModel);
370
+ }
357
371
 
358
372
  return env;
359
373
  };
@@ -413,6 +427,7 @@ const defaultAvailableModels = `(
413
427
  opus
414
428
  sonnet
415
429
  haiku
430
+ opusplan
416
431
  )`;
417
432
 
418
433
  export const modelConfig = {
@@ -2,7 +2,7 @@
2
2
  // GitHub-related utility functions. Check if use is already defined (when imported from solve.mjs), if not, fetch it (when running standalone)
3
3
  if (typeof globalThis.use === 'undefined') globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
4
4
  const { $ } = await use('command-stream'); // Use command-stream for consistent $ behavior
5
- import { log, maskToken, cleanErrorMessage } from './lib.mjs';
5
+ import { log, maskToken, cleanErrorMessage, isENOSPC } from './lib.mjs';
6
6
  import { reportError } from './sentry.lib.mjs';
7
7
  import { githubLimits, timeouts } from './config.lib.mjs';
8
8
  import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
@@ -162,16 +162,7 @@ export const checkGitHubPermissions = async () => {
162
162
  return true; // Continue despite permission check failure
163
163
  }
164
164
  };
165
- /**
166
- * Check if the current user has write (push) permissions to a specific repository
167
- * This helps fail early before wasting AI tokens when --fork option is not used
168
- * @param {string} owner - Repository owner
169
- * @param {string} repo - Repository name
170
- * @param {Object} options - Configuration options
171
- * @param {boolean} options.useFork - Whether --fork flag is enabled
172
- * @param {string} options.issueUrl - Original issue URL for error messages
173
- * @returns {Promise<boolean>} True if has write access OR fork mode is enabled, false otherwise
174
- */
165
+ /** Check if user has write permissions to repo. Fails early if --fork not used. */
175
166
  export const checkRepositoryWritePermission = async (owner, repo, options = {}) => {
176
167
  const { useFork = false, issueUrl = '' } = options;
177
168
  // Skip check if fork mode is enabled - user will work in their own fork
@@ -379,6 +370,17 @@ export async function attachLogToGitHub(options) {
379
370
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
380
371
  const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
381
372
  try {
373
+ // Issue #1212: Check disk space before attempting log upload (100MB minimum)
374
+ try {
375
+ const { checkDiskSpace } = await import('./memory-check.mjs');
376
+ const diskCheck = await checkDiskSpace(100, { log: async () => {} });
377
+ if (!diskCheck.success) {
378
+ await log(` ❌ Insufficient disk space for log upload (${diskCheck.availableMB}MB available, 100MB required). Free disk space and retry.`);
379
+ return false;
380
+ }
381
+ } catch {
382
+ /* disk check failure is non-fatal — continue to actual operation */
383
+ }
382
384
  // Check if log file exists and is not empty
383
385
  const logStats = await fs.stat(logFile);
384
386
  if (logStats.size === 0) {
@@ -390,9 +392,7 @@ export async function attachLogToGitHub(options) {
390
392
  if (useLargeFileMode && verbose) {
391
393
  await log(` 📁 Large log file (${Math.round(logStats.size / 1024 / 1024)}MB), will use gh-upload-log`, { verbose: true });
392
394
  }
393
- // Calculate token usage if sessionId and tempDir are provided (skip for agent tool with pricing)
394
- let totalCostUSD = publicPricingEstimate;
395
- // Issue #1225: Collect actual model IDs from Claude session JSON output
395
+ let totalCostUSD = publicPricingEstimate; // Issue #1225: token usage + actual model IDs
396
396
  let actualModelIds = null;
397
397
  if (totalCostUSD === null && sessionId && tempDir && !errorMessage) {
398
398
  try {
@@ -401,23 +401,15 @@ export async function attachLogToGitHub(options) {
401
401
  if (tokenUsage) {
402
402
  if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
403
403
  totalCostUSD = tokenUsage.totalCostUSD;
404
- if (verbose) {
405
- await log(` 💰 Calculated cost: $${totalCostUSD.toFixed(6)}`, { verbose: true });
406
- }
404
+ if (verbose) await log(` 💰 Calculated cost: $${totalCostUSD.toFixed(6)}`, { verbose: true });
407
405
  }
408
- // Extract actual model IDs from session data (Issue #1225)
409
406
  if (tokenUsage.modelUsage && Object.keys(tokenUsage.modelUsage).length > 0) {
410
407
  actualModelIds = Object.keys(tokenUsage.modelUsage);
411
- if (verbose) {
412
- await log(` 🤖 Actual models used: ${actualModelIds.join(', ')}`, { verbose: true });
413
- }
408
+ if (verbose) await log(` 🤖 Actual models used: ${actualModelIds.join(', ')}`, { verbose: true });
414
409
  }
415
410
  }
416
411
  } catch (tokenError) {
417
- // Don't fail the entire upload if token calculation fails
418
- if (verbose) {
419
- await log(` ⚠️ Could not calculate token cost: ${tokenError.message}`, { verbose: true });
420
- }
412
+ if (verbose) await log(` ⚠️ Could not calculate token cost: ${tokenError.message}`, { verbose: true });
421
413
  }
422
414
  }
423
415
  // Issue #1454: Use resultModelUsage from result JSON when it has more models (includes subagent models)
@@ -433,6 +425,11 @@ export async function attachLogToGitHub(options) {
433
425
  if (!actualModelIds && pricingInfo?.modelId) {
434
426
  actualModelIds = [pricingInfo.modelId];
435
427
  }
428
+ // Issue #1486: Filter out internal/synthetic model entries (e.g., "<synthetic>" from Claude CLI's inference router)
429
+ if (actualModelIds) {
430
+ actualModelIds = actualModelIds.filter(id => !(id.startsWith('<') && id.endsWith('>')));
431
+ if (actualModelIds.length === 0) actualModelIds = null;
432
+ }
436
433
  // Issue #1225: Fetch model information for comment using actual models from CLI output
437
434
  let modelInfoString = '';
438
435
  if (requestedModel || tool || actualModelIds) {
@@ -805,7 +802,9 @@ ${sessionNote}
805
802
  return await attachRegularComment(options, logComment);
806
803
  }
807
804
  } catch (uploadError) {
808
- await log(` ❌ Error uploading log file: ${uploadError.message}`);
805
+ // Issue #1212: ENOSPC-specific actionable guidance
806
+ const msg = isENOSPC(uploadError) ? 'ENOSPC: No space left on device during log upload. Free disk space and retry.' : `Error uploading log file: ${uploadError.message}`;
807
+ await log(` ❌ ${msg}`);
809
808
  return false;
810
809
  }
811
810
  }
@@ -21,7 +21,7 @@ const HIVE_CUSTOM_SOLVE_OPTIONS = {
21
21
  model: {
22
22
  type: 'string',
23
23
  description: `${buildModelOptionDescription()}, or any model ID supported by the tool`,
24
- alias: 'm',
24
+ alias: ['m', 'worker-model'],
25
25
  default: 'sonnet',
26
26
  },
27
27
  'dry-run': {
package/src/hive.mjs CHANGED
@@ -152,9 +152,7 @@ if (isDirectExecution) {
152
152
  // Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
153
153
  await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
154
154
 
155
- // First, get list of ALL repositories using gh api with --paginate for unlimited pagination
156
- // This approach uses the GitHub API directly to fetch all repositories without any limits
157
- // Include isArchived field to filter out archived repositories
155
+ // Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
158
156
  let repoListCmd;
159
157
  if (scope === 'organization') {
160
158
  repoListCmd = `gh api orgs/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
@@ -436,8 +434,6 @@ if (isDirectExecution) {
436
434
  initializeExitHandler(getAbsoluteLogPath, log);
437
435
  installGlobalExitHandlers();
438
436
 
439
- // Unhandled error handlers are now managed by exit-handler.lib.mjs
440
-
441
437
  // Validate GitHub URL requirement
442
438
  if (!githubUrl) {
443
439
  await log('❌ GitHub URL is required', { level: 'error' });
@@ -470,18 +466,28 @@ if (isDirectExecution) {
470
466
  }
471
467
  }
472
468
 
473
- // Validate model name EARLY - this always runs regardless of --skip-tool-connection-check
474
- // Model validation is a simple string check and should always be performed
469
+ // --plan flag expansion: shortcut for --plan-model opus --worker-model sonnet (Issue #1223)
470
+ if (argv.plan) {
471
+ if (!rawArgs.includes('--plan-model')) argv.planModel = 'opus';
472
+ if (!rawArgs.includes('--model') && !rawArgs.includes('-m') && !rawArgs.includes('--worker-model')) argv.model = 'sonnet';
473
+ }
474
+
475
+ // Validate model names EARLY (simple string check, always runs)
475
476
  const tool = argv.tool || 'claude';
476
477
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
478
+ if (argv.planModel) {
479
+ if (tool !== 'claude') {
480
+ await log(`❌ --plan-model is only supported with --tool claude (current tool: ${tool})`, { level: 'error' });
481
+ await safeExit(1, '--plan-model requires --tool claude');
482
+ }
483
+ await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
484
+ }
477
485
 
478
486
  // Handle -s (--skip-issues-with-prs) and --auto-continue interaction
479
- // Detect if user explicitly passed --auto-continue or --no-auto-continue
480
487
  const hasExplicitAutoContinue = rawArgs.includes('--auto-continue');
481
488
  const hasExplicitNoAutoContinue = rawArgs.includes('--no-auto-continue');
482
489
 
483
490
  if (argv.skipIssuesWithPrs) {
484
- // If user explicitly passed --auto-continue with -s, that's a conflict
485
491
  if (hasExplicitAutoContinue) {
486
492
  await log('❌ Conflicting options: --skip-issues-with-prs and --auto-continue cannot be used together', {
487
493
  level: 'error',
@@ -492,8 +498,7 @@ if (isDirectExecution) {
492
498
  await safeExit(1, 'Error occurred');
493
499
  }
494
500
 
495
- // If user didn't explicitly set auto-continue, disable it when -s is used
496
- // This is because -s means "skip issues with PRs" which conflicts with auto-continue
501
+ // -s implies disabling auto-continue unless explicitly set
497
502
  if (!hasExplicitNoAutoContinue) {
498
503
  argv.autoContinue = false;
499
504
  }
@@ -778,10 +783,8 @@ if (isDirectExecution) {
778
783
  }
779
784
  if (argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) args.push('--skip-tool-connection-check');
780
785
  if (argv.dryRun) args.push('--dry-run');
781
- if (argv.autoCleanup) args.push('--auto-cleanup'); // hive default differs from solve's auto-detect default
782
-
783
- // Options already handled above or deprecated aliases (skip in generic loop)
784
- const SKIP_AUTO_FORWARD = new Set(['model', 'base-branch', 'skip-tool-connection-check', 'tool-connection-check', 'skip-tool-check', 'skip-claude-check', 'tool-check', 'dry-run', 'auto-cleanup']);
786
+ if (argv.autoCleanup) args.push('--auto-cleanup');
787
+ const SKIP_AUTO_FORWARD = new Set(['model', 'worker-model', 'base-branch', 'skip-tool-connection-check', 'tool-connection-check', 'skip-tool-check', 'skip-claude-check', 'tool-check', 'dry-run', 'auto-cleanup']);
785
788
 
786
789
  for (const optionName of getSolvePassthroughOptionNames()) {
787
790
  if (SKIP_AUTO_FORWARD.has(optionName)) continue;
@@ -1431,8 +1434,7 @@ if (isDirectExecution) {
1431
1434
  await safeExit(0, 'Process completed');
1432
1435
  }
1433
1436
 
1434
- // Function to validate Claude CLI connection
1435
- // validateClaudeConnection is now imported from lib.mjs
1437
+ // validateClaudeConnection is imported from lib.mjs
1436
1438
 
1437
1439
  // Handle graceful shutdown
1438
1440
  process.on('SIGINT', () => gracefulShutdown('interrupt'));
@@ -1484,8 +1486,7 @@ if (isDirectExecution) {
1484
1486
  await safeExit(1, 'Error occurred');
1485
1487
  }
1486
1488
  } catch (fatalError) {
1487
- // Handle any errors that occurred during initialization or execution
1488
- // This prevents silent failures when the script hangs or crashes
1489
+ // Handle fatal errors during initialization or execution
1489
1490
  console.error('\n❌ Fatal error occurred during hive initialization or execution');
1490
1491
  console.error(` ${fatalError.message || fatalError}`);
1491
1492
  if (fatalError.stack) {
package/src/lib.mjs CHANGED
@@ -340,6 +340,28 @@ export const measureTime = async (fn, label = 'Operation') => {
340
340
  }
341
341
  };
342
342
 
343
+ /**
344
+ * Check if an error is an ENOSPC (no space left on device) error
345
+ * Issue #1212: ENOSPC errors need specific handling because they cascade
346
+ * (once disk is full, all operations fail) and require user action (cleanup).
347
+ * @param {Error|string} error - Error object or message
348
+ * @returns {boolean} True if the error is an ENOSPC error
349
+ */
350
+ export const isENOSPC = error => {
351
+ if (!error) return false;
352
+ const message = error?.message || (typeof error === 'string' ? error : '');
353
+ const lowerMessage = message.toLowerCase();
354
+ return (
355
+ error?.code === 'ENOSPC' ||
356
+ message.includes('ENOSPC') ||
357
+ lowerMessage.includes('no space left on device') ||
358
+ // Issue #1211: git clone ENOSPC patterns — "unable to write file" and
359
+ // "cannot create directory" occur when disk fills during checkout
360
+ (lowerMessage.includes('unable to write file') && lowerMessage.includes('error')) ||
361
+ (lowerMessage.includes('cannot create directory') && lowerMessage.includes('no space left'))
362
+ );
363
+ };
364
+
343
365
  /**
344
366
  * Clean up error messages for better user experience
345
367
  * @param {Error|string} error - Error object or message
@@ -502,6 +524,7 @@ export default {
502
524
  retry,
503
525
  formatBytes,
504
526
  measureTime,
527
+ isENOSPC,
505
528
  cleanErrorMessage,
506
529
  formatAligned,
507
530
  displayFormattedError,
@@ -30,6 +30,7 @@ export const claudeModels = {
30
30
  haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
31
31
  'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
32
32
  'haiku-3': 'claude-3-haiku-20240307', // Haiku 3
33
+ opusplan: 'opusplan', // Special mode: Opus for planning, Sonnet for execution (Issue #1223)
33
34
  // Shorter version aliases (Issue #1221, Issue #1329 - PR comment feedback)
34
35
  'sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 short alias (Issue #1329)
35
36
  'opus-4-6': 'claude-opus-4-6', // Opus 4.6 short alias
@@ -258,7 +259,7 @@ export const isModelCompatibleWithTool = (tool, model) => {
258
259
 
259
260
  switch (tool) {
260
261
  case 'claude':
261
- return mappedModel.startsWith('claude-');
262
+ return mappedModel.startsWith('claude-') || mappedModel === 'opusplan';
262
263
  case 'agent':
263
264
  return mappedModel.includes('/') || Object.keys(agentModels).includes(model);
264
265
  case 'opencode':
@@ -293,7 +294,7 @@ export const getValidModelsForTool = tool => {
293
294
  // Primary (non-alias, non-deprecated) short names shown in CLI help descriptions
294
295
  // These are the recommended model names users should see in --model help text
295
296
  export const primaryModelNames = {
296
- claude: ['opus', 'sonnet', 'haiku'],
297
+ claude: ['opus', 'sonnet', 'haiku', 'opusplan'],
297
298
  opencode: ['grok', 'gpt4o'],
298
299
  codex: ['gpt5', 'gpt5-codex', 'o3'],
299
300
  agent: ['minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free'],
@@ -284,6 +284,21 @@ export const SOLVE_OPTION_DEFINITIONS = {
284
284
  choices: ['claude', 'opencode', 'codex', 'agent'],
285
285
  default: 'claude',
286
286
  },
287
+ plan: {
288
+ type: 'boolean',
289
+ description: 'Enable plan mode: uses opus for planning, sonnet for execution (shortcut for --plan-model opus --worker-model sonnet). Only works with --tool claude.',
290
+ default: false,
291
+ },
292
+ 'plan-model': {
293
+ type: 'string',
294
+ description: 'Model to use for plan mode (e.g., opus). When specified, auto-switches to opusplan mode and sets ANTHROPIC_DEFAULT_OPUS_MODEL. Use with --model/--worker-model to set separate plan and execution models (e.g., --plan-model opus --model sonnet). Only works with --tool claude.',
295
+ default: undefined,
296
+ },
297
+ 'worker-model': {
298
+ type: 'string',
299
+ description: 'Alias for --model: Model to use for execution/worker mode when --plan-model is specified. When used with --plan-model, sets ANTHROPIC_DEFAULT_SONNET_MODEL for Claude Code opusplan mode.',
300
+ default: undefined,
301
+ },
287
302
  'execute-tool-with-bun': {
288
303
  type: 'boolean',
289
304
  description: 'Execute the AI tool using bunx (experimental, may improve speed and memory usage)',
@@ -441,7 +456,7 @@ export const createYargsConfig = yargsInstance => {
441
456
  .option('model', {
442
457
  type: 'string',
443
458
  description: buildModelOptionDescription(),
444
- alias: 'm',
459
+ alias: ['m', 'worker-model'],
445
460
  default: currentParsedArgs => {
446
461
  // Dynamic default based on tool selection (Issue #1473: centralized in models/index.mjs)
447
462
  return defaultModels[currentParsedArgs?.tool] || defaultModels.claude;
@@ -549,7 +564,20 @@ export const parseArguments = async (yargs, hideBin) => {
549
564
  // Post-processing: Fix model default for opencode and codex tools
550
565
  // Yargs doesn't properly handle dynamic defaults based on other arguments,
551
566
  // so we need to handle this manually after parsing
552
- const modelExplicitlyProvided = rawArgs.includes('--model') || rawArgs.includes('-m');
567
+ const modelExplicitlyProvided = rawArgs.includes('--model') || rawArgs.includes('-m') || rawArgs.includes('--worker-model');
568
+ const planModelExplicitlyProvided = rawArgs.includes('--plan-model');
569
+
570
+ // --plan flag expansion (Issue #1223)
571
+ // When --plan is set, it acts as a shortcut for --plan-model opus --worker-model sonnet
572
+ // Explicit --plan-model and --model/--worker-model values take precedence
573
+ if (argv && argv.plan) {
574
+ if (!planModelExplicitlyProvided) {
575
+ argv.planModel = 'opus';
576
+ }
577
+ if (!modelExplicitlyProvided) {
578
+ argv.model = 'sonnet';
579
+ }
580
+ }
553
581
 
554
582
  // Normalize alias flags: legacy --skip-tool-check and --skip-claude-check behave like --skip-tool-connection-check
555
583
  if (argv) {
@@ -43,7 +43,7 @@ export const handleFailure = async options => {
43
43
 
44
44
  // If --attach-logs is enabled, try to attach failure logs
45
45
  if (shouldAttachLogs && getLogFile()) {
46
- // Issue #1462: Upload logs to PR if available, otherwise fall back to the issue
46
+ // Issues #1212, #1462: Upload logs to PR if available, otherwise fall back to the issue
47
47
  const hasPR = global.createdPR && global.createdPR.number;
48
48
  const hasIssue = global.issueNumber;
49
49
  const targetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
package/src/solve.mjs CHANGED
@@ -211,6 +211,15 @@ if (!(await validateContinueOnlyOnFeedback(argv, isPrUrl, isIssueUrl))) {
211
211
  const tool = argv.tool || 'claude';
212
212
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
213
213
 
214
+ // Validate --plan-model if provided (Issue #1223)
215
+ if (argv.planModel) {
216
+ if (tool !== 'claude') {
217
+ await log(`❌ --plan-model is only supported with --tool claude (current tool: ${tool})`, { level: 'error' });
218
+ await safeExit(1, '--plan-model requires --tool claude');
219
+ }
220
+ await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
221
+ }
222
+
214
223
  // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
215
224
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
216
225
  if (!(await performSystemChecks(argv.minDiskSpace || 2048, skipToolConnectionCheck, argv.model, argv))) {
@@ -499,8 +508,7 @@ if (isPrUrl) {
499
508
  issueNumber = urlNumber;
500
509
  await log(`📝 Issue mode: Working with issue #${issueNumber}`);
501
510
  }
502
- // Issue #1462: Store issueNumber in global so error handlers can upload logs to the issue
503
- // as a fallback when PR creation fails and global.createdPR is not available
511
+ // Issues #1212, #1462: Store issueNumber globally for error handlers (attach failure logs to issue when no PR exists)
504
512
  global.issueNumber = issueNumber;
505
513
  const workspaceInfo = argv.enableWorkspaces ? { owner, repo, issueNumber } : null;
506
514
  const { tempDir, workspaceTmpDir, needsClone } = await setupTempDirectory(argv, workspaceInfo);
@@ -952,10 +960,12 @@ try {
952
960
  if (logUploadSuccess) {
953
961
  await log(' ✅ Logs uploaded successfully');
954
962
  } else {
955
- await log(' ⚠️ Failed to upload logs', { verbose: true });
963
+ // Issue #1212: Always show log upload failures (not just verbose)
964
+ await log(' ⚠️ Failed to upload logs');
956
965
  }
957
966
  } catch (uploadError) {
958
- await log(` ⚠️ Error uploading logs: ${uploadError.message}`, { verbose: true });
967
+ // Issue #1212: Always show log upload errors (not just verbose)
968
+ await log(` ⚠️ Error uploading logs: ${uploadError.message}`);
959
969
  }
960
970
  } else if (prNumber) {
961
971
  // Fallback: Post simple failure comment if logs are not attached
@@ -1020,10 +1030,12 @@ try {
1020
1030
  if (logUploadSuccess) {
1021
1031
  await log(' ✅ Logs uploaded successfully');
1022
1032
  } else {
1023
- await log(' ⚠️ Failed to upload logs', { verbose: true });
1033
+ // Issue #1212: Always show log upload failures (not just verbose)
1034
+ await log(' ⚠️ Failed to upload logs');
1024
1035
  }
1025
1036
  } catch (uploadError) {
1026
- await log(` ⚠️ Error uploading logs: ${uploadError.message}`, { verbose: true });
1037
+ // Issue #1212: Always show log upload errors (not just verbose)
1038
+ await log(` ⚠️ Error uploading logs: ${uploadError.message}`);
1027
1039
  }
1028
1040
  } else {
1029
1041
  // Fallback: Post simple waiting comment if logs are not attached
@@ -1084,7 +1096,7 @@ try {
1084
1096
 
1085
1097
  // If --attach-logs is enabled, attach failure logs before exiting
1086
1098
  // Note: sessionId is not required - logs should be uploaded even if agent failed before establishing a session
1087
- // Issue #1462: Fall back to uploading logs to the issue if PR is not available
1099
+ // Issues #1212, #1462: Fall back to uploading logs to the issue if PR is not available
1088
1100
  const hasPR = global.createdPR && global.createdPR.number;
1089
1101
  const hasIssue = global.issueNumber;
1090
1102
  const logTargetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
@@ -1124,10 +1136,12 @@ try {
1124
1136
  if (logUploadSuccess) {
1125
1137
  await log(` ✅ Failure logs uploaded to ${logTargetLabel} successfully`);
1126
1138
  } else {
1127
- await log(' ⚠️ Failed to upload logs', { verbose: true });
1139
+ // Issue #1212: Always show log upload failures (not just verbose)
1140
+ await log(' ⚠️ Failed to upload failure logs');
1128
1141
  }
1129
1142
  } catch (uploadError) {
1130
- await log(` ⚠️ Error uploading logs: ${uploadError.message}`, { verbose: true });
1143
+ // Issue #1212: Always show log upload errors (not just verbose)
1144
+ await log(` ⚠️ Error uploading failure logs: ${uploadError.message}`);
1131
1145
  }
1132
1146
  }
1133
1147
 
@@ -901,6 +901,11 @@ Thank you!`;
901
901
  export const classifyCloneError = errorOutput => {
902
902
  const output = errorOutput.toLowerCase();
903
903
 
904
+ // Issue #1211: ENOSPC (disk full) errors - NOT retryable, requires user action
905
+ if (lib.isENOSPC(errorOutput) || output.includes('no space left on device') || (output.includes('unable to write file') && output.includes('error')) || output.includes('errno -28')) {
906
+ return { type: 'ENOSPC', retryable: false, description: 'No space left on device' };
907
+ }
908
+
904
909
  // Transient server errors (5xx) - typically retryable
905
910
  if (output.includes('error: 500') || output.includes('internal server error') || output.includes('error: 502') || output.includes('error: 503') || output.includes('error: 504')) {
906
911
  return { type: 'TRANSIENT', retryable: true, description: 'GitHub server error' };
@@ -983,36 +988,34 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
983
988
  if (line.trim()) await log(` ${line}`);
984
989
  }
985
990
  await log('');
986
- await log(' 💡 Common causes:');
987
- await log(" • Repository doesn't exist or is private");
988
- await log(' • No GitHub authentication');
989
- await log(' Network connectivity issues');
990
- if (errorClassification.type === 'TRANSIENT') {
991
- await log(' GitHub server issues (temporary)');
992
- }
993
- if (errorClassification.type === 'RATE_LIMIT') {
994
- await log(' API rate limiting exceeded');
995
- }
996
- if (argv.fork) {
997
- await log(' Fork not ready yet (try again in a moment)');
998
- }
999
- await log('');
1000
- await log(' 🔧 How to fix:');
1001
- await log(' 1. Check authentication: gh auth status');
1002
- await log(' 2. Login if needed: gh auth login');
1003
- await log(` 3. Verify access: gh repo view ${owner}/${repo}`);
1004
- if (argv.fork) {
1005
- await log(` 4. Check fork: gh repo view ${repoToClone}`);
1006
- }
1007
- if (errorClassification.type === 'TRANSIENT') {
1008
- await log(' 5. Wait a few minutes and retry (GitHub server issue)');
1009
- await log(' 6. Check GitHub status: https://www.githubstatus.com');
1010
- }
1011
- if (errorClassification.type === 'RATE_LIMIT') {
1012
- await log(' 5. Wait for rate limit to reset (check your quota)');
1013
- await log(' 6. Use --token flag with different token if available');
991
+
992
+ // Issue #1211: ENOSPC-specific guidance
993
+ if (errorClassification.type === 'ENOSPC') {
994
+ await log(' 💡 Cause: Disk is full — not enough space to clone the repository');
995
+ await log('');
996
+ await log(' 🔧 How to fix:');
997
+ await log(' 1. Free disk space: sudo rm -rf /tmp/* /var/tmp/*');
998
+ await log(' 2. Check disk usage: df -h');
999
+ await log(' 3. Clean Docker/npm: docker system prune -af && npm cache clean --force');
1000
+ await log('');
1001
+ } else {
1002
+ await log(' 💡 Common causes:');
1003
+ await log(" • Repository doesn't exist or is private");
1004
+ await log(' • No GitHub authentication');
1005
+ await log(' Network connectivity issues');
1006
+ if (errorClassification.type === 'TRANSIENT') await log(' GitHub server issues (temporary)');
1007
+ if (errorClassification.type === 'RATE_LIMIT') await log(' API rate limiting exceeded');
1008
+ if (argv.fork) await log(' Fork not ready yet (try again in a moment)');
1009
+ await log('');
1010
+ await log(' 🔧 How to fix:');
1011
+ await log(' 1. Check authentication: gh auth status');
1012
+ await log(' 2. Login if needed: gh auth login');
1013
+ await log(` 3. Verify access: gh repo view ${owner}/${repo}`);
1014
+ if (argv.fork) await log(` 4. Check fork: gh repo view ${repoToClone}`);
1015
+ if (errorClassification.type === 'TRANSIENT') await log(' 5. Wait and retry / check: https://www.githubstatus.com');
1016
+ if (errorClassification.type === 'RATE_LIMIT') await log(' 5. Wait for rate limit to reset or use --token with different token');
1017
+ await log('');
1014
1018
  }
1015
- await log('');
1016
1019
  await safeExit(1, 'Repository setup failed');
1017
1020
  }
1018
1021
 
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  // Import shared utility from lib.mjs
18
- import { maskToken, log } from './lib.mjs';
18
+ import { maskToken, log, isENOSPC } from './lib.mjs';
19
19
  import { reportError } from './sentry.lib.mjs';
20
20
 
21
21
  // Dynamic imports for runtime dependencies
@@ -537,11 +537,18 @@ export const sanitizeLogContent = async (logContent, options = {}) => {
537
537
  }
538
538
  }
539
539
  } catch (error) {
540
+ // Issue #1212: Detect ENOSPC specifically and log at non-verbose level
541
+ const isNoSpace = isENOSPC(error);
540
542
  reportError(error, {
541
543
  context: 'sanitize_log_content',
542
- level: 'warning',
544
+ level: isNoSpace ? 'error' : 'warning',
543
545
  });
544
- await log(` ⚠️ Warning: Could not fully sanitize log content: ${error.message}`, { verbose: true });
546
+ if (isNoSpace) {
547
+ await log(` ❌ ENOSPC: No space left on device during log sanitization. Skipping sanitization.`);
548
+ await log(` Consider freeing disk space (e.g., rm -rf ~/.claude/debug/*.txt) and retrying.`);
549
+ } else {
550
+ await log(` ⚠️ Warning: Could not fully sanitize log content: ${error.message}`, { verbose: true });
551
+ }
545
552
  }
546
553
 
547
554
  return sanitized;