@link-assistant/hive-mind 1.9.0 → 1.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,31 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.9.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d39bf3e: Fix disk threshold to use one-at-a-time mode instead of blocking all commands
8
+ - When disk usage exceeds threshold (90%), now allows exactly one command to run
9
+ - Previously, disk threshold blocked ALL commands unconditionally (like RAM/CPU)
10
+ - Now matches behavior of Claude API thresholds (CLAUDE_5_HOUR_SESSION_THRESHOLD, CLAUDE_WEEKLY_THRESHOLD)
11
+ - Allows controlled task execution during high disk usage while preventing multiple tasks from exhausting resources
12
+
13
+ Fixes #1155
14
+
15
+ ## 1.9.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 06da02c: Improve /accept_invites command output with grouped items and real-time updates
20
+
21
+ **Changes:**
22
+ - Group output by "Repositories:" and "Organizations:" instead of repeating "Repository:" for each item
23
+ - Add clickable GitHub links for each repository and organization
24
+ - Implement real-time message updates after each invitation is processed
25
+ - Show progress indicator (e.g., "Processing GitHub Invitations (3/10)") during processing
26
+
27
+ Fixes #1148
28
+
3
29
  ## 1.9.0
4
30
 
5
31
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.9.0",
3
+ "version": "1.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",
@@ -7,7 +7,9 @@
7
7
  * Features:
8
8
  * - Accepts all pending repository invitations
9
9
  * - Accepts all pending organization invitations
10
- * - Provides detailed feedback on accepted invitations
10
+ * - Groups output by Repositories and Organizations
11
+ * - Provides clickable links to repositories and organizations
12
+ * - Real-time progress updates during processing
11
13
  * - Error handling with detailed error messages
12
14
  *
13
15
  * @see https://docs.github.com/en/rest/collaborators/invitations
@@ -28,6 +30,83 @@ function escapeMarkdown(text) {
28
30
  return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
29
31
  }
30
32
 
33
+ /**
34
+ * Build progress message from current state
35
+ * @param {Object} state - Current state object
36
+ * @param {string[]} state.acceptedRepos - List of accepted repo names
37
+ * @param {string[]} state.acceptedOrgs - List of accepted org names
38
+ * @param {string[]} state.errors - List of errors
39
+ * @param {number} state.totalRepos - Total number of repo invitations
40
+ * @param {number} state.totalOrgs - Total number of org invitations
41
+ * @param {number} state.processedRepos - Number of processed repo invitations
42
+ * @param {number} state.processedOrgs - Number of processed org invitations
43
+ * @param {boolean} state.isComplete - Whether processing is complete
44
+ * @returns {string} Formatted message
45
+ */
46
+ function buildProgressMessage(state) {
47
+ const { acceptedRepos, acceptedOrgs, errors, totalRepos, totalOrgs, processedRepos, processedOrgs, isComplete } = state;
48
+
49
+ // Calculate totals
50
+ const totalInvitations = totalRepos + totalOrgs;
51
+ const processedTotal = processedRepos + processedOrgs;
52
+ const acceptedTotal = acceptedRepos.length + acceptedOrgs.length;
53
+
54
+ // Build header with progress indicator
55
+ let message = isComplete ? 'āœ… *GitHub Invitations Processed*\n\n' : `šŸ”„ *Processing GitHub Invitations* \\(${processedTotal}/${totalInvitations}\\)\n\n`;
56
+
57
+ // Show Repositories section if any
58
+ if (acceptedRepos.length > 0 || (!isComplete && totalRepos > 0)) {
59
+ message += '*Repositories:*\n';
60
+ for (const repoName of acceptedRepos) {
61
+ // Create clickable link: [owner/repo](https://github.com/owner/repo)
62
+ const escapedName = escapeMarkdown(repoName);
63
+ const escapedLink = escapeMarkdown(`https://github.com/${repoName}`);
64
+ message += ` • šŸ“¦ [${escapedName}](${escapedLink})\n`;
65
+ }
66
+ // Show pending indicator if still processing repos
67
+ if (!isComplete && processedRepos < totalRepos) {
68
+ const remaining = totalRepos - processedRepos;
69
+ message += ` • _\\.\\.\\. ${remaining} more pending_\n`;
70
+ }
71
+ message += '\n';
72
+ }
73
+
74
+ // Show Organizations section if any
75
+ if (acceptedOrgs.length > 0 || (!isComplete && totalOrgs > 0)) {
76
+ message += '*Organizations:*\n';
77
+ for (const orgName of acceptedOrgs) {
78
+ // Create clickable link: [org](https://github.com/org)
79
+ const escapedName = escapeMarkdown(orgName);
80
+ const escapedLink = escapeMarkdown(`https://github.com/${orgName}`);
81
+ message += ` • šŸ¢ [${escapedName}](${escapedLink})\n`;
82
+ }
83
+ // Show pending indicator if still processing orgs
84
+ if (!isComplete && processedOrgs < totalOrgs) {
85
+ const remaining = totalOrgs - processedOrgs;
86
+ message += ` • _\\.\\.\\. ${remaining} more pending_\n`;
87
+ }
88
+ message += '\n';
89
+ }
90
+
91
+ // Show errors if any
92
+ if (errors.length > 0) {
93
+ message += '*Errors:*\n' + errors.map(e => ` • ${escapeMarkdown(e)}`).join('\n') + '\n\n';
94
+ }
95
+
96
+ // Show summary
97
+ if (isComplete) {
98
+ if (acceptedTotal === 0 && errors.length === 0) {
99
+ message += 'No pending invitations found\\.';
100
+ } else if (acceptedTotal > 0 && errors.length === 0) {
101
+ message += `\nšŸŽ‰ Successfully accepted ${acceptedTotal} invitation\\(s\\)\\!`;
102
+ } else if (acceptedTotal > 0 && errors.length > 0) {
103
+ message += `\nāš ļø Accepted ${acceptedTotal} invitation\\(s\\), ${errors.length} error\\(s\\)\\.`;
104
+ }
105
+ }
106
+
107
+ return message;
108
+ }
109
+
31
110
  /**
32
111
  * Registers the /accept_invites command handler with the bot
33
112
  * @param {Object} bot - The Telegraf bot instance
@@ -61,68 +140,97 @@ export function registerAcceptInvitesCommand(bot, options) {
61
140
  reply_to_message_id: ctx.message.message_id,
62
141
  });
63
142
 
64
- const fetchingMessage = await ctx.reply('šŸ”„ Fetching pending GitHub invitations...', { reply_to_message_id: ctx.message.message_id });
65
- const accepted = [];
66
- const errors = [];
143
+ const fetchingMessage = await ctx.reply('šŸ”„ Fetching pending GitHub invitations\\.\\.\\.', {
144
+ reply_to_message_id: ctx.message.message_id,
145
+ parse_mode: 'MarkdownV2',
146
+ });
147
+
148
+ // State for tracking progress
149
+ const state = {
150
+ acceptedRepos: [],
151
+ acceptedOrgs: [],
152
+ errors: [],
153
+ totalRepos: 0,
154
+ totalOrgs: 0,
155
+ processedRepos: 0,
156
+ processedOrgs: 0,
157
+ isComplete: false,
158
+ };
159
+
160
+ // Helper to update the message safely
161
+ const updateMessage = async () => {
162
+ try {
163
+ const message = buildProgressMessage(state);
164
+ await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'MarkdownV2' });
165
+ } catch (err) {
166
+ // Ignore "message not modified" errors
167
+ if (!err.message?.includes('message is not modified')) {
168
+ VERBOSE && console.log(`[VERBOSE] /accept-invites: Error updating message: ${err.message}`);
169
+ }
170
+ }
171
+ };
67
172
 
68
173
  try {
69
174
  // Fetch repository invitations
70
175
  const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"');
71
176
  const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
177
+ state.totalRepos = repoInvitations.length;
72
178
  VERBOSE && console.log(`[VERBOSE] Found ${repoInvitations.length} pending repo invitations`);
73
179
 
74
- // Accept each repo invitation
180
+ // Fetch organization invitations
181
+ const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
182
+ const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
183
+ const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
184
+ state.totalOrgs = pendingOrgs.length;
185
+ VERBOSE && console.log(`[VERBOSE] Found ${pendingOrgs.length} pending org invitations`);
186
+
187
+ // Check if there are any invitations
188
+ if (state.totalRepos === 0 && state.totalOrgs === 0) {
189
+ state.isComplete = true;
190
+ await updateMessage();
191
+ return;
192
+ }
193
+
194
+ // Update to show we found invitations
195
+ await updateMessage();
196
+
197
+ // Accept each repo invitation with progress updates
75
198
  for (const inv of repoInvitations) {
76
199
  const repoName = inv.repository?.full_name || 'unknown';
77
200
  try {
78
201
  await exec(`gh api -X PATCH /user/repository_invitations/${inv.id}`);
79
- accepted.push(`šŸ“¦ Repository: ${repoName}`);
202
+ state.acceptedRepos.push(repoName);
80
203
  VERBOSE && console.log(`[VERBOSE] Accepted repo invitation: ${repoName}`);
81
204
  } catch (e) {
82
- errors.push(`šŸ“¦ ${repoName}: ${e.message}`);
205
+ state.errors.push(`šŸ“¦ ${repoName}: ${e.message}`);
83
206
  VERBOSE && console.log(`[VERBOSE] Failed to accept repo invitation ${repoName}: ${e.message}`);
84
207
  }
208
+ state.processedRepos++;
209
+ await updateMessage();
85
210
  }
86
211
 
87
- // Fetch organization invitations
88
- const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
89
- const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
90
- const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
91
- VERBOSE && console.log(`[VERBOSE] Found ${pendingOrgs.length} pending org invitations`);
92
-
93
- // Accept each org invitation
212
+ // Accept each org invitation with progress updates
94
213
  for (const membership of pendingOrgs) {
95
214
  const orgName = membership.organization?.login || 'unknown';
96
215
  try {
97
216
  await exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`);
98
- accepted.push(`šŸ¢ Organization: ${orgName}`);
217
+ state.acceptedOrgs.push(orgName);
99
218
  VERBOSE && console.log(`[VERBOSE] Accepted org invitation: ${orgName}`);
100
219
  } catch (e) {
101
- errors.push(`šŸ¢ ${orgName}: ${e.message}`);
220
+ state.errors.push(`šŸ¢ ${orgName}: ${e.message}`);
102
221
  VERBOSE && console.log(`[VERBOSE] Failed to accept org invitation ${orgName}: ${e.message}`);
103
222
  }
223
+ state.processedOrgs++;
224
+ await updateMessage();
104
225
  }
105
226
 
106
- // Build response message
107
- let message = 'āœ… *GitHub Invitations Processed*\n\n';
108
- if (accepted.length === 0 && errors.length === 0) {
109
- message += 'No pending invitations found.';
110
- } else {
111
- if (accepted.length > 0) {
112
- message += '*Accepted:*\n' + accepted.map(a => ` • ${escapeMarkdown(a)}`).join('\n') + '\n\n';
113
- }
114
- if (errors.length > 0) {
115
- message += '*Errors:*\n' + errors.map(e => ` • ${escapeMarkdown(e)}`).join('\n');
116
- }
117
- if (accepted.length > 0 && errors.length === 0) {
118
- message += `\nšŸŽ‰ Successfully accepted ${accepted.length} invitation(s)!`;
119
- }
120
- }
121
-
122
- await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown' });
227
+ // Final update
228
+ state.isComplete = true;
229
+ await updateMessage();
123
230
  } catch (error) {
124
231
  console.error('Error in /accept-invites:', error);
125
- await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `āŒ Error fetching invitations: ${escapeMarkdown(error.message)}\n\nMake sure \`gh\` CLI is installed and authenticated.`, { parse_mode: 'Markdown' });
232
+ const escapedError = escapeMarkdown(error.message);
233
+ await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `āŒ Error fetching invitations: ${escapedError}\n\nMake sure \`gh\` CLI is installed and authenticated\\.`, { parse_mode: 'MarkdownV2' });
126
234
  }
127
235
  });
128
236
  }
@@ -34,16 +34,16 @@ import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getC
34
34
  export const QUEUE_CONFIG = {
35
35
  // Resource thresholds (usage ratios: 0.0 - 1.0)
36
36
  // All thresholds use >= comparison (inclusive)
37
- RAM_THRESHOLD: 0.65, // Stop if RAM usage >= 65%
37
+ RAM_THRESHOLD: 0.65, // Enqueue if RAM usage >= 65%
38
38
  // CPU threshold uses 5-minute load average, not instantaneous CPU usage
39
- CPU_THRESHOLD: 0.75, // Stop if 5-minute load average >= 75% of CPU count
40
- DISK_THRESHOLD: 0.95, // One-at-a-time if disk usage >= 95%
39
+ CPU_THRESHOLD: 0.65, // Enqueue if 5-minute load average >= 65% of CPU count
40
+ DISK_THRESHOLD: 0.9, // One-at-a-time if disk usage >= 90%
41
41
 
42
42
  // API limit thresholds (usage ratios: 0.0 - 1.0)
43
43
  // All thresholds use >= comparison (inclusive)
44
- CLAUDE_5_HOUR_SESSION_THRESHOLD: 0.85, // Stop if 5-hour limit >= 85%
44
+ CLAUDE_5_HOUR_SESSION_THRESHOLD: 0.85, // One-at-a-time if 5-hour limit >= 85%
45
45
  CLAUDE_WEEKLY_THRESHOLD: 0.98, // One-at-a-time if weekly limit >= 98%
46
- GITHUB_API_THRESHOLD: 0.8, // Stop if GitHub >= 80% with parallel claude
46
+ GITHUB_API_THRESHOLD: 0.8, // Enqueue if GitHub >= 80% with parallel claude
47
47
 
48
48
  // Timing
49
49
  // MIN_START_INTERVAL_MS: Time to allow solve command to start actual claude process
@@ -431,11 +431,15 @@ export class SolveQueue {
431
431
  this.recordThrottle('claude_running');
432
432
  }
433
433
 
434
- // Check system resources (ultimate restrictions - block unconditionally)
435
- const resourceCheck = await this.checkSystemResources();
434
+ // Check system resources (RAM, CPU block unconditionally; disk uses one-at-a-time mode)
435
+ // See: https://github.com/link-assistant/hive-mind/issues/1155
436
+ const resourceCheck = await this.checkSystemResources(totalProcessing);
436
437
  if (!resourceCheck.ok) {
437
438
  reasons.push(...resourceCheck.reasons);
438
439
  }
440
+ if (resourceCheck.oneAtATime) {
441
+ oneAtATime = true;
442
+ }
439
443
 
440
444
  // Check API limits (pass hasRunningClaude and totalProcessing for uniform checking)
441
445
  const limitCheck = await this.checkApiLimits(hasRunningClaude, totalProcessing);
@@ -478,17 +482,22 @@ export class SolveQueue {
478
482
  * This provides a more stable metric that isn't affected by brief spikes
479
483
  * during claude process startup.
480
484
  *
481
- * Note: System resource thresholds are ultimate restrictions - they block unconditionally
482
- * when triggered. Unlike Claude API thresholds which allow one command at a time via
483
- * totalProcessing check, system resources block all new commands immediately.
484
- * See: https://github.com/link-assistant/hive-mind/issues/1133
485
+ * Resource threshold modes:
486
+ * - RAM_THRESHOLD: Enqueue mode - blocks all commands unconditionally
487
+ * - CPU_THRESHOLD: Enqueue mode - blocks all commands unconditionally
488
+ * - DISK_THRESHOLD: One-at-a-time mode - allows exactly one command when nothing is processing
489
+ *
490
+ * See: https://github.com/link-assistant/hive-mind/issues/1155
485
491
  *
486
- * @returns {Promise<{ok: boolean, reasons: string[]}>}
492
+ * @param {number} totalProcessing - Total processing count (queue + external claude processes)
493
+ * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
487
494
  */
488
- async checkSystemResources() {
495
+ async checkSystemResources(totalProcessing = 0) {
489
496
  const reasons = [];
497
+ let oneAtATime = false;
490
498
 
491
499
  // Check RAM (using cached value)
500
+ // Enqueue mode: blocks all commands unconditionally
492
501
  const memResult = await getCachedMemoryInfo(this.verbose);
493
502
  if (memResult.success) {
494
503
  const usedRatio = memResult.memory.usedPercentage / 100;
@@ -499,6 +508,7 @@ export class SolveQueue {
499
508
  }
500
509
 
501
510
  // Check CPU using 5-minute load average (more stable than 1-minute)
511
+ // Enqueue mode: blocks all commands unconditionally
502
512
  // Cache TTL is 2 minutes, which is appropriate for this metric
503
513
  const cpuResult = await getCachedCpuInfo(this.verbose);
504
514
  if (cpuResult.success) {
@@ -522,22 +532,26 @@ export class SolveQueue {
522
532
  }
523
533
 
524
534
  // Check disk space (using cached value)
525
- // Note: Disk threshold is an ultimate restriction - it blocks unconditionally when triggered
526
- // Unlike CLAUDE_5_HOUR_SESSION_THRESHOLD and CLAUDE_WEEKLY_THRESHOLD which use totalProcessing
527
- // to allow one command at a time, disk threshold blocks all new commands immediately
528
- // See: https://github.com/link-assistant/hive-mind/issues/1133
535
+ // One-at-a-time mode: allows exactly one command when nothing is processing
536
+ // Unlike RAM and CPU which block unconditionally, disk uses one-at-a-time mode
537
+ // because we cannot predict how much disk space a task will use
538
+ // See: https://github.com/link-assistant/hive-mind/issues/1155
529
539
  const diskResult = await getCachedDiskInfo(this.verbose);
530
540
  if (diskResult.success) {
531
541
  // Calculate usage from free percentage
532
542
  const usedPercent = 100 - diskResult.diskSpace.freePercentage;
533
543
  const usedRatio = usedPercent / 100;
534
544
  if (usedRatio >= QUEUE_CONFIG.DISK_THRESHOLD) {
535
- reasons.push(formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.DISK_THRESHOLD));
545
+ oneAtATime = true;
536
546
  this.recordThrottle('disk_high');
547
+ // Only block if something is already processing (one-at-a-time mode)
548
+ if (totalProcessing > 0) {
549
+ reasons.push(formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.DISK_THRESHOLD) + ' (waiting for current command)');
550
+ }
537
551
  }
538
552
  }
539
553
 
540
- return { ok: reasons.length === 0, reasons };
554
+ return { ok: reasons.length === 0, reasons, oneAtATime };
541
555
  }
542
556
 
543
557
  /**