@link-assistant/hive-mind 1.58.0 → 1.59.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.
@@ -56,9 +56,31 @@ const isSystemComment = body => {
56
56
  * Format a user feedback message for Claude CLI's stream-json input
57
57
  *
58
58
  * @param {string} feedbackText - The user's feedback text
59
+ * @param {Object} [options]
60
+ * @param {string} [options.kind='comment'] - Source kind: 'comment', 'ci', 'uncommitted', 'metadata'
59
61
  * @returns {string} JSON string ready to write to Claude's stdin
60
62
  */
61
- const formatFeedbackForClaude = feedbackText => {
63
+ const formatFeedbackForClaude = (feedbackText, options = {}) => {
64
+ const kind = options.kind || 'comment';
65
+ const headers = {
66
+ comment: {
67
+ open: '[USER FEEDBACK FROM PR COMMENT]',
68
+ close: '[END OF USER FEEDBACK - Please address this feedback in your current work]',
69
+ },
70
+ ci: {
71
+ open: '[CI/CD STATUS UPDATE — auto-input-until-mergeable]',
72
+ close: '[END OF CI STATUS — Please address the failing checks before continuing]',
73
+ },
74
+ uncommitted: {
75
+ open: '[UNCOMMITTED CHANGES DETECTED — auto-input-until-mergeable]',
76
+ close: '[END OF UNCOMMITTED CHANGES — Please commit them if part of the solution, or revert them otherwise]',
77
+ },
78
+ metadata: {
79
+ open: '[ISSUE/PR METADATA UPDATE — auto-input-until-mergeable]',
80
+ close: '[END OF METADATA UPDATE — Please incorporate this into your current work]',
81
+ },
82
+ };
83
+ const { open, close } = headers[kind] || headers.comment;
62
84
  const message = {
63
85
  type: 'user',
64
86
  message: {
@@ -66,7 +88,7 @@ const formatFeedbackForClaude = feedbackText => {
66
88
  content: [
67
89
  {
68
90
  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]`,
91
+ text: `${open}\n\n${feedbackText}\n\n${close}`,
70
92
  },
71
93
  ],
72
94
  },
@@ -140,15 +162,20 @@ const writeFrameToStdin = async (stream, jsonFrame, logFn, verbose = false) => {
140
162
  * @param {string} options.owner - Repository owner
141
163
  * @param {string} options.repo - Repository name
142
164
  * @param {number} options.prNumber - Pull request number
165
+ * @param {number} [options.issueNumber] - Issue number (for issue body/title polling — Issue #1708)
166
+ * @param {string} [options.tempDir] - Local clone directory (for uncommitted-changes polling — Issue #1708)
143
167
  * @param {Function} options.$ - command-stream $ function
144
168
  * @param {Function} options.log - Logging function
145
169
  * @param {boolean} [options.verbose=false] - Enable verbose logging
146
170
  * @param {number} [options.pollInterval=15000] - Interval between comment checks (ms)
147
171
  * @param {boolean} [options.excludeOwnComments=false] - Exclude comments authored by the same GitHub user that solve runs as (prevents "talking to yourself")
172
+ * @param {string} [options.deliveryMode='stream'] - 'stream' (immediate forward) or 'queue' (hold until AI idle). Issue #1708.
173
+ * @param {boolean} [options.streamStatusToInput=false] - Also stream CI/uncommitted/PR-status changes as NDJSON frames. Issue #1708.
174
+ * @param {number} [options.statusPollInterval=60000] - Status-poller interval (ms) when streamStatusToInput is on.
148
175
  * @returns {Object} Handler object with monitoring methods
149
176
  */
150
177
  export const createBidirectionalHandler = options => {
151
- const { owner, repo, prNumber, $, log, verbose = false, pollInterval = CONFIG.DEFAULT_POLL_INTERVAL, excludeOwnComments = false } = options;
178
+ const { owner, repo, prNumber, issueNumber, tempDir, $, log, verbose = false, pollInterval = CONFIG.DEFAULT_POLL_INTERVAL, excludeOwnComments = false, deliveryMode = 'stream', streamStatusToInput = false, statusPollInterval = 60000 } = options;
152
179
  // Resolved lazily on first check, cached for the lifetime of the handler
153
180
  let ownUserLogin = null;
154
181
  let ownUserResolved = false;
@@ -182,6 +209,23 @@ export const createBidirectionalHandler = options => {
182
209
  // only accumulated in feedbackQueue.
183
210
  claudeStdin: null,
184
211
  totalFeedbackStreamed: 0,
212
+ // Issue #1708: Queue-comments-to-input support — buffer NDJSON frames
213
+ // until the AI signals it is idle (result event seen) and only then
214
+ // flush them to stdin. In stream mode pendingFrames is bypassed; the
215
+ // frame is written immediately as before.
216
+ isAiBusy: false,
217
+ pendingFrames: [],
218
+ totalFramesQueued: 0,
219
+ totalFramesFlushed: 0,
220
+ // Issue #1708: Status-poller state. When streamStatusToInput is true,
221
+ // a separate poller emits NDJSON frames for CI/uncommitted/PR-metadata
222
+ // changes detected during the session. Signatures dedupe so the same
223
+ // failing check doesn't generate a frame on every poll.
224
+ statusPollIntervalId: null,
225
+ statusSignatures: new Set(),
226
+ lastIssueSnapshot: null,
227
+ lastPrSnapshot: null,
228
+ totalStatusFramesSent: 0,
185
229
  };
186
230
 
187
231
  /**
@@ -210,6 +254,96 @@ export const createBidirectionalHandler = options => {
210
254
  }
211
255
  };
212
256
 
257
+ /**
258
+ * Issue #1708: Delivery-mode-aware frame dispatcher.
259
+ *
260
+ * - In stream mode (default for --accept-incomming-comments-as-input),
261
+ * the frame is written to Claude stdin immediately.
262
+ * - In queue mode (default for --auto-input-until-mergeable), the frame
263
+ * is buffered in state.pendingFrames while the AI is busy; on idle
264
+ * (markAiIdle) the buffer is flushed to stdin in FIFO order.
265
+ *
266
+ * Always returns true if the frame was either written or buffered,
267
+ * false if the stream is missing/closed.
268
+ *
269
+ * @param {string} jsonFrame
270
+ * @param {Object} [meta]
271
+ * @param {string} [meta.kind]
272
+ * @param {string} [meta.label]
273
+ * @returns {Promise<boolean>}
274
+ * @private
275
+ */
276
+ const dispatchFrame = async (jsonFrame, meta = {}) => {
277
+ if (!state.claudeStdin) return false;
278
+ const useQueue = deliveryMode === 'queue';
279
+ if (useQueue && state.isAiBusy) {
280
+ if (state.pendingFrames.length < CONFIG.MAX_QUEUE_SIZE) {
281
+ state.pendingFrames.push({ jsonFrame, meta });
282
+ state.totalFramesQueued++;
283
+ if (verbose) {
284
+ await log(`⏸️ Bidirectional mode: Queued frame (${meta.kind || 'frame'}: ${meta.label || ''}) — AI is busy, will flush on idle`, { verbose: true });
285
+ }
286
+ return true;
287
+ }
288
+ if (verbose) {
289
+ await log(`⚠️ Bidirectional mode: Pending-frames buffer full, dropping frame (${meta.kind || 'frame'})`, { verbose: true });
290
+ }
291
+ return false;
292
+ }
293
+ const ok = await writeFrameToStdin(state.claudeStdin, jsonFrame, log, verbose);
294
+ if (ok) {
295
+ state.totalFeedbackStreamed++;
296
+ if (verbose) {
297
+ await log(`📤 Bidirectional mode: Streamed frame (${meta.kind || 'frame'}: ${meta.label || ''}) into Claude stdin`, { verbose: true });
298
+ }
299
+ }
300
+ return ok;
301
+ };
302
+
303
+ /**
304
+ * Issue #1708: Mark the AI as actively processing. While busy, queue mode
305
+ * holds new frames in state.pendingFrames instead of writing to stdin.
306
+ * Safe to call repeatedly — idempotent.
307
+ */
308
+ const markAiBusy = () => {
309
+ state.isAiBusy = true;
310
+ };
311
+
312
+ /**
313
+ * Issue #1708: Mark the AI as idle (waiting for next user input). Triggers
314
+ * a flush of any frames queued while busy, in FIFO order. Safe to call
315
+ * repeatedly — flushes only if pendingFrames is non-empty.
316
+ *
317
+ * @returns {Promise<number>} Number of frames flushed.
318
+ */
319
+ const markAiIdle = async () => {
320
+ state.isAiBusy = false;
321
+ if (state.pendingFrames.length === 0) return 0;
322
+ if (!state.claudeStdin) {
323
+ if (verbose) {
324
+ await log(`⚠️ Bidirectional mode: AI idle but no stdin attached — ${state.pendingFrames.length} frame(s) remain queued`, { verbose: true });
325
+ }
326
+ return 0;
327
+ }
328
+ let flushed = 0;
329
+ while (state.pendingFrames.length > 0) {
330
+ const { jsonFrame, meta } = state.pendingFrames.shift();
331
+ const ok = await writeFrameToStdin(state.claudeStdin, jsonFrame, log, verbose);
332
+ if (!ok) {
333
+ // Stream closed mid-flush — push the frame back so finalize can surface it.
334
+ state.pendingFrames.unshift({ jsonFrame, meta });
335
+ break;
336
+ }
337
+ flushed++;
338
+ state.totalFramesFlushed++;
339
+ state.totalFeedbackStreamed++;
340
+ if (verbose) {
341
+ await log(`📤 Bidirectional mode: Flushed pending frame (${meta?.kind || 'frame'}: ${meta?.label || ''}) into Claude stdin`, { verbose: true });
342
+ }
343
+ }
344
+ return flushed;
345
+ };
346
+
213
347
  /**
214
348
  * Check for new user comments and queue them as feedback
215
349
  * @private
@@ -253,7 +387,7 @@ export const createBidirectionalHandler = options => {
253
387
 
254
388
  // This is a new user comment - queue it as feedback
255
389
  if (state.feedbackQueue.length < CONFIG.MAX_QUEUE_SIZE) {
256
- const formattedMessage = formatFeedbackForClaude(comment.body);
390
+ const formattedMessage = formatFeedbackForClaude(comment.body, { kind: 'comment' });
257
391
  state.feedbackQueue.push({
258
392
  id: comment.id,
259
393
  body: comment.body,
@@ -267,19 +401,12 @@ export const createBidirectionalHandler = options => {
267
401
  await log(`📥 Bidirectional mode: Queued feedback from @${comment.user} (comment #${comment.id})`, { verbose: true });
268
402
  }
269
403
 
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
- }
404
+ // Issue #817 / #1708: Dispatch through the delivery-mode router so
405
+ // queue-comments-to-input can hold the frame until the AI is idle.
406
+ await dispatchFrame(formattedMessage, {
407
+ kind: 'comment',
408
+ label: `comment #${comment.id} from @${comment.user}`,
409
+ });
283
410
  } else {
284
411
  if (verbose) {
285
412
  await log(`⚠️ Bidirectional mode: Feedback queue full, skipping comment #${comment.id}`, { verbose: true });
@@ -296,6 +423,154 @@ export const createBidirectionalHandler = options => {
296
423
  }
297
424
  };
298
425
 
426
+ /**
427
+ * Issue #1708: Fetch a snapshot of an issue's or PR's title+body for
428
+ * change detection. Returns null when fetching fails — callers treat
429
+ * null as "skip this round" rather than as an empty snapshot.
430
+ *
431
+ * @param {'issue'|'pr'} kind
432
+ * @param {number} number
433
+ * @returns {Promise<{title:string, body:string}|null>}
434
+ * @private
435
+ */
436
+ const fetchMetadataSnapshot = async (kind, number) => {
437
+ if (!number || !owner || !repo) return null;
438
+ try {
439
+ const endpoint = kind === 'pr' ? `repos/${owner}/${repo}/pulls/${number}` : `repos/${owner}/${repo}/issues/${number}`;
440
+ const result = await $`gh api ${endpoint} --jq '{title, body}'`;
441
+ if (!result || result.code !== 0) return null;
442
+ const parsed = JSON.parse(result.stdout.toString() || '{}');
443
+ return { title: parsed.title || '', body: parsed.body || '' };
444
+ } catch (error) {
445
+ if (verbose) {
446
+ await log(`⚠️ Bidirectional mode: Metadata fetch failed for ${kind} #${number}: ${error.message}`, { verbose: true });
447
+ }
448
+ return null;
449
+ }
450
+ };
451
+
452
+ /**
453
+ * Issue #1708: Diff two metadata snapshots and produce a human-readable
454
+ * summary. Returns null when nothing changed.
455
+ *
456
+ * @param {{title:string, body:string}|null} prev
457
+ * @param {{title:string, body:string}|null} next
458
+ * @returns {string|null}
459
+ * @private
460
+ */
461
+ const diffMetadataSnapshot = (prev, next) => {
462
+ if (!prev || !next) return null;
463
+ const lines = [];
464
+ if (prev.title !== next.title) {
465
+ lines.push(`Title changed:\n before: ${prev.title}\n after: ${next.title}`);
466
+ }
467
+ if (prev.body !== next.body) {
468
+ lines.push(`Body changed (length ${prev.body.length} → ${next.body.length}). New body:\n${next.body}`);
469
+ }
470
+ return lines.length > 0 ? lines.join('\n\n') : null;
471
+ };
472
+
473
+ /**
474
+ * Issue #1708: Status poller. Runs alongside the comment poller while
475
+ * --auto-input-until-mergeable is on. On every tick, it checks:
476
+ * - PR title/body changes (vs the previous snapshot)
477
+ * - Issue title/body changes (vs the previous snapshot)
478
+ * - Uncommitted changes (git status --porcelain in tempDir)
479
+ * - CI/CD blockers (via getMergeBlockers)
480
+ *
481
+ * For each change, a one-shot NDJSON frame is dispatched through the
482
+ * delivery-mode router. Each change is keyed by a stable signature so
483
+ * the same failing check doesn't re-emit on every poll.
484
+ *
485
+ * Failures in any sub-check are swallowed and logged — the poller must
486
+ * never break the live Claude session.
487
+ *
488
+ * @private
489
+ */
490
+ const checkForStatusChanges = async () => {
491
+ if (!state.isMonitoring) return;
492
+ if (!state.claudeStdin) return;
493
+ // PR metadata
494
+ if (prNumber) {
495
+ const next = await fetchMetadataSnapshot('pr', prNumber);
496
+ if (next) {
497
+ if (state.lastPrSnapshot === null) {
498
+ state.lastPrSnapshot = next;
499
+ } else {
500
+ const summary = diffMetadataSnapshot(state.lastPrSnapshot, next);
501
+ if (summary) {
502
+ const frame = formatFeedbackForClaude(`Pull request #${prNumber} metadata changed during this session:\n\n${summary}`, { kind: 'metadata' });
503
+ await dispatchFrame(frame, { kind: 'metadata', label: `PR #${prNumber} title/body` });
504
+ state.totalStatusFramesSent++;
505
+ state.lastPrSnapshot = next;
506
+ }
507
+ }
508
+ }
509
+ }
510
+ // Issue metadata (only when issueNumber differs from prNumber to avoid double-fetch)
511
+ if (issueNumber && issueNumber !== prNumber) {
512
+ const next = await fetchMetadataSnapshot('issue', issueNumber);
513
+ if (next) {
514
+ if (state.lastIssueSnapshot === null) {
515
+ state.lastIssueSnapshot = next;
516
+ } else {
517
+ const summary = diffMetadataSnapshot(state.lastIssueSnapshot, next);
518
+ if (summary) {
519
+ const frame = formatFeedbackForClaude(`Issue #${issueNumber} metadata changed during this session:\n\n${summary}`, { kind: 'metadata' });
520
+ await dispatchFrame(frame, { kind: 'metadata', label: `issue #${issueNumber} title/body` });
521
+ state.totalStatusFramesSent++;
522
+ state.lastIssueSnapshot = next;
523
+ }
524
+ }
525
+ }
526
+ }
527
+ // Uncommitted changes
528
+ if (tempDir) {
529
+ try {
530
+ const result = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
531
+ if (result && result.code === 0) {
532
+ const out = (result.stdout?.toString() || '').trim();
533
+ const sig = `uncommitted::${out}`;
534
+ if (out && !state.statusSignatures.has(sig)) {
535
+ state.statusSignatures.add(sig);
536
+ const frame = formatFeedbackForClaude(`The local clone has uncommitted changes (git status --porcelain):\n\n${out}\n\nPlease either commit them (git add + git commit + git push) if they belong to the solution, or revert them (git checkout -- <file> or git clean -fd) otherwise.`, { kind: 'uncommitted' });
537
+ await dispatchFrame(frame, { kind: 'uncommitted', label: 'git status --porcelain' });
538
+ state.totalStatusFramesSent++;
539
+ }
540
+ }
541
+ } catch (error) {
542
+ if (verbose) {
543
+ await log(`⚠️ Bidirectional mode: Uncommitted-changes poll failed: ${error.message}`, { verbose: true });
544
+ }
545
+ }
546
+ }
547
+ // CI/CD blockers — only when we have prNumber + a working ./solve.auto-merge-helpers
548
+ if (prNumber) {
549
+ try {
550
+ const helpers = await import('./solve.auto-merge-helpers.lib.mjs');
551
+ if (helpers && typeof helpers.getMergeBlockers === 'function') {
552
+ const { blockers } = await helpers.getMergeBlockers(owner, repo, prNumber, false, 1, null);
553
+ for (const b of blockers || []) {
554
+ // Only stream actionable failures (not "ci_pending" or "ci_cancelled"
555
+ // — those don't need AI involvement and would be noise).
556
+ if (b.type !== 'ci_failure' && b.type !== 'not_mergeable') continue;
557
+ const sig = `ci::${b.type}::${(b.details && b.details[0]) || b.message}`;
558
+ if (state.statusSignatures.has(sig)) continue;
559
+ state.statusSignatures.add(sig);
560
+ const detailLines = (b.details || []).map(d => ` - ${d}`).join('\n');
561
+ const frame = formatFeedbackForClaude(`CI/CD blocker detected during this session: ${b.message}\n${detailLines || ''}\n\nPlease address the failing checks before continuing.`, { kind: 'ci' });
562
+ await dispatchFrame(frame, { kind: 'ci', label: b.message });
563
+ state.totalStatusFramesSent++;
564
+ }
565
+ }
566
+ } catch (error) {
567
+ if (verbose) {
568
+ await log(`⚠️ Bidirectional mode: CI status poll failed: ${error.message}`, { verbose: true });
569
+ }
570
+ }
571
+ }
572
+ };
573
+
299
574
  /**
300
575
  * Start monitoring PR comments for user feedback
301
576
  *
@@ -330,6 +605,29 @@ export const createBidirectionalHandler = options => {
330
605
  if (verbose) {
331
606
  await log(`🔌 Bidirectional mode: Started monitoring PR #${prNumber} (polling every ${interval / 1000}s)`, { verbose: true });
332
607
  }
608
+
609
+ // Issue #1708: When --auto-input-until-mergeable enables status streaming,
610
+ // start a parallel poller that watches CI/uncommitted/PR-metadata changes
611
+ // and emits NDJSON frames so the live AI session reacts to them without
612
+ // requiring an auto-restart.
613
+ if (streamStatusToInput) {
614
+ // Take an initial snapshot so the first real diff is meaningful.
615
+ if (prNumber) state.lastPrSnapshot = await fetchMetadataSnapshot('pr', prNumber);
616
+ if (issueNumber && issueNumber !== prNumber) state.lastIssueSnapshot = await fetchMetadataSnapshot('issue', issueNumber);
617
+ const statusInterval = Math.max(statusPollInterval, CONFIG.MIN_POLL_INTERVAL);
618
+ state.statusPollIntervalId = setInterval(async () => {
619
+ try {
620
+ await checkForStatusChanges();
621
+ } catch (error) {
622
+ if (verbose) {
623
+ await log(`⚠️ Bidirectional mode: Status poll error: ${error.message}`, { verbose: true });
624
+ }
625
+ }
626
+ }, statusInterval);
627
+ if (verbose) {
628
+ await log(`🔌 Bidirectional mode: Started status poller (CI/uncommitted/PR-metadata) every ${statusInterval / 1000}s`, { verbose: true });
629
+ }
630
+ }
333
631
  };
334
632
 
335
633
  /**
@@ -348,9 +646,13 @@ export const createBidirectionalHandler = options => {
348
646
  clearInterval(state.pollIntervalId);
349
647
  state.pollIntervalId = null;
350
648
  }
649
+ if (state.statusPollIntervalId) {
650
+ clearInterval(state.statusPollIntervalId);
651
+ state.statusPollIntervalId = null;
652
+ }
351
653
 
352
654
  if (verbose) {
353
- await log(`🔌 Bidirectional mode: Stopped monitoring (processed ${state.totalCommentsProcessed} comments, queued ${state.totalFeedbackQueued} feedback)`, { verbose: true });
655
+ await log(`🔌 Bidirectional mode: Stopped monitoring (processed ${state.totalCommentsProcessed} comments, queued ${state.totalFeedbackQueued} feedback, ${state.totalStatusFramesSent} status frames)`, { verbose: true });
354
656
  }
355
657
  };
356
658
 
@@ -500,6 +802,14 @@ export const createBidirectionalHandler = options => {
500
802
  totalFeedbackQueued: state.totalFeedbackQueued,
501
803
  totalFeedbackStreamed: state.totalFeedbackStreamed,
502
804
  isStreamingAttached: !!state.claudeStdin,
805
+ // Issue #1708 additions
806
+ deliveryMode,
807
+ streamStatusToInput,
808
+ isAiBusy: state.isAiBusy,
809
+ pendingFramesLength: state.pendingFrames.length,
810
+ totalFramesQueued: state.totalFramesQueued,
811
+ totalFramesFlushed: state.totalFramesFlushed,
812
+ totalStatusFramesSent: state.totalStatusFramesSent,
503
813
  });
504
814
 
505
815
  return {
@@ -517,6 +827,10 @@ export const createBidirectionalHandler = options => {
517
827
  attachClaudeStdin,
518
828
  detachClaudeStdin,
519
829
  streamInitialPrompt,
830
+ // Issue #1708: queue mode + status streaming
831
+ markAiBusy,
832
+ markAiIdle,
833
+ checkForStatusChanges,
520
834
  getState,
521
835
  // Expose for testing
522
836
  _internal: {
@@ -526,6 +840,9 @@ export const createBidirectionalHandler = options => {
526
840
  formatFeedbackForClaude,
527
841
  buildInitialUserFrame,
528
842
  writeFrameToStdin,
843
+ dispatchFrame,
844
+ fetchMetadataSnapshot,
845
+ diffMetadataSnapshot,
529
846
  },
530
847
  };
531
848
  };
@@ -558,6 +875,27 @@ export const isBidirectionalModeSupported = tool => {
558
875
  * @returns {Promise<boolean>} Whether configuration is valid for the chosen tool
559
876
  */
560
877
  export const validateBidirectionalModeConfig = async (argv, log) => {
878
+ // Issue #1708 Stage 1: --auto-input-until-mergeable enables only the
879
+ // input-side of bidirectional mode — accepting incoming PR/issue comments
880
+ // as new input — without enabling --interactive-mode (which would push
881
+ // tool output back as PR comments). The full streaming-aware
882
+ // watchUntilMergeable replacement is staged in subsequent PRs — until
883
+ // those land, this composition gives users on --tool claude the
884
+ // mid-session NDJSON input pipe that already exists for issue #817 and
885
+ // is a graceful no-op for non-Claude tools (the validator below disables
886
+ // it). The --auto-restart-until-mergeable / --auto-resume loops remain
887
+ // active as fallbacks; the goal is for them to stay dormant when input
888
+ // streaming keeps the session alive.
889
+ if (argv.autoInputUntilMergeable) {
890
+ if (!argv.acceptIncommingCommentsAsInput) argv.acceptIncommingCommentsAsInput = true;
891
+ // Default delivery mode for --auto-input-until-mergeable is queue:
892
+ // hold comments until the AI is idle so the model can finish the
893
+ // current step before being interrupted.
894
+ if (!argv.streamCommentsToInput && !argv.queueCommentsToInput) {
895
+ argv.queueCommentsToInput = true;
896
+ }
897
+ }
898
+
561
899
  // Composition: --bidirectional-interactive-mode implies the three experimental flags.
562
900
  if (argv.bidirectionalInteractiveMode) {
563
901
  if (!argv.interactiveMode) argv.interactiveMode = true;
@@ -565,6 +903,19 @@ export const validateBidirectionalModeConfig = async (argv, log) => {
565
903
  if (!argv.excludeAllOwnIncommingCommentsFromInput) argv.excludeAllOwnIncommingCommentsFromInput = true;
566
904
  }
567
905
 
906
+ // Default delivery mode for --accept-incomming-comments-as-input on its
907
+ // own is stream (matches the existing #817 behavior of forwarding
908
+ // comments immediately as pollIncomingComments sees them).
909
+ if (argv.acceptIncommingCommentsAsInput && !argv.streamCommentsToInput && !argv.queueCommentsToInput) {
910
+ argv.streamCommentsToInput = true;
911
+ }
912
+
913
+ // queue mode wins if both delivery modes are set (defensive, in case the
914
+ // user passes both flags explicitly).
915
+ if (argv.queueCommentsToInput && argv.streamCommentsToInput) {
916
+ argv.streamCommentsToInput = false;
917
+ }
918
+
568
919
  // Nothing more to validate if no incoming-comment acceptance is requested
569
920
  if (!argv.acceptIncommingCommentsAsInput) return true;
570
921
 
@@ -574,11 +925,15 @@ export const validateBidirectionalModeConfig = async (argv, log) => {
574
925
  await log(' Incoming-comment acceptance will be disabled for this session.', { level: 'warning' });
575
926
  argv.acceptIncommingCommentsAsInput = false;
576
927
  argv.excludeAllOwnIncommingCommentsFromInput = false;
928
+ argv.streamCommentsToInput = false;
929
+ argv.queueCommentsToInput = false;
577
930
  return false;
578
931
  }
579
932
 
933
+ const deliveryMode = argv.queueCommentsToInput ? 'queue' : 'stream';
580
934
  await log('🔌 Bidirectional Interactive Mode: ENABLED (experimental)', { level: 'info' });
581
935
  await log(` accept-incomming-comments-as-input: true${argv.excludeAllOwnIncommingCommentsFromInput ? ', exclude-all-own-incomming-comments-from-input: true' : ''}`, { level: 'info' });
936
+ await log(` delivery mode: ${deliveryMode}-comments-to-input`, { level: 'info' });
582
937
  await log(' PR comments will be monitored and queued as feedback for Claude.', { level: 'info' });
583
938
 
584
939
  return true;
@@ -591,34 +946,50 @@ export const validateBidirectionalModeConfig = async (argv, log) => {
591
946
  *
592
947
  * @param {Object} params
593
948
  * @param {Object} params.argv - Parsed CLI args (expects `acceptIncommingCommentsAsInput`,
594
- * `excludeAllOwnIncommingCommentsFromInput`, `verbose`).
949
+ * `excludeAllOwnIncommingCommentsFromInput`, `queueCommentsToInput`,
950
+ * `streamCommentsToInput`, `autoInputUntilMergeable`, `verbose`).
595
951
  * @param {string} params.owner
596
952
  * @param {string} params.repo
597
953
  * @param {string|number} params.prNumber
954
+ * @param {string|number} [params.issueNumber] - Issue #1708: enables issue title/body polling
955
+ * @param {string} [params.tempDir] - Issue #1708: enables uncommitted-changes polling
598
956
  * @param {Function} params.$ - command-stream tagged template
599
957
  * @param {Function} params.log
600
958
  * @returns {Promise<Object|null>} Started handler or null when inactive.
601
959
  */
602
- export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, $, log }) => {
960
+ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, issueNumber, tempDir, $, log }) => {
603
961
  if (!argv.acceptIncommingCommentsAsInput) return null;
604
962
  if (!owner || !repo || !prNumber) {
605
963
  await log('⚠️ Bidirectional mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
606
964
  return null;
607
965
  }
966
+ // Issue #1708: Resolve delivery mode from argv. validateBidirectionalModeConfig
967
+ // already enforces queue-wins-over-stream and the per-flag defaults; here we
968
+ // just translate the booleans into the handler-side enum.
969
+ const deliveryMode = argv.queueCommentsToInput ? 'queue' : 'stream';
970
+ // Issue #1708: Status streaming (CI/uncommitted/PR-metadata → NDJSON frames)
971
+ // is only enabled by --auto-input-until-mergeable; the standalone
972
+ // --accept-incomming-comments-as-input path keeps the existing #817 behavior
973
+ // of forwarding only comments.
974
+ const streamStatusToInput = !!argv.autoInputUntilMergeable;
608
975
  await log('🔌 Bidirectional mode: Creating handler to accept incoming PR comments as Claude input', { verbose: true });
609
976
  const handler = createBidirectionalHandler({
610
977
  owner,
611
978
  repo,
612
979
  prNumber,
980
+ issueNumber,
981
+ tempDir,
613
982
  $,
614
983
  log,
615
984
  verbose: argv.verbose,
616
985
  pollInterval: 15000,
617
986
  excludeOwnComments: !!argv.excludeAllOwnIncommingCommentsFromInput,
987
+ deliveryMode,
988
+ streamStatusToInput,
618
989
  });
619
990
  await handler.initializeFromCurrentComments();
620
991
  await handler.startMonitoring();
621
- await log('🔌 Bidirectional mode: Started monitoring PR comments for feedback', { verbose: true });
992
+ await log(`🔌 Bidirectional mode: Started monitoring (delivery: ${deliveryMode}-comments-to-input${streamStatusToInput ? ', status streaming: on' : ''})`, { verbose: true });
622
993
  return handler;
623
994
  };
624
995