@link-assistant/hive-mind 1.55.0 → 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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.56.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 391dbde: Add `hive-screens` bin command. Converts the `hive-screens.sh` script that was
8
+ embedded in README.md into a real JavaScript command shipped with the package.
9
+ Supports `--list` (safe preview), `--enter` (attach), and `--close` (terminate)
10
+ across detached GNU screen sessions that completed a mergeable solve run.
11
+ `--list`, `--enter`, and `--close` share the same matching predicate, so any
12
+ session visible under `--list` is guaranteed to be actionable by the other
13
+ flags. Selection flags `--oldest` (default), `--newest`, and `--all` are
14
+ preserved from the legacy script. Closes #1649.
15
+
3
16
  ## 1.55.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -818,124 +818,34 @@ s=$(screen -ls | awk '/Detached/ {last=$1} END{print last}'); echo "Entering $s"
818
818
 
819
819
  ### Script for managing screens
820
820
 
821
- ```bash
822
- cat <<'EOF' > hive-screens.sh
823
- #!/usr/bin/env bash
824
-
825
- enter=false
826
- close=false
827
- oldest=false
828
- newest=false
829
- all=false
830
-
831
- # --- parse args ---
832
- for arg in "$@"; do
833
- case "$arg" in
834
- --enter) enter=true ;;
835
- --close) close=true ;;
836
- --oldest) oldest=true ;;
837
- --newest) newest=true ;;
838
- --all) all=true ;;
839
- *)
840
- echo "Unknown option: $arg"
841
- exit 1
842
- ;;
843
- esac
844
- done
845
-
846
- # --- validate ---
847
- if ! $enter && ! $close; then
848
- echo "Must specify --enter or --close"
849
- exit 1
850
- fi
851
-
852
- # --- default ---
853
- if ! $oldest && ! $newest && ! $all; then
854
- oldest=true
855
- fi
856
-
857
- matches=()
858
-
859
- # --- sorting ---
860
- if $newest; then
861
- sorter="sort -nr"
862
- else
863
- sorter="sort -n"
864
- fi
865
-
866
- # --- scan sessions ---
867
- while read -r sess; do
868
- tmp=$(mktemp)
869
- clean=$(mktemp)
870
-
871
- # FIX: better capture
872
- screen -S "$sess" -X scrollback 200000 2>/dev/null
873
- sleep 0.15
874
- screen -S "$sess" -X hardcopy -h "$tmp" 2>/dev/null
875
-
876
- # strip garbage / non-printable chars
877
- tr -cd '\11\12\15\40-\176' < "$tmp" > "$clean"
878
-
879
- if grep -qi 'process completed' "$clean" &&
880
- grep -qiE 'pr is mergeable!|pr merged!' "$clean"; then
881
-
882
- log_path=$(tac "$clean" | grep -m1 -i 'full log file:' \
883
- | sed 's/.*Full log file:[[:space:]]*//')
884
-
885
- issue=$(tac "$clean" | grep -m1 -i 'Issue:[[:space:]]*https://github\.com/' \
886
- | sed 's/Issue:[[:space:]]*//')
887
-
888
- matches+=("$sess|$log_path|$issue")
889
- fi
890
-
891
- rm -f "$tmp" "$clean"
892
- done < <(screen -ls | awk '/Detached/ {print $1}' | $sorter)
893
-
894
- # --- no matches ---
895
- if [ ${#matches[@]} -eq 0 ]; then
896
- echo "No matching sessions"
897
- exit 0
898
- fi
899
-
900
- process_one() {
901
- IFS="|" read -r sess log issue <<< "$1"
902
-
903
- echo "Session: $sess"
904
-
905
- if $enter; then
906
- echo "Entering $sess"
907
- screen -r "$sess"
908
- echo "Left $sess"
909
- fi
821
+ The legacy `hive-screens.sh` script has been promoted to a first-class command:
822
+ `hive-screens`. It ships with `@link-assistant/hive-mind`, so once the package is
823
+ installed (globally, through `npx`, or in a project) it is available on `PATH`.
910
824
 
911
- [ -n "$log" ] && echo "Log: $log" || echo "Log: (not found)"
912
- [ -n "$issue" ] && echo "Issue: $issue" || echo "Issue: (not found)"
825
+ It scans detached GNU screen sessions, looks for solve runs that are done and
826
+ mergeable (scrollback contains both `process completed` and `PR is mergeable!`
827
+ or `PR merged!`), and then either lists, enters, or closes them. `--list`,
828
+ `--enter`, and `--close` share the **same matching predicate**, so anything
829
+ you see under `--list` is guaranteed to be the same set `--close` will act on
830
+ — use `--list` first to debug, then rerun with `--close`.
913
831
 
914
- if $close; then
915
- echo "Closing $sess"
916
- screen -S "$sess" -X stuff $'exit\n'
917
- fi
918
-
919
- echo "-----------------------------------"
920
- }
832
+ ```bash
833
+ # Safe preview — show every finished, mergeable solve session.
834
+ hive-screens --list --all
921
835
 
922
- # --- execution ---
923
- if $all; then
924
- for m in "${matches[@]}"; do
925
- process_one "$m"
926
- done
927
- elif $oldest; then
928
- process_one "${matches[0]}"
929
- elif $newest; then
930
- last_index=$((${#matches[@]} - 1))
931
- process_one "${matches[$last_index]}"
932
- fi
836
+ # Close the oldest finished session (same as the legacy script's default).
837
+ hive-screens --close
933
838
 
934
- EOF
839
+ # Attach to the newest finished session.
840
+ hive-screens --enter --newest
935
841
 
936
- chmod +x hive-screens.sh
842
+ # Close every finished session.
843
+ hive-screens --close --all
937
844
  ```
938
845
 
846
+ Selection defaults to `--oldest`. Supply `--newest` or `--all` to change it.
847
+ Run `hive-screens --help` for the full option list.
848
+
939
849
  ### Reboot server.
940
850
 
941
851
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.55.0",
3
+ "version": "1.56.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",
@@ -11,10 +11,11 @@
11
11
  "review": "./src/review.mjs",
12
12
  "configure-claude": "./src/configure-claude.mjs",
13
13
  "start-screen": "./src/start-screen.mjs",
14
+ "hive-screens": "./src/hive-screens.mjs",
14
15
  "hive-telegram-bot": "./src/telegram-bot.mjs"
15
16
  },
16
17
  "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-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
+ "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-hive-screens.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
19
  "test:queue": "node tests/solve-queue.test.mjs",
19
20
  "test:limits-display": "node tests/limits-display.test.mjs",
20
21
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -26,7 +27,7 @@
26
27
  "changeset": "changeset",
27
28
  "changeset:version": "changeset version",
28
29
  "changeset:publish": "npm run build:pre && changeset publish",
29
- "build:pre": "chmod +x src/hive.mjs && chmod +x src/solve.mjs && chmod +x src/configure-claude.mjs",
30
+ "build:pre": "chmod +x src/hive.mjs && chmod +x src/solve.mjs && chmod +x src/configure-claude.mjs && chmod +x src/hive-screens.mjs",
30
31
  "prepare": "husky"
31
32
  },
32
33
  "repository": {
@@ -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);