@link-assistant/hive-mind 1.54.8 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +21 -111
- package/package.json +4 -3
- package/src/bidirectional-interactive.lib.mjs +710 -0
- package/src/claude.lib.mjs +39 -2
- package/src/config.lib.mjs +8 -0
- package/src/hive-screens.lib.mjs +287 -0
- package/src/hive-screens.mjs +18 -0
- package/src/solve.config.lib.mjs +16 -0
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) {
|
|
@@ -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);
|
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.',
|