@link-assistant/hive-mind 1.6.3 → 1.7.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,39 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d86ba79: Prevent duplicate URLs from being added to the /solve queue (Issue #1080)
8
+ - Added `findByUrl()` method to SolveQueue to detect existing items by URL
9
+ - Updated /solve command handler to check for duplicates before queueing
10
+ - Uses normalized URLs for consistent comparison
11
+ - Returns informative error message when duplicate is detected
12
+
13
+ ## 1.7.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 5794e2f: Add `--working-directory` / `-d` option for proper session resume
18
+
19
+ Claude Code stores sessions per-directory path, so resuming a session in a different directory fails. This change:
20
+ 1. Adds `--working-directory` / `-d` option to solve.mjs
21
+ - If directory exists with git repo, uses it without cloning
22
+ - If directory exists but empty, clones into it
23
+ - If directory doesn't exist, creates it and clones
24
+ 2. Updates `--auto-resume-on-limit-reset` to pass `--working-directory`
25
+ - When limit resets and session auto-resumes, it uses the same directory as the original session
26
+ - This ensures Claude Code can find and resume the session
27
+ 3. Improves resume error messaging
28
+ - Warns when resuming without --working-directory
29
+ - Explains that Claude Code sessions are tied to directory paths
30
+
31
+ Example usage:
32
+
33
+ ```bash
34
+ ./solve.mjs "<url>" --resume <session-id> --working-directory /tmp/gh-issue-solver-123
35
+ ```
36
+
3
37
  ## 1.6.3
4
38
 
5
39
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.6.3",
3
+ "version": "1.7.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",
@@ -65,7 +65,9 @@ const formatWaitTime = ms => {
65
65
  };
66
66
 
67
67
  // Auto-continue function that waits until limit resets
68
- export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, shouldAttachLogs) => {
68
+ // tempDir parameter is required for passing --working-directory to the resumed session
69
+ // (Claude Code sessions are stored per-working-directory, so resume must use same directory)
70
+ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, shouldAttachLogs, tempDir = null) => {
69
71
  try {
70
72
  const resetTime = global.limitResetTime;
71
73
  const waitMs = calculateWaitTime(resetTime);
@@ -115,6 +117,17 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
115
117
  if (argv.fork) resumeArgs.push('--fork');
116
118
  if (shouldAttachLogs) resumeArgs.push('--attach-logs');
117
119
 
120
+ // CRITICAL: Pass --working-directory to ensure Claude Code session resume works correctly
121
+ // Claude Code stores sessions per working directory, so resume MUST use the same directory
122
+ // Without this, resume creates a NEW temp directory and session is not found
123
+ if (tempDir) {
124
+ resumeArgs.push('--working-directory', tempDir);
125
+ await log(`šŸ“‚ Using working directory for session continuity: ${tempDir}`);
126
+ } else {
127
+ await log(`āš ļø Warning: No working directory specified - session resume may fail`);
128
+ await log(` Claude Code sessions are stored per-directory, consider using --working-directory`);
129
+ }
130
+
118
131
  await log(`\nšŸ”„ Executing: ${resumeArgs.join(' ')}`);
119
132
 
120
133
  // Execute the resume command
@@ -136,7 +149,11 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
136
149
  });
137
150
  await log(`\nāŒ Auto-continue failed: ${cleanErrorMessage(error)}`, { level: 'error' });
138
151
  await log('\nšŸ”„ Manual resume command:');
139
- await log(`./solve.mjs "${issueUrl}" --resume ${sessionId}`);
152
+ if (tempDir) {
153
+ await log(`./solve.mjs "${issueUrl}" --resume ${sessionId} --working-directory "${tempDir}"`);
154
+ } else {
155
+ await log(`./solve.mjs "${issueUrl}" --resume ${sessionId}`);
156
+ }
140
157
  await safeExit(1, 'Auto-continue failed');
141
158
  }
142
159
  };
@@ -47,6 +47,11 @@ export const createYargsConfig = yargsInstance => {
47
47
  description: 'Resume from a previous session ID (when limit was reached)',
48
48
  alias: 'r',
49
49
  })
50
+ .option('working-directory', {
51
+ type: 'string',
52
+ description: 'Use specified working directory instead of creating a new temp directory. If directory does not exist, it will be created and the repository will be cloned. Essential for --resume to work correctly with Claude Code sessions.',
53
+ alias: 'd',
54
+ })
50
55
  .option('only-prepare-command', {
51
56
  type: 'boolean',
52
57
  description: 'Only prepare and print the claude command without executing it',
package/src/solve.mjs CHANGED
@@ -513,7 +513,7 @@ if (isPrUrl) {
513
513
  // Create or find temporary directory for cloning the repository
514
514
  // Pass workspace info for --enable-workspaces mode (works with all tools)
515
515
  const workspaceInfo = argv.enableWorkspaces ? { owner, repo, issueNumber } : null;
516
- const { tempDir, workspaceTmpDir } = await setupTempDirectory(argv, workspaceInfo);
516
+ const { tempDir, workspaceTmpDir, needsClone } = await setupTempDirectory(argv, workspaceInfo);
517
517
  // Populate cleanup context for signal handlers
518
518
  cleanupContext.tempDir = tempDir;
519
519
  cleanupContext.argv = argv;
@@ -521,6 +521,7 @@ cleanupContext.argv = argv;
521
521
  let limitReached = false;
522
522
  try {
523
523
  // Set up repository and clone using the new module
524
+ // If --working-directory points to existing repo, needsClone is false and we skip cloning
524
525
  const { forkedRepo } = await setupRepositoryAndClone({
525
526
  argv,
526
527
  owner,
@@ -532,6 +533,7 @@ try {
532
533
  log,
533
534
  formatAligned,
534
535
  $,
536
+ needsClone,
535
537
  });
536
538
 
537
539
  // Verify default branch and status using the new module
@@ -3,14 +3,23 @@
3
3
  * Handles repository cloning, forking, and remote setup
4
4
  */
5
5
 
6
- export async function setupRepositoryAndClone({ argv, owner, repo, forkOwner, tempDir, isContinueMode, issueUrl, log, $ }) {
6
+ export async function setupRepositoryAndClone({ argv, owner, repo, forkOwner, tempDir, isContinueMode, issueUrl, log, $, needsClone = true }) {
7
7
  // Set up repository and handle forking
8
8
  const { repoToClone, forkedRepo, upstreamRemote, prForkOwner } = await setupRepository(argv, owner, repo, forkOwner, issueUrl);
9
9
 
10
- // Clone repository and set up remotes
11
- await cloneRepository(repoToClone, tempDir, argv, owner, repo);
12
- // Set up upstream remote and sync fork if needed
13
- await setupUpstreamAndSync(tempDir, forkedRepo, upstreamRemote, owner, repo, argv);
10
+ // Clone repository and set up remotes (skip if needsClone is false - directory already has repo)
11
+ if (needsClone) {
12
+ await cloneRepository(repoToClone, tempDir, argv, owner, repo);
13
+ // Set up upstream remote and sync fork if needed
14
+ await setupUpstreamAndSync(tempDir, forkedRepo, upstreamRemote, owner, repo, argv);
15
+ } else {
16
+ await log('ā„¹ļø Skipping clone: Using existing repository in working directory');
17
+ // Still need to ensure upstream remote is set up if using fork mode
18
+ if (forkedRepo && upstreamRemote) {
19
+ await setupUpstreamAndSync(tempDir, forkedRepo, upstreamRemote, owner, repo, argv);
20
+ }
21
+ }
22
+
14
23
  // Set up pr-fork remote if we're continuing someone else's fork PR with --fork flag
15
24
  const prForkRemote = await setupPrForkRemote(tempDir, argv, prForkOwner, repo, isContinueMode, owner);
16
25
 
@@ -220,16 +220,58 @@ export const buildWorkspacePath = (owner, repo, issueNumber, timestamp) => {
220
220
  // When --enable-workspaces is used, creates:
221
221
  // {workspace}/repository - for the cloned repo
222
222
  // {workspace}/tmp - for temp files, logs, downloads
223
+ // When --working-directory is used, uses the specified directory (creates if needed)
223
224
  export const setupTempDirectory = async (argv, workspaceInfo = null) => {
224
225
  let tempDir;
225
226
  let workspaceTmpDir = null;
226
227
  let isResuming = argv.resume;
228
+ // needsClone indicates if the repository needs to be cloned into the directory
229
+ // This is true when: new directory is created, or existing directory is empty
230
+ let needsClone = true;
227
231
 
228
232
  // Check if workspace mode should be enabled (works with all tools)
229
233
  const useWorkspaces = argv.enableWorkspaces;
230
234
 
235
+ // Priority 1: --working-directory option takes precedence over all other directory selection
236
+ // This is essential for --resume to work correctly with Claude Code sessions,
237
+ // because Claude Code stores sessions by working directory path, not session ID alone
238
+ if (argv.workingDirectory) {
239
+ tempDir = path.resolve(argv.workingDirectory);
240
+
241
+ // Check if directory exists
242
+ try {
243
+ const stat = await fs.stat(tempDir);
244
+ if (stat.isDirectory()) {
245
+ // Directory exists - check if it contains a git repository
246
+ try {
247
+ await fs.access(path.join(tempDir, '.git'));
248
+ // Git repository exists - no need to clone
249
+ needsClone = false;
250
+ await log(`\n${formatAligned('šŸ“‚', 'Working directory:', tempDir)}`);
251
+ await log(formatAligned('', 'Status:', 'Using existing repository', 2));
252
+ if (isResuming) {
253
+ await log(formatAligned('', 'Session:', `Resuming ${argv.resume}`, 2));
254
+ }
255
+ } catch {
256
+ // No .git directory - directory is empty or doesn't have a repo, will clone
257
+ await log(`\n${formatAligned('šŸ“‚', 'Working directory:', tempDir)}`);
258
+ await log(formatAligned('', 'Status:', 'Directory exists but no repository - will clone', 2));
259
+ }
260
+ }
261
+ } catch {
262
+ // Directory doesn't exist - create it
263
+ await fs.mkdir(tempDir, { recursive: true });
264
+ await log(`\n${formatAligned('šŸ“‚', 'Working directory:', tempDir)}`);
265
+ await log(formatAligned('', 'Status:', 'Created new directory - will clone repository', 2));
266
+ }
267
+
268
+ return { tempDir, workspaceTmpDir, isResuming, needsClone };
269
+ }
270
+
231
271
  if (isResuming) {
232
- // When resuming, try to find existing directory or create a new one
272
+ // When resuming without --working-directory, create a new temp directory
273
+ // WARNING: This will NOT work correctly with Claude Code because the session
274
+ // is stored in a path-specific location. Use --working-directory for proper resume.
233
275
  const scriptDir = path.dirname(process.argv[1]);
234
276
  const sessionLogPattern = path.join(scriptDir, `${argv.resume}.log`);
235
277
 
@@ -241,7 +283,9 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
241
283
  // For resumed sessions, create new temp directory since old one may be cleaned up
242
284
  tempDir = path.join(os.tmpdir(), `gh-issue-solver-resume-${argv.resume}-${Date.now()}`);
243
285
  await fs.mkdir(tempDir, { recursive: true });
244
- await log(`Creating new temporary directory for resumed session: ${tempDir}`);
286
+ await log(`āš ļø Creating new temporary directory for resumed session: ${tempDir}`);
287
+ await log(` Note: Claude Code sessions are tied to working directory paths.`);
288
+ await log(` If session resume fails, use --working-directory to specify the original directory.`);
245
289
  } catch (err) {
246
290
  reportError(err, {
247
291
  context: 'resume_session_lookup',
@@ -280,7 +324,7 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
280
324
  await log(`\nCreating temporary directory: ${tempDir}`);
281
325
  }
282
326
 
283
- return { tempDir, workspaceTmpDir, isResuming };
327
+ return { tempDir, workspaceTmpDir, isResuming, needsClone };
284
328
  };
285
329
 
286
330
  // Try to initialize an empty repository by creating a simple README.md
@@ -377,7 +377,9 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
377
377
 
378
378
  if (argv.autoResumeOnLimitReset && global.limitResetTime) {
379
379
  await log(`\nšŸ”„ AUTO-RESUME ON LIMIT RESET ENABLED - Will resume at ${global.limitResetTime}`);
380
- await autoContinueWhenLimitResets(issueUrl, sessionId, argv, shouldAttachLogs);
380
+ // Pass tempDir to ensure resumed session uses the same working directory
381
+ // This is critical for Claude Code session resume to work correctly
382
+ await autoContinueWhenLimitResets(issueUrl, sessionId, argv, shouldAttachLogs, tempDir);
381
383
  } else {
382
384
  if (global.limitResetTime) {
383
385
  await log(`\nā° Limit resets at: ${global.limitResetTime}`);
@@ -1069,18 +1069,32 @@ bot.command(/^solve$/i, async ctx => {
1069
1069
  return;
1070
1070
  }
1071
1071
 
1072
+ // Use normalized URL from validation to ensure consistent duplicate detection
1073
+ // See: https://github.com/link-assistant/hive-mind/issues/1080
1074
+ const normalizedUrl = validation.parsed.normalized;
1075
+
1072
1076
  const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
1073
1077
  const optionsText = args.slice(1).join(' ') || 'none';
1074
- let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(args[0])}\nOptions: ${optionsText}`;
1078
+ let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(normalizedUrl)}\nOptions: ${optionsText}`;
1075
1079
  if (solveOverrides.length > 0) infoBlock += `\nšŸ”’ Locked options: ${solveOverrides.join(' ')}`;
1076
1080
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
1081
+
1082
+ // Check for duplicate URL in queue
1083
+ // See: https://github.com/link-assistant/hive-mind/issues/1080
1084
+ const existingItem = solveQueue.findByUrl(normalizedUrl);
1085
+ if (existingItem) {
1086
+ const statusText = existingItem.status === 'starting' || existingItem.status === 'started' ? 'being processed' : 'already in the queue';
1087
+ await ctx.reply(`āŒ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\nšŸ’” Use /solve-queue to check the queue status.`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1088
+ return;
1089
+ }
1090
+
1077
1091
  const check = await solveQueue.canStartCommand();
1078
1092
  const queueStats = solveQueue.getStats();
1079
1093
  if (check.canStart && queueStats.queued === 0) {
1080
1094
  const startingMessage = await ctx.reply(`šŸš€ Starting solve command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1081
1095
  await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
1082
1096
  } else {
1083
- const queueItem = solveQueue.enqueue({ url: args[0], args, ctx, requester, infoBlock, tool: solveTool });
1097
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool });
1084
1098
  let queueMessage = `šŸ“‹ Solve command queued (position #${queueStats.queued + 1})\n\n${infoBlock}`;
1085
1099
  if (check.reason) queueMessage += `\n\nā³ Waiting: ${check.reason}`;
1086
1100
  const queuedMessage = await ctx.reply(queueMessage, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
@@ -303,6 +303,30 @@ export class SolveQueue {
303
303
  return item;
304
304
  }
305
305
 
306
+ /**
307
+ * Find an item by URL in the queue or processing items
308
+ * Used to prevent duplicate URLs from being added to the queue
309
+ * @param {string} url - The URL to search for
310
+ * @returns {SolveQueueItem|null} The found item or null
311
+ * @see https://github.com/link-assistant/hive-mind/issues/1080
312
+ */
313
+ findByUrl(url) {
314
+ // Check queued items
315
+ const queuedItem = this.queue.find(item => item.url === url);
316
+ if (queuedItem) {
317
+ return queuedItem;
318
+ }
319
+
320
+ // Check processing items
321
+ for (const item of this.processing.values()) {
322
+ if (item.url === url) {
323
+ return item;
324
+ }
325
+ }
326
+
327
+ return null;
328
+ }
329
+
306
330
  /**
307
331
  * Cancel a queued item by ID
308
332
  * @param {string} id - Item ID