@link-assistant/hive-mind 2.0.3 → 2.0.5

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.
@@ -570,6 +570,12 @@ export function listActiveTaskRefsFromProc() {
570
570
  * Discover currently-running isolation session UUIDs from start-command's live
571
571
  * session managers (screen / tmux). These names are the session UUIDs.
572
572
  *
573
+ * @deprecated Superseded by {@link listSessionTasks}, which sources every
574
+ * session (active *and* finished) from the single `$ --list` catalog rather
575
+ * than re-deriving liveness from `screen -ls`/`tmux ls`. Retained as a
576
+ * documented building block (issue #1848 case study) and for callers that only
577
+ * want live screen/tmux UUIDs without start-command.
578
+ *
573
579
  * @returns {string[]}
574
580
  */
575
581
  export function listLiveSessionIds() {
@@ -597,6 +603,11 @@ export function listLiveSessionIds() {
597
603
  * Query `$ --status <uuid>` for each live session and extract task references
598
604
  * from executing sessions' command lines. Optional; reuses isolation-runner.
599
605
  *
606
+ * @deprecated Superseded by {@link listSessionTasks} (issue #1927 review), which
607
+ * reads the whole catalog from one `$ --list` call instead of N per-session
608
+ * `$ --status` queries and also surfaces finished sessions. Kept for the issue
609
+ * #1848 case study and backward compatibility.
610
+ *
600
611
  * @param {string[]} sessionIds
601
612
  * @returns {Promise<Array<{owner, repo, type, number}>>}
602
613
  */
@@ -650,33 +661,108 @@ export function resolvePrHeadBranch(ref) {
650
661
  return out || null;
651
662
  }
652
663
 
664
+ /**
665
+ * Enumerate ALL tasks known to start-command from the single `$ --list` source
666
+ * (issue #1927 review): one record per GitHub issue/PR reference found in each
667
+ * session's command line, carrying that session's id/name/status/workspace and a
668
+ * `terminal` flag (whether the session has finished). Unlike
669
+ * {@link listActiveTaskRefsFromSessions}, this includes *completed* sessions so a
670
+ * stale `gh-issue-solver-*` folder can be annotated with the PR and session it
671
+ * once belonged to — even after the task is no longer running.
672
+ *
673
+ * This consolidates session enumeration onto start-command's own `$ --list`
674
+ * (which knows every session, not just the ones still alive in screen/tmux) so
675
+ * `/queue`, `/limits`, the monitor and cleanup all read the same `$` data.
676
+ *
677
+ * @param {Object} [options]
678
+ * @param {boolean} [options.verbose=false]
679
+ * @param {boolean} [options.resolveBranches=false] - resolve PR head branches via gh
680
+ * @returns {Promise<Array<{owner, repo, type, number, branch: string|null, sessionId: string|null, sessionName: string|null, status: string|null, workspace: string|null, terminal: boolean, startTime: string|null}>>}
681
+ */
682
+ export async function listSessionTasks(options = {}) {
683
+ const { verbose = false, resolveBranches = false } = options;
684
+ let listIsolationSessions;
685
+ let isTerminalSessionStatus;
686
+ try {
687
+ ({ listIsolationSessions, isTerminalSessionStatus } = await import('./isolation-runner.lib.mjs'));
688
+ } catch {
689
+ return [];
690
+ }
691
+
692
+ let sessions = [];
693
+ try {
694
+ sessions = await listIsolationSessions(verbose);
695
+ } catch {
696
+ return [];
697
+ }
698
+
699
+ // Newest session first, so when several sessions worked the same issue/PR the
700
+ // most recent one is the match a folder gets annotated with.
701
+ const sorted = [...sessions].sort((a, b) => new Date(b.startTime || 0).getTime() - new Date(a.startTime || 0).getTime());
702
+
703
+ const tasks = [];
704
+ for (const session of sorted) {
705
+ if (!session || !session.command) continue;
706
+ const terminal = !!(session.status && isTerminalSessionStatus(session.status));
707
+ for (const ref of extractTaskRefsFromCommand(session.command)) {
708
+ tasks.push({
709
+ ...ref,
710
+ branch: null,
711
+ sessionId: session.uuid || null,
712
+ sessionName: session.sessionName || null,
713
+ status: session.status || null,
714
+ workspace: session.workingDirectory || null,
715
+ terminal,
716
+ startTime: session.startTime || null,
717
+ });
718
+ }
719
+ }
720
+
721
+ if (resolveBranches) {
722
+ const branchCache = new Map();
723
+ for (const task of tasks) {
724
+ if (task.type !== 'pull') continue;
725
+ const key = `${task.owner}/${task.repo}#${task.number}`;
726
+ if (!branchCache.has(key)) branchCache.set(key, resolvePrHeadBranch(task));
727
+ task.branch = branchCache.get(key);
728
+ }
729
+ }
730
+
731
+ return tasks;
732
+ }
733
+
653
734
  /**
654
735
  * Build the full active-task list, resolving PR head branches where possible.
655
736
  *
656
737
  * @param {Object} [options]
657
- * @param {boolean} [options.useSessions=true] - also query `$ --status`
738
+ * @param {boolean} [options.useSessions=true] - also consult `$ --list` sessions
658
739
  * @param {boolean} [options.resolveBranches=true] - resolve PR head branches via gh
740
+ * @param {Array} [options.sessionTasks] - pre-fetched `listSessionTasks()` result to reuse
659
741
  * @returns {Promise<Array<{owner, repo, type, number, branch: string|null}>>}
660
742
  */
661
743
  export async function getActiveTasks(options = {}) {
662
- const { useSessions = true, resolveBranches = true } = options;
744
+ const { useSessions = true, resolveBranches = true, sessionTasks = null } = options;
663
745
  const refs = [...listActiveTaskRefsFromProc()];
664
746
  const seen = new Set(refs.map(r => `${r.owner}/${r.repo}#${r.number}:${r.type}`));
665
747
 
666
748
  if (useSessions) {
667
- const sessionRefs = await listActiveTaskRefsFromSessions(listLiveSessionIds());
668
- for (const ref of sessionRefs) {
669
- const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
749
+ // Active = sessions start-command still reports as non-terminal. Reuse the
750
+ // shared `$ --list` enumeration (optionally pre-fetched by the caller so the
751
+ // catalog is read only once).
752
+ const allSessionTasks = sessionTasks || (await listSessionTasks({ verbose: false, resolveBranches: false }));
753
+ for (const task of allSessionTasks) {
754
+ if (task.terminal) continue;
755
+ const key = `${task.owner}/${task.repo}#${task.number}:${task.type}`;
670
756
  if (!seen.has(key)) {
671
757
  seen.add(key);
672
- refs.push(ref);
758
+ refs.push(task);
673
759
  }
674
760
  }
675
761
  }
676
762
 
677
763
  return refs.map(ref => {
678
- let branch = null;
679
- if (ref.type === 'pull' && resolveBranches) {
764
+ let branch = ref.branch || null;
765
+ if (!branch && ref.type === 'pull' && resolveBranches) {
680
766
  branch = resolvePrHeadBranch(ref);
681
767
  }
682
768
  return { ...ref, branch };
@@ -1,21 +1,36 @@
1
1
  const PLAYWRIGHT_TOOL_PREFIX = 'mcp__playwright__';
2
2
 
3
- export const isUnavailableMcpStatus = status => {
3
+ // A `pending` (or `connecting`) MCP server is still being connected/reconnected
4
+ // in the background. It is NOT a failure: Claude Code enables Tool Search by
5
+ // default, so MCP tools are deferred and load on demand, and Claude waits for a
6
+ // still-connecting server before it uses one of that server's tools. See
7
+ // https://code.claude.com/docs/en/mcp and issue #1901.
8
+ export const isConnectingMcpStatus = status => /\b(pending|connecting)\b/i.test(String(status || ''));
9
+
10
+ // Terminal/unhealthy states where the MCP client has given up (or the server is
11
+ // turned off). Claude Code reconnects an HTTP/SSE server with exponential
12
+ // backoff and only marks it `failed` after the attempts are exhausted; at that
13
+ // point the server's tools never load.
14
+ export const isFailedMcpStatus = status => {
4
15
  const normalized = String(status || '').toLowerCase();
5
- return /\b(pending|disabled|failed|error|disconnected|not[-_\s]+connected|unavailable|timed[-_\s]+out)\b|(?:^|[^a-z0-9_-])timeout(?:$|[^a-z0-9_-])/.test(normalized);
16
+ return /\b(disabled|failed|error|disconnected|not[-_\s]+connected|unavailable|timed[-_\s]+out)\b|(?:^|[^a-z0-9_-])timeout(?:$|[^a-z0-9_-])/.test(normalized);
6
17
  };
7
18
 
19
+ // Backwards-compatible umbrella: any non-connected status (still connecting OR
20
+ // failed). Prefer the narrower helpers above when the connecting/failed
21
+ // distinction matters (e.g. whether to warn a human reviewer).
22
+ export const isUnavailableMcpStatus = status => isConnectingMcpStatus(status) || isFailedMcpStatus(status);
23
+
8
24
  export const hasPlaywrightMcpTools = tools => (Array.isArray(tools) ? tools : []).some(tool => String(tool || '').startsWith(PLAYWRIGHT_TOOL_PREFIX));
9
25
 
10
26
  export const formatInteractiveMcpServerStatus = server => {
11
27
  const name = server?.name || 'unknown';
12
28
  const status = String(server?.status || 'unknown').trim() || 'unknown';
13
- const normalizedStatus = status.toLowerCase();
14
29
  let displayStatus = status;
15
30
 
16
- if (normalizedStatus === 'pending') {
17
- displayStatus = 'pending - not connected; MCP tools unavailable';
18
- } else if (isUnavailableMcpStatus(status)) {
31
+ if (isConnectingMcpStatus(status)) {
32
+ displayStatus = `${status} - connecting; tools load on demand via Tool Search`;
33
+ } else if (isFailedMcpStatus(status)) {
19
34
  displayStatus = `${status} - MCP tools unavailable`;
20
35
  }
21
36
 
@@ -29,10 +44,16 @@ export const getInteractiveMcpDiagnostics = (mcpServers = [], tools = []) => {
29
44
  for (const server of servers) {
30
45
  const name = String(server?.name || '').toLowerCase();
31
46
  if (!name.includes('playwright')) continue;
32
- if (!isUnavailableMcpStatus(server?.status)) continue;
47
+ // With Tool Search the deferred `mcp__playwright__*` tools are intentionally
48
+ // absent from system.init `tools`, so their absence is not a problem by
49
+ // itself. If they are already present the server is fully connected.
33
50
  if (hasPlaywrightMcpTools(tools)) continue;
51
+ // `pending`/`connecting` is the normal startup state — Claude waits for the
52
+ // server before using a browser tool — so only warn when the MCP client has
53
+ // actually failed to connect.
54
+ if (!isFailedMcpStatus(server?.status)) continue;
34
55
 
35
- diagnostics.push(`⚠️ Playwright MCP server is ${server?.status || 'unknown'}, but no \`${PLAYWRIGHT_TOOL_PREFIX}*\` browser tools were exposed. Browser automation hints are disabled until the MCP client reports the server as connected.`);
56
+ diagnostics.push(`⚠️ Playwright MCP server is ${server?.status || 'unknown'} (failed to connect), so no \`${PLAYWRIGHT_TOOL_PREFIX}*\` browser tools are available. Browser automation stays disabled until the MCP server connects.`);
36
57
  }
37
58
 
38
59
  return diagnostics;
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process';
18
18
  import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
+ import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
21
22
 
22
23
  if (typeof use === 'undefined') {
23
24
  await ensureUseM();
@@ -25,10 +26,14 @@ if (typeof use === 'undefined') {
25
26
 
26
27
  const { $ } = await use('command-stream');
27
28
 
29
+ // Re-export the shared status predicates so existing callers that reach them via
30
+ // the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`)
31
+ // keep working. The canonical definitions live in session-status.lib.mjs so the
32
+ // killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
33
+ export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
34
+
28
35
  // Valid isolation backends
29
36
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
30
- const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
31
- const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
32
37
  const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
33
38
  const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
34
39
  const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
@@ -379,14 +384,6 @@ export function parseSessionStatusOutput(output) {
379
384
  };
380
385
  }
381
386
 
382
- export function isExecutingSessionStatus(status) {
383
- return RUNNING_SESSION_STATUSES.has(String(status || '').toLowerCase());
384
- }
385
-
386
- export function isTerminalSessionStatus(status) {
387
- return TERMINAL_SESSION_STATUSES.has(String(status || '').toLowerCase());
388
- }
389
-
390
387
  /**
391
388
  * Decide whether a detached-docker exit code is "unknown" (not a real result).
392
389
  *
@@ -409,6 +406,82 @@ export function shouldFallbackToScreenStatus(statusResult) {
409
406
  return !statusResult?.exists || !statusResult?.status;
410
407
  }
411
408
 
409
+ /**
410
+ * Parse the footer start-command appends to every execution log when the wrapped
411
+ * command exits. The footer is authoritative about the terminal exit code even
412
+ * when `$ --status` is wrong: start-command writes it from the command's own
413
+ * `close`/`exited` handler, so its presence proves the command terminated.
414
+ *
415
+ * Footer shape (see start-command spawn-helpers.js):
416
+ *
417
+ * ==================================================
418
+ * Finished: 2026-06-14 19:10:49.822
419
+ * Exit Code: 137
420
+ *
421
+ * Issue #1927: start-command's `enrichDetachedStatus` can flip a completed
422
+ * `executed/137` record back to `executing` (nulling the exit code) when a
423
+ * lingering shell keeps the screen session alive — so `$ --status` reports
424
+ * `executing` forever and the bot never notices the kill. Reading this footer
425
+ * lets hive-mind detect the real terminal exit regardless of that flip.
426
+ *
427
+ * @param {string} text - Log text (typically the tail of the log file)
428
+ * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
429
+ */
430
+ export function parseSessionExitFooter(text) {
431
+ if (!text) return { finished: false, exitCode: null, endTime: null };
432
+ // Match the LAST footer block in the text (a re-run could append more than
433
+ // one). Anchor on the `=` separator so command output that merely prints
434
+ // "Exit Code: N" mid-stream is not mistaken for the footer.
435
+ const re = /={10,}\s*\r?\nFinished:\s*([^\r\n]+)\r?\nExit Code:\s*(-?\d+)/g;
436
+ let match;
437
+ let last = null;
438
+ while ((match = re.exec(text)) !== null) last = match;
439
+ if (!last) return { finished: false, exitCode: null, endTime: null };
440
+ return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
441
+ }
442
+
443
+ /**
444
+ * Read the terminal exit code from the tail of a start-command execution log.
445
+ *
446
+ * Only the last `tailBytes` of the file are read (the footer lives at the end),
447
+ * so this is cheap even for multi-megabyte logs. Never throws — a missing or
448
+ * unreadable log yields `{ finished: false }`.
449
+ *
450
+ * @param {string} logPath
451
+ * @param {Object} [options]
452
+ * @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
453
+ * @param {number} [options.tailBytes=16384] - How many trailing bytes to scan
454
+ * @param {boolean} [options.verbose]
455
+ * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
456
+ */
457
+ export function readSessionExitFromLog(logPath, options = {}) {
458
+ const { fsImpl = fs, tailBytes = 16384, verbose = false } = options;
459
+ if (!logPath) return { finished: false, exitCode: null, endTime: null };
460
+ try {
461
+ const { size } = fsImpl.statSync(logPath);
462
+ if (!size) return { finished: false, exitCode: null, endTime: null };
463
+ const start = Math.max(0, size - tailBytes);
464
+ const length = size - start;
465
+ const buffer = Buffer.alloc(length);
466
+ const fd = fsImpl.openSync(logPath, 'r');
467
+ try {
468
+ fsImpl.readSync(fd, buffer, 0, length, start);
469
+ } finally {
470
+ fsImpl.closeSync(fd);
471
+ }
472
+ const result = parseSessionExitFooter(buffer.toString('utf8'));
473
+ if (verbose && result.finished) {
474
+ console.log(`[VERBOSE] isolation-runner: log footer for ${logPath} reports exit ${result.exitCode} (finished ${result.endTime})`);
475
+ }
476
+ return result;
477
+ } catch (error) {
478
+ if (verbose) {
479
+ console.log(`[VERBOSE] isolation-runner: could not read exit footer from ${logPath}: ${error.message}`);
480
+ }
481
+ return { finished: false, exitCode: null, endTime: null };
482
+ }
483
+ }
484
+
412
485
  /**
413
486
  * Find the `$` CLI binary path
414
487
  * @returns {Promise<string|null>} Path to `$` binary or null
@@ -583,6 +656,78 @@ export async function querySessionStatus(sessionId, verbose = false) {
583
656
  }
584
657
  }
585
658
 
659
+ /**
660
+ * Parse output from `$ --list --output-format json`.
661
+ *
662
+ * start-command may return a top-level array, or an object with an
663
+ * `executions`/`sessions` array. Each entry is normalized to the same shape used
664
+ * by {@link parseSessionStatusOutput} (uuid/status/exitCode/command/isolation/…).
665
+ * Tolerant of unknown layouts — anything unparseable yields an empty list.
666
+ *
667
+ * @param {string} output - Raw stdout from `$ --list`
668
+ * @returns {Array<{uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null}>}
669
+ */
670
+ export function parseSessionListOutput(output) {
671
+ const raw = (output || '').trim();
672
+ if (!raw) return [];
673
+ let parsed;
674
+ try {
675
+ parsed = JSON.parse(raw);
676
+ } catch {
677
+ return [];
678
+ }
679
+ const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
680
+
681
+ return records
682
+ .map(data => {
683
+ if (!data || typeof data !== 'object') return null;
684
+ const isolationCandidate = (typeof data.isolation === 'string' && data.isolation) || (typeof data.options?.isolated === 'string' && data.options.isolated) || (typeof data.options?.isolation === 'string' && data.options.isolation) || null;
685
+ return {
686
+ uuid: data.uuid || data.session || data.sessionId || null,
687
+ status: typeof data.status === 'string' ? data.status.toLowerCase() : null,
688
+ exitCode: data.exitCode !== undefined && data.exitCode !== null ? Number(data.exitCode) : null,
689
+ startTime: data.startTime || null,
690
+ endTime: data.endTime || null,
691
+ command: data.command || null,
692
+ isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
693
+ workingDirectory: data.workingDirectory || null,
694
+ sessionName: data.sessionName || data.options?.sessionName || null,
695
+ };
696
+ })
697
+ .filter(Boolean);
698
+ }
699
+
700
+ /**
701
+ * List all executions known to start-command via `$ --list --output-format json`.
702
+ *
703
+ * Unlike `$ --status`, the `--list` path does NOT run start-command's
704
+ * `enrichDetachedStatus` liveness gate, so it reports the recorded status/exit
705
+ * code as stored. Used by the bot's restart-resume scan to discover detached
706
+ * solve/hive/task sessions that were launched before the bot last started
707
+ * (issue #1927, requirement #2). Never throws — returns an empty list on any
708
+ * failure.
709
+ *
710
+ * @param {boolean} [verbose]
711
+ * @returns {Promise<Array<object>>} Normalized session records (see parseSessionListOutput)
712
+ */
713
+ export async function listIsolationSessions(verbose = false) {
714
+ const binPath = await findStartCommandBinary();
715
+ if (!binPath) {
716
+ if (verbose) console.log('[VERBOSE] isolation-runner: Cannot list sessions - $ binary not found');
717
+ return [];
718
+ }
719
+ try {
720
+ const result = await $({ mirror: false })`${binPath} --list --output-format json`;
721
+ const stdout = result.stdout?.toString().trim() || '';
722
+ const sessions = parseSessionListOutput(stdout);
723
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --list returned ${sessions.length} session(s)`);
724
+ return sessions;
725
+ } catch (error) {
726
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --list error: ${error.message}`);
727
+ return [];
728
+ }
729
+ }
730
+
586
731
  /**
587
732
  * Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
588
733
  *
@@ -686,6 +831,45 @@ export async function checkDockerContainerRunning(containerName, verbose = false
686
831
  }
687
832
  }
688
833
 
834
+ /**
835
+ * Check whether a tmux session with the given name still exists.
836
+ * `tmux has-session -t <name>` exits 0 when it exists and non-zero otherwise,
837
+ * so command-stream throwing is treated as "not found".
838
+ *
839
+ * @param {string} sessionName
840
+ * @param {boolean} [verbose]
841
+ * @returns {Promise<boolean>}
842
+ */
843
+ export async function checkTmuxSessionRunning(sessionName, verbose = false) {
844
+ try {
845
+ await $({ mirror: false })`tmux has-session -t ${sessionName}`;
846
+ if (verbose) console.log(`[VERBOSE] isolation-runner: tmux has-session '${sessionName}': running`);
847
+ return true;
848
+ } catch {
849
+ if (verbose) console.log(`[VERBOSE] isolation-runner: tmux has-session '${sessionName}': not found`);
850
+ return false;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Directly probe whether the backend session/container is still alive, bypassing
856
+ * `$ --status`. This is the cross-check used to detect a session that
857
+ * start-command still reports as `executing` even though its backing process is
858
+ * gone (issue #1927). Returns `null` for unknown backends so callers can treat
859
+ * an indeterminate probe as "no signal" rather than "dead".
860
+ *
861
+ * @param {string} sessionId - Session UUID (also the screen name / container name)
862
+ * @param {string} backend - 'screen' | 'tmux' | 'docker'
863
+ * @param {boolean} [verbose]
864
+ * @returns {Promise<boolean|null>}
865
+ */
866
+ export async function checkBackendSessionAlive(sessionId, backend, verbose = false) {
867
+ if (backend === 'screen') return checkScreenSessionRunning(sessionId, verbose);
868
+ if (backend === 'tmux') return checkTmuxSessionRunning(sessionId, verbose);
869
+ if (backend === 'docker') return checkDockerContainerRunning(sessionId, verbose);
870
+ return null;
871
+ }
872
+
689
873
  /**
690
874
  * Check whether an image is present in the local Docker daemon.
691
875
  *