@link-assistant/hive-mind 1.54.7 → 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 +12 -0
- package/package.json +4 -4
- package/src/bidirectional-interactive.lib.mjs +710 -0
- package/src/claude.lib.mjs +39 -2
- package/src/config.lib.mjs +8 -0
- package/src/github-merge-ci.lib.mjs +4 -2
- package/src/github-merge-repo-actions.lib.mjs +5 -4
- package/src/github-merge.lib.mjs +29 -34
- package/src/log-upload.lib.mjs +1 -1
- package/src/solve.accept-invite.lib.mjs +2 -2
- package/src/solve.auto-merge-helpers.lib.mjs +5 -3
- package/src/solve.auto-pr.lib.mjs +3 -3
- package/src/solve.config.lib.mjs +16 -0
- package/src/solve.feedback.lib.mjs +6 -4
- package/src/solve.progress-monitoring.lib.mjs +1 -1
- package/src/solve.repository.lib.mjs +1 -1
- package/src/telegram-accept-invitations.lib.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.54.8
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 12f5761: Fix `--auto-restart-until-mergeable` readiness comment deduplication for pull requests with more than one page of comments, and enforce pagination on list-returning `gh api` calls.
|
|
14
|
+
|
|
3
15
|
## 1.54.7
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
17
|
+
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
18
18
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
19
19
|
"test:limits-display": "node tests/limits-display.test.mjs",
|
|
20
20
|
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
21
|
-
"lint": "eslint 'src/**/*.{js,mjs,cjs}'",
|
|
22
|
-
"lint:fix": "eslint 'src/**/*.{js,mjs,cjs}' --fix",
|
|
21
|
+
"lint": "eslint 'src/**/*.{js,mjs,cjs}' 'scripts/**/*.{js,mjs,cjs}' 'eslint-rules/**/*.{js,mjs,cjs}'",
|
|
22
|
+
"lint:fix": "eslint 'src/**/*.{js,mjs,cjs}' 'scripts/**/*.{js,mjs,cjs}' 'eslint-rules/**/*.{js,mjs,cjs}' --fix",
|
|
23
23
|
"check:duplication": "jscpd .",
|
|
24
24
|
"format": "prettier --write \"**/*.{js,mjs,json,md}\" --ignore-path .prettierignore",
|
|
25
25
|
"format:check": "prettier --check \"**/*.{js,mjs,json,md}\" --ignore-path .prettierignore",
|
|
@@ -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
|
+
};
|
package/src/claude.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/src/config.lib.mjs
CHANGED
|
@@ -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) {
|
|
@@ -185,8 +185,10 @@ export async function checkBranchCIHealth(owner, repo, branch = 'main', options,
|
|
|
185
185
|
|
|
186
186
|
// Issue #1425: Query CI runs specifically for the HEAD SHA (no status filter).
|
|
187
187
|
// This ensures we see in-progress runs for the latest commit, not just completed ones.
|
|
188
|
-
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${headSha}&per_page=
|
|
189
|
-
const runs = JSON.parse(stdout.trim() || '[]')
|
|
188
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${headSha}&per_page=100" --paginate --slurp`);
|
|
189
|
+
const runs = JSON.parse(stdout.trim() || '[]')
|
|
190
|
+
.flatMap(page => page.workflow_runs || [])
|
|
191
|
+
.map(run => ({ id: run.id, name: run.name, status: run.status, conclusion: run.conclusion, html_url: run.html_url, head_sha: run.head_sha, created_at: run.created_at }));
|
|
190
192
|
|
|
191
193
|
if (verbose) {
|
|
192
194
|
console.log(`[VERBOSE] /merge: Found ${runs.length} CI run(s) for HEAD commit ${headSha.substring(0, 7)} on ${owner}/${repo} branch ${branch}`);
|
|
@@ -22,10 +22,11 @@ const exec = promisify(execCallback);
|
|
|
22
22
|
*/
|
|
23
23
|
export async function getAllActiveRepoRuns(owner, repo, verbose = false) {
|
|
24
24
|
try {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?per_page=100" --paginate --slurp`);
|
|
26
|
+
const runs = JSON.parse(stdout.trim() || '[]')
|
|
27
|
+
.flatMap(page => page.workflow_runs || [])
|
|
28
|
+
.filter(run => ['in_progress', 'queued', 'waiting', 'requested', 'pending'].includes(run.status))
|
|
29
|
+
.map(run => ({ id: run.id, name: run.name, status: run.status, head_branch: run.head_branch, head_sha: run.head_sha?.slice(0, 7) }));
|
|
29
30
|
if (verbose && runs.length > 0) {
|
|
30
31
|
console.log(`[VERBOSE] repo-actions: ${runs.length} active run(s) in ${owner}/${repo}`);
|
|
31
32
|
for (const r of runs) console.log(`[VERBOSE] repo-actions: ${r.name} (${r.status}) on ${r.head_branch}`);
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -313,9 +313,8 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
|
|
|
313
313
|
const prData = JSON.parse(prJson.trim());
|
|
314
314
|
const sha = prData.headRefOid;
|
|
315
315
|
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
const checkRuns = JSON.parse(checksJson.trim() || '[]');
|
|
316
|
+
const { stdout: checksJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/check-runs --paginate --slurp`);
|
|
317
|
+
const checkRuns = JSON.parse(checksJson.trim() || '[]').flatMap(page => page.check_runs || []);
|
|
319
318
|
|
|
320
319
|
// Get commit statuses (some CI systems use status API instead of checks API)
|
|
321
320
|
const { stdout: statusJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/status --jq '.statuses'`);
|
|
@@ -685,9 +684,11 @@ export function parseRepositoryUrl(url) {
|
|
|
685
684
|
export async function getActiveBranchRuns(owner, repo, branch = 'main', verbose = false) {
|
|
686
685
|
try {
|
|
687
686
|
// Query for in_progress and queued runs on the specified branch
|
|
688
|
-
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?branch=${branch}&per_page=
|
|
689
|
-
|
|
690
|
-
|
|
687
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?branch=${branch}&per_page=100" --paginate --slurp`);
|
|
688
|
+
const runs = JSON.parse(stdout.trim() || '[]')
|
|
689
|
+
.flatMap(page => page.workflow_runs || [])
|
|
690
|
+
.filter(run => run.status === 'in_progress' || run.status === 'queued')
|
|
691
|
+
.map(run => ({ id: run.id, name: run.name, status: run.status, created_at: run.created_at, html_url: run.html_url }));
|
|
691
692
|
|
|
692
693
|
if (verbose) {
|
|
693
694
|
console.log(`[VERBOSE] /merge: Found ${runs.length} active runs on ${owner}/${repo} branch ${branch}`);
|
|
@@ -839,7 +840,7 @@ export async function getDefaultBranch(owner, repo, verbose = false) {
|
|
|
839
840
|
*/
|
|
840
841
|
export async function getCheckRunAnnotations(owner, repo, checkRunId, verbose = false) {
|
|
841
842
|
try {
|
|
842
|
-
const { stdout } = await exec(`gh api repos/${owner}/${repo}/check-runs/${checkRunId}/annotations 2>/dev/null || echo "[]"`);
|
|
843
|
+
const { stdout } = await exec(`gh api repos/${owner}/${repo}/check-runs/${checkRunId}/annotations --paginate 2>/dev/null || echo "[]"`);
|
|
843
844
|
const annotations = JSON.parse(stdout.trim() || '[]');
|
|
844
845
|
|
|
845
846
|
if (verbose) {
|
|
@@ -895,12 +896,6 @@ export const BILLING_LIMIT_ERROR_PATTERN = 'The job was not started because rece
|
|
|
895
896
|
* Check if CI failure is due to billing/spending limits
|
|
896
897
|
* Issue #1314: Detects when GitHub Actions jobs fail due to billing issues rather than code problems
|
|
897
898
|
*
|
|
898
|
-
* Detection criteria:
|
|
899
|
-
* 1. Job has conclusion='failure'
|
|
900
|
-
* 2. Job has empty steps array (no steps were executed)
|
|
901
|
-
* 3. Job has runner_id=0 or null (no runner was assigned)
|
|
902
|
-
* 4. Annotation contains the billing limit error message
|
|
903
|
-
*
|
|
904
899
|
* @param {string} owner - Repository owner
|
|
905
900
|
* @param {string} repo - Repository name
|
|
906
901
|
* @param {number} prNumber - Pull request number
|
|
@@ -909,14 +904,14 @@ export const BILLING_LIMIT_ERROR_PATTERN = 'The job was not started because rece
|
|
|
909
904
|
*/
|
|
910
905
|
export async function checkForBillingLimitError(owner, repo, prNumber, verbose = false) {
|
|
911
906
|
try {
|
|
912
|
-
// Get the PR's head SHA
|
|
913
907
|
const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid`);
|
|
914
908
|
const prData = JSON.parse(prJson.trim());
|
|
915
909
|
const sha = prData.headRefOid;
|
|
916
910
|
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
911
|
+
const { stdout: runsJson } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=100" --paginate --slurp`);
|
|
912
|
+
const runIds = JSON.parse(runsJson.trim() || '[]')
|
|
913
|
+
.flatMap(page => page.workflow_runs || [])
|
|
914
|
+
.map(run => run.id);
|
|
920
915
|
|
|
921
916
|
if (verbose) {
|
|
922
917
|
console.log(`[VERBOSE] /merge: Found ${runIds.length} workflow runs for PR #${prNumber} at SHA ${sha.substring(0, 7)}`);
|
|
@@ -925,11 +920,10 @@ export async function checkForBillingLimitError(owner, repo, prNumber, verbose =
|
|
|
925
920
|
const affectedJobs = [];
|
|
926
921
|
let totalJobs = 0;
|
|
927
922
|
|
|
928
|
-
// Check each workflow run's jobs
|
|
929
923
|
for (const runId of runIds) {
|
|
930
924
|
try {
|
|
931
|
-
const { stdout: jobsJson } = await exec(`gh api repos/${owner}/${repo}/actions/runs/${runId}/jobs --
|
|
932
|
-
const jobs = JSON.parse(jobsJson.trim() || '[]');
|
|
925
|
+
const { stdout: jobsJson } = await exec(`gh api repos/${owner}/${repo}/actions/runs/${runId}/jobs --paginate --slurp`);
|
|
926
|
+
const jobs = JSON.parse(jobsJson.trim() || '[]').flatMap(page => page.jobs || []);
|
|
933
927
|
|
|
934
928
|
for (const job of jobs) {
|
|
935
929
|
totalJobs++;
|
|
@@ -1068,9 +1062,8 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
|
|
|
1068
1062
|
const prData = JSON.parse(prJson.trim());
|
|
1069
1063
|
const sha = prData.headRefOid;
|
|
1070
1064
|
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
const checkRuns = JSON.parse(checksJson.trim() || '[]');
|
|
1065
|
+
const { stdout: checksJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/check-runs --paginate --slurp`);
|
|
1066
|
+
const checkRuns = JSON.parse(checksJson.trim() || '[]').flatMap(page => page.check_runs || []);
|
|
1074
1067
|
|
|
1075
1068
|
// Get commit statuses
|
|
1076
1069
|
const { stdout: statusJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/status --jq '.statuses'`);
|
|
@@ -1214,8 +1207,10 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
|
|
|
1214
1207
|
*/
|
|
1215
1208
|
export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
|
|
1216
1209
|
try {
|
|
1217
|
-
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=
|
|
1218
|
-
const runs = JSON.parse(stdout.trim() || '[]')
|
|
1210
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=100" --paginate --slurp`);
|
|
1211
|
+
const runs = JSON.parse(stdout.trim() || '[]')
|
|
1212
|
+
.flatMap(page => page.workflow_runs || [])
|
|
1213
|
+
.map(run => ({ id: run.id, status: run.status, conclusion: run.conclusion, name: run.name, html_url: run.html_url }));
|
|
1219
1214
|
|
|
1220
1215
|
if (verbose) {
|
|
1221
1216
|
console.log(`[VERBOSE] /merge: Found ${runs.length} workflow runs for SHA ${sha.substring(0, 7)}`);
|
|
@@ -1251,13 +1246,13 @@ export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
|
|
|
1251
1246
|
*/
|
|
1252
1247
|
export async function getActiveRepoWorkflows(owner, repo, verbose = false) {
|
|
1253
1248
|
try {
|
|
1254
|
-
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/workflows" --
|
|
1255
|
-
const allWorkflows = JSON.parse(stdout.trim() || '[]')
|
|
1249
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/workflows" --paginate --slurp`);
|
|
1250
|
+
const allWorkflows = JSON.parse(stdout.trim() || '[]')
|
|
1251
|
+
.flatMap(page => page.workflows || [])
|
|
1252
|
+
.filter(workflow => workflow.state === 'active')
|
|
1253
|
+
.map(workflow => ({ id: workflow.id, name: workflow.name, state: workflow.state, path: workflow.path }));
|
|
1256
1254
|
|
|
1257
|
-
//
|
|
1258
|
-
// These have path "dynamic/pages/pages-build-deployment" and only run on the
|
|
1259
|
-
// default branch after merge — they never produce check-runs on PR branches.
|
|
1260
|
-
// Including them causes an infinite loop when waiting for PR CI checks.
|
|
1255
|
+
// GitHub Pages workflows only run after merge and never produce PR check-runs.
|
|
1261
1256
|
const workflows = allWorkflows.filter(wf => !wf.path.startsWith('dynamic/pages/'));
|
|
1262
1257
|
|
|
1263
1258
|
if (verbose) {
|
|
@@ -1351,8 +1346,8 @@ export async function checkPreviousPRCommitsHadCI(owner, repo, prNumber, headSha
|
|
|
1351
1346
|
|
|
1352
1347
|
for (const sha of commitsToCheck) {
|
|
1353
1348
|
try {
|
|
1354
|
-
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=
|
|
1355
|
-
const count =
|
|
1349
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=100" --paginate --slurp`);
|
|
1350
|
+
const count = JSON.parse(stdout.trim() || '[]').reduce((sum, page) => sum + (page.workflow_runs?.length || 0), 0);
|
|
1356
1351
|
if (count > 0) {
|
|
1357
1352
|
commitsWithCI++;
|
|
1358
1353
|
}
|
|
@@ -1390,7 +1385,7 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false,
|
|
|
1390
1385
|
// Issue #1503: Support querying workflow files from a specific branch (ref)
|
|
1391
1386
|
const refParam = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
1392
1387
|
// List workflow files in .github/workflows/ (uses ref if provided, otherwise default branch)
|
|
1393
|
-
const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows${refParam}" --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
|
|
1388
|
+
const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows${refParam}" --paginate --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
|
|
1394
1389
|
const files = JSON.parse(listJson.trim() || '[]');
|
|
1395
1390
|
|
|
1396
1391
|
if (files.length === 0) {
|
package/src/log-upload.lib.mjs
CHANGED
|
@@ -149,7 +149,7 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
|
|
|
149
149
|
if (verbose) {
|
|
150
150
|
await log(` 🔍 Fetching repository contents for raw URL resolution (repoPath=${repoPath})`, { verbose: true });
|
|
151
151
|
}
|
|
152
|
-
const contentsResult = await $silent`gh api repos/${repoPath}/contents --jq '.[].name'`;
|
|
152
|
+
const contentsResult = await $silent`gh api repos/${repoPath}/contents --paginate --jq '.[].name'`;
|
|
153
153
|
if (verbose) {
|
|
154
154
|
await log(` 📥 Repository contents fetch completed (code=${contentsResult.code ?? 'unknown'})`, { verbose: true });
|
|
155
155
|
}
|
|
@@ -43,7 +43,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
43
43
|
|
|
44
44
|
// Check for pending repository invitation
|
|
45
45
|
try {
|
|
46
|
-
const { stdout: repoInvJson } = await ghRetry(() => exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"'), { label: 'fetch repo invitations' });
|
|
46
|
+
const { stdout: repoInvJson } = await ghRetry(() => exec('gh api /user/repository_invitations --paginate 2>/dev/null || echo "[]"'), { label: 'fetch repo invitations' });
|
|
47
47
|
const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
|
|
48
48
|
verbose && (await log(` Found ${repoInvitations.length} total pending repo invitation(s)`, { verbose: true }));
|
|
49
49
|
|
|
@@ -66,7 +66,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
66
66
|
|
|
67
67
|
// Check for pending organization membership
|
|
68
68
|
try {
|
|
69
|
-
const { stdout: orgMemJson } = await ghRetry(() => exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"'), { label: 'fetch org memberships' });
|
|
69
|
+
const { stdout: orgMemJson } = await ghRetry(() => exec('gh api /user/memberships/orgs --paginate 2>/dev/null || echo "[]"'), { label: 'fetch org memberships' });
|
|
70
70
|
const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
|
|
71
71
|
const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
|
|
72
72
|
verbose && (await log(` Found ${pendingOrgs.length} total pending org invitation(s)`, { verbose: true }));
|
|
@@ -63,12 +63,14 @@ const { SESSION_ENDING_MARKERS } = toolComments;
|
|
|
63
63
|
* @param {number} prNumber - Pull request number
|
|
64
64
|
* @param {string} commentSignature - Unique signature to search for in comment body (e.g., "✅ Ready to merge")
|
|
65
65
|
* @param {boolean} verbose - Enable verbose logging
|
|
66
|
+
* @param {Function} commandRunner - Tagged-template command runner, injectable for tests
|
|
66
67
|
* @returns {Promise<boolean>} - True if a matching comment already exists
|
|
67
68
|
*/
|
|
68
|
-
export const checkForExistingComment = async (owner, repo, prNumber, commentSignature, verbose = false) => {
|
|
69
|
+
export const checkForExistingComment = async (owner, repo, prNumber, commentSignature, verbose = false, commandRunner = $) => {
|
|
69
70
|
try {
|
|
70
|
-
// Fetch
|
|
71
|
-
|
|
71
|
+
// Fetch every PR comment page so long threads don't scope deduplication to
|
|
72
|
+
// a stale first-page session-ending marker.
|
|
73
|
+
const result = await commandRunner`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate --jq '[.[].body]' 2>/dev/null`;
|
|
72
74
|
if (result.code === 0 && result.stdout) {
|
|
73
75
|
const rawOutput = result.stdout.toString().trim();
|
|
74
76
|
if (!rawOutput) return false;
|
|
@@ -604,7 +604,7 @@ Proceed.
|
|
|
604
604
|
}
|
|
605
605
|
compareResult = await $({
|
|
606
606
|
silent: true,
|
|
607
|
-
})`gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${headRef} --jq '.ahead_by' 2>&1`;
|
|
607
|
+
})`gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${headRef} --paginate --jq '.ahead_by' 2>&1`;
|
|
608
608
|
|
|
609
609
|
if (compareResult.code === 0) {
|
|
610
610
|
const aheadBy = parseInt(compareResult.stdout.toString().trim(), 10);
|
|
@@ -798,9 +798,9 @@ Proceed.
|
|
|
798
798
|
// Use the correct head reference for the compare API check
|
|
799
799
|
if (argv.fork && forkedRepo) {
|
|
800
800
|
const forkUser = forkedRepo.split('/')[0];
|
|
801
|
-
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${forkUser}:${branchName}`);
|
|
801
|
+
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${forkUser}:${branchName} --paginate`);
|
|
802
802
|
} else {
|
|
803
|
-
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${branchName}`);
|
|
803
|
+
await log(` gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${branchName} --paginate`);
|
|
804
804
|
}
|
|
805
805
|
await log('');
|
|
806
806
|
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -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.',
|
|
@@ -329,10 +329,12 @@ export const detectAndCountFeedback = async params => {
|
|
|
329
329
|
|
|
330
330
|
// 6. Check for failed PR checks
|
|
331
331
|
try {
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
const
|
|
335
|
-
const
|
|
332
|
+
const prHeadResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '.head.sha'`;
|
|
333
|
+
if (prHeadResult.code === 0) {
|
|
334
|
+
const prHeadSha = prHeadResult.stdout.toString().trim();
|
|
335
|
+
const checksResult = await $`gh api repos/${owner}/${repo}/commits/${prHeadSha}/check-runs --paginate --slurp`;
|
|
336
|
+
const checkRuns = checksResult.code === 0 ? JSON.parse(checksResult.stdout.toString() || '[]').flatMap(page => page.check_runs || []) : [];
|
|
337
|
+
const failedChecks = checkRuns.filter(check => check.conclusion === 'failure' && new Date(check.completed_at) > lastCommitTime);
|
|
336
338
|
|
|
337
339
|
if (failedChecks.length > 0) {
|
|
338
340
|
feedbackLines.push(`Failed pull request checks: ${failedChecks.length}`);
|
|
@@ -242,7 +242,7 @@ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose =
|
|
|
242
242
|
state.commentId = posted.commentId;
|
|
243
243
|
} else {
|
|
244
244
|
// Fallback: find the comment we just created by looking for our marker
|
|
245
|
-
const commentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq ${`[.[] | select(.body | contains("${CONFIG.PROGRESS_SECTION_START}")) | .id] | last`}`;
|
|
245
|
+
const commentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate --jq ${`[.[] | select(.body | contains("${CONFIG.PROGRESS_SECTION_START}")) | .id] | last`}`;
|
|
246
246
|
const commentId = commentsResult.stdout?.toString?.().trim();
|
|
247
247
|
if (commentId && commentId !== 'null') {
|
|
248
248
|
state.commentId = commentId;
|
|
@@ -525,7 +525,7 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
525
525
|
await log(`${formatAligned('🔍', 'Safety check:', 'Comparing commits against upstream...')}`);
|
|
526
526
|
let safeToDelete = false;
|
|
527
527
|
try {
|
|
528
|
-
const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --jq '.ahead_by' 2>&1`;
|
|
528
|
+
const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --paginate --jq '.ahead_by' 2>&1`;
|
|
529
529
|
if (cmp.code === 0 && parseInt(cmp.stdout.toString().trim(), 10) === 0) {
|
|
530
530
|
await log(`${formatAligned('✅', 'Safe to delete:', 'No additional commits in non-fork repository')}`);
|
|
531
531
|
safeToDelete = true;
|
|
@@ -174,13 +174,13 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
174
174
|
|
|
175
175
|
try {
|
|
176
176
|
// Fetch repository invitations
|
|
177
|
-
const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"');
|
|
177
|
+
const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations --paginate 2>/dev/null || echo "[]"');
|
|
178
178
|
const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
|
|
179
179
|
state.totalRepos = repoInvitations.length;
|
|
180
180
|
VERBOSE && console.log(`[VERBOSE] Found ${repoInvitations.length} pending repo invitations`);
|
|
181
181
|
|
|
182
182
|
// Fetch organization invitations
|
|
183
|
-
const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
|
|
183
|
+
const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs --paginate 2>/dev/null || echo "[]"');
|
|
184
184
|
const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
|
|
185
185
|
const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
|
|
186
186
|
state.totalOrgs = pendingOrgs.length;
|