@link-assistant/hive-mind 1.69.7 → 1.69.9

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
+ ## 1.69.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 9d04a2f: Detect empty repositories before branch creation when Git reports an unborn branch name.
8
+
9
+ ## 1.69.8
10
+
11
+ ### Patch Changes
12
+
13
+ - 175eaee: Fix two defects in the Telegram `/stop` command. (1) When `/stop` cancels a queued task by URL or reply, the original "⏳ Waiting (… queue #N)" card is now edited in place to show the task was cancelled (instead of leaving it stale). (2) Allow the user who originally ran `/solve` or `/hive` to `/stop` their own task by UUID or URL in a group chat, mirroring the requester authorization already used by `/terminal_watch` and `/watch` (PR #1779). The chat-creator fallback is preserved, so chat owners can still stop any task.
14
+
3
15
  ## 1.69.7
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": "1.69.7",
3
+ "version": "1.69.9",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -71,12 +71,10 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
71
71
  }
72
72
 
73
73
  let defaultBranch = defaultBranchResult.stdout.toString().trim();
74
- if (!defaultBranch) {
75
- // Repository is likely empty (no commits) - detect and handle
76
- const isEmptyRepo = await detectEmptyRepository(tempDir, $);
74
+ const isEmptyRepo = await detectEmptyRepository(tempDir, $);
77
75
 
78
- if (isEmptyRepo && argv && argv.autoInitRepository && owner && repo) {
79
- // --auto-init-repository is enabled, try to initialize
76
+ if (isEmptyRepo) {
77
+ if (argv && argv.autoInitRepository && owner && repo) {
80
78
  await log('');
81
79
  await log(`${formatAligned('⚠️', 'EMPTY REPOSITORY', 'detected')}`, { level: 'warn' });
82
80
  await log(`${formatAligned('', '', `Repository ${owner}/${repo} contains no commits`)}`);
@@ -126,7 +124,6 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
126
124
  await log(`${formatAligned('✅', 'Repository initialized:', `Now on branch ${defaultBranch}`)}`);
127
125
  await log(`\n${formatAligned('📌', 'Default branch:', defaultBranch)}`);
128
126
  } else {
129
- // Auto-init failed - provide helpful message with --auto-init-repository context
130
127
  await log('');
131
128
  await log(`${formatAligned('❌', 'AUTO-INIT FAILED', '')}`, { level: 'error' });
132
129
  await log('');
@@ -149,8 +146,7 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
149
146
 
150
147
  throw new Error('Empty repository auto-initialization failed');
151
148
  }
152
- } else if (isEmptyRepo) {
153
- // Empty repo detected but --auto-init-repository is not enabled
149
+ } else {
154
150
  await log('');
155
151
  await log(`${formatAligned('❌', 'EMPTY REPOSITORY DETECTED', '')}`, { level: 'error' });
156
152
  await log('');
@@ -170,25 +166,24 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
170
166
  await tryCommentOnIssueAboutEmptyRepo({ issueUrl, owner, repo, log, formatAligned, $ });
171
167
 
172
168
  throw new Error('Empty repository detected - use --auto-init-repository to initialize');
173
- } else {
174
- // Not an empty repo, some other issue with branch detection
175
- await log('');
176
- await log(`${formatAligned('❌', 'DEFAULT BRANCH DETECTION FAILED', '')}`, { level: 'error' });
177
- await log('');
178
- await log(' 🔍 What happened:');
179
- await log(" Unable to determine the repository's default branch.");
180
- await log('');
181
- await log(' 💡 This might mean:');
182
- await log(' • Unusual repository configuration');
183
- await log(' • Git command issues');
184
- await log('');
185
- await log(' 🔧 How to fix:');
186
- await log(' 1. Check repository status');
187
- await log(` 2. Verify locally: cd ${tempDir} && git branch`);
188
- await log(` 3. Check remote: cd ${tempDir} && git branch -r`);
189
- await log('');
190
- throw new Error('Default branch detection failed');
191
169
  }
170
+ } else if (!defaultBranch) {
171
+ await log('');
172
+ await log(`${formatAligned('❌', 'DEFAULT BRANCH DETECTION FAILED', '')}`, { level: 'error' });
173
+ await log('');
174
+ await log(' 🔍 What happened:');
175
+ await log(" Unable to determine the repository's default branch.");
176
+ await log('');
177
+ await log(' 💡 This might mean:');
178
+ await log(' • Unusual repository configuration');
179
+ await log(' • Git command issues');
180
+ await log('');
181
+ await log(' 🔧 How to fix:');
182
+ await log(' 1. Check repository status');
183
+ await log(` 2. Verify locally: cd ${tempDir} && git branch`);
184
+ await log(` 3. Check remote: cd ${tempDir} && git branch -r`);
185
+ await log('');
186
+ throw new Error('Default branch detection failed');
192
187
  } else {
193
188
  await log(`\n${formatAligned('📌', 'Default branch:', defaultBranch)}`);
194
189
  }
@@ -267,16 +262,19 @@ Thank you!`;
267
262
  */
268
263
  async function detectEmptyRepository(tempDir, $) {
269
264
  // Check if there are any commits in the repository
270
- const logResult = await $({ cwd: tempDir })`git rev-parse HEAD 2>&1`;
271
- if (logResult.code !== 0) {
272
- // git rev-parse HEAD fails when there are no commits
273
- const output = (logResult.stdout || logResult.stderr || '').toString();
274
- if (output.includes('unknown revision') || output.includes('bad default revision') || output.includes('does not have any commits')) {
275
- return true;
276
- }
265
+ const logResult = await $({ cwd: tempDir })`git rev-parse --verify HEAD 2>&1`;
266
+ if (logResult.code === 0) {
267
+ return false;
268
+ }
269
+
270
+ // git rev-parse HEAD fails when there are no commits
271
+ const output = (logResult.stdout || logResult.stderr || '').toString();
272
+ if (output.includes('unknown revision') || output.includes('ambiguous argument') || output.includes('bad default revision') || output.includes('does not have any commits') || output.includes('Needed a single revision')) {
273
+ return true;
277
274
  }
278
275
 
279
- // Also check if there are any remote branches
276
+ // Fall back to remote branch absence for Git versions with different
277
+ // no-commit messages, but only after HEAD lookup failed.
280
278
  const remoteBranchResult = await $({ cwd: tempDir })`git branch -r`;
281
279
  if (remoteBranchResult.code === 0) {
282
280
  const branches = remoteBranchResult.stdout.toString().trim();
@@ -185,6 +185,62 @@ export function extractStopTarget(text, repliedTo) {
185
185
  return { kind: null, value: null, source: null };
186
186
  }
187
187
 
188
+ /**
189
+ * Update the queue card message in Telegram to show that the task was
190
+ * cancelled via /stop. Best-effort: silently swallows edit failures (e.g.,
191
+ * the card was already cleared by the consumer loop) — the dispatcher still
192
+ * sends its own ack reply, so the user always gets feedback.
193
+ *
194
+ * @param {Object} item - SolveQueueItem; reads .messageInfo and .ctx
195
+ * @param {string} url - GitHub issue/PR URL of the cancelled task
196
+ * @param {string|null} tool - Per-tool queue name (claude/agent/codex/...)
197
+ * @param {string} stopperName - Display name of the user who ran /stop
198
+ * @returns {Promise<boolean>} true when the card was edited
199
+ * @see https://github.com/link-assistant/hive-mind/issues/1783
200
+ */
201
+ export async function updateQueueCardForCancellation(item, url, tool, stopperName) {
202
+ if (!item || !item.messageInfo || !item.ctx) return false;
203
+ const toolSuffix = tool ? ` from \`${tool}\` queue` : '';
204
+ const stopperSuffix = stopperName ? ` by ${stopperName}` : '';
205
+ const text = `🗑 *Cancelled*\n\n${url}\n\nRemoved${toolSuffix}${stopperSuffix} via /stop.`;
206
+ try {
207
+ const { chatId, messageId } = item.messageInfo;
208
+ await item.ctx.telegram.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
209
+ // Match the consumer's contract: once a card reaches a terminal state we
210
+ // forget the message coordinates so nothing else tries to edit it.
211
+ item.messageInfo = null;
212
+ return true;
213
+ } catch (error) {
214
+ console.error('[ERROR] /stop: failed to update queue card for cancellation:', error?.message || error);
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Returns true when the user issuing /stop is the same user who originally
221
+ * requested the targeted task. Mirrors `isTerminalWatchSessionRequester` from
222
+ * /terminal_watch (PR #1779) so the two commands behave consistently.
223
+ *
224
+ * Accepts the requester either from a `SolveQueueItem` (URL flow) or from
225
+ * a tracked session info record (UUID flow).
226
+ *
227
+ * @param {Object} args
228
+ * @param {number|string|null|undefined} args.userId - ctx.from.id of the /stop sender
229
+ * @param {Object|null} [args.queueItem] - matched SolveQueueItem, if any
230
+ * @param {Object|null} [args.sessionInfo] - tracked session info, if any
231
+ * @returns {boolean}
232
+ * @see https://github.com/link-assistant/hive-mind/issues/1783
233
+ */
234
+ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo = null } = {}) {
235
+ if (userId === null || userId === undefined) return false;
236
+ const candidates = [queueItem?.requesterUserId, sessionInfo?.requesterUserId];
237
+ for (const candidate of candidates) {
238
+ if (candidate === null || candidate === undefined) continue;
239
+ if (String(candidate) === String(userId)) return true;
240
+ }
241
+ return false;
242
+ }
243
+
188
244
  /**
189
245
  * Registers the /start and /stop command handlers with the bot
190
246
  * @param {Object} bot - The Telegraf bot instance
@@ -205,6 +261,18 @@ export function extractStopTarget(text, repliedTo) {
205
261
  export function registerStartStopCommands(bot, options) {
206
262
  const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
207
263
  const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
264
+ // Issue #1783: look the UUID up in the session monitor so /stop can let the
265
+ // user who started the task stop it (mirrors /terminal_watch from PR #1779).
266
+ // Test stubs can inject getTrackedSessionInfo directly via options.
267
+ // The real session-monitor.getTrackedSessionInfo is sync; we wrap it in an
268
+ // async tolerant helper so tests can pass either a sync or async stub.
269
+ async function lookupTrackedSessionInfo(sessionId) {
270
+ if (typeof options.getTrackedSessionInfo === 'function') {
271
+ return await options.getTrackedSessionInfo(sessionId);
272
+ }
273
+ const mod = await import('./session-monitor.lib.mjs');
274
+ return mod.getTrackedSessionInfo(sessionId);
275
+ }
208
276
 
209
277
  /**
210
278
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
@@ -253,16 +321,26 @@ export function registerStartStopCommands(bot, options) {
253
321
  }
254
322
 
255
323
  /**
256
- * Owner-only auth check for the /stop UUID and /stop URL flows. Mirrors the
257
- * /log auth model: in private DMs the user is implicitly the owner; in
258
- * groups they must be the chat creator. Replies with the appropriate error
259
- * directly when auth fails.
324
+ * Authorization check for the /stop UUID and /stop URL flows.
325
+ *
326
+ * In private DMs the user is implicitly authorized. In group chats the user
327
+ * is authorized when EITHER:
328
+ * - They are the chat creator, OR
329
+ * - They are the original requester of the task being stopped (the user
330
+ * who ran the /solve or /hive that produced the queue item / session).
331
+ *
332
+ * This mirrors /terminal_watch and /watch (PR #1779) which already let the
333
+ * task requester act on their own session without requiring chat-owner
334
+ * privileges. See https://github.com/link-assistant/hive-mind/issues/1783.
260
335
  *
261
336
  * @param {Object} ctx - Telegraf context
262
337
  * @param {string} label - Short human-readable label for the variant ('UUID', 'URL')
338
+ * @param {Object} [opts]
339
+ * @param {Object|null} [opts.queueItem] - matched SolveQueueItem, if known
340
+ * @param {Object|null} [opts.sessionInfo] - tracked session info, if known
263
341
  * @returns {Promise<boolean>} true when authorized
264
342
  */
265
- async function authorizeTargetedStop(ctx, label) {
343
+ async function authorizeTargetedStop(ctx, label, { queueItem = null, sessionInfo = null } = {}) {
266
344
  const message = ctx.message;
267
345
  const chatId = ctx.chat?.id;
268
346
  const chatType = ctx.chat?.type;
@@ -278,11 +356,17 @@ export function registerStartStopCommands(bot, options) {
278
356
  return false;
279
357
  }
280
358
  }
359
+
360
+ if (isStopTargetRequester({ userId: ctx.from?.id, queueItem, sessionInfo })) {
361
+ VERBOSE && console.log(`[VERBOSE] /stop <${label}> allowed for task requester ${ctx.from?.id}`);
362
+ return true;
363
+ }
364
+
281
365
  try {
282
366
  const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
283
367
  if (!member || member.status !== 'creator') {
284
- VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner`);
285
- await ctx.reply(`❌ /stop <${label}> is only available to the chat owner.`, { reply_to_message_id: message.message_id });
368
+ VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner or task requester`);
369
+ await ctx.reply(`❌ /stop <${label}> is only available to the chat owner or the user who started this task.`, { reply_to_message_id: message.message_id });
286
370
  return false;
287
371
  }
288
372
  } catch (error) {
@@ -350,19 +434,35 @@ export function registerStartStopCommands(bot, options) {
350
434
  }
351
435
 
352
436
  /**
353
- * Resolve a `/stop <url>` request against the in-memory solve queue.
354
- * Returns an action descriptor that the dispatcher executes.
437
+ * Find the candidate queue/processing item for a `/stop <url>` request
438
+ * without mutating queue state. Used by the dispatcher to look up the
439
+ * task's requesterUserId for authorization before any cancellation
440
+ * happens (#1783).
355
441
  *
356
442
  * @param {string} url - Normalized GitHub issue or PR URL
357
- * @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
443
+ * @returns {{ action: 'no-queue'|'not-found'|'candidate', item?: Object, queue?: Object }}
358
444
  */
359
- function resolveQueueLookupForUrl(url) {
445
+ function findQueueCandidateForUrl(url) {
360
446
  if (typeof getSolveQueue !== 'function') {
361
447
  return { action: 'no-queue' };
362
448
  }
363
449
  const queue = getSolveQueue({ verbose: VERBOSE });
364
450
  const item = queue?.findByUrl?.(url);
365
451
  if (!item) return { action: 'not-found' };
452
+ return { action: 'candidate', item, queue };
453
+ }
454
+
455
+ /**
456
+ * Resolve a `/stop <url>` request against the in-memory solve queue.
457
+ * Returns an action descriptor that the dispatcher executes.
458
+ *
459
+ * @param {string} url - Normalized GitHub issue or PR URL
460
+ * @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
461
+ */
462
+ function resolveQueueLookupForUrl(url) {
463
+ const candidate = findQueueCandidateForUrl(url);
464
+ if (candidate.action !== 'candidate') return candidate;
465
+ const { item, queue } = candidate;
366
466
 
367
467
  // Queued items have a defined .id and live in one of the per-tool queues.
368
468
  // The cancel(id) call walks every per-tool queue and returns true on hit.
@@ -409,7 +509,17 @@ export function registerStartStopCommands(bot, options) {
409
509
  if (target.kind === 'uuid') {
410
510
  const sessionId = target.value;
411
511
  VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
412
- const ok = await authorizeTargetedStop(ctx, 'UUID');
512
+ // Look up the session's owner before auth so the original task requester
513
+ // can /stop their own session in a group even if they aren't the chat
514
+ // creator (#1783). Lookup failures are non-fatal: we fall through to the
515
+ // chat-owner-only check.
516
+ let sessionInfo = null;
517
+ try {
518
+ sessionInfo = await lookupTrackedSessionInfo(sessionId);
519
+ } catch (error) {
520
+ console.error('[ERROR] /stop: getTrackedSessionInfo failed:', error);
521
+ }
522
+ const ok = await authorizeTargetedStop(ctx, 'UUID', { sessionInfo });
413
523
  if (!ok) return;
414
524
  await runStopIsolatedSessionFlow(ctx, sessionId);
415
525
  return;
@@ -418,7 +528,13 @@ export function registerStartStopCommands(bot, options) {
418
528
  if (target.kind === 'url') {
419
529
  const url = target.value;
420
530
  VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
421
- const ok = await authorizeTargetedStop(ctx, 'URL');
531
+
532
+ // Look up the queue item BEFORE auth so we can allow the original task
533
+ // requester to cancel their own task in a group (#1783). The lookup
534
+ // here does NOT mutate the queue — actual cancel happens below in
535
+ // resolveQueueLookupForUrl after auth has passed.
536
+ const candidate = findQueueCandidateForUrl(url);
537
+ const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null });
422
538
  if (!ok) return;
423
539
 
424
540
  const lookup = resolveQueueLookupForUrl(url);
@@ -443,6 +559,14 @@ export function registerStartStopCommands(bot, options) {
443
559
  if (lookup.action === 'cancel-queued') {
444
560
  VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
445
561
  const toolLabel = lookup.tool ? ` from \`${lookup.tool}\` queue` : '';
562
+
563
+ // Update the original queue card ("⏳ Waiting (claude queue #3)") to
564
+ // reflect that the task was cancelled. The queue stores the message
565
+ // coordinates on the item itself when the card was first posted —
566
+ // see telegram-bot.mjs where `item.messageInfo` is wired up.
567
+ const stopperName = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || `user ${ctx.from?.id}`;
568
+ await updateQueueCardForCancellation(lookup.item, url, lookup.tool, stopperName);
569
+
446
570
  await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
447
571
  parse_mode: 'Markdown',
448
572
  reply_to_message_id: message.message_id,