@link-assistant/hive-mind 0.39.0

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +24 -0
  3. package/README.md +769 -0
  4. package/package.json +58 -0
  5. package/src/agent.lib.mjs +705 -0
  6. package/src/agent.prompts.lib.mjs +196 -0
  7. package/src/buildUserMention.lib.mjs +71 -0
  8. package/src/claude-limits.lib.mjs +389 -0
  9. package/src/claude.lib.mjs +1445 -0
  10. package/src/claude.prompts.lib.mjs +203 -0
  11. package/src/codex.lib.mjs +552 -0
  12. package/src/codex.prompts.lib.mjs +194 -0
  13. package/src/config.lib.mjs +207 -0
  14. package/src/contributing-guidelines.lib.mjs +268 -0
  15. package/src/exit-handler.lib.mjs +205 -0
  16. package/src/git.lib.mjs +145 -0
  17. package/src/github-issue-creator.lib.mjs +246 -0
  18. package/src/github-linking.lib.mjs +152 -0
  19. package/src/github.batch.lib.mjs +272 -0
  20. package/src/github.graphql.lib.mjs +258 -0
  21. package/src/github.lib.mjs +1479 -0
  22. package/src/hive.config.lib.mjs +254 -0
  23. package/src/hive.mjs +1500 -0
  24. package/src/instrument.mjs +191 -0
  25. package/src/interactive-mode.lib.mjs +1000 -0
  26. package/src/lenv-reader.lib.mjs +206 -0
  27. package/src/lib.mjs +490 -0
  28. package/src/lino.lib.mjs +176 -0
  29. package/src/local-ci-checks.lib.mjs +324 -0
  30. package/src/memory-check.mjs +419 -0
  31. package/src/model-mapping.lib.mjs +145 -0
  32. package/src/model-validation.lib.mjs +278 -0
  33. package/src/opencode.lib.mjs +479 -0
  34. package/src/opencode.prompts.lib.mjs +194 -0
  35. package/src/protect-branch.mjs +159 -0
  36. package/src/review.mjs +433 -0
  37. package/src/reviewers-hive.mjs +643 -0
  38. package/src/sentry.lib.mjs +284 -0
  39. package/src/solve.auto-continue.lib.mjs +568 -0
  40. package/src/solve.auto-pr.lib.mjs +1374 -0
  41. package/src/solve.branch-errors.lib.mjs +341 -0
  42. package/src/solve.branch.lib.mjs +230 -0
  43. package/src/solve.config.lib.mjs +342 -0
  44. package/src/solve.error-handlers.lib.mjs +256 -0
  45. package/src/solve.execution.lib.mjs +291 -0
  46. package/src/solve.feedback.lib.mjs +436 -0
  47. package/src/solve.mjs +1128 -0
  48. package/src/solve.preparation.lib.mjs +210 -0
  49. package/src/solve.repo-setup.lib.mjs +114 -0
  50. package/src/solve.repository.lib.mjs +961 -0
  51. package/src/solve.results.lib.mjs +558 -0
  52. package/src/solve.session.lib.mjs +135 -0
  53. package/src/solve.validation.lib.mjs +325 -0
  54. package/src/solve.watch.lib.mjs +572 -0
  55. package/src/start-screen.mjs +324 -0
  56. package/src/task.mjs +308 -0
  57. package/src/telegram-bot.mjs +1481 -0
  58. package/src/telegram-markdown.lib.mjs +64 -0
  59. package/src/usage-limit.lib.mjs +218 -0
  60. package/src/version.lib.mjs +41 -0
  61. package/src/youtrack/solve.youtrack.lib.mjs +116 -0
  62. package/src/youtrack/youtrack-sync.mjs +219 -0
  63. package/src/youtrack/youtrack.lib.mjs +425 -0
@@ -0,0 +1,1000 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive Mode Library
4
+ *
5
+ * [EXPERIMENTAL] This module provides real-time PR comment updates during Claude execution.
6
+ * It parses Claude CLI's NDJSON output and posts relevant events as GitHub PR comments.
7
+ *
8
+ * Supported JSON event types:
9
+ * - system.init: Session initialization
10
+ * - assistant (text): AI text responses
11
+ * - assistant (tool_use): Tool invocations
12
+ * - user (tool_result): Tool execution results
13
+ * - result: Session completion
14
+ * - unrecognized: Any unknown event types
15
+ *
16
+ * Features:
17
+ * - Full GitHub markdown support with collapsible sections
18
+ * - Smart content truncation (keeps start and end, removes middle)
19
+ * - Collapsed raw JSON in each comment for debugging
20
+ * - Rate limiting and comment queue management
21
+ *
22
+ * Usage:
23
+ * const { createInteractiveHandler } = await import('./interactive-mode.lib.mjs');
24
+ * const handler = createInteractiveHandler({ owner, repo, prNumber, $ });
25
+ * await handler.processEvent(jsonObject);
26
+ *
27
+ * @module interactive-mode.lib.mjs
28
+ * @experimental
29
+ */
30
+
31
+ // Configuration constants
32
+ const CONFIG = {
33
+ // Minimum time between comments to avoid rate limiting (in ms)
34
+ MIN_COMMENT_INTERVAL: 5000,
35
+ // Maximum lines to show before truncation kicks in
36
+ MAX_LINES_BEFORE_TRUNCATION: 50,
37
+ // Lines to keep at start when truncating
38
+ LINES_TO_KEEP_START: 20,
39
+ // Lines to keep at end when truncating
40
+ LINES_TO_KEEP_END: 20,
41
+ // Maximum JSON depth for raw JSON display
42
+ MAX_JSON_DEPTH: 10
43
+ };
44
+
45
+ /**
46
+ * Truncate content in the middle, keeping start and end
47
+ * This helps show context while reducing size for large outputs
48
+ *
49
+ * @param {string} content - Content to potentially truncate
50
+ * @param {Object} options - Truncation options
51
+ * @param {number} [options.maxLines=50] - Maximum lines before truncation
52
+ * @param {number} [options.keepStart=20] - Lines to keep at start
53
+ * @param {number} [options.keepEnd=20] - Lines to keep at end
54
+ * @returns {string} Truncated content with ellipsis indicator
55
+ */
56
+ const truncateMiddle = (content, options = {}) => {
57
+ const {
58
+ maxLines = CONFIG.MAX_LINES_BEFORE_TRUNCATION,
59
+ keepStart = CONFIG.LINES_TO_KEEP_START,
60
+ keepEnd = CONFIG.LINES_TO_KEEP_END
61
+ } = options;
62
+
63
+ if (!content || typeof content !== 'string') {
64
+ return content || '';
65
+ }
66
+
67
+ const lines = content.split('\n');
68
+ if (lines.length <= maxLines) {
69
+ return content;
70
+ }
71
+
72
+ const startLines = lines.slice(0, keepStart);
73
+ const endLines = lines.slice(-keepEnd);
74
+ const removedCount = lines.length - keepStart - keepEnd;
75
+
76
+ return [
77
+ ...startLines,
78
+ '',
79
+ `... [${removedCount} lines truncated] ...`,
80
+ '',
81
+ ...endLines
82
+ ].join('\n');
83
+ };
84
+
85
+ /**
86
+ * Safely stringify JSON with depth limit and circular reference handling
87
+ *
88
+ * @param {any} obj - Object to stringify
89
+ * @param {number} [indent=2] - Indentation spaces
90
+ * @returns {string} Formatted JSON string
91
+ */
92
+ const safeJsonStringify = (obj, indent = 2) => {
93
+ const seen = new WeakSet();
94
+ return JSON.stringify(obj, (key, value) => {
95
+ if (typeof value === 'object' && value !== null) {
96
+ if (seen.has(value)) {
97
+ return '[Circular]';
98
+ }
99
+ seen.add(value);
100
+ }
101
+ return value;
102
+ }, indent);
103
+ };
104
+
105
+ /**
106
+ * Create a collapsible section in GitHub markdown
107
+ *
108
+ * @param {string} summary - Summary text shown when collapsed
109
+ * @param {string} content - Content shown when expanded
110
+ * @param {boolean} [startOpen=false] - Whether to start expanded
111
+ * @returns {string} GitHub markdown details block
112
+ */
113
+ const createCollapsible = (summary, content, startOpen = false) => {
114
+ const openAttr = startOpen ? ' open' : '';
115
+ return `<details${openAttr}>
116
+ <summary>${summary}</summary>
117
+
118
+ ${content}
119
+
120
+ </details>`;
121
+ };
122
+
123
+ /**
124
+ * Create a collapsible raw JSON section
125
+ * Always wraps data in an array for consistent merging
126
+ *
127
+ * @param {Object|Array} data - JSON data to display (will be wrapped in array if not already)
128
+ * @returns {string} Collapsible JSON block
129
+ */
130
+ const createRawJsonSection = (data) => {
131
+ // Ensure data is always an array at root level for easier merging
132
+ const dataArray = Array.isArray(data) ? data : [data];
133
+ const jsonContent = truncateMiddle(safeJsonStringify(dataArray, 2), {
134
+ maxLines: 100,
135
+ keepStart: 40,
136
+ keepEnd: 40
137
+ });
138
+ return createCollapsible(
139
+ '📄 Raw JSON',
140
+ '```json\n' + jsonContent + '\n```'
141
+ );
142
+ };
143
+
144
+ /**
145
+ * Format duration from milliseconds to human-readable string
146
+ *
147
+ * @param {number} ms - Duration in milliseconds
148
+ * @returns {string} Formatted duration (e.g., "12m 7s")
149
+ */
150
+ const formatDuration = (ms) => {
151
+ if (!ms || ms < 0) return 'unknown';
152
+
153
+ const seconds = Math.floor(ms / 1000);
154
+ const minutes = Math.floor(seconds / 60);
155
+ const hours = Math.floor(minutes / 60);
156
+
157
+ if (hours > 0) {
158
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
159
+ } else if (minutes > 0) {
160
+ return `${minutes}m ${seconds % 60}s`;
161
+ } else {
162
+ return `${seconds}s`;
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Format cost to USD string
168
+ *
169
+ * @param {number} cost - Cost in USD
170
+ * @returns {string} Formatted cost (e.g., "$1.60")
171
+ */
172
+ const formatCost = (cost) => {
173
+ if (typeof cost !== 'number' || isNaN(cost)) return 'unknown';
174
+ return `$${cost.toFixed(2)}`;
175
+ };
176
+
177
+ /**
178
+ * Escape special markdown characters in text
179
+ *
180
+ * @param {string} text - Text to escape
181
+ * @returns {string} Escaped text
182
+ */
183
+ const escapeMarkdown = (text) => {
184
+ if (!text || typeof text !== 'string') return '';
185
+ // Escape backticks that would break code blocks
186
+ return text.replace(/```/g, '\\`\\`\\`');
187
+ };
188
+
189
+ /**
190
+ * Get tool icon based on tool name
191
+ *
192
+ * @param {string} toolName - Name of the tool
193
+ * @returns {string} Emoji icon
194
+ */
195
+ const getToolIcon = (toolName) => {
196
+ const icons = {
197
+ 'Bash': '💻',
198
+ 'Read': '📖',
199
+ 'Write': '✏️',
200
+ 'Edit': '📝',
201
+ 'Glob': '🔍',
202
+ 'Grep': '🔎',
203
+ 'WebFetch': '🌐',
204
+ 'WebSearch': '🔍',
205
+ 'TodoWrite': '📋',
206
+ 'Task': '🎯',
207
+ 'NotebookEdit': '📓',
208
+ 'default': '🔧'
209
+ };
210
+ return icons[toolName] || icons.default;
211
+ };
212
+
213
+ /**
214
+ * Creates an interactive mode handler for processing Claude CLI events
215
+ *
216
+ * @param {Object} options - Handler configuration
217
+ * @param {string} options.owner - Repository owner
218
+ * @param {string} options.repo - Repository name
219
+ * @param {number} options.prNumber - Pull request number
220
+ * @param {Function} options.$ - command-stream $ function
221
+ * @param {Function} options.log - Logging function
222
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
223
+ * @returns {Object} Handler object with event processing methods
224
+ */
225
+ export const createInteractiveHandler = (options) => {
226
+ const { owner, repo, prNumber, $, log, verbose = false } = options;
227
+
228
+ // State tracking for the handler
229
+ const state = {
230
+ sessionId: null,
231
+ messageCount: 0,
232
+ toolUseCount: 0,
233
+ toolResultCount: 0,
234
+ lastCommentTime: 0,
235
+ // Queue stores objects with body and optional toolId for tracking
236
+ // { body: string, toolId?: string }
237
+ commentQueue: [],
238
+ isProcessing: false,
239
+ startTime: Date.now(),
240
+ // Track pending tool calls for merging with results
241
+ // Map of tool_use_id -> { commentId, toolData, inputDisplay, toolName, toolIcon, commentIdPromise, resolveCommentId }
242
+ // commentId may be null initially if comment is queued; commentIdPromise resolves when comment is posted
243
+ pendingToolCalls: new Map(),
244
+ // Simple map of tool_use_id -> { toolName, toolIcon } for standalone tool results
245
+ // This is preserved even after pendingToolCalls entry is deleted
246
+ toolUseRegistry: new Map()
247
+ };
248
+
249
+ /**
250
+ * Post a comment to the PR (with rate limiting)
251
+ * @param {string} body - Comment body
252
+ * @param {string} [toolId] - Optional tool ID for tracking pending tool calls
253
+ * @returns {Promise<string|null>} Comment ID if successful, null if queued or failed
254
+ * @private
255
+ */
256
+ const postComment = async (body, toolId = null) => {
257
+ if (!prNumber || !owner || !repo) {
258
+ if (verbose) {
259
+ await log('⚠️ Interactive mode: Cannot post comment - missing PR info', { verbose: true });
260
+ }
261
+ return null;
262
+ }
263
+
264
+ const now = Date.now();
265
+ const timeSinceLastComment = now - state.lastCommentTime;
266
+
267
+ if (timeSinceLastComment < CONFIG.MIN_COMMENT_INTERVAL) {
268
+ // Queue the comment for later with toolId for tracking
269
+ state.commentQueue.push({ body, toolId });
270
+ if (verbose) {
271
+ await log(`📝 Interactive mode: Comment queued (${state.commentQueue.length} in queue)${toolId ? ` [tool: ${toolId}]` : ''}`, { verbose: true });
272
+ }
273
+ return null;
274
+ }
275
+
276
+ try {
277
+ // Post comment and capture the output to get the comment URL/ID
278
+ const result = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${body}`;
279
+ state.lastCommentTime = Date.now();
280
+
281
+ // Extract comment ID from the result (gh outputs the comment URL)
282
+ // Format: https://github.com/owner/repo/pull/123#issuecomment-1234567890
283
+ // Note: command-stream returns stdout as a Buffer, so we need to call .toString()
284
+ const output = result.stdout?.toString() || result.toString() || '';
285
+ const match = output.match(/issuecomment-(\d+)/);
286
+ const commentId = match ? match[1] : null;
287
+
288
+ if (verbose) {
289
+ await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''}`, { verbose: true });
290
+ }
291
+ return commentId;
292
+ } catch (error) {
293
+ if (verbose) {
294
+ await log(`⚠️ Interactive mode: Failed to post comment: ${error.message}`, { verbose: true });
295
+ }
296
+ return null;
297
+ }
298
+ };
299
+
300
+ /**
301
+ * Edit an existing comment on the PR
302
+ * @param {string} commentId - Comment ID to edit
303
+ * @param {string} body - New comment body
304
+ * @returns {Promise<boolean>} True if successful
305
+ * @private
306
+ */
307
+ const editComment = async (commentId, body) => {
308
+ if (!prNumber || !owner || !repo || !commentId) {
309
+ if (verbose) {
310
+ await log('⚠️ Interactive mode: Cannot edit comment - missing info', { verbose: true });
311
+ }
312
+ return false;
313
+ }
314
+
315
+ try {
316
+ await $`gh api repos/${owner}/${repo}/issues/comments/${commentId} -X PATCH -f body=${body}`;
317
+ if (verbose) {
318
+ await log(`✅ Interactive mode: Comment ${commentId} updated`, { verbose: true });
319
+ }
320
+ return true;
321
+ } catch (error) {
322
+ if (verbose) {
323
+ await log(`⚠️ Interactive mode: Failed to edit comment: ${error.message}`, { verbose: true });
324
+ }
325
+ return false;
326
+ }
327
+ };
328
+
329
+ /**
330
+ * Process queued comments
331
+ * When a queued comment is posted, if it has an associated toolId,
332
+ * update the pending tool call with the new comment ID
333
+ * @private
334
+ */
335
+ const processQueue = async () => {
336
+ if (state.isProcessing || state.commentQueue.length === 0) {
337
+ return;
338
+ }
339
+
340
+ state.isProcessing = true;
341
+
342
+ while (state.commentQueue.length > 0) {
343
+ const now = Date.now();
344
+ const timeSinceLastComment = now - state.lastCommentTime;
345
+
346
+ if (timeSinceLastComment < CONFIG.MIN_COMMENT_INTERVAL) {
347
+ // Wait until we can post
348
+ await new Promise(resolve => setTimeout(resolve, CONFIG.MIN_COMMENT_INTERVAL - timeSinceLastComment));
349
+ }
350
+
351
+ const queueItem = state.commentQueue.shift();
352
+ if (queueItem) {
353
+ const { body, toolId } = queueItem;
354
+ // Post the comment (don't pass toolId to avoid re-queueing)
355
+ const commentId = await postComment(body);
356
+
357
+ // If this was a tool use comment, update the pending call with the comment ID
358
+ if (toolId && commentId) {
359
+ const pendingCall = state.pendingToolCalls.get(toolId);
360
+ if (pendingCall) {
361
+ pendingCall.commentId = commentId;
362
+ // Resolve the promise so tool_result handler can proceed
363
+ if (pendingCall.resolveCommentId) {
364
+ pendingCall.resolveCommentId(commentId);
365
+ }
366
+ if (verbose) {
367
+ await log(`📋 Interactive mode: Updated pending tool call ${toolId} with comment ID ${commentId}`, { verbose: true });
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ state.isProcessing = false;
375
+ };
376
+
377
+ /**
378
+ * Handle system.init event
379
+ * @param {Object} data - Event data
380
+ */
381
+ const handleSystemInit = async (data) => {
382
+ state.sessionId = data.session_id;
383
+ state.startTime = Date.now();
384
+
385
+ const tools = data.tools || [];
386
+ const toolsList = tools.length > 0
387
+ ? tools.map(t => `\`${t}\``).join(', ')
388
+ : '_No tools available_';
389
+
390
+ // Format MCP servers
391
+ const mcpServers = data.mcp_servers || [];
392
+ const mcpServersList = mcpServers.length > 0
393
+ ? mcpServers.map(s => `\`${s.name}\` (${s.status || 'unknown'})`).join(', ')
394
+ : '_None_';
395
+
396
+ // Format slash commands
397
+ const slashCommands = data.slash_commands || [];
398
+ const slashCommandsList = slashCommands.length > 0
399
+ ? slashCommands.map(c => `\`/${c}\``).join(', ')
400
+ : '_None_';
401
+
402
+ // Format agents
403
+ const agents = data.agents || [];
404
+ const agentsList = agents.length > 0
405
+ ? agents.map(a => `\`${a}\``).join(', ')
406
+ : '_None_';
407
+
408
+ const comment = `## 🚀 Interactive session started
409
+
410
+ | Property | Value |
411
+ |----------|-------|
412
+ | **Session ID** | \`${data.session_id || 'unknown'}\` |
413
+ | **Model** | \`${data.model || 'unknown'}\` |
414
+ | **Claude Code Version** | \`${data.claude_code_version || 'unknown'}\` |
415
+ | **Permission Mode** | \`${data.permissionMode || 'unknown'}\` |
416
+ | **Working Directory** | \`${data.cwd || 'unknown'}\` |
417
+ | **Available Tools** | ${toolsList} |
418
+ | **MCP Servers** | ${mcpServersList} |
419
+ | **Slash Commands** | ${slashCommandsList} |
420
+ | **Agents** | ${agentsList} |
421
+
422
+ ---
423
+
424
+ ${createRawJsonSection(data)}`;
425
+
426
+ await postComment(comment);
427
+
428
+ if (verbose) {
429
+ await log(`🔌 Interactive mode: Session initialized (${state.sessionId})`, { verbose: true });
430
+ }
431
+ };
432
+
433
+ /**
434
+ * Handle assistant text event
435
+ * @param {Object} data - Event data
436
+ * @param {string} text - The text content
437
+ */
438
+ const handleAssistantText = async (data, text) => {
439
+ state.messageCount++;
440
+
441
+ // Truncate very long text responses
442
+ const displayText = truncateMiddle(text, {
443
+ maxLines: 80,
444
+ keepStart: 35,
445
+ keepEnd: 35
446
+ });
447
+
448
+ // Simple format: just the message and collapsed Raw JSON
449
+ const comment = `${displayText}
450
+
451
+ ---
452
+
453
+ ${createRawJsonSection(data)}`;
454
+
455
+ await postComment(comment);
456
+
457
+ if (verbose) {
458
+ await log(`💬 Interactive mode: Assistant text (${text.length} chars)`, { verbose: true });
459
+ }
460
+ };
461
+
462
+ /**
463
+ * Handle assistant tool_use event
464
+ * @param {Object} data - Event data
465
+ * @param {Object} toolUse - Tool use details
466
+ */
467
+ const handleToolUse = async (data, toolUse) => {
468
+ state.toolUseCount++;
469
+
470
+ const toolName = toolUse.name || 'Unknown';
471
+ const toolIcon = getToolIcon(toolName);
472
+ const toolId = toolUse.id || 'unknown';
473
+
474
+ // Register this tool use for potential standalone result rendering
475
+ state.toolUseRegistry.set(toolId, { toolName, toolIcon });
476
+
477
+ // Format tool input based on tool type
478
+ let inputDisplay = '';
479
+ const input = toolUse.input || {};
480
+
481
+ if (toolName === 'Bash' && input.command) {
482
+ const truncatedCommand = truncateMiddle(input.command, {
483
+ maxLines: 30,
484
+ keepStart: 12,
485
+ keepEnd: 12
486
+ });
487
+ inputDisplay = createCollapsible(
488
+ '📋 Executed command',
489
+ '```bash\n' + escapeMarkdown(truncatedCommand) + '\n```',
490
+ true
491
+ );
492
+ } else if (toolName === 'Read' && input.file_path) {
493
+ inputDisplay = `**File:** \`${input.file_path}\``;
494
+ if (input.offset || input.limit) {
495
+ inputDisplay += `\n**Range:** offset=${input.offset || 0}, limit=${input.limit || 'all'}`;
496
+ }
497
+ } else if (toolName === 'Write' && input.file_path) {
498
+ inputDisplay = `**File:** \`${input.file_path}\``;
499
+ if (input.content) {
500
+ const truncatedContent = truncateMiddle(input.content, {
501
+ maxLines: 30,
502
+ keepStart: 12,
503
+ keepEnd: 12
504
+ });
505
+ // Format content as diff with + prefix for added lines
506
+ const diffContent = truncatedContent.split('\n').map(line => `+ ${line}`).join('\n');
507
+ inputDisplay += '\n\n' + createCollapsible(
508
+ '📄 Content',
509
+ '```diff\n' + escapeMarkdown(diffContent) + '\n```'
510
+ );
511
+ }
512
+ } else if (toolName === 'Edit' && input.file_path) {
513
+ inputDisplay = `**File:** \`${input.file_path}\``;
514
+ if (input.old_string && input.new_string) {
515
+ const truncatedOld = truncateMiddle(input.old_string, { maxLines: 15, keepStart: 6, keepEnd: 6 });
516
+ const truncatedNew = truncateMiddle(input.new_string, { maxLines: 15, keepStart: 6, keepEnd: 6 });
517
+ // Format as unified diff with - for removed lines and + for added lines
518
+ const diffOld = truncatedOld.split('\n').map(line => `- ${line}`).join('\n');
519
+ const diffNew = truncatedNew.split('\n').map(line => `+ ${line}`).join('\n');
520
+ inputDisplay += '\n\n' + createCollapsible(
521
+ '🔄 Change',
522
+ '```diff\n' + escapeMarkdown(diffOld + '\n' + diffNew) + '\n```',
523
+ true
524
+ );
525
+ }
526
+ } else if ((toolName === 'Glob' || toolName === 'Grep') && input.pattern) {
527
+ inputDisplay = `**Pattern:** \`${input.pattern}\``;
528
+ if (input.path) inputDisplay += `\n**Path:** \`${input.path}\``;
529
+ } else if (toolName === 'WebFetch' && input.url) {
530
+ inputDisplay = `**URL:** ${input.url}`;
531
+ if (input.prompt) inputDisplay += `\n**Prompt:** ${input.prompt}`;
532
+ } else if (toolName === 'WebSearch' && input.query) {
533
+ inputDisplay = `**Query:** ${input.query}`;
534
+ } else if (toolName === 'TodoWrite' && input.todos) {
535
+ // Show up to 30 todos, skip items in the middle if more
536
+ const MAX_TODOS_DISPLAY = 30;
537
+ const todos = input.todos;
538
+ let todosPreview;
539
+
540
+ if (todos.length <= MAX_TODOS_DISPLAY) {
541
+ // Show all todos if 30 or fewer
542
+ todosPreview = todos.map(t => `- [${t.status === 'completed' ? 'x' : ' '}] ${t.content}`).join('\n');
543
+ } else {
544
+ // Show first 15, "...and N more" in middle, then last 15
545
+ const KEEP_START = 15;
546
+ const KEEP_END = 15;
547
+ const skipped = todos.length - KEEP_START - KEEP_END;
548
+
549
+ const startTodos = todos.slice(0, KEEP_START).map(t => `- [${t.status === 'completed' ? 'x' : ' '}] ${t.content}`);
550
+ const endTodos = todos.slice(-KEEP_END).map(t => `- [${t.status === 'completed' ? 'x' : ' '}] ${t.content}`);
551
+
552
+ todosPreview = [
553
+ ...startTodos,
554
+ `- _...and ${skipped} more_`,
555
+ ...endTodos
556
+ ].join('\n');
557
+ }
558
+
559
+ inputDisplay = createCollapsible(
560
+ `📋 Todos (${todos.length} items)`,
561
+ todosPreview,
562
+ true
563
+ );
564
+ } else if (toolName === 'Task') {
565
+ inputDisplay = `**Description:** ${input.description || 'N/A'}`;
566
+ if (input.prompt) {
567
+ const truncatedPrompt = truncateMiddle(input.prompt, { maxLines: 20, keepStart: 8, keepEnd: 8 });
568
+ inputDisplay += '\n\n' + createCollapsible('📝 Prompt', truncatedPrompt);
569
+ }
570
+ } else {
571
+ // Generic input display
572
+ const inputJson = truncateMiddle(safeJsonStringify(input, 2), {
573
+ maxLines: 30,
574
+ keepStart: 12,
575
+ keepEnd: 12
576
+ });
577
+ inputDisplay = createCollapsible(
578
+ '📥 Input',
579
+ '```json\n' + inputJson + '\n```'
580
+ );
581
+ }
582
+
583
+ // Post the tool use comment and store info for merging with result later
584
+ const comment = `## ${toolIcon} ${toolName} tool use
585
+
586
+ ${inputDisplay}
587
+
588
+ _⏳ Waiting for result..._
589
+
590
+ ---
591
+
592
+ ${createRawJsonSection(data)}`;
593
+
594
+ // Create a promise that will resolve with the comment ID
595
+ // This handles both immediate posting and queued posting
596
+ let resolveCommentId;
597
+ const commentIdPromise = new Promise((resolve) => {
598
+ resolveCommentId = resolve;
599
+ });
600
+
601
+ // Store pending tool call BEFORE posting to ensure it's tracked
602
+ // even if the comment gets queued
603
+ state.pendingToolCalls.set(toolId, {
604
+ commentId: null, // Will be set when comment is actually posted
605
+ commentIdPromise,
606
+ resolveCommentId,
607
+ toolData: data,
608
+ inputDisplay,
609
+ toolName,
610
+ toolIcon
611
+ });
612
+
613
+ // Post the comment, passing toolId for queue tracking
614
+ const commentId = await postComment(comment, toolId);
615
+
616
+ // If posted immediately (not queued), update the pending call and resolve the promise
617
+ if (commentId) {
618
+ const pendingCall = state.pendingToolCalls.get(toolId);
619
+ if (pendingCall) {
620
+ pendingCall.commentId = commentId;
621
+ resolveCommentId(commentId);
622
+ }
623
+ }
624
+ // If queued (commentId is null), processQueue will update it later
625
+
626
+ if (verbose) {
627
+ await log(`🔧 Interactive mode: Tool use - ${toolName}${commentId ? ` (comment: ${commentId})` : ' (queued)'}`, { verbose: true });
628
+ }
629
+ };
630
+
631
+ /**
632
+ * Handle user tool_result event
633
+ * @param {Object} data - Event data
634
+ * @param {Object} toolResult - Tool result details
635
+ */
636
+ const handleToolResult = async (data, toolResult) => {
637
+ state.toolResultCount++;
638
+
639
+ const toolUseId = toolResult.tool_use_id || 'unknown';
640
+ const isError = toolResult.is_error || false;
641
+ const statusIcon = isError ? '❌' : '✅';
642
+ const statusText = isError ? 'Error' : 'Success';
643
+
644
+ // Get content - can be string or array
645
+ let content = '';
646
+ if (typeof toolResult.content === 'string') {
647
+ content = toolResult.content;
648
+ } else if (Array.isArray(toolResult.content)) {
649
+ content = toolResult.content.map(c => {
650
+ if (typeof c === 'string') return c;
651
+ if (c.type === 'text') return c.text || '';
652
+ return safeJsonStringify(c);
653
+ }).join('\n');
654
+ }
655
+
656
+ // Truncate large outputs
657
+ const truncatedContent = truncateMiddle(content, {
658
+ maxLines: 60,
659
+ keepStart: 25,
660
+ keepEnd: 25
661
+ });
662
+
663
+ // Check if we have a pending tool call to merge with
664
+ const pendingCall = state.pendingToolCalls.get(toolUseId);
665
+
666
+ if (pendingCall) {
667
+ const { toolData, inputDisplay, toolName, toolIcon, commentIdPromise } = pendingCall;
668
+ let { commentId } = pendingCall;
669
+
670
+ // If comment ID is not yet available (comment was queued), wait for it
671
+ // But use a timeout to avoid blocking forever
672
+ if (!commentId && commentIdPromise) {
673
+ if (verbose) {
674
+ await log(`⏳ Interactive mode: Waiting for tool use comment to be posted (tool: ${toolUseId})`, { verbose: true });
675
+ }
676
+ // Wait for the comment to be posted (with 30 second timeout)
677
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 30000));
678
+ commentId = await Promise.race([commentIdPromise, timeoutPromise]);
679
+
680
+ if (!commentId) {
681
+ if (verbose) {
682
+ await log('⚠️ Interactive mode: Timeout waiting for tool use comment, posting result separately', { verbose: true });
683
+ }
684
+ }
685
+ }
686
+
687
+ if (commentId) {
688
+ // Create merged comment with both call and result
689
+ const mergedComment = `## ${toolIcon} ${toolName} tool use
690
+
691
+ ${inputDisplay}
692
+
693
+ ${createCollapsible(
694
+ `📤 Output (${statusIcon} ${statusText.toLowerCase()})`,
695
+ '```\n' + escapeMarkdown(truncatedContent) + '\n```',
696
+ true
697
+ )}
698
+
699
+ ---
700
+
701
+ ${createRawJsonSection([toolData, data])}`;
702
+
703
+ // Edit the existing comment
704
+ const editSuccess = await editComment(commentId, mergedComment);
705
+
706
+ if (editSuccess) {
707
+ state.pendingToolCalls.delete(toolUseId);
708
+ if (verbose) {
709
+ await log(`📋 Interactive mode: Tool result merged into comment ${commentId} (${content.length} chars)`, { verbose: true });
710
+ }
711
+ return;
712
+ }
713
+ // If edit failed, fall through to posting new comment
714
+ if (verbose) {
715
+ await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}, posting result separately`, { verbose: true });
716
+ }
717
+ }
718
+
719
+ // Clean up the pending call since we're posting separately
720
+ state.pendingToolCalls.delete(toolUseId);
721
+ }
722
+
723
+ // Post as new comment if no pending call or edit failed
724
+ // Look up tool name from registry for better header
725
+ const registryEntry = state.toolUseRegistry.get(toolUseId);
726
+ const standaloneToolName = registryEntry?.toolName;
727
+ const standaloneToolIcon = registryEntry?.toolIcon || '🔧';
728
+ const standaloneHeader = standaloneToolName
729
+ ? `${standaloneToolIcon} ${standaloneToolName} tool result`
730
+ : 'Tool result';
731
+
732
+ const comment = `## ${standaloneHeader}
733
+
734
+ ${createCollapsible(
735
+ `📤 Output (${statusIcon} ${statusText.toLowerCase()})`,
736
+ '```\n' + escapeMarkdown(truncatedContent) + '\n```',
737
+ true
738
+ )}
739
+
740
+ ---
741
+
742
+ ${createRawJsonSection(data)}`;
743
+
744
+ await postComment(comment);
745
+
746
+ if (verbose) {
747
+ const contentLength = content.length;
748
+ await log(`📋 Interactive mode: Tool result posted as separate comment (${contentLength} chars)`, { verbose: true });
749
+ }
750
+ };
751
+
752
+ /**
753
+ * Handle result event (session complete)
754
+ * @param {Object} data - Event data
755
+ */
756
+ const handleResult = async (data) => {
757
+ const isError = data.is_error || false;
758
+ const statusIcon = isError ? '❌' : '✅';
759
+ const statusText = isError ? 'Session Failed' : 'Session Complete';
760
+
761
+ // Format result text
762
+ const resultText = data.result || '_No result message_';
763
+ const truncatedResult = truncateMiddle(resultText, {
764
+ maxLines: 50,
765
+ keepStart: 20,
766
+ keepEnd: 20
767
+ });
768
+
769
+ // Build stats table
770
+ let statsTable = '| Metric | Value |\n|--------|-------|\n';
771
+ statsTable += `| **Status** | ${statusText} |\n`;
772
+ statsTable += `| **Session ID** | \`${data.session_id || 'unknown'}\` |\n`;
773
+
774
+ if (data.duration_ms) {
775
+ statsTable += `| **Duration** | ${formatDuration(data.duration_ms)} |\n`;
776
+ }
777
+ if (data.duration_api_ms) {
778
+ statsTable += `| **API Time** | ${formatDuration(data.duration_api_ms)} |\n`;
779
+ }
780
+ if (data.num_turns) {
781
+ statsTable += `| **Turns** | ${data.num_turns} |\n`;
782
+ }
783
+ if (typeof data.total_cost_usd === 'number') {
784
+ statsTable += `| **Cost** | ${formatCost(data.total_cost_usd)} |\n`;
785
+ }
786
+
787
+ // Usage breakdown if available
788
+ let usageSection = '';
789
+ if (data.usage) {
790
+ const u = data.usage;
791
+ usageSection = '\n### 📊 Token Usage\n\n| Type | Count |\n|------|-------|\n';
792
+ if (u.input_tokens) usageSection += `| Input | ${u.input_tokens.toLocaleString()} |\n`;
793
+ if (u.output_tokens) usageSection += `| Output | ${u.output_tokens.toLocaleString()} |\n`;
794
+ if (u.cache_creation_input_tokens) usageSection += `| Cache Creation | ${u.cache_creation_input_tokens.toLocaleString()} |\n`;
795
+ if (u.cache_read_input_tokens) usageSection += `| Cache Read | ${u.cache_read_input_tokens.toLocaleString()} |\n`;
796
+ }
797
+
798
+ const comment = `## ${statusIcon} ${statusText}
799
+
800
+ ${statsTable}
801
+ ${usageSection}
802
+
803
+ ### 📝 Result
804
+
805
+ ${createCollapsible('View Result', truncatedResult, !isError)}
806
+
807
+ ---
808
+
809
+ ${createRawJsonSection(data)}`;
810
+
811
+ await postComment(comment);
812
+
813
+ if (verbose) {
814
+ await log(`🏁 Interactive mode: Session ${statusText.toLowerCase()}`, { verbose: true });
815
+ }
816
+ };
817
+
818
+ /**
819
+ * Handle unrecognized event types
820
+ * @param {Object} data - Event data
821
+ */
822
+ const handleUnrecognized = async (data) => {
823
+ const eventType = data.type || 'unknown';
824
+ const subtype = data.subtype ? `.${data.subtype}` : '';
825
+
826
+ const comment = `## ❓ Unrecognized Event: \`${eventType}${subtype}\`
827
+
828
+ This event type is not yet supported by interactive mode.
829
+
830
+ ${createRawJsonSection(data)}`;
831
+
832
+ await postComment(comment);
833
+
834
+ if (verbose) {
835
+ await log(`❓ Interactive mode: Unrecognized event type: ${eventType}${subtype}`, { verbose: true });
836
+ }
837
+ };
838
+
839
+ /**
840
+ * Process a single JSON event from Claude CLI
841
+ *
842
+ * @param {Object} data - Parsed JSON object from Claude CLI output
843
+ * @returns {Promise<void>}
844
+ */
845
+ const processEvent = async (data) => {
846
+ if (!data || typeof data !== 'object') {
847
+ return;
848
+ }
849
+
850
+ // Handle events without type as unrecognized
851
+ if (!data.type) {
852
+ await handleUnrecognized(data);
853
+ return;
854
+ }
855
+
856
+ switch (data.type) {
857
+ case 'system':
858
+ if (data.subtype === 'init') {
859
+ await handleSystemInit(data);
860
+ } else {
861
+ // Unknown system subtype
862
+ await handleUnrecognized(data);
863
+ }
864
+ break;
865
+
866
+ case 'assistant':
867
+ if (data.message && data.message.content) {
868
+ const content = Array.isArray(data.message.content)
869
+ ? data.message.content
870
+ : [data.message.content];
871
+
872
+ for (const item of content) {
873
+ if (item.type === 'text' && item.text) {
874
+ await handleAssistantText(data, item.text);
875
+ } else if (item.type === 'tool_use') {
876
+ await handleToolUse(data, item);
877
+ }
878
+ }
879
+ }
880
+ break;
881
+
882
+ case 'user':
883
+ if (data.message && data.message.content) {
884
+ const content = Array.isArray(data.message.content)
885
+ ? data.message.content
886
+ : [data.message.content];
887
+
888
+ for (const item of content) {
889
+ if (item.type === 'tool_result') {
890
+ await handleToolResult(data, item);
891
+ }
892
+ }
893
+ }
894
+ break;
895
+
896
+ case 'result':
897
+ await handleResult(data);
898
+ break;
899
+
900
+ default:
901
+ await handleUnrecognized(data);
902
+ }
903
+
904
+ // Process any queued comments
905
+ await processQueue();
906
+ };
907
+
908
+ /**
909
+ * Flush any remaining queued comments
910
+ * Should be called at the end of a session
911
+ *
912
+ * @returns {Promise<void>}
913
+ */
914
+ const flush = async () => {
915
+ await processQueue();
916
+ };
917
+
918
+ /**
919
+ * Get current handler state (for debugging)
920
+ *
921
+ * @returns {Object} Current state
922
+ */
923
+ const getState = () => ({ ...state });
924
+
925
+ return {
926
+ processEvent,
927
+ flush,
928
+ getState,
929
+ // Expose individual handlers for testing
930
+ _handlers: {
931
+ handleSystemInit,
932
+ handleAssistantText,
933
+ handleToolUse,
934
+ handleToolResult,
935
+ handleResult,
936
+ handleUnrecognized
937
+ }
938
+ };
939
+ };
940
+
941
+ /**
942
+ * Check if interactive mode is supported for the given tool
943
+ *
944
+ * @param {string} tool - Tool name (claude, opencode, codex)
945
+ * @returns {boolean} Whether interactive mode is supported
946
+ */
947
+ export const isInteractiveModeSupported = (tool) => {
948
+ // Currently only supported for Claude
949
+ return tool === 'claude';
950
+ };
951
+
952
+ /**
953
+ * Validate interactive mode configuration
954
+ *
955
+ * @param {Object} argv - Parsed command line arguments
956
+ * @param {Function} log - Logging function
957
+ * @returns {Promise<boolean>} Whether configuration is valid
958
+ */
959
+ export const validateInteractiveModeConfig = async (argv, log) => {
960
+ if (!argv.interactiveMode) {
961
+ return true; // Not enabled, nothing to validate
962
+ }
963
+
964
+ // Check tool support
965
+ if (!isInteractiveModeSupported(argv.tool)) {
966
+ await log(`⚠️ --interactive-mode is only supported for --tool claude (current: ${argv.tool})`, { level: 'warning' });
967
+ await log(' Interactive mode will be disabled for this session.', { level: 'warning' });
968
+ return false;
969
+ }
970
+
971
+ // Check PR requirement
972
+ // Note: This should be called after PR is created/determined
973
+ // The actual PR number check happens during execution
974
+
975
+ await log('🔌 Interactive mode: ENABLED (experimental)', { level: 'info' });
976
+ await log(' Claude output will be posted as PR comments in real-time.', { level: 'info' });
977
+
978
+ return true;
979
+ };
980
+
981
+ // Export utilities for testing
982
+ export const utils = {
983
+ truncateMiddle,
984
+ safeJsonStringify,
985
+ createCollapsible,
986
+ createRawJsonSection,
987
+ formatDuration,
988
+ formatCost,
989
+ escapeMarkdown,
990
+ getToolIcon,
991
+ CONFIG
992
+ };
993
+
994
+ // Export all functions
995
+ export default {
996
+ createInteractiveHandler,
997
+ isInteractiveModeSupported,
998
+ validateInteractiveModeConfig,
999
+ utils
1000
+ };