@link-assistant/hive-mind 1.57.3 → 1.59.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 +186 -0
- package/package.json +1 -1
- package/src/anthropic-server-tool-pricing.lib.mjs +34 -0
- package/src/bidirectional-interactive.lib.mjs +392 -21
- package/src/claude.budget-stats.lib.mjs +154 -27
- package/src/claude.cost.lib.mjs +88 -0
- package/src/claude.lib.mjs +54 -58
- package/src/codex.lib.mjs +31 -0
- package/src/config.lib.mjs +39 -2
- package/src/github-cost-info.lib.mjs +4 -1
- package/src/lino.lib.mjs +3 -1
- package/src/solve.auto-merge.lib.mjs +5 -0
- package/src/solve.config.lib.mjs +39 -0
- package/src/sub-session-size.lib.mjs +239 -0
- package/src/use-with-retry.lib.mjs +91 -0
|
@@ -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:
|
|
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
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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`, `
|
|
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(
|
|
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
|
|