@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.
@@ -11,6 +11,7 @@ import { reportError } from './sentry.lib.mjs';
11
11
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
12
12
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
+ import { setupBidirectionalHandler, finalizeBidirectionalHandler, validateBidirectionalModeConfig, attachStreamingInput } from './bidirectional-interactive.lib.mjs';
14
15
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
15
16
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
16
17
  import Decimal from 'decimal.js-light';
@@ -632,6 +633,10 @@ export const executeClaudeCommand = async params => {
632
633
  repo,
633
634
  prNumber,
634
635
  } = params;
636
+ // Issue #817: Apply bidirectional-mode composition and tool-support validation before running.
637
+ // This may enable argv.interactiveMode, argv.acceptIncommingCommentsAsInput, and
638
+ // argv.excludeAllOwnIncommingCommentsFromInput when --bidirectional-interactive-mode is set.
639
+ await validateBidirectionalModeConfig(argv, log);
635
640
  // Issue #1331: Unified retry configuration for all transient API errors
636
641
  // (Overloaded, 503 Network Error, Internal Server Error) - same params, all with session preservation
637
642
  let retryCount = 0;
@@ -715,6 +720,9 @@ export const executeClaudeCommand = async params => {
715
720
  } else if (argv.interactiveMode) {
716
721
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
717
722
  }
723
+ // Issue #817: Set up bidirectional handler when --accept-incomming-comments-as-input
724
+ // (or composite --bidirectional-interactive-mode) is enabled. Returns null when inactive.
725
+ const bidirectionalHandler = await setupBidirectionalHandler({ argv, owner, repo, prNumber, $, log });
718
726
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
719
727
  let execCommand;
720
728
  const mappedModel = mapModelToId(argv.model);
@@ -722,6 +730,12 @@ export const executeClaudeCommand = async params => {
722
730
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
723
731
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
724
732
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
733
+ // Declare queuedFeedback for use in catch/finally blocks and return value
734
+ let queuedFeedback = [];
735
+ // Issue #817: When --accept-incomming-comments-as-input is set and we are
736
+ // not resuming a prior session, drive Claude via NDJSON stream-json input
737
+ // so incoming PR comments can be streamed as additional user turns.
738
+ const streamingInput = !!(argv.acceptIncommingCommentsAsInput && bidirectionalHandler && !argv.resume);
725
739
  if (argv.resume) {
726
740
  await log(`🔄 Resuming from session: ${argv.resume}`);
727
741
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
@@ -730,7 +744,12 @@ export const executeClaudeCommand = async params => {
730
744
  const { mcpConfigPath, disallowedToolsList } = await resolveClaudeSessionToolFlags({ argv, log, fallbackBuildMcpConfigWithoutPlaywright: buildMcpConfigWithoutPlaywright });
731
745
  if (mcpConfigPath) claudeArgs += ` --strict-mcp-config --mcp-config "${mcpConfigPath}"`;
732
746
  if (disallowedToolsList.length) claudeArgs += ` --disallowedTools ${disallowedToolsList.join(' ')}`;
733
- claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
747
+ if (streamingInput) {
748
+ // Prompt is delivered as the first NDJSON frame on stdin (not as -p).
749
+ claudeArgs += ` -p --input-format stream-json --append-system-prompt "${escapedSystemPrompt}"`;
750
+ } else {
751
+ claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
752
+ }
734
753
  const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
735
754
  await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
736
755
  await log(`${fullCommand}`);
@@ -741,7 +760,9 @@ export const executeClaudeCommand = async params => {
741
760
  }
742
761
  try {
743
762
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
744
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent });
763
+ // Issue #817: Streaming mode sets exitAfterStopDelayMs=60000 so the
764
+ // headless Claude process stays alive between NDJSON turns.
765
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined });
745
766
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
746
767
  const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
747
768
  if (argv.verbose) {
@@ -758,9 +779,18 @@ export const executeClaudeCommand = async params => {
758
779
  if (argv.resume) {
759
780
  const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
760
781
  execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
782
+ } else if (streamingInput) {
783
+ // Issue #817: Drive Claude via --input-format stream-json on a pipe
784
+ // stdin. Initial prompt + later PR comments are written as NDJSON
785
+ // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
786
+ const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
787
+ execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
761
788
  } else {
762
789
  execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
763
790
  }
791
+ if (streamingInput) {
792
+ await attachStreamingInput(bidirectionalHandler, execCommand, prompt, log, !!argv.verbose);
793
+ }
764
794
  await log(`${formatAligned('📋', 'Command details:', '')}`);
765
795
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
766
796
  await log(formatAligned('🌿', 'Branch:', branchName, 2));
@@ -1116,6 +1146,8 @@ export const executeClaudeCommand = async params => {
1116
1146
  }
1117
1147
  }
1118
1148
 
1149
+ // Issue #817: Stop bidirectional mode monitoring and collect queued feedback
1150
+ queuedFeedback = await finalizeBidirectionalHandler(bidirectionalHandler, log);
1119
1151
  // Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
1120
1152
  const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
1121
1153
  if ((commandFailed || isTransientError) && isTransientError) {
@@ -1141,6 +1173,7 @@ export const executeClaudeCommand = async params => {
1141
1173
  is503Error,
1142
1174
  anthropicTotalCostUSD,
1143
1175
  resultSummary,
1176
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1144
1177
  };
1145
1178
  }
1146
1179
  if (retryCount < maxRetries) {
@@ -1183,6 +1216,7 @@ export const executeClaudeCommand = async params => {
1183
1216
  is503Error, // preserve for callers that check this
1184
1217
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1185
1218
  resultSummary, // Issue #1263: Include result summary
1219
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1186
1220
  };
1187
1221
  }
1188
1222
  }
@@ -1242,6 +1276,7 @@ export const executeClaudeCommand = async params => {
1242
1276
  errorDuringExecution,
1243
1277
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1244
1278
  resultSummary, // Issue #1263: Include result summary
1279
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1245
1280
  };
1246
1281
  }
1247
1282
  // Issue #1088/#1351: Log execution result status
@@ -1330,6 +1365,7 @@ export const executeClaudeCommand = async params => {
1330
1365
  resultModelUsage, // Issue #1454
1331
1366
  streamTokenUsage: streamTokenUsage.eventCount > 0 ? streamTokenUsage : null, // Issue #1491
1332
1367
  subAgentCalls: subAgentCalls.length > 0 ? subAgentCalls : null, // Issue #1590
1368
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1333
1369
  };
1334
1370
  } catch (error) {
1335
1371
  reportError(error, {
@@ -1371,6 +1407,7 @@ export const executeClaudeCommand = async params => {
1371
1407
  toolUseCount,
1372
1408
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1373
1409
  resultSummary, // Issue #1263: Include result summary
1410
+ queuedFeedback, // Issue #817: Bidirectional mode feedback
1374
1411
  };
1375
1412
  }
1376
1413
  }; // End of executeWithRetry function
@@ -463,6 +463,14 @@ export const getClaudeEnv = (options = {}) => {
463
463
  if (options.showThinkingContent) {
464
464
  env.CLAUDE_CODE_SHOW_THINKING = '1';
465
465
  }
466
+ // Issue #817: When bidirectional streaming input is enabled, keep the headless
467
+ // Claude process alive between turns so newly arriving PR comments can be
468
+ // streamed into stdin as additional user messages. Without this env var the
469
+ // process would exit as soon as the first --input-format stream-json frame
470
+ // is processed. Default is 1 minute (60000ms), matching the reference gist.
471
+ if (options.exitAfterStopDelayMs) {
472
+ env.CLAUDE_CODE_EXIT_AFTER_STOP_DELAY_MS = String(options.exitAfterStopDelayMs);
473
+ }
466
474
  // Set ANTHROPIC_DEFAULT_OPUS_MODEL when planModel is specified (Issue #1223)
467
475
  // This tells Claude Code which model to use during plan mode in opusplan
468
476
  if (options.planModel) {
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared runner for the `hive-screens` bin command. Scans detached GNU
5
+ * screen sessions for completed solve runs and lists, enters, or closes
6
+ * them. Ports the `hive-screens.sh` script that previously lived in
7
+ * README.md, and keeps a single matching function so that `--list` is
8
+ * a safe preview for `--close` / `--enter`.
9
+ *
10
+ * See issue #1649.
11
+ */
12
+
13
+ import { exec as execCallback } from 'node:child_process';
14
+ import fs from 'node:fs/promises';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { promisify } from 'node:util';
18
+
19
+ const execAsync = promisify(execCallback);
20
+
21
+ export const HIVE_SCREENS_HELP = `Usage: hive-screens (--list | --enter | --close) [--oldest|--newest|--all]
22
+
23
+ Scan detached GNU screen sessions for completed solve runs and either list,
24
+ enter, or close them. A session matches when its scrollback contains both
25
+ "process completed" and either "pr is mergeable!" or "pr merged!" (case
26
+ insensitive) — the exact predicate from the legacy hive-screens.sh script.
27
+
28
+ Actions (one required):
29
+ --list Print matching sessions without touching them
30
+ --enter Attach to the selected match (blocking)
31
+ --close Send \`exit\\n\` to the selected match so it terminates
32
+
33
+ Selection (optional, default: --oldest):
34
+ --oldest Act on the oldest match (default)
35
+ --newest Act on the newest match
36
+ --all Act on every match in oldest-first order
37
+
38
+ Options:
39
+ -h, --help Show this help and exit
40
+
41
+ Examples:
42
+ hive-screens --list # safe preview of matches
43
+ hive-screens --list --all # preview every match
44
+ hive-screens --close --oldest # close the oldest finished run
45
+ hive-screens --enter --newest # attach to the newest finished run
46
+
47
+ Reference: https://github.com/link-assistant/hive-mind/issues/1649
48
+ `;
49
+
50
+ const ACTION_FLAGS = new Set(['--enter', '--close', '--list']);
51
+ const SELECTION_FLAGS = new Set(['--oldest', '--newest', '--all']);
52
+
53
+ /**
54
+ * Parse the argv for `hive-screens`. Returns the parsed flags plus an
55
+ * `error` string when validation fails (so callers can print it and exit
56
+ * with a non-zero status without throwing).
57
+ */
58
+ export const parseHiveScreensArgs = argv => {
59
+ const result = {
60
+ enter: false,
61
+ close: false,
62
+ list: false,
63
+ selection: null,
64
+ help: false,
65
+ error: null,
66
+ };
67
+
68
+ for (const arg of argv) {
69
+ if (arg === '--help' || arg === '-h') {
70
+ result.help = true;
71
+ continue;
72
+ }
73
+ if (ACTION_FLAGS.has(arg)) {
74
+ if (arg === '--enter') result.enter = true;
75
+ else if (arg === '--close') result.close = true;
76
+ else result.list = true;
77
+ continue;
78
+ }
79
+ if (SELECTION_FLAGS.has(arg)) {
80
+ if (result.selection && result.selection !== arg.slice(2)) {
81
+ result.error = `Conflicting selection flags: --${result.selection} and ${arg}`;
82
+ return result;
83
+ }
84
+ result.selection = arg.slice(2);
85
+ continue;
86
+ }
87
+ result.error = `Unknown option: ${arg}`;
88
+ return result;
89
+ }
90
+
91
+ if (result.help) return result;
92
+
93
+ const actions = [result.enter, result.close, result.list].filter(Boolean).length;
94
+ if (actions === 0) {
95
+ result.error = 'Must specify --list, --enter, or --close';
96
+ return result;
97
+ }
98
+ if (actions > 1) {
99
+ result.error = 'Specify only one of --list, --enter, --close';
100
+ return result;
101
+ }
102
+
103
+ if (!result.selection) result.selection = 'oldest';
104
+ return result;
105
+ };
106
+
107
+ /**
108
+ * List detached screen sessions in oldest-first order. GNU screen prints
109
+ * them newest-first, so we reverse to mirror `sort -n` (ascending PID) on
110
+ * the typical `NNNNN.name` session names.
111
+ */
112
+ export const listDetachedSessions = async ({ exec = execAsync } = {}) => {
113
+ let stdout = '';
114
+ try {
115
+ ({ stdout } = await exec('screen -ls'));
116
+ } catch (err) {
117
+ // `screen -ls` exits 1 when there are no sessions. It still prints the
118
+ // session header to stdout, so keep parsing whatever we got.
119
+ stdout = err.stdout || '';
120
+ }
121
+ const sessions = [];
122
+ for (const rawLine of stdout.split('\n')) {
123
+ const line = rawLine.trim();
124
+ if (!/\((?:Detached|Attached)\)/i.test(line)) continue;
125
+ if (!/Detached/i.test(line)) continue;
126
+ const match = line.match(/^(\S+)/);
127
+ if (match) sessions.push(match[1]);
128
+ }
129
+ return sessions.sort((a, b) => {
130
+ const na = parseInt(a, 10);
131
+ const nb = parseInt(b, 10);
132
+ if (Number.isNaN(na) || Number.isNaN(nb)) return a.localeCompare(b);
133
+ return na - nb;
134
+ });
135
+ };
136
+
137
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
138
+
139
+ const stripNonPrintable = text => text.replace(/[^\t\n\r\x20-\x7E]/g, '');
140
+
141
+ /**
142
+ * Capture the scrollback of a single screen session. Mirrors the sh
143
+ * script: bump scrollback to 200000, settle, `hardcopy -h`, read, strip.
144
+ */
145
+ export const captureSessionScrollback = async (session, { exec = execAsync, fsModule = fs, tmpDir = os.tmpdir(), scrollback = 200000, settleMs = 150 } = {}) => {
146
+ const tmpFile = path.join(tmpDir, `hive-screens-${session}-${Date.now()}-${Math.random().toString(36).slice(2)}.hardcopy`);
147
+ const shellSession = session.replace(/'/g, "'\\''");
148
+ const shellTmp = tmpFile.replace(/'/g, "'\\''");
149
+ try {
150
+ await exec(`screen -S '${shellSession}' -X scrollback ${scrollback}`).catch(() => {});
151
+ if (settleMs > 0) await sleep(settleMs);
152
+ await exec(`screen -S '${shellSession}' -X hardcopy -h '${shellTmp}'`).catch(() => {});
153
+ let raw = '';
154
+ try {
155
+ raw = await fsModule.readFile(tmpFile, 'utf-8');
156
+ } catch {
157
+ raw = '';
158
+ }
159
+ return stripNonPrintable(raw);
160
+ } finally {
161
+ await fsModule.unlink(tmpFile).catch(() => {});
162
+ }
163
+ };
164
+
165
+ /**
166
+ * The single source of truth for session matching. `--list`, `--enter`,
167
+ * and `--close` all route through this predicate, so a session visible in
168
+ * `--list` is guaranteed to be actionable by the other two flags.
169
+ */
170
+ export const sessionMatches = text => {
171
+ if (!text) return { matched: false, logPath: null, issueUrl: null };
172
+ const hasCompletion = /process completed/i.test(text);
173
+ const hasMerge = /pr is mergeable!|pr merged!/i.test(text);
174
+ if (!hasCompletion || !hasMerge) {
175
+ return { matched: false, logPath: null, issueUrl: null };
176
+ }
177
+ const logMatches = [...text.matchAll(/Full log file:\s*(\S+)/gi)];
178
+ const issueMatches = [...text.matchAll(/Issue:\s*(https:\/\/github\.com\/\S+)/gi)];
179
+ return {
180
+ matched: true,
181
+ logPath: logMatches.length ? logMatches[logMatches.length - 1][1] : null,
182
+ issueUrl: issueMatches.length ? issueMatches[issueMatches.length - 1][1] : null,
183
+ };
184
+ };
185
+
186
+ /**
187
+ * Scan every detached session and return the ones that pass
188
+ * `sessionMatches`, in the requested order.
189
+ */
190
+ export const findMatchingSessions = async ({ exec = execAsync, fsModule = fs, tmpDir = os.tmpdir(), order = 'oldest', captureOptions = {} } = {}) => {
191
+ const sessions = await listDetachedSessions({ exec });
192
+ const ordered = order === 'newest' ? [...sessions].reverse() : sessions;
193
+ const matches = [];
194
+ for (const session of ordered) {
195
+ const text = await captureSessionScrollback(session, { exec, fsModule, tmpDir, ...captureOptions });
196
+ const result = sessionMatches(text);
197
+ if (result.matched) {
198
+ matches.push({ session, logPath: result.logPath, issueUrl: result.issueUrl });
199
+ }
200
+ }
201
+ return matches;
202
+ };
203
+
204
+ /**
205
+ * Apply `--oldest / --newest / --all` to the ordered match list produced
206
+ * by `findMatchingSessions`. The orderer already did the directional
207
+ * sort, so picking element 0 is always "the selected one" in that order.
208
+ */
209
+ export const selectMatches = (matches, selection) => {
210
+ if (!matches.length) return [];
211
+ if (selection === 'all') return matches;
212
+ return [matches[0]];
213
+ };
214
+
215
+ const printSession = ({ session, logPath, issueUrl }, { log }) => {
216
+ log(`Session: ${session}`);
217
+ log(logPath ? `Log: ${logPath}` : 'Log: (not found)');
218
+ log(issueUrl ? `Issue: ${issueUrl}` : 'Issue: (not found)');
219
+ };
220
+
221
+ const SEPARATOR = '-----------------------------------';
222
+
223
+ /**
224
+ * Top-level orchestrator used by the bin. `deps` is injected so tests can
225
+ * stub `exec`, `fs`, stdio, and process spawning without touching real
226
+ * screen sessions.
227
+ */
228
+ export const runHiveScreens = async (argv, deps = {}) => {
229
+ const { exec = execAsync, fsModule = fs, tmpDir = os.tmpdir(), log = (...args) => console.log(...args), error = (...args) => console.error(...args), spawnScreen, captureOptions } = deps;
230
+
231
+ const args = parseHiveScreensArgs(argv);
232
+ if (args.help) {
233
+ log(HIVE_SCREENS_HELP);
234
+ return 0;
235
+ }
236
+ if (args.error) {
237
+ error(args.error);
238
+ return 1;
239
+ }
240
+
241
+ const order = args.selection === 'newest' ? 'newest' : 'oldest';
242
+ const matches = await findMatchingSessions({ exec, fsModule, tmpDir, order, captureOptions });
243
+
244
+ if (!matches.length) {
245
+ log('No matching sessions');
246
+ return 0;
247
+ }
248
+
249
+ const selected = selectMatches(matches, args.selection);
250
+
251
+ for (const match of selected) {
252
+ printSession(match, { log });
253
+ if (args.enter) {
254
+ log(`Entering ${match.session}`);
255
+ if (spawnScreen) {
256
+ await spawnScreen(match.session);
257
+ } else {
258
+ await attachScreen(match.session);
259
+ }
260
+ log(`Left ${match.session}`);
261
+ }
262
+ if (args.close) {
263
+ log(`Closing ${match.session}`);
264
+ const shellSession = match.session.replace(/'/g, "'\\''");
265
+ await exec(`screen -S '${shellSession}' -X stuff $'exit\\n'`).catch(err => {
266
+ error(`Failed to send exit to ${match.session}: ${err.message}`);
267
+ });
268
+ }
269
+ log(SEPARATOR);
270
+ }
271
+
272
+ return 0;
273
+ };
274
+
275
+ /**
276
+ * Default `--enter` side-effect: spawn `screen -r <session>` attached to
277
+ * the parent stdio so the user can actually interact with it. Split from
278
+ * `runHiveScreens` so tests can inject a no-op spawn.
279
+ */
280
+ const attachScreen = async session => {
281
+ const { spawn } = await import('node:child_process');
282
+ return new Promise((resolve, reject) => {
283
+ const child = spawn('screen', ['-r', session], { stdio: 'inherit' });
284
+ child.on('error', reject);
285
+ child.on('exit', () => resolve());
286
+ });
287
+ };
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `hive-screens` — list, enter, or close detached GNU screen sessions
5
+ * produced by `solve` / `hive` runs that have completed a mergeable PR.
6
+ *
7
+ * Replaces the embedded `hive-screens.sh` script that previously lived
8
+ * in README.md. The matching predicate is shared across `--list`,
9
+ * `--enter`, and `--close`, so any session visible under `--list` is
10
+ * guaranteed to be actionable by the other two flags.
11
+ *
12
+ * See issue #1649.
13
+ */
14
+
15
+ import { runHiveScreens } from './hive-screens.lib.mjs';
16
+
17
+ const exitCode = await runHiveScreens(process.argv.slice(2));
18
+ process.exit(exitCode);
@@ -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.',