@link-assistant/hive-mind 1.54.8 → 1.55.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d696423: Add experimental bidirectional interactive mode (issue #817). Introduces three composable opt-in flags for `solve` (auto-forwarded to `hive`): `--accept-incomming-comments-as-input` (feed new PR/issue comments into Claude as stream-json input, excluding solve's own system comments), `--exclude-all-own-incomming-comments-from-input` (also skip comments authored by the same GitHub user that solve runs as), and `--bidirectional-interactive-mode` (composite convenience flag that enables `--interactive-mode` plus the two flags above). All flags default off and only take effect with `--tool claude`.
8
+
3
9
  ## 1.54.8
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": "1.54.8",
3
+ "version": "1.55.0",
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,710 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bidirectional Interactive Mode Library
4
+ *
5
+ * [EXPERIMENTAL] This module provides bidirectional real-time communication during Claude execution.
6
+ * It monitors PR comments for user feedback and queues it for injection into the running Claude session.
7
+ *
8
+ * Key features:
9
+ * - Monitors GitHub PR comments for new user feedback
10
+ * - Queues feedback messages for injection into Claude's stdin
11
+ * - Works with Claude CLI's --input-format stream-json mode
12
+ * - Filters out system-generated comments (from interactive mode itself)
13
+ *
14
+ * Usage:
15
+ * const { createBidirectionalHandler } = await import('./bidirectional-interactive.lib.mjs');
16
+ * const handler = createBidirectionalHandler({ owner, repo, prNumber, $ });
17
+ * await handler.startMonitoring();
18
+ * // Later...
19
+ * const feedback = handler.getQueuedFeedback();
20
+ *
21
+ * @module bidirectional-interactive.lib.mjs
22
+ * @experimental
23
+ */
24
+
25
+ // Configuration constants
26
+ const CONFIG = {
27
+ // Minimum time between comment checks to avoid rate limiting (in ms)
28
+ MIN_POLL_INTERVAL: 10000,
29
+ // Default poll interval (in ms)
30
+ DEFAULT_POLL_INTERVAL: 15000,
31
+ // Maximum queued feedback messages
32
+ MAX_QUEUE_SIZE: 50,
33
+ // Default keep-alive for the headless Claude process between stream-json
34
+ // turns. Claude Code exits after this many ms with no new input once it
35
+ // has replied, so new PR comments have a window to flow in as additional
36
+ // user messages. Issue #817.
37
+ DEFAULT_EXIT_AFTER_STOP_DELAY_MS: 60_000,
38
+ // Signature to identify system-generated comments
39
+ SYSTEM_COMMENT_SIGNATURES: ['## 🚀 Session Started', '## 💬 Assistant Response', '## 💻 Tool: ', '## 📝 Tool: ', '## 📖 Tool: ', '## ✏️ Tool: ', '## 🔍 Tool: ', '## 🔎 Tool: ', '## 🌐 Tool: ', '## 📋 Tool: ', '## 🎯 Tool: ', '## 📓 Tool: ', '## 🔧 Tool: ', '## ✅ Tool Result:', '## ❌ Tool Result:', '## ✅ Session Complete', '## ❌ Session Failed', '## ❓ Unrecognized Event:', '📄 Raw JSON', '🤖 Generated with [Claude Code]', '🤖 AI-Powered Solution Draft', '*This PR was created automatically by the AI issue solver*'],
40
+ };
41
+
42
+ /**
43
+ * Check if a comment body is system-generated (from interactive mode)
44
+ *
45
+ * @param {string} body - Comment body to check
46
+ * @returns {boolean} True if the comment is system-generated
47
+ */
48
+ const isSystemComment = body => {
49
+ if (!body || typeof body !== 'string') {
50
+ return false;
51
+ }
52
+ return CONFIG.SYSTEM_COMMENT_SIGNATURES.some(sig => body.includes(sig));
53
+ };
54
+
55
+ /**
56
+ * Format a user feedback message for Claude CLI's stream-json input
57
+ *
58
+ * @param {string} feedbackText - The user's feedback text
59
+ * @returns {string} JSON string ready to write to Claude's stdin
60
+ */
61
+ const formatFeedbackForClaude = feedbackText => {
62
+ const message = {
63
+ type: 'user',
64
+ message: {
65
+ role: 'user',
66
+ content: [
67
+ {
68
+ type: 'text',
69
+ text: `[USER FEEDBACK FROM PR COMMENT]\n\n${feedbackText}\n\n[END OF USER FEEDBACK - Please address this feedback in your current work]`,
70
+ },
71
+ ],
72
+ },
73
+ };
74
+ return JSON.stringify(message);
75
+ };
76
+
77
+ /**
78
+ * Build the first stream-json user frame for a Claude Code headless session.
79
+ *
80
+ * Issue #817: When --accept-incomming-comments-as-input is enabled, solve
81
+ * spawns Claude with `--input-format stream-json` and a pipe stdin. The
82
+ * initial user prompt must therefore be delivered as a NDJSON frame rather
83
+ * than via `-p`. This matches the pattern from the reference gist
84
+ * `claude-stream-persistent.mjs`.
85
+ *
86
+ * @param {string} promptText - The initial user prompt
87
+ * @param {Object} [options]
88
+ * @param {string} [options.sessionId] - Optional session_id to stamp on the frame
89
+ * @returns {string} NDJSON-ready JSON string (no trailing newline)
90
+ */
91
+ const buildInitialUserFrame = (promptText, options = {}) => {
92
+ const frame = {
93
+ type: 'user',
94
+ message: {
95
+ role: 'user',
96
+ content: [{ type: 'text', text: String(promptText ?? '') }],
97
+ },
98
+ parent_tool_use_id: null,
99
+ };
100
+ if (options.sessionId) frame.session_id = options.sessionId;
101
+ return JSON.stringify(frame);
102
+ };
103
+
104
+ /**
105
+ * Write one NDJSON frame into a live Claude stdin stream.
106
+ *
107
+ * Returns true on a successful write, false when the stream is missing or
108
+ * closed. Never throws — callers just log and continue. Internal helper used
109
+ * by both the comment-polling loop and `streamInitialPrompt`.
110
+ *
111
+ * @param {Object} stream - A writable stream (child.stdin)
112
+ * @param {string} jsonFrame - A single JSON frame (no trailing newline)
113
+ * @param {Function} [logFn] - Optional logger
114
+ * @param {boolean} [verbose=false]
115
+ * @returns {Promise<boolean>}
116
+ * @private
117
+ */
118
+ const writeFrameToStdin = async (stream, jsonFrame, logFn, verbose = false) => {
119
+ if (!stream || typeof stream.write !== 'function') return false;
120
+ if (stream.destroyed || stream.writableEnded || stream.closed) return false;
121
+ try {
122
+ stream.write(`${jsonFrame}\n`);
123
+ return true;
124
+ } catch (err) {
125
+ if (logFn && verbose) {
126
+ try {
127
+ await logFn(`⚠️ Bidirectional mode: Failed to write to Claude stdin: ${err.message}`, { verbose: true });
128
+ } catch {
129
+ /* ignore logger errors */
130
+ }
131
+ }
132
+ return false;
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Creates a bidirectional interactive mode handler
138
+ *
139
+ * @param {Object} options - Handler configuration
140
+ * @param {string} options.owner - Repository owner
141
+ * @param {string} options.repo - Repository name
142
+ * @param {number} options.prNumber - Pull request number
143
+ * @param {Function} options.$ - command-stream $ function
144
+ * @param {Function} options.log - Logging function
145
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
146
+ * @param {number} [options.pollInterval=15000] - Interval between comment checks (ms)
147
+ * @param {boolean} [options.excludeOwnComments=false] - Exclude comments authored by the same GitHub user that solve runs as (prevents "talking to yourself")
148
+ * @returns {Object} Handler object with monitoring methods
149
+ */
150
+ export const createBidirectionalHandler = options => {
151
+ const { owner, repo, prNumber, $, log, verbose = false, pollInterval = CONFIG.DEFAULT_POLL_INTERVAL, excludeOwnComments = false } = options;
152
+ // Resolved lazily on first check, cached for the lifetime of the handler
153
+ let ownUserLogin = null;
154
+ let ownUserResolved = false;
155
+ const resolveOwnUserLogin = async () => {
156
+ if (ownUserResolved) return ownUserLogin;
157
+ try {
158
+ const result = await $`gh api user --jq .login`;
159
+ ownUserLogin = (result.stdout?.toString() || '').trim() || null;
160
+ } catch (error) {
161
+ if (verbose) {
162
+ await log(`⚠️ Bidirectional mode: Could not resolve current gh user: ${error.message}`, { verbose: true });
163
+ }
164
+ ownUserLogin = null;
165
+ }
166
+ ownUserResolved = true;
167
+ return ownUserLogin;
168
+ };
169
+
170
+ // State tracking for the handler
171
+ const state = {
172
+ isMonitoring: false,
173
+ lastCheckedCommentId: null,
174
+ lastCheckedTimestamp: null,
175
+ feedbackQueue: [],
176
+ pollIntervalId: null,
177
+ processedCommentIds: new Set(),
178
+ totalCommentsProcessed: 0,
179
+ totalFeedbackQueued: 0,
180
+ // Issue #817: Writable stdin of the live Claude process. When set, new
181
+ // non-system comments are written directly as NDJSON frames rather than
182
+ // only accumulated in feedbackQueue.
183
+ claudeStdin: null,
184
+ totalFeedbackStreamed: 0,
185
+ };
186
+
187
+ /**
188
+ * Fetch recent comments from the PR
189
+ * @returns {Promise<Array>} Array of comment objects
190
+ * @private
191
+ */
192
+ const fetchRecentComments = async () => {
193
+ if (!prNumber || !owner || !repo) {
194
+ if (verbose) {
195
+ await log('⚠️ Bidirectional mode: Cannot fetch comments - missing PR info', { verbose: true });
196
+ }
197
+ return [];
198
+ }
199
+
200
+ try {
201
+ // Fetch comments using gh api with pagination (GitHub defaults to 30/page), sorted by created_at desc
202
+ const result = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate --jq '[.[] | {id: .id, body: .body, created_at: .created_at, user: .user.login}] | sort_by(.created_at) | reverse'`;
203
+ const comments = JSON.parse(result.stdout.toString());
204
+ return comments;
205
+ } catch (error) {
206
+ if (verbose) {
207
+ await log(`⚠️ Bidirectional mode: Failed to fetch comments: ${error.message}`, { verbose: true });
208
+ }
209
+ return [];
210
+ }
211
+ };
212
+
213
+ /**
214
+ * Check for new user comments and queue them as feedback
215
+ * @private
216
+ */
217
+ const checkForNewComments = async () => {
218
+ if (!state.isMonitoring) {
219
+ return;
220
+ }
221
+
222
+ try {
223
+ const comments = await fetchRecentComments();
224
+
225
+ if (comments.length === 0) {
226
+ return;
227
+ }
228
+
229
+ // Resolve current user login once if we need to exclude own comments
230
+ const ownLogin = excludeOwnComments ? await resolveOwnUserLogin() : null;
231
+
232
+ // Filter for new comments we haven't processed yet
233
+ for (const comment of comments) {
234
+ // Skip if already processed
235
+ if (state.processedCommentIds.has(comment.id)) {
236
+ continue;
237
+ }
238
+
239
+ // Skip if this is a system-generated comment (comments generated by our own solve command)
240
+ if (isSystemComment(comment.body)) {
241
+ state.processedCommentIds.add(comment.id);
242
+ continue;
243
+ }
244
+
245
+ // Issue #817: Optionally skip comments authored by the same GitHub user that solve runs as
246
+ if (excludeOwnComments && ownLogin && comment.user === ownLogin) {
247
+ if (verbose) {
248
+ await log(`⏭️ Bidirectional mode: Skipping comment #${comment.id} from own user @${ownLogin} (--exclude-all-own-incomming-comments-from-input)`, { verbose: true });
249
+ }
250
+ state.processedCommentIds.add(comment.id);
251
+ continue;
252
+ }
253
+
254
+ // This is a new user comment - queue it as feedback
255
+ if (state.feedbackQueue.length < CONFIG.MAX_QUEUE_SIZE) {
256
+ const formattedMessage = formatFeedbackForClaude(comment.body);
257
+ state.feedbackQueue.push({
258
+ id: comment.id,
259
+ body: comment.body,
260
+ user: comment.user,
261
+ created_at: comment.created_at,
262
+ formattedMessage,
263
+ });
264
+ state.totalFeedbackQueued++;
265
+
266
+ if (verbose) {
267
+ await log(`📥 Bidirectional mode: Queued feedback from @${comment.user} (comment #${comment.id})`, { verbose: true });
268
+ }
269
+
270
+ // Issue #817: If we have a live Claude stdin attached, stream the
271
+ // comment as an NDJSON frame right now so Claude can pick it up on
272
+ // the next turn. This is the core of "real JSON streaming input"
273
+ // requested in the issue and the reference gist.
274
+ if (state.claudeStdin) {
275
+ const streamed = await writeFrameToStdin(state.claudeStdin, formattedMessage, log, verbose);
276
+ if (streamed) {
277
+ state.totalFeedbackStreamed++;
278
+ if (verbose) {
279
+ await log(`📤 Bidirectional mode: Streamed feedback from @${comment.user} (comment #${comment.id}) into Claude stdin`, { verbose: true });
280
+ }
281
+ }
282
+ }
283
+ } else {
284
+ if (verbose) {
285
+ await log(`⚠️ Bidirectional mode: Feedback queue full, skipping comment #${comment.id}`, { verbose: true });
286
+ }
287
+ }
288
+
289
+ state.processedCommentIds.add(comment.id);
290
+ state.totalCommentsProcessed++;
291
+ }
292
+ } catch (error) {
293
+ if (verbose) {
294
+ await log(`⚠️ Bidirectional mode: Error checking comments: ${error.message}`, { verbose: true });
295
+ }
296
+ }
297
+ };
298
+
299
+ /**
300
+ * Start monitoring PR comments for user feedback
301
+ *
302
+ * @returns {Promise<void>}
303
+ */
304
+ const startMonitoring = async () => {
305
+ if (state.isMonitoring) {
306
+ if (verbose) {
307
+ await log('ℹ️ Bidirectional mode: Already monitoring', { verbose: true });
308
+ }
309
+ return;
310
+ }
311
+
312
+ if (!prNumber || !owner || !repo) {
313
+ if (verbose) {
314
+ await log('⚠️ Bidirectional mode: Cannot start monitoring - missing PR info', { verbose: true });
315
+ }
316
+ return;
317
+ }
318
+
319
+ state.isMonitoring = true;
320
+
321
+ // Do initial check
322
+ await checkForNewComments();
323
+
324
+ // Set up polling interval
325
+ const interval = Math.max(pollInterval, CONFIG.MIN_POLL_INTERVAL);
326
+ state.pollIntervalId = setInterval(async () => {
327
+ await checkForNewComments();
328
+ }, interval);
329
+
330
+ if (verbose) {
331
+ await log(`🔌 Bidirectional mode: Started monitoring PR #${prNumber} (polling every ${interval / 1000}s)`, { verbose: true });
332
+ }
333
+ };
334
+
335
+ /**
336
+ * Stop monitoring PR comments
337
+ *
338
+ * @returns {Promise<void>}
339
+ */
340
+ const stopMonitoring = async () => {
341
+ if (!state.isMonitoring) {
342
+ return;
343
+ }
344
+
345
+ state.isMonitoring = false;
346
+
347
+ if (state.pollIntervalId) {
348
+ clearInterval(state.pollIntervalId);
349
+ state.pollIntervalId = null;
350
+ }
351
+
352
+ if (verbose) {
353
+ await log(`🔌 Bidirectional mode: Stopped monitoring (processed ${state.totalCommentsProcessed} comments, queued ${state.totalFeedbackQueued} feedback)`, { verbose: true });
354
+ }
355
+ };
356
+
357
+ /**
358
+ * Get next queued feedback message (FIFO)
359
+ * Does not remove from queue - use acknowledgeFeedback() after processing
360
+ *
361
+ * @returns {Object|null} Next feedback object or null if queue is empty
362
+ */
363
+ const peekFeedback = () => {
364
+ if (state.feedbackQueue.length === 0) {
365
+ return null;
366
+ }
367
+ return state.feedbackQueue[0];
368
+ };
369
+
370
+ /**
371
+ * Get and remove next queued feedback message (FIFO)
372
+ *
373
+ * @returns {Object|null} Next feedback object or null if queue is empty
374
+ */
375
+ const popFeedback = () => {
376
+ if (state.feedbackQueue.length === 0) {
377
+ return null;
378
+ }
379
+ return state.feedbackQueue.shift();
380
+ };
381
+
382
+ /**
383
+ * Get all queued feedback messages without removing them
384
+ *
385
+ * @returns {Array} Array of queued feedback objects
386
+ */
387
+ const getAllQueuedFeedback = () => {
388
+ return [...state.feedbackQueue];
389
+ };
390
+
391
+ /**
392
+ * Check if there is any queued feedback
393
+ *
394
+ * @returns {boolean} True if there is queued feedback
395
+ */
396
+ const hasFeedback = () => {
397
+ return state.feedbackQueue.length > 0;
398
+ };
399
+
400
+ /**
401
+ * Get the count of queued feedback messages
402
+ *
403
+ * @returns {number} Number of queued feedback messages
404
+ */
405
+ const getFeedbackCount = () => {
406
+ return state.feedbackQueue.length;
407
+ };
408
+
409
+ /**
410
+ * Clear all queued feedback
411
+ */
412
+ const clearFeedbackQueue = () => {
413
+ state.feedbackQueue = [];
414
+ };
415
+
416
+ /**
417
+ * Mark a specific comment ID as already processed
418
+ * Useful for filtering out comments that existed before monitoring started
419
+ *
420
+ * @param {number} commentId - Comment ID to mark as processed
421
+ */
422
+ const markCommentAsProcessed = commentId => {
423
+ state.processedCommentIds.add(commentId);
424
+ };
425
+
426
+ /**
427
+ * Initialize with existing comment IDs to skip
428
+ * Call this before startMonitoring() to avoid processing old comments
429
+ *
430
+ * @param {Array<number>} commentIds - Array of comment IDs to skip
431
+ */
432
+ const initializeWithExistingComments = commentIds => {
433
+ for (const id of commentIds) {
434
+ state.processedCommentIds.add(id);
435
+ }
436
+ };
437
+
438
+ /**
439
+ * Fetch and mark all existing comments as processed
440
+ * Call this before startMonitoring() to only get new comments
441
+ *
442
+ * @returns {Promise<number>} Number of existing comments marked
443
+ */
444
+ const initializeFromCurrentComments = async () => {
445
+ const comments = await fetchRecentComments();
446
+ for (const comment of comments) {
447
+ state.processedCommentIds.add(comment.id);
448
+ }
449
+ if (verbose) {
450
+ await log(`📋 Bidirectional mode: Initialized with ${comments.length} existing comments`, { verbose: true });
451
+ }
452
+ return comments.length;
453
+ };
454
+
455
+ /**
456
+ * Attach a live Claude stdin stream to the handler.
457
+ *
458
+ * Issue #817: Once attached, every new non-system comment detected by the
459
+ * polling loop is also written to this stream as a NDJSON `user` frame.
460
+ * Safe to call before or after monitoring starts.
461
+ *
462
+ * @param {Object} stream - Writable stream (child.stdin)
463
+ */
464
+ const attachClaudeStdin = stream => {
465
+ state.claudeStdin = stream || null;
466
+ };
467
+
468
+ /**
469
+ * Detach the Claude stdin stream. After this call, comments are only queued.
470
+ */
471
+ const detachClaudeStdin = () => {
472
+ state.claudeStdin = null;
473
+ };
474
+
475
+ /**
476
+ * Stream the initial user prompt as a stream-json frame into the attached
477
+ * Claude stdin. Use this when running Claude with `--input-format stream-json`.
478
+ *
479
+ * @param {string} promptText
480
+ * @param {Object} [options]
481
+ * @param {string} [options.sessionId]
482
+ * @returns {Promise<boolean>} Whether the write succeeded
483
+ */
484
+ const streamInitialPrompt = async (promptText, options = {}) => {
485
+ if (!state.claudeStdin) return false;
486
+ const frame = buildInitialUserFrame(promptText, options);
487
+ return writeFrameToStdin(state.claudeStdin, frame, log, verbose);
488
+ };
489
+
490
+ /**
491
+ * Get current handler state (for debugging)
492
+ *
493
+ * @returns {Object} Current state
494
+ */
495
+ const getState = () => ({
496
+ isMonitoring: state.isMonitoring,
497
+ feedbackQueueLength: state.feedbackQueue.length,
498
+ processedCommentCount: state.processedCommentIds.size,
499
+ totalCommentsProcessed: state.totalCommentsProcessed,
500
+ totalFeedbackQueued: state.totalFeedbackQueued,
501
+ totalFeedbackStreamed: state.totalFeedbackStreamed,
502
+ isStreamingAttached: !!state.claudeStdin,
503
+ });
504
+
505
+ return {
506
+ startMonitoring,
507
+ stopMonitoring,
508
+ peekFeedback,
509
+ popFeedback,
510
+ getAllQueuedFeedback,
511
+ hasFeedback,
512
+ getFeedbackCount,
513
+ clearFeedbackQueue,
514
+ markCommentAsProcessed,
515
+ initializeWithExistingComments,
516
+ initializeFromCurrentComments,
517
+ attachClaudeStdin,
518
+ detachClaudeStdin,
519
+ streamInitialPrompt,
520
+ getState,
521
+ // Expose for testing
522
+ _internal: {
523
+ checkForNewComments,
524
+ fetchRecentComments,
525
+ isSystemComment,
526
+ formatFeedbackForClaude,
527
+ buildInitialUserFrame,
528
+ writeFrameToStdin,
529
+ },
530
+ };
531
+ };
532
+
533
+ /**
534
+ * Check if bidirectional interactive mode is supported for the given tool
535
+ *
536
+ * @param {string} tool - Tool name (claude, opencode, codex)
537
+ * @returns {boolean} Whether bidirectional interactive mode is supported
538
+ */
539
+ export const isBidirectionalModeSupported = tool => {
540
+ // Currently only supported for Claude due to --input-format stream-json support
541
+ return tool === 'claude';
542
+ };
543
+
544
+ /**
545
+ * Apply bidirectional interactive mode composition and validation.
546
+ *
547
+ * Semantics (Issue #817):
548
+ * - --bidirectional-interactive-mode is a convenience flag that automatically enables
549
+ * --interactive-mode, --accept-incomming-comments-as-input and
550
+ * --exclude-all-own-incomming-comments-from-input.
551
+ * - Individual flags can still be used on their own (e.g. --interactive-mode with
552
+ * --accept-incomming-comments-as-input but without --exclude-all-own-..., which lets
553
+ * the same GitHub user "talk to themself").
554
+ * - All three flags default to disabled and the behavior is experimental.
555
+ *
556
+ * @param {Object} argv - Parsed command line arguments (mutated in place)
557
+ * @param {Function} log - Logging function
558
+ * @returns {Promise<boolean>} Whether configuration is valid for the chosen tool
559
+ */
560
+ export const validateBidirectionalModeConfig = async (argv, log) => {
561
+ // Composition: --bidirectional-interactive-mode implies the three experimental flags.
562
+ if (argv.bidirectionalInteractiveMode) {
563
+ if (!argv.interactiveMode) argv.interactiveMode = true;
564
+ if (!argv.acceptIncommingCommentsAsInput) argv.acceptIncommingCommentsAsInput = true;
565
+ if (!argv.excludeAllOwnIncommingCommentsFromInput) argv.excludeAllOwnIncommingCommentsFromInput = true;
566
+ }
567
+
568
+ // Nothing more to validate if no incoming-comment acceptance is requested
569
+ if (!argv.acceptIncommingCommentsAsInput) return true;
570
+
571
+ // Tool support: currently only Claude (uses --input-format stream-json)
572
+ if (!isBidirectionalModeSupported(argv.tool)) {
573
+ await log(`⚠️ --accept-incomming-comments-as-input is only supported for --tool claude (current: ${argv.tool})`, { level: 'warning' });
574
+ await log(' Incoming-comment acceptance will be disabled for this session.', { level: 'warning' });
575
+ argv.acceptIncommingCommentsAsInput = false;
576
+ argv.excludeAllOwnIncommingCommentsFromInput = false;
577
+ return false;
578
+ }
579
+
580
+ await log('🔌 Bidirectional Interactive Mode: ENABLED (experimental)', { level: 'info' });
581
+ await log(` accept-incomming-comments-as-input: true${argv.excludeAllOwnIncommingCommentsFromInput ? ', exclude-all-own-incomming-comments-from-input: true' : ''}`, { level: 'info' });
582
+ await log(' PR comments will be monitored and queued as feedback for Claude.', { level: 'info' });
583
+
584
+ return true;
585
+ };
586
+
587
+ /**
588
+ * Set up the bidirectional handler for an execution. Returns `null` when the
589
+ * feature is not requested or PR info is missing — callers can treat a null
590
+ * return as "no-op".
591
+ *
592
+ * @param {Object} params
593
+ * @param {Object} params.argv - Parsed CLI args (expects `acceptIncommingCommentsAsInput`,
594
+ * `excludeAllOwnIncommingCommentsFromInput`, `verbose`).
595
+ * @param {string} params.owner
596
+ * @param {string} params.repo
597
+ * @param {string|number} params.prNumber
598
+ * @param {Function} params.$ - command-stream tagged template
599
+ * @param {Function} params.log
600
+ * @returns {Promise<Object|null>} Started handler or null when inactive.
601
+ */
602
+ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, $, log }) => {
603
+ if (!argv.acceptIncommingCommentsAsInput) return null;
604
+ if (!owner || !repo || !prNumber) {
605
+ await log('⚠️ Bidirectional mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
606
+ return null;
607
+ }
608
+ await log('🔌 Bidirectional mode: Creating handler to accept incoming PR comments as Claude input', { verbose: true });
609
+ const handler = createBidirectionalHandler({
610
+ owner,
611
+ repo,
612
+ prNumber,
613
+ $,
614
+ log,
615
+ verbose: argv.verbose,
616
+ pollInterval: 15000,
617
+ excludeOwnComments: !!argv.excludeAllOwnIncommingCommentsFromInput,
618
+ });
619
+ await handler.initializeFromCurrentComments();
620
+ await handler.startMonitoring();
621
+ await log('🔌 Bidirectional mode: Started monitoring PR comments for feedback', { verbose: true });
622
+ return handler;
623
+ };
624
+
625
+ /**
626
+ * Attach a live Claude process to the handler so new comments stream into
627
+ * its stdin as NDJSON frames. Also writes the initial user prompt as the
628
+ * first frame so the run starts normally. Issue #817.
629
+ *
630
+ * Safe to call with a null handler (no-op). Logs diagnostics but never throws.
631
+ *
632
+ * @param {Object|null} handler - Handler from setupBidirectionalHandler, or null
633
+ * @param {Object} execCommand - command-stream ProcessRunner with `streams.stdin`
634
+ * @param {string} prompt - Initial user prompt text
635
+ * @param {Function} log
636
+ * @param {boolean} [verbose=false]
637
+ * @returns {Promise<boolean>} Whether streaming input is active
638
+ */
639
+ export const attachStreamingInput = async (handler, execCommand, prompt, log, verbose = false) => {
640
+ if (!handler || !execCommand) return false;
641
+ try {
642
+ const stdinStream = await execCommand.streams.stdin;
643
+ if (!stdinStream) {
644
+ if (verbose) await log('⚠️ Bidirectional mode: Could not acquire Claude stdin stream; falling back to queued-only feedback.', { verbose: true });
645
+ return false;
646
+ }
647
+ handler.attachClaudeStdin(stdinStream);
648
+ const ok = await handler.streamInitialPrompt(prompt);
649
+ if (verbose) await log(`🔌 Bidirectional mode: Streaming input ${ok ? 'ENABLED' : 'FAILED'} (wrote initial user frame to Claude stdin).`, { verbose: true });
650
+ return ok;
651
+ } catch (attachError) {
652
+ await log(`⚠️ Bidirectional mode: Failed to attach stdin (${attachError.message}); continuing without live streaming.`, { verbose: true });
653
+ return false;
654
+ }
655
+ };
656
+
657
+ /**
658
+ * Stop the handler, flush its queue, and log a summary. Safe to call with a
659
+ * null handler (returns an empty array).
660
+ *
661
+ * @param {Object|null} handler - Handler returned by setupBidirectionalHandler, or null.
662
+ * @param {Function} log
663
+ * @returns {Promise<Array>} Queued feedback messages (possibly empty).
664
+ */
665
+ export const finalizeBidirectionalHandler = async (handler, log) => {
666
+ if (!handler) return [];
667
+ try {
668
+ handler.detachClaudeStdin?.();
669
+ await handler.stopMonitoring();
670
+ const state = handler.getState();
671
+ const queuedFeedback = handler.getAllQueuedFeedback();
672
+ if (queuedFeedback.length > 0) {
673
+ await log(`\n📥 Bidirectional mode: ${queuedFeedback.length} feedback message(s) received during execution`, { level: 'info' });
674
+ for (const feedback of queuedFeedback) {
675
+ await log(` • From @${feedback.user}: ${feedback.body.substring(0, 100)}${feedback.body.length > 100 ? '...' : ''}`, { level: 'info' });
676
+ }
677
+ if (state.totalFeedbackStreamed > 0) {
678
+ await log(` 📤 ${state.totalFeedbackStreamed} of these were streamed live into Claude stdin.`, { level: 'info' });
679
+ } else {
680
+ await log(' 💡 This feedback will be available for the next continuation of this task.', { level: 'info' });
681
+ }
682
+ } else {
683
+ await log('📊 Bidirectional mode: No new feedback received during execution', { verbose: true });
684
+ }
685
+ await log(`📊 Bidirectional mode stats: ${state.totalCommentsProcessed} comments processed, ${state.totalFeedbackQueued} feedback queued, ${state.totalFeedbackStreamed} streamed into Claude stdin`, { verbose: true });
686
+ return queuedFeedback;
687
+ } catch (bidirectionalError) {
688
+ await log(`⚠️ Bidirectional mode cleanup error: ${bidirectionalError.message}`, { verbose: true });
689
+ return [];
690
+ }
691
+ };
692
+
693
+ // Export utilities for testing
694
+ export const utils = {
695
+ isSystemComment,
696
+ formatFeedbackForClaude,
697
+ buildInitialUserFrame,
698
+ writeFrameToStdin,
699
+ CONFIG,
700
+ };
701
+
702
+ // Export all functions
703
+ export default {
704
+ createBidirectionalHandler,
705
+ isBidirectionalModeSupported,
706
+ validateBidirectionalModeConfig,
707
+ setupBidirectionalHandler,
708
+ finalizeBidirectionalHandler,
709
+ utils,
710
+ };
@@ -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 { setupBidirectionalHandler, finalizeBidirectionalHandler, validateBidirectionalModeConfig, attachStreamingInput } from './bidirectional-interactive.lib.mjs';
14
15
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
15
16
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
16
17
  import Decimal from 'decimal.js-light';
@@ -632,6 +633,10 @@ export const executeClaudeCommand = async params => {
632
633
  repo,
633
634
  prNumber,
634
635
  } = params;
636
+ // Issue #817: Apply bidirectional-mode composition and tool-support validation before running.
637
+ // This may enable argv.interactiveMode, argv.acceptIncommingCommentsAsInput, and
638
+ // argv.excludeAllOwnIncommingCommentsFromInput when --bidirectional-interactive-mode is set.
639
+ await validateBidirectionalModeConfig(argv, log);
635
640
  // Issue #1331: Unified retry configuration for all transient API errors
636
641
  // (Overloaded, 503 Network Error, Internal Server Error) - same params, all with session preservation
637
642
  let retryCount = 0;
@@ -715,6 +720,9 @@ export const executeClaudeCommand = async params => {
715
720
  } else if (argv.interactiveMode) {
716
721
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
717
722
  }
723
+ // Issue #817: Set up bidirectional handler when --accept-incomming-comments-as-input
724
+ // (or composite --bidirectional-interactive-mode) is enabled. Returns null when inactive.
725
+ const bidirectionalHandler = await setupBidirectionalHandler({ argv, owner, repo, prNumber, $, log });
718
726
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
719
727
  let execCommand;
720
728
  const mappedModel = mapModelToId(argv.model);
@@ -722,6 +730,12 @@ export const executeClaudeCommand = async params => {
722
730
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
723
731
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
724
732
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
733
+ // Declare queuedFeedback for use in catch/finally blocks and return value
734
+ let queuedFeedback = [];
735
+ // Issue #817: When --accept-incomming-comments-as-input is set and we are
736
+ // not resuming a prior session, drive Claude via NDJSON stream-json input
737
+ // so incoming PR comments can be streamed as additional user turns.
738
+ const streamingInput = !!(argv.acceptIncommingCommentsAsInput && bidirectionalHandler && !argv.resume);
725
739
  if (argv.resume) {
726
740
  await log(`🔄 Resuming from session: ${argv.resume}`);
727
741
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
@@ -730,7 +744,12 @@ export const executeClaudeCommand = async params => {
730
744
  const { mcpConfigPath, disallowedToolsList } = await resolveClaudeSessionToolFlags({ argv, log, fallbackBuildMcpConfigWithoutPlaywright: buildMcpConfigWithoutPlaywright });
731
745
  if (mcpConfigPath) claudeArgs += ` --strict-mcp-config --mcp-config "${mcpConfigPath}"`;
732
746
  if (disallowedToolsList.length) claudeArgs += ` --disallowedTools ${disallowedToolsList.join(' ')}`;
733
- claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
747
+ if (streamingInput) {
748
+ // Prompt is delivered as the first NDJSON frame on stdin (not as -p).
749
+ claudeArgs += ` -p --input-format stream-json --append-system-prompt "${escapedSystemPrompt}"`;
750
+ } else {
751
+ claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
752
+ }
734
753
  const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
735
754
  await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
736
755
  await log(`${fullCommand}`);
@@ -741,7 +760,9 @@ export const executeClaudeCommand = async params => {
741
760
  }
742
761
  try {
743
762
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
744
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent });
763
+ // Issue #817: Streaming mode sets exitAfterStopDelayMs=60000 so the
764
+ // headless Claude process stays alive between NDJSON turns.
765
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined });
745
766
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
746
767
  const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
747
768
  if (argv.verbose) {
@@ -758,9 +779,18 @@ export const executeClaudeCommand = async params => {
758
779
  if (argv.resume) {
759
780
  const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
760
781
  execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
782
+ } else if (streamingInput) {
783
+ // Issue #817: Drive Claude via --input-format stream-json on a pipe
784
+ // stdin. Initial prompt + later PR comments are written as NDJSON
785
+ // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
786
+ const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
787
+ execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
761
788
  } else {
762
789
  execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
763
790
  }
791
+ if (streamingInput) {
792
+ await attachStreamingInput(bidirectionalHandler, execCommand, prompt, log, !!argv.verbose);
793
+ }
764
794
  await log(`${formatAligned('📋', 'Command details:', '')}`);
765
795
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
766
796
  await log(formatAligned('🌿', 'Branch:', branchName, 2));
@@ -1116,6 +1146,8 @@ export const executeClaudeCommand = async params => {
1116
1146
  }
1117
1147
  }
1118
1148
 
1149
+ // Issue #817: Stop bidirectional mode monitoring and collect queued feedback
1150
+ queuedFeedback = await finalizeBidirectionalHandler(bidirectionalHandler, log);
1119
1151
  // Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
1120
1152
  const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
1121
1153
  if ((commandFailed || isTransientError) && isTransientError) {
@@ -1141,6 +1173,7 @@ export const executeClaudeCommand = async params => {
1141
1173
  is503Error,
1142
1174
  anthropicTotalCostUSD,
1143
1175
  resultSummary,
1176
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1144
1177
  };
1145
1178
  }
1146
1179
  if (retryCount < maxRetries) {
@@ -1183,6 +1216,7 @@ export const executeClaudeCommand = async params => {
1183
1216
  is503Error, // preserve for callers that check this
1184
1217
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1185
1218
  resultSummary, // Issue #1263: Include result summary
1219
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1186
1220
  };
1187
1221
  }
1188
1222
  }
@@ -1242,6 +1276,7 @@ export const executeClaudeCommand = async params => {
1242
1276
  errorDuringExecution,
1243
1277
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1244
1278
  resultSummary, // Issue #1263: Include result summary
1279
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1245
1280
  };
1246
1281
  }
1247
1282
  // Issue #1088/#1351: Log execution result status
@@ -1330,6 +1365,7 @@ export const executeClaudeCommand = async params => {
1330
1365
  resultModelUsage, // Issue #1454
1331
1366
  streamTokenUsage: streamTokenUsage.eventCount > 0 ? streamTokenUsage : null, // Issue #1491
1332
1367
  subAgentCalls: subAgentCalls.length > 0 ? subAgentCalls : null, // Issue #1590
1368
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1333
1369
  };
1334
1370
  } catch (error) {
1335
1371
  reportError(error, {
@@ -1371,6 +1407,7 @@ export const executeClaudeCommand = async params => {
1371
1407
  toolUseCount,
1372
1408
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1373
1409
  resultSummary, // Issue #1263: Include result summary
1410
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1374
1411
  };
1375
1412
  }
1376
1413
  }; // End of executeWithRetry function
@@ -463,6 +463,14 @@ export const getClaudeEnv = (options = {}) => {
463
463
  if (options.showThinkingContent) {
464
464
  env.CLAUDE_CODE_SHOW_THINKING = '1';
465
465
  }
466
+ // Issue #817: When bidirectional streaming input is enabled, keep the headless
467
+ // Claude process alive between turns so newly arriving PR comments can be
468
+ // streamed into stdin as additional user messages. Without this env var the
469
+ // process would exit as soon as the first --input-format stream-json frame
470
+ // is processed. Default is 1 minute (60000ms), matching the reference gist.
471
+ if (options.exitAfterStopDelayMs) {
472
+ env.CLAUDE_CODE_EXIT_AFTER_STOP_DELAY_MS = String(options.exitAfterStopDelayMs);
473
+ }
466
474
  // Set ANTHROPIC_DEFAULT_OPUS_MODEL when planModel is specified (Issue #1223)
467
475
  // This tells Claude Code which model to use during plan mode in opusplan
468
476
  if (options.planModel) {
@@ -334,6 +334,22 @@ export const SOLVE_OPTION_DEFINITIONS = {
334
334
  description: '[EXPERIMENTAL] Post tool output as PR comments in real-time. Supported for --tool claude and --tool codex.',
335
335
  default: false,
336
336
  },
337
+ // Issue #817: Bidirectional interactive options
338
+ 'accept-incomming-comments-as-input': {
339
+ type: 'boolean',
340
+ description: '[EXPERIMENTAL] Accept new PR/issue comments as input for Claude during execution (excludes outgoing comments generated by solve itself). Does not require --interactive-mode; disabled by default. Only supported for --tool claude.',
341
+ default: false,
342
+ },
343
+ 'exclude-all-own-incomming-comments-from-input': {
344
+ type: 'boolean',
345
+ description: '[EXPERIMENTAL] When combined with --accept-incomming-comments-as-input, also exclude comments written by the same GitHub user that solve runs as (prevents self-talk). Disabled by default.',
346
+ default: false,
347
+ },
348
+ 'bidirectional-interactive-mode': {
349
+ type: 'boolean',
350
+ description: '[EXPERIMENTAL] Convenience flag that enables --interactive-mode, --accept-incomming-comments-as-input and --exclude-all-own-incomming-comments-from-input together. Only supported for --tool claude.',
351
+ default: false,
352
+ },
337
353
  'prompt-explore-sub-agent': {
338
354
  type: 'boolean',
339
355
  description: 'Encourage AI to use Explore-style sub-agent workflow for codebase exploration. Supported for --tool claude and --tool codex.',