@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 +12 -0
- package/package.json +1 -1
- package/src/solve.repo-setup.lib.mjs +32 -34
- package/src/telegram-start-stop-command.lib.mjs +137 -13
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
|
@@ -71,12 +71,10 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
let defaultBranch = defaultBranchResult.stdout.toString().trim();
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
-
*
|
|
354
|
-
*
|
|
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'|'
|
|
443
|
+
* @returns {{ action: 'no-queue'|'not-found'|'candidate', item?: Object, queue?: Object }}
|
|
358
444
|
*/
|
|
359
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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,
|