@link-assistant/hive-mind 2.7.1 → 2.7.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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.7.2
4
+
5
+ ### Patch Changes
6
+
7
+ - c6add61: Add experimental `--auto-resume-on-uncommitted-changes` flag (#1056) that complements the existing `--auto-restart-on-uncommitted-changes` by reusing the previous Claude Code session via `--resume <sessionId>` when uncommitted changes are detected, preserving the agent's accumulated context instead of starting a fresh session. The flag is disabled by default. A companion knob, `--auto-resume-on-uncommitted-changes-maximum-context-window-usage` (default 50%), bounds the worst-case peak usage of the usable pre-compaction context (respecting `--sub-session-size`); sessions at or above the threshold, or sessions whose usage cannot be verified, fall back to a fresh run.
8
+
3
9
  ## 2.7.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.7.1",
3
+ "version": "2.7.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",
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Auto-resume on uncommitted changes — decision helpers.
5
+ *
6
+ * Issue #1056: when uncommitted changes are detected and the user has
7
+ * enabled `--auto-resume-on-uncommitted-changes`, we want to call the
8
+ * agent again with `--resume <sessionId>` (preserving context) instead
9
+ * of starting a fresh session — but only when the previous session has
10
+ * not already filled most of its usable pre-compaction context. The threshold
11
+ * defaults to 50% of that usable limit and is configurable via
12
+ * `--auto-resume-on-uncommitted-changes-maximum-context-window-usage`.
13
+ *
14
+ * This module is intentionally tool-agnostic. It does not perform the
15
+ * resume itself — it just decides whether resuming is viable and
16
+ * computes the percentage that the caller can log.
17
+ */
18
+
19
+ import { parseSubSessionSize } from './sub-session-size.lib.mjs';
20
+
21
+ export const DEFAULT_MAX_CONTEXT_USAGE_PERCENT = 50;
22
+
23
+ /**
24
+ * Read the configured max-context-usage threshold (in percent) from argv.
25
+ *
26
+ * Accepts both the camelCase form populated by yargs
27
+ * (`autoResumeOnUncommittedChangesMaximumContextWindowUsage`) and the
28
+ * dash-cased flag itself, so that programmatic callers and CLI users
29
+ * see the same default.
30
+ *
31
+ * @param {Object} argv - parsed CLI arguments
32
+ * @returns {number} threshold in [0, 100]
33
+ */
34
+ export const getAutoResumeMaxContextUsage = (argv = {}) => {
35
+ const candidates = [argv.autoResumeOnUncommittedChangesMaximumContextWindowUsage, argv['auto-resume-on-uncommitted-changes-maximum-context-window-usage']];
36
+ for (const value of candidates) {
37
+ if (value === undefined || value === null || value === '') continue;
38
+ const parsed = typeof value === 'number' ? value : Number(value);
39
+ if (Number.isFinite(parsed)) return Math.max(0, Math.min(100, parsed));
40
+ }
41
+ return DEFAULT_MAX_CONTEXT_USAGE_PERCENT;
42
+ };
43
+
44
+ /**
45
+ * Whether `--auto-resume-on-uncommitted-changes` is enabled.
46
+ * @param {Object} argv
47
+ * @returns {boolean}
48
+ */
49
+ export const isAutoResumeOnUncommittedChangesEnabled = (argv = {}) => {
50
+ return argv.autoResumeOnUncommittedChanges === true || argv['auto-resume-on-uncommitted-changes'] === true;
51
+ };
52
+
53
+ /**
54
+ * Pick the largest peak-context-input across all models in a token-usage map
55
+ * that has a known model context limit, and return both the peak and the
56
+ * matching limit. We use the worst (highest-utilisation) model so that
57
+ * resuming with multi-model sessions does not silently exceed the threshold
58
+ * for the model that has the smallest remaining headroom.
59
+ *
60
+ * @param {Object|null} tokenUsage - shape returned by calculateSessionTokens
61
+ * @returns {{peak: number, limit: number, contextLimit: number, ratio: number}|null} null when no model with verified usage and a known limit was found
62
+ */
63
+ export const pickWorstContextUtilisation = (tokenUsage, argv = {}) => {
64
+ if (!tokenUsage || !tokenUsage.modelUsage) return null;
65
+ let worst = null;
66
+ for (const usage of Object.values(tokenUsage.modelUsage)) {
67
+ const contextLimit = usage?.modelInfo?.limit?.context;
68
+ if (!contextLimit || contextLimit <= 0) continue;
69
+ let limit = contextLimit;
70
+ try {
71
+ const configured = argv.subSessionSize ?? argv['sub-session-size'];
72
+ const subSession = parseSubSessionSize(configured, { contextWindow: contextLimit });
73
+ if (subSession.kind === 'tokens' && subSession.tokens > 0) {
74
+ limit = Math.min(contextLimit, subSession.tokens);
75
+ } else if (subSession.kind === 'percent' && subSession.tokens > 0) {
76
+ limit = Math.min(contextLimit, subSession.tokens);
77
+ }
78
+ } catch {
79
+ // Invalid values are reported by normal CLI validation. Programmatic
80
+ // callers still get a conservative decision against the model limit.
81
+ }
82
+ const peak = usage.peakContextUsage;
83
+ if (!Number.isFinite(peak) || peak <= 0) continue;
84
+ const ratio = peak / limit;
85
+ if (!worst || ratio > worst.ratio) worst = { peak, limit, contextLimit, ratio };
86
+ }
87
+ return worst;
88
+ };
89
+
90
+ /**
91
+ * Decide whether resuming is viable given a session ID and the previous
92
+ * session's token-usage data. Returns a structured result that the caller
93
+ * can log and act on.
94
+ *
95
+ * The decision tree is:
96
+ * - no auto-resume flag → 'disabled'
97
+ * - flag set, no session ID known → 'no_session_id'
98
+ * - flag set, session id known, no usable context-stat data → 'no_context_data'
99
+ * (fall back to a fresh run because available headroom cannot be verified)
100
+ * - flag set, peak >= threshold → 'context_too_full'
101
+ * - flag set, peak < threshold → 'ok'
102
+ *
103
+ * @param {Object} params
104
+ * @param {Object} params.argv - parsed CLI arguments
105
+ * @param {string|null} params.sessionId - the session ID to resume, if any
106
+ * @param {Object|null} params.tokenUsage - result of calculateSessionTokens (may be null)
107
+ * @returns {{resume: boolean, reason: string, threshold: number, usedPercent: number|null, peak: number|null, limit: number|null}}
108
+ */
109
+ export const decideAutoResumeOnUncommittedChanges = ({ argv = {}, sessionId = null, tokenUsage = null } = {}) => {
110
+ const threshold = getAutoResumeMaxContextUsage(argv);
111
+ if (!isAutoResumeOnUncommittedChangesEnabled(argv)) {
112
+ return { resume: false, reason: 'disabled', threshold, usedPercent: null, peak: null, limit: null };
113
+ }
114
+ if (!sessionId) {
115
+ return { resume: false, reason: 'no_session_id', threshold, usedPercent: null, peak: null, limit: null };
116
+ }
117
+ const worst = pickWorstContextUtilisation(tokenUsage, argv);
118
+ if (!worst) {
119
+ return { resume: false, reason: 'no_context_data', threshold, usedPercent: null, peak: null, limit: null };
120
+ }
121
+ const usedPercent = (worst.peak / worst.limit) * 100;
122
+ if (usedPercent >= threshold) {
123
+ return {
124
+ resume: false,
125
+ reason: 'context_too_full',
126
+ threshold,
127
+ usedPercent,
128
+ peak: worst.peak,
129
+ limit: worst.limit,
130
+ };
131
+ }
132
+ return {
133
+ resume: true,
134
+ reason: 'ok',
135
+ threshold,
136
+ usedPercent,
137
+ peak: worst.peak,
138
+ limit: worst.limit,
139
+ };
140
+ };
141
+
142
+ export default {
143
+ DEFAULT_MAX_CONTEXT_USAGE_PERCENT,
144
+ getAutoResumeMaxContextUsage,
145
+ isAutoResumeOnUncommittedChangesEnabled,
146
+ pickWorstContextUtilisation,
147
+ decideAutoResumeOnUncommittedChanges,
148
+ };
@@ -197,6 +197,8 @@ const KNOWN_OPTION_NAMES = [
197
197
  'auto-pull-request-creation',
198
198
  'auto-commit-uncommitted-changes',
199
199
  'auto-restart-on-uncommitted-changes',
200
+ 'auto-resume-on-uncommitted-changes',
201
+ 'auto-resume-on-uncommitted-changes-maximum-context-window-usage',
200
202
  'continue-only-on-feedback',
201
203
  'claude-file',
202
204
  'gitkeep-file',
@@ -201,6 +201,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
201
201
  description: 'Automatically restart when uncommitted changes are detected to allow the tool to handle them (default: true, use --no-auto-restart-on-uncommitted-changes to disable)',
202
202
  default: true,
203
203
  },
204
+ 'auto-resume-on-uncommitted-changes': {
205
+ type: 'boolean',
206
+ description: 'EXPERIMENTAL: Automatically resume the previous Claude session when uncommitted changes are detected. Falls back to a fresh session when usable context headroom cannot be verified or is too low. Disabled by default; use --no-auto-resume-on-uncommitted-changes to switch it off explicitly.',
207
+ default: false,
208
+ },
209
+ 'auto-resume-on-uncommitted-changes-maximum-context-window-usage': {
210
+ type: 'number',
211
+ description: 'Maximum usable pre-compaction context usage (percent) that still allows --auto-resume-on-uncommitted-changes to resume. The usable limit respects --sub-session-size. At or above this threshold the tool starts a fresh session (default: 50).',
212
+ default: 50,
213
+ },
204
214
  'auto-restart-max-iterations': {
205
215
  type: 'number',
206
216
  description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
package/src/solve.mjs CHANGED
@@ -12,7 +12,6 @@ const { configureGitHubRateLimitLogging, wrapDollarWithGhRetry } = await import(
12
12
  const $ = wrapDollarWithGhRetry(__rawDollar$);
13
13
  const config = await import('./solve.config.lib.mjs');
14
14
  const { initializeConfig, parseArguments } = config;
15
- // Import Sentry integration
16
15
  const sentryLib = await import('./sentry.lib.mjs');
17
16
  const { initializeSentry, addBreadcrumb, reportError, closeSentry } = sentryLib;
18
17
  const { yargs, hideBin } = await initializeConfig(use);
@@ -1334,6 +1333,8 @@ try {
1334
1333
  prBranch,
1335
1334
  branchName,
1336
1335
  tempDir,
1336
+ initialSessionId: sessionId,
1337
+ initialResultModelUsage: resultModelUsage,
1337
1338
  argv: {
1338
1339
  ...argv,
1339
1340
  watch: argv.watch || shouldRestart, // Enable watch if uncommitted changes
@@ -1341,7 +1342,6 @@ try {
1341
1342
  },
1342
1343
  });
1343
1344
 
1344
- // Update session data with latest from watch mode for accurate pricing
1345
1345
  if (watchResult && watchResult.latestSessionId) {
1346
1346
  sessionId = watchResult.latestSessionId;
1347
1347
  anthropicTotalCostUSD = watchResult.latestAnthropicCost;
@@ -65,6 +65,11 @@ const { trackAuthenticatedUserCommentsSince } = autoMergeHelpers;
65
65
  const resultsLib = await import('./solve.results.lib.mjs');
66
66
  const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = resultsLib;
67
67
 
68
+ // Issue #1056: Auto-resume on uncommitted changes — decide whether to call the
69
+ // agent again with --resume <sessionId> instead of restarting from scratch.
70
+ const autoResumeLib = await import('./auto-resume-uncommitted.lib.mjs');
71
+ const { decideAutoResumeOnUncommittedChanges, isAutoResumeOnUncommittedChangesEnabled } = autoResumeLib;
72
+
68
73
  /**
69
74
  * Monitor for feedback in a loop and trigger restart when detected
70
75
  */
@@ -76,8 +81,12 @@ export const watchForFeedback = async params => {
76
81
  const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
77
82
 
78
83
  // Track latest session data across all iterations for accurate pricing
79
- let latestSessionId = null;
84
+ // Issue #1056: Seed from the *initial* tool execution so the first auto-restart
85
+ // iteration can attempt --resume on the original session if the user opted in
86
+ // via --auto-resume-on-uncommitted-changes.
87
+ let latestSessionId = params.initialSessionId || null;
80
88
  let latestAnthropicCost = null;
89
+ let latestResultModelUsage = params.initialResultModelUsage || null;
81
90
 
82
91
  // Issue #1290: Track whether auto-restart iterations actually ran and whether logs were uploaded
83
92
  // This helps solve.mjs decide whether to upload final logs
@@ -323,16 +332,43 @@ export const watchForFeedback = async params => {
323
332
 
324
333
  let restartFeedbackLines = feedbackLines;
325
334
  let restartArgv = argv;
326
- const shouldUseSessionResume = Boolean(isTemporaryWatch && (firstIterationInTemporaryMode || hasUncommittedInTempMode) && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart']) && (argv.tool === 'claude' || !argv.tool) && global.previousSessionId);
335
+ const isUncommittedChangesRestart = isTemporaryWatch && (firstIterationInTemporaryMode || hasUncommittedInTempMode);
336
+ const isClaudeTool = argv.tool === 'claude' || !argv.tool;
337
+ const autoResumeOnUncommittedChanges = isUncommittedChangesRestart && isClaudeTool && isAutoResumeOnUncommittedChangesEnabled(argv);
338
+ let autoResumeDecision = null;
339
+
340
+ if (autoResumeOnUncommittedChanges) {
341
+ let tokenUsage = null;
342
+ if (latestSessionId && tempDir) {
343
+ try {
344
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
345
+ tokenUsage = await calculateSessionTokens(latestSessionId, tempDir, latestResultModelUsage);
346
+ } catch (tokenError) {
347
+ await log(` ⚠️ Could not calculate token usage for auto-resume decision: ${tokenError.message}`, { verbose: true });
348
+ }
349
+ }
350
+ autoResumeDecision = decideAutoResumeOnUncommittedChanges({ argv, sessionId: latestSessionId, tokenUsage });
351
+ }
352
+
353
+ // Keep the older issue #661 experiment working independently. The issue
354
+ // #1056 option takes precedence when enabled because it adds the required
355
+ // context-headroom safety check.
356
+ const legacyResumeRequested = isUncommittedChangesRestart && isClaudeTool && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart']);
357
+ const shouldUseSessionResume = autoResumeOnUncommittedChanges ? autoResumeDecision?.resume === true : Boolean(legacyResumeRequested && global.previousSessionId);
358
+ const resumeSessionId = autoResumeOnUncommittedChanges ? latestSessionId : global.previousSessionId;
327
359
 
328
360
  if (shouldUseSessionResume) {
329
361
  await log(formatAligned('', 'Experimental session resume: using minimal auto-restart prompt', '', 2));
330
- await log(formatAligned('', `Resuming session: ${global.previousSessionId}`, '', 2));
362
+ await log(formatAligned('', `Resuming session: ${resumeSessionId}`, '', 2));
363
+
364
+ if (autoResumeDecision?.reason === 'ok') {
365
+ await log(formatAligned('', `Peak context usage: ${autoResumeDecision.peak.toLocaleString()} / ${autoResumeDecision.limit.toLocaleString()} usable tokens (${autoResumeDecision.usedPercent.toFixed(1)}%, threshold ${autoResumeDecision.threshold}%)`, '', 2));
366
+ }
331
367
 
332
- if (argv.verbose) {
368
+ if (!autoResumeOnUncommittedChanges && argv.verbose) {
333
369
  try {
334
370
  const { calculateSessionTokens } = await import('./claude.lib.mjs');
335
- const tokenUsage = await calculateSessionTokens(global.previousSessionId, tempDir);
371
+ const tokenUsage = await calculateSessionTokens(resumeSessionId, tempDir);
336
372
  if (tokenUsage?.totalTokens) {
337
373
  await log(formatAligned('', `Previous session tokens: ${tokenUsage.totalTokens.toLocaleString()}`, '', 2));
338
374
  }
@@ -346,11 +382,14 @@ export const watchForFeedback = async params => {
346
382
  restartFeedbackLines = [minimalPrompt];
347
383
  restartArgv = {
348
384
  ...argv,
349
- resume: global.previousSessionId,
385
+ resume: resumeSessionId,
350
386
  minimalRestartContext: true,
351
387
  };
352
388
 
353
389
  await log(formatAligned('', `Minimal restart prompt size: ${minimalPrompt.length} characters`, '', 2));
390
+ } else if (autoResumeOnUncommittedChanges) {
391
+ const skipReason = autoResumeDecision?.reason === 'context_too_full' ? `peak context usage ${autoResumeDecision.usedPercent.toFixed(1)}% reached the ${autoResumeDecision.threshold}% threshold` : autoResumeDecision?.reason === 'no_session_id' ? 'no previous session ID is available' : 'context usage could not be verified';
392
+ await log(formatAligned('', 'Auto-resume skipped:', `${skipReason}; starting a fresh session`, 2));
354
393
  }
355
394
 
356
395
  // Execute tool using shared utility
@@ -498,6 +537,12 @@ export const watchForFeedback = async params => {
498
537
  }
499
538
  }
500
539
 
540
+ // Issue #1056: Track latest model usage so the next auto-resume decision
541
+ // can re-evaluate context-window usage against the most recent peak.
542
+ if (toolResult.resultModelUsage) {
543
+ latestResultModelUsage = toolResult.resultModelUsage;
544
+ }
545
+
501
546
  // Issue #1508: Compute budget stats for auto-restart log comment
502
547
  let autoRestartBudgetStatsData = null;
503
548
  if (argv.tokensBudgetStats && latestSessionId && tempDir) {