@link-assistant/hive-mind 1.44.0 → 1.45.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,47 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.45.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 003c5ca: Fix premature finish signaling and leaked child processes (Issue #1516)
8
+ - Kill entire process group on stream timeout using negative PID, preventing leaked /bin/sh child processes from continuing to make commits after completion
9
+ - Move .gitkeep cleanup to after all completion signals (log upload, "Ready to merge" comment) so no new commits appear after the system reports "session ended"
10
+ - drainHandles now reports surviving child processes as errors instead of silently killing them, so root causes are investigated rather than hidden
11
+
12
+ ## 1.45.0
13
+
14
+ ### Minor Changes
15
+
16
+ - c308660: Add experimental live progress monitoring for work sessions
17
+ - Implement `--working-session-live-progress [comment|pr]` CLI flag for both solve and hive commands
18
+ - `comment` mode (default): Creates a per-session PR comment with updatable progress section
19
+ - `pr` mode: Updates PR description with live progress section
20
+ - Plain `--working-session-live-progress` defaults to `comment` mode
21
+ - Create progress monitoring module (`solve.progress-monitoring.lib.mjs`) with:
22
+ - Live TODO list tracking from TodoWrite tool calls
23
+ - Progress bar visualization (percentage complete)
24
+ - Comment mode: creates/edits a dedicated PR comment per work session
25
+ - PR mode: updates PR description with progress section
26
+ - Task list is always shown expanded (never collapsible)
27
+ - Rate limiting to avoid GitHub API throttling
28
+ - Integrate progress monitoring into claude.lib.mjs event stream processing
29
+ - Detects TodoWrite tool_use events (assistant) and tool_use_result events (user)
30
+ - Updates progress when TodoWrite tool is invoked
31
+ - Displays task completion stats and progress bar
32
+ - Supports work session identification
33
+ - Works with or without `--interactive-mode` (independent feature)
34
+ - Auto-registered in hive via SOLVE_OPTION_DEFINITIONS (no manual forwarding needed)
35
+ - Add comprehensive test suite (89 tests) covering:
36
+ - Progress calculation and formatting
37
+ - Display mode normalization
38
+ - CLI configuration in solve and hive
39
+ - Auto-registration and forwarding via getSolvePassthroughOptionNames
40
+ - Claude integration for TodoWrite detection
41
+ - Comment and PR display modes
42
+ - Feature is experimental, opt-in via `--working-session-live-progress`
43
+ - Implements issue #936
44
+
3
45
  ## 1.44.0
4
46
 
5
47
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.44.0",
3
+ "version": "1.45.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",
@@ -11,6 +11,7 @@ import { reportError } from './sentry.lib.mjs';
11
11
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
12
12
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
+ import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
14
15
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
15
16
  import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
16
17
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
@@ -668,10 +669,8 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
668
669
  export const isStderrError = message => {
669
670
  const trimmed = message.trim();
670
671
  if (!trimmed) return false;
671
-
672
672
  // Detection 1: Emoji-prefixed warnings (Issue #477)
673
673
  let isWarning = trimmed.startsWith('⚠️') || trimmed.startsWith('⚠');
674
-
675
674
  // Detection 2: JSON-structured log messages (Issue #1337)
676
675
  if (!isWarning && trimmed.startsWith('{')) {
677
676
  try {
@@ -687,13 +686,11 @@ export const isStderrError = message => {
687
686
  // Not valid JSON — fall through to keyword matching
688
687
  }
689
688
  }
690
-
691
689
  if (!isWarning && (trimmed.includes('Error:') || trimmed.includes('error') || trimmed.includes('failed') || trimmed.includes('not found'))) {
692
690
  return true;
693
691
  }
694
692
  return false;
695
693
  };
696
-
697
694
  export const executeClaudeCommand = async params => {
698
695
  const {
699
696
  tempDir,
@@ -796,6 +793,7 @@ export const executeClaudeCommand = async params => {
796
793
  } else if (argv.interactiveMode) {
797
794
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
798
795
  }
796
+ const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
799
797
  let execCommand;
800
798
  const mappedModel = mapModelToId(argv.model);
801
799
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
@@ -854,22 +852,22 @@ export const executeClaudeCommand = async params => {
854
852
  let lastEventTime = null;
855
853
  let activityTimeoutId = null;
856
854
  let isActivityTimeout = false;
857
- // Issue #1510: Separate SIGTERM (graceful) and SIGKILL (force) phases to allow
858
- // capturing final output from the process during graceful shutdown
855
+ // Issue #1516: Kill process group (-pid) so leaked /bin/sh children don't survive
856
+ // prettier-ignore
857
+ const killProcessTree = signal => { try { const pid = execCommand.pid || execCommand._pid; if (pid) { process.kill(-pid, signal); return; } } catch { /* not group leader */ } execCommand.kill(signal); };
859
858
  const forceExitOnTimeout = async () => {
860
859
  if (forceExitTriggered) return;
861
860
  forceExitTriggered = true;
862
- await log(`⚠️ Stream timeout — sending SIGTERM for graceful shutdown (Issue #1280, #1510)`, { verbose: true });
861
+ await log(`⚠️ Stream timeout — sending SIGTERM for graceful shutdown (Issue #1280, #1510, #1516)`, { verbose: true });
863
862
  try {
864
863
  if (execCommand.kill) {
865
- execCommand.kill('SIGTERM');
864
+ killProcessTree('SIGTERM');
866
865
  // Issue #1346/#1510: Follow up with SIGKILL after 5s if still alive
867
- // Increased from 2s to 5s to give more time for final output capture
868
866
  const t = setTimeout(() => {
869
867
  try {
870
868
  if (!execCommand.result?.code) {
871
- log(`⚠️ Process did not exit after SIGTERM, sending SIGKILL`, { verbose: true });
872
- execCommand.kill('SIGKILL');
869
+ log(`⚠️ Process tree did not exit after SIGTERM, sending SIGKILL (Issue #1516)`, { verbose: true });
870
+ killProcessTree('SIGKILL');
873
871
  }
874
872
  } catch {
875
873
  /* exited */
@@ -962,6 +960,7 @@ export const executeClaudeCommand = async params => {
962
960
  }
963
961
  if (data.type === 'message') messageCount++;
964
962
  else if (data.type === 'tool_use') toolUseCount++;
963
+ if (progressMonitor) await progressMonitor.processStreamEvent(data).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
965
964
  if (data.type === 'result') {
966
965
  if (!resultEventReceived) {
967
966
  resultEventReceived = true;
@@ -1121,6 +1120,7 @@ export const executeClaudeCommand = async params => {
1121
1120
  await log(`⚠️ Interactive mode error (remaining buffer): ${interactiveError.message}`, { verbose: true });
1122
1121
  }
1123
1122
  }
1123
+ if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
1124
1124
  } catch {
1125
1125
  if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'raw' });
1126
1126
  }
@@ -121,12 +121,30 @@ const drainHandles = async () => {
121
121
  // undici may not be available in all Node versions — safe to ignore
122
122
  }
123
123
 
124
- // 3. Unref surviving child processes from command-stream.
125
- // These are typically already-exited but their OS handle entry lingers.
124
+ // 3. Detect surviving child processes from command-stream.
125
+ // Issue #1516: Surviving ChildProcess handles indicate a bug a leaked /bin/sh
126
+ // child can continue executing (making commits, pushing to GitHub) after we've
127
+ // declared completion. Instead of silently killing them (which hides root causes),
128
+ // we log an error so each occurrence is investigated and the root cause is fixed.
129
+ // The process group kill in claude.lib.mjs (killProcessTree) should have already
130
+ // terminated all children; if any survive, that's a bug we need to know about.
126
131
  try {
127
132
  for (const handle of process._getActiveHandles()) {
128
- if (handle?.constructor?.name === 'ChildProcess' && typeof handle.unref === 'function') {
129
- handle.unref();
133
+ if (handle?.constructor?.name === 'ChildProcess') {
134
+ // Issue #1516: Report surviving child processes as errors instead of killing them.
135
+ // This surfaces the root cause for investigation rather than hiding it.
136
+ const detail = [handle.pid != null ? `pid=${handle.pid}` : null, handle.spawnfile ? `file=${handle.spawnfile}` : null, handle.killed ? 'killed=true' : 'killed=false'].filter(Boolean).join(', ');
137
+ const errorMsg = `❌ ERROR: Surviving ChildProcess detected at exit (${detail}). This indicates a leaked process that was not properly terminated. Investigate the root cause — do NOT suppress this error by killing the process. (Issue #1516)`;
138
+ if (logFunction) {
139
+ await logFunction(errorMsg, { level: 'error' });
140
+ } else {
141
+ console.error(errorMsg);
142
+ }
143
+ // Still unref so Node.js can exit, but do NOT kill — let the OS process
144
+ // continue so its effects are visible and the root cause can be diagnosed.
145
+ if (typeof handle.unref === 'function') {
146
+ handle.unref();
147
+ }
130
148
  }
131
149
  }
132
150
  } catch {
@@ -429,6 +429,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
429
429
  description: '[EXPERIMENTAL] Model to use for --finalize iterations. Defaults to the same model as --model.',
430
430
  default: undefined,
431
431
  },
432
+ 'working-session-live-progress': {
433
+ type: 'string',
434
+ description: '[EXPERIMENTAL] Enable live progress monitoring. Accepts "comment" (default, updates a per-session PR comment) or "pr" (updates PR description). Plain --working-session-live-progress means "comment". Works with or without --interactive-mode.',
435
+ default: false,
436
+ },
432
437
  };
433
438
 
434
439
  // Function to create yargs configuration - avoids duplication
@@ -607,6 +612,20 @@ export const parseArguments = async (yargs, hideBin) => {
607
612
  }
608
613
  }
609
614
 
615
+ // --working-session-live-progress normalization
616
+ // When passed as --working-session-live-progress (no value), yargs gives true for string type
617
+ // Normalize: true → "comment", validate known values
618
+ if (argv && argv.workingSessionLiveProgress) {
619
+ const val = argv.workingSessionLiveProgress;
620
+ if (val === true || val === 'true') {
621
+ argv.workingSessionLiveProgress = 'comment';
622
+ } else if (typeof val === 'string' && !['comment', 'pr'].includes(val.toLowerCase())) {
623
+ throw new Error(`Invalid --working-session-live-progress value: "${val}". Expected "comment" or "pr".`);
624
+ } else if (typeof val === 'string') {
625
+ argv.workingSessionLiveProgress = val.toLowerCase();
626
+ }
627
+ }
628
+
610
629
  // Validate --base-branch value (issue #1482: reject URLs and invalid git branch names)
611
630
  if (argv.baseBranch) {
612
631
  const branchValidation = validateBranchName(argv.baseBranch);
package/src/solve.mjs CHANGED
@@ -1175,8 +1175,7 @@ try {
1175
1175
  const autoRestartEnabled = argv['autoRestartOnUncommittedChanges'] !== false;
1176
1176
  const shouldRestart = await checkForUncommittedChanges(tempDir, owner, repo, branchName, $, log, shouldAutoCommit, autoRestartEnabled);
1177
1177
 
1178
- // Remove initial commit file (CLAUDE.md or .gitkeep) now that Claude command has finished
1179
- await cleanupClaudeFile(tempDir, branchName, claudeCommitHash, argv);
1178
+ // Issue #1516: cleanupClaudeFile() moved to after completion signals (before endWorkSession)
1180
1179
 
1181
1180
  // Show summary of session and log file
1182
1181
  await showSessionSummary(sessionId, limitReached, argv, issueUrl, tempDir, shouldAttachLogs);
@@ -1442,6 +1441,9 @@ try {
1442
1441
  }
1443
1442
  }
1444
1443
 
1444
+ // Issue #1516: Cleanup after all signals (was before verifyResults, caused premature commits)
1445
+ await cleanupClaudeFile(tempDir, branchName, claudeCommitHash, argv);
1446
+
1445
1447
  // End work session using the new module
1446
1448
  await endWorkSession({
1447
1449
  isContinueMode,
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Progress Monitoring Library
4
+ *
5
+ * [EXPERIMENTAL] This module provides live progress monitoring for work sessions
6
+ * by tracking TODO list updates and reflecting them in PR comments or descriptions.
7
+ *
8
+ * Display modes:
9
+ * - "comment" (default): Creates a per-session PR comment with updatable progress section
10
+ * - "pr": Updates the PR description with a live progress section
11
+ *
12
+ * Features:
13
+ * - Tracks TODO list state from TodoWrite tool calls
14
+ * - Calculates progress percentage (completed/total)
15
+ * - Generates progress bar visualization
16
+ * - Task list is always shown expanded (never collapsible)
17
+ *
18
+ * Usage:
19
+ * const { createProgressMonitor } = await import('./solve.progress-monitoring.lib.mjs');
20
+ * const monitor = createProgressMonitor({ owner, repo, prNumber, $, log, displayMode: 'comment' });
21
+ * await monitor.updateProgress(todos);
22
+ *
23
+ * @module solve.progress-monitoring.lib.mjs
24
+ * @experimental
25
+ */
26
+
27
+ /**
28
+ * Configuration constants for progress monitoring
29
+ */
30
+ const CONFIG = {
31
+ // Progress bar width in characters
32
+ PROGRESS_BAR_WIDTH: 30,
33
+ // Progress bar characters
34
+ PROGRESS_CHAR_FILLED: '█',
35
+ PROGRESS_CHAR_EMPTY: '░',
36
+ // Marker comments for identifying the progress section
37
+ PROGRESS_SECTION_START: '<!-- LIVE-PROGRESS-START -->',
38
+ PROGRESS_SECTION_END: '<!-- LIVE-PROGRESS-END -->',
39
+ // Minimum interval between PR description updates (in ms)
40
+ MIN_UPDATE_INTERVAL: 10000, // 10 seconds to avoid rate limiting
41
+ // Valid display modes
42
+ DISPLAY_MODES: ['comment', 'pr'],
43
+ // Default display mode when enabled without explicit value
44
+ DEFAULT_DISPLAY_MODE: 'comment',
45
+ };
46
+
47
+ /**
48
+ * Generate a progress bar visualization
49
+ *
50
+ * @param {number} percentage - Progress percentage (0-100)
51
+ * @param {number} width - Width of the progress bar in characters
52
+ * @returns {string} Progress bar string
53
+ */
54
+ const generateProgressBar = (percentage, width = CONFIG.PROGRESS_BAR_WIDTH) => {
55
+ const clamped = Math.max(0, Math.min(100, percentage));
56
+ const filled = Math.round((clamped / 100) * width);
57
+ const empty = width - filled;
58
+ return CONFIG.PROGRESS_CHAR_FILLED.repeat(filled) + CONFIG.PROGRESS_CHAR_EMPTY.repeat(empty);
59
+ };
60
+
61
+ /**
62
+ * Calculate progress statistics from TODO list
63
+ *
64
+ * @param {Array<Object>} todos - Array of TODO items with status property
65
+ * @returns {Object} Progress statistics
66
+ */
67
+ const calculateProgress = todos => {
68
+ if (!todos || !Array.isArray(todos) || todos.length === 0) {
69
+ return {
70
+ total: 0,
71
+ completed: 0,
72
+ inProgress: 0,
73
+ pending: 0,
74
+ percentage: 0,
75
+ };
76
+ }
77
+
78
+ const total = todos.length;
79
+ const completed = todos.filter(t => t.status === 'completed').length;
80
+ const inProgress = todos.filter(t => t.status === 'in_progress').length;
81
+ const pending = todos.filter(t => t.status === 'pending').length;
82
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
83
+
84
+ return { total, completed, inProgress, pending, percentage };
85
+ };
86
+
87
+ /**
88
+ * Format TODO list for display
89
+ *
90
+ * @param {Array<Object>} todos - Array of TODO items
91
+ * @param {number} maxDisplay - Maximum number of TODOs to show (0 for all)
92
+ * @returns {string} Formatted TODO list
93
+ */
94
+ const formatTodoList = (todos, maxDisplay = 0) => {
95
+ if (!todos || todos.length === 0) {
96
+ return '_No tasks yet_';
97
+ }
98
+
99
+ const getStatusIcon = status => {
100
+ switch (status) {
101
+ case 'completed':
102
+ return '[x]';
103
+ case 'in_progress':
104
+ return '[~]';
105
+ case 'pending':
106
+ return '[ ]';
107
+ default:
108
+ return '[ ]';
109
+ }
110
+ };
111
+
112
+ let todosToShow = todos;
113
+ if (maxDisplay > 0 && todos.length > maxDisplay) {
114
+ const half = Math.floor(maxDisplay / 2);
115
+ const firstHalf = todos.slice(0, half);
116
+ const secondHalf = todos.slice(-half);
117
+ const skipped = todos.length - maxDisplay;
118
+
119
+ todosToShow = [...firstHalf, { content: `_...and ${skipped} more tasks_`, status: 'info' }, ...secondHalf];
120
+ }
121
+
122
+ return todosToShow
123
+ .map(todo => {
124
+ if (todo.status === 'info') {
125
+ return `- ${todo.content}`;
126
+ }
127
+ return `- ${getStatusIcon(todo.status)} ${todo.content}`;
128
+ })
129
+ .join('\n');
130
+ };
131
+
132
+ /**
133
+ * Generate the progress section markdown (never collapsible)
134
+ *
135
+ * @param {Array<Object>} todos - Array of TODO items
136
+ * @param {string} sessionId - Work session identifier (optional)
137
+ * @returns {string} Progress section markdown
138
+ */
139
+ const generateProgressSection = (todos, sessionId = null) => {
140
+ const stats = calculateProgress(todos);
141
+ const progressBar = generateProgressBar(stats.percentage);
142
+ const timestamp = new Date().toISOString();
143
+
144
+ return `${CONFIG.PROGRESS_SECTION_START}
145
+ ## 📊 Live Progress Monitor
146
+
147
+ **Session:** ${sessionId || 'Current'}
148
+ **Last Updated:** ${timestamp}
149
+
150
+ ### Progress: ${stats.percentage}% Complete
151
+
152
+ \`\`\`
153
+ ${progressBar} ${stats.percentage}%
154
+ \`\`\`
155
+
156
+ **Tasks:** ${stats.completed}/${stats.total} completed · ${stats.inProgress} in progress · ${stats.pending} pending
157
+
158
+ 📋 **Task List** (${stats.total} total)
159
+
160
+ ${formatTodoList(todos)}
161
+
162
+ ${CONFIG.PROGRESS_SECTION_END}`;
163
+ };
164
+
165
+ /**
166
+ * Normalize the display mode value.
167
+ * - false/falsy → null (disabled)
168
+ * - true or "true" → default mode ("comment")
169
+ * - "comment" or "pr" → that mode
170
+ *
171
+ * @param {*} value - Raw option value
172
+ * @returns {string|null} Normalized display mode or null if disabled
173
+ */
174
+ export const normalizeDisplayMode = value => {
175
+ if (!value || value === 'false') return null;
176
+ if (value === true || value === 'true') return CONFIG.DEFAULT_DISPLAY_MODE;
177
+ const mode = String(value).toLowerCase();
178
+ if (CONFIG.DISPLAY_MODES.includes(mode)) return mode;
179
+ // Unknown value falls back to default
180
+ return CONFIG.DEFAULT_DISPLAY_MODE;
181
+ };
182
+
183
+ /**
184
+ * Create a progress monitor instance
185
+ *
186
+ * @param {Object} options - Configuration options
187
+ * @param {string} options.owner - Repository owner
188
+ * @param {string} options.repo - Repository name
189
+ * @param {number} options.prNumber - Pull request number
190
+ * @param {Function} options.$ - Zx executor function
191
+ * @param {Function} options.log - Logging function
192
+ * @param {boolean} options.verbose - Enable verbose logging
193
+ * @param {string} options.sessionId - Work session identifier
194
+ * @param {string} options.displayMode - Display mode: "comment" or "pr"
195
+ * @returns {Object} Progress monitor instance
196
+ */
197
+ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose = false, sessionId = null, displayMode = 'comment' }) => {
198
+ const state = {
199
+ lastUpdate: 0,
200
+ currentTodos: null,
201
+ sessionId: sessionId || `session-${Date.now()}`,
202
+ commentId: null, // For comment mode: the ID of the progress comment to update
203
+ displayMode: displayMode || CONFIG.DEFAULT_DISPLAY_MODE,
204
+ };
205
+
206
+ /**
207
+ * Update progress via PR comment (comment mode)
208
+ * Creates a new comment on first call, then edits it on subsequent calls.
209
+ *
210
+ * @param {Array<Object>} todos - Array of TODO items
211
+ * @returns {Promise<boolean>} True if update was successful
212
+ */
213
+ const updateProgressComment = async todos => {
214
+ try {
215
+ state.currentTodos = todos;
216
+ const progressSection = generateProgressSection(todos, state.sessionId);
217
+
218
+ if (state.commentId) {
219
+ // Edit existing comment
220
+ const fs = (await import('fs')).promises;
221
+ const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
222
+ await fs.writeFile(tempFile, progressSection);
223
+ await $`gh api repos/${owner}/${repo}/issues/comments/${state.commentId} --method PATCH --field body=@${tempFile}`;
224
+ await fs.unlink(tempFile).catch(() => {});
225
+ } else {
226
+ // Create new comment
227
+ const fs = (await import('fs')).promises;
228
+ const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
229
+ await fs.writeFile(tempFile, progressSection);
230
+ const result = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body-file ${tempFile}`;
231
+ await fs.unlink(tempFile).catch(() => {});
232
+
233
+ // Extract comment ID from the created comment URL for future edits
234
+ // gh pr comment outputs the URL of the created comment
235
+ const output = result.stdout?.toString?.() || '';
236
+ const urlMatch = output.match(/\/comments\/(\d+)/);
237
+ if (urlMatch) {
238
+ state.commentId = urlMatch[1];
239
+ } else {
240
+ // Fallback: find the comment we just created by looking for our marker
241
+ const commentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq ${`[.[] | select(.body | contains("${CONFIG.PROGRESS_SECTION_START}")) | .id] | last`}`;
242
+ const commentId = commentsResult.stdout?.toString?.().trim();
243
+ if (commentId && commentId !== 'null') {
244
+ state.commentId = commentId;
245
+ }
246
+ }
247
+ }
248
+
249
+ const stats = calculateProgress(todos);
250
+ await log(`📊 Updated progress comment: ${stats.percentage}% (${stats.completed}/${stats.total} tasks completed)`);
251
+ return true;
252
+ } catch (error) {
253
+ await log(`⚠️ Failed to update progress comment: ${error.message}`);
254
+ return false;
255
+ }
256
+ };
257
+
258
+ /**
259
+ * Update progress via PR description (pr mode)
260
+ *
261
+ * @param {Array<Object>} todos - Array of TODO items
262
+ * @returns {Promise<boolean>} True if update was successful
263
+ */
264
+ const updateProgressPrDescription = async todos => {
265
+ try {
266
+ state.currentTodos = todos;
267
+
268
+ // Fetch current PR description
269
+ const prData = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json body`;
270
+ const prInfo = JSON.parse(prData.stdout);
271
+ let currentBody = prInfo.body || '';
272
+
273
+ // Generate new progress section
274
+ const progressSection = generateProgressSection(todos, state.sessionId);
275
+
276
+ // Check if progress section already exists
277
+ const hasProgressSection = currentBody.includes(CONFIG.PROGRESS_SECTION_START);
278
+
279
+ let updatedBody;
280
+ if (hasProgressSection) {
281
+ // Replace existing progress section
282
+ const startIdx = currentBody.indexOf(CONFIG.PROGRESS_SECTION_START);
283
+ const endIdx = currentBody.indexOf(CONFIG.PROGRESS_SECTION_END);
284
+
285
+ if (startIdx !== -1 && endIdx !== -1) {
286
+ updatedBody = currentBody.substring(0, startIdx) + progressSection + currentBody.substring(endIdx + CONFIG.PROGRESS_SECTION_END.length);
287
+ } else {
288
+ updatedBody = currentBody + '\n\n' + progressSection;
289
+ }
290
+ } else {
291
+ updatedBody = currentBody + '\n\n' + progressSection;
292
+ }
293
+
294
+ // Write to temp file and update PR
295
+ const fs = (await import('fs')).promises;
296
+ const tempBodyFile = `/tmp/pr-progress-${prNumber}-${Date.now()}.md`;
297
+ await fs.writeFile(tempBodyFile, updatedBody);
298
+ await $`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
299
+ await fs.unlink(tempBodyFile).catch(() => {});
300
+
301
+ const stats = calculateProgress(todos);
302
+ await log(`📊 Updated PR progress: ${stats.percentage}% (${stats.completed}/${stats.total} tasks completed)`);
303
+ return true;
304
+ } catch (error) {
305
+ await log(`⚠️ Failed to update PR progress: ${error.message}`);
306
+ return false;
307
+ }
308
+ };
309
+
310
+ /**
311
+ * Update progress using the configured display mode
312
+ *
313
+ * @param {Array<Object>} todos - Array of TODO items
314
+ * @param {boolean} force - Force update even if within rate limit interval
315
+ * @returns {Promise<boolean>} True if update was successful
316
+ */
317
+ const updateProgress = async (todos, force = false) => {
318
+ const now = Date.now();
319
+
320
+ // Rate limiting: don't update too frequently unless forced
321
+ if (!force && now - state.lastUpdate < CONFIG.MIN_UPDATE_INTERVAL) {
322
+ if (verbose) {
323
+ await log(`⏭️ Skipping progress update (rate limited, ${Math.round((CONFIG.MIN_UPDATE_INTERVAL - (now - state.lastUpdate)) / 1000)}s remaining)`, { verbose: true });
324
+ }
325
+ return false;
326
+ }
327
+
328
+ let result;
329
+ if (state.displayMode === 'pr') {
330
+ result = await updateProgressPrDescription(todos);
331
+ } else {
332
+ result = await updateProgressComment(todos);
333
+ }
334
+
335
+ if (result) {
336
+ state.lastUpdate = now;
337
+ }
338
+ return result;
339
+ };
340
+
341
+ /**
342
+ * Get current progress statistics
343
+ *
344
+ * @returns {Object} Current progress statistics
345
+ */
346
+ const getStats = () => {
347
+ return calculateProgress(state.currentTodos);
348
+ };
349
+
350
+ /**
351
+ * Generate progress section without updating
352
+ *
353
+ * @param {Array<Object>} todos - Array of TODO items
354
+ * @returns {string} Progress section markdown
355
+ */
356
+ const generateSection = todos => {
357
+ return generateProgressSection(todos, state.sessionId);
358
+ };
359
+
360
+ /**
361
+ * Process a Claude CLI stream event, detecting TodoWrite tool calls
362
+ * and updating progress automatically. Call this for each parsed NDJSON event.
363
+ *
364
+ * @param {Object} data - Parsed JSON event from Claude CLI stream
365
+ * @param {boolean} force - Force update even if within rate limit interval
366
+ * @returns {Promise<boolean>} True if a progress update was triggered
367
+ */
368
+ const processStreamEvent = async (data, force = false) => {
369
+ if (!data || typeof data !== 'object') return false;
370
+ let updated = false;
371
+ // Pattern 1: assistant event with tool_use containing TodoWrite input
372
+ if (data.type === 'assistant' && data.message?.content) {
373
+ const contentItems = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
374
+ for (const item of contentItems) {
375
+ if (item.type === 'tool_use' && item.name === 'TodoWrite' && item.input?.todos) {
376
+ updated = await updateProgress(item.input.todos, force);
377
+ }
378
+ }
379
+ }
380
+ // Pattern 2: user event with tool_use_result containing newTodos (confirmation)
381
+ if (data.type === 'user' && data.tool_use_result?.newTodos) {
382
+ updated = await updateProgress(data.tool_use_result.newTodos, force);
383
+ }
384
+ return updated;
385
+ };
386
+
387
+ return {
388
+ updateProgress,
389
+ processStreamEvent,
390
+ getStats,
391
+ generateSection,
392
+ get currentTodos() {
393
+ return state.currentTodos;
394
+ },
395
+ get sessionId() {
396
+ return state.sessionId;
397
+ },
398
+ get displayMode() {
399
+ return state.displayMode;
400
+ },
401
+ get commentId() {
402
+ return state.commentId;
403
+ },
404
+ };
405
+ };
406
+
407
+ /**
408
+ * Initialize progress monitoring if enabled. Returns null if disabled or missing PR info.
409
+ * Logs status to the provided log function. Designed to minimize integration lines in claude.lib.mjs.
410
+ *
411
+ * @param {Object} argv - Parsed CLI arguments (needs workingSessionLiveProgress)
412
+ * @param {Object} context - { owner, repo, prNumber, $, log }
413
+ * @returns {Promise<Object|null>} Progress monitor instance or null
414
+ */
415
+ export const initProgressMonitoring = async (argv, { owner, repo, prNumber, $, log }) => {
416
+ const displayMode = normalizeDisplayMode(argv.workingSessionLiveProgress);
417
+ if (!displayMode) return null;
418
+ if (!owner || !repo || !prNumber) {
419
+ await log('⚠️ Live progress monitoring: Disabled - missing PR info', { verbose: true });
420
+ return null;
421
+ }
422
+ const monitor = createProgressMonitor({ owner, repo, prNumber, $, log, verbose: argv.verbose, sessionId: `session-${Date.now()}`, displayMode });
423
+ await log(`📊 Live progress monitoring: ENABLED (mode: ${displayMode}, session: ${monitor.sessionId})`, { verbose: true });
424
+ return monitor;
425
+ };
426
+
427
+ /**
428
+ * Export utility functions for testing and standalone use
429
+ */
430
+ export const utils = {
431
+ generateProgressBar,
432
+ calculateProgress,
433
+ formatTodoList,
434
+ generateProgressSection,
435
+ CONFIG,
436
+ };