@link-assistant/hive-mind 1.54.8 ā 1.56.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 +19 -0
- package/README.md +21 -111
- package/package.json +4 -3
- package/src/bidirectional-interactive.lib.mjs +710 -0
- package/src/claude.lib.mjs +39 -2
- package/src/config.lib.mjs +8 -0
- package/src/hive-screens.lib.mjs +287 -0
- package/src/hive-screens.mjs +18 -0
- package/src/solve.config.lib.mjs +16 -0
|
@@ -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
|
+
};
|