@link-assistant/hive-mind 2.0.20 → 2.0.22

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 4d65c05: Make disk admission safer by default: the disk usage queue gate now waits at 80%, the absolute free-space default is 10240 MB, and isolation defaults to Docker.
8
+
9
+ ## 2.0.21
10
+
11
+ ### Patch Changes
12
+
13
+ - da88ee5: Add session-aware Docker-isolation container cleanup to hive-cleanup.
14
+
3
15
  ## 2.0.20
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  * @see https://github.com/link-assistant/hive-mind/issues/1848
18
18
  */
19
19
 
20
+ import { classifyExitStatus, isExecutingSessionStatus, isFailureSessionStatus, isTerminalSessionStatus, normalizeExitCode } from './session-status.lib.mjs';
20
21
  import { isValidIssueBranchName, parseIssueBranchName } from './solve.branch.lib.mjs';
21
22
 
22
23
  /**
@@ -161,6 +162,8 @@ export function buildActiveMatchers(activeTasks) {
161
162
  sessionId: task.sessionId || null,
162
163
  sessionName: task.sessionName || null,
163
164
  status: task.status || null,
165
+ exitCode: task.exitCode ?? null,
166
+ isolation: task.isolation || null,
164
167
  workspace: task.workspace || null,
165
168
  });
166
169
  }
@@ -431,3 +434,227 @@ export function formatEntryContext(item) {
431
434
 
432
435
  return details.length > 0 ? ` (${details.join('; ')})` : '';
433
436
  }
437
+
438
+ export const DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE = 'succeeded';
439
+ export const DOCKER_ISOLATION_CLEANUP_MODES = new Set(['succeeded', 'all', 'none']);
440
+
441
+ const DOCKER_ISOLATION_SESSION_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
442
+
443
+ function normalizeText(value) {
444
+ return String(value || '')
445
+ .trim()
446
+ .toLowerCase();
447
+ }
448
+
449
+ /**
450
+ * Normalize the docker-isolation cleanup policy.
451
+ *
452
+ * Modes:
453
+ * - succeeded: default; remove successful exited containers, keep failures
454
+ * - all: remove all terminal/exited task containers
455
+ * - none: report only
456
+ *
457
+ * @param {string|null|undefined} value
458
+ * @returns {'succeeded'|'all'|'none'}
459
+ */
460
+ export function normalizeDockerIsolationCleanupMode(value) {
461
+ const mode = normalizeText(value);
462
+ if (!mode || ['default', 'true', '1', 'yes', 'on', 'succeeded', 'success', 'successful', 'failed-kept', 'keep-failed', 'on-failure'].includes(mode)) {
463
+ return DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE;
464
+ }
465
+ if (['false', '0', 'no', 'off', 'none', 'disabled'].includes(mode)) return 'none';
466
+ if (['all', 'everything', 'finished', 'terminal'].includes(mode)) return 'all';
467
+ throw new Error(`Invalid docker isolation cleanup mode: ${value}. Expected one of: succeeded, all, none`);
468
+ }
469
+
470
+ /**
471
+ * True when a Docker container name looks like a start-command session UUID.
472
+ *
473
+ * @param {string} name
474
+ * @returns {boolean}
475
+ */
476
+ export function isDockerIsolationSessionName(name) {
477
+ return DOCKER_ISOLATION_SESSION_NAME_RE.test(String(name || '').trim());
478
+ }
479
+
480
+ /**
481
+ * Parse Docker's status text, e.g. "Exited (137) 10 minutes ago".
482
+ *
483
+ * @param {string} status
484
+ * @returns {number|null}
485
+ */
486
+ export function parseDockerContainerExitCode(status) {
487
+ const match = String(status || '').match(/\bExited\s+\((-?\d+)\)/i);
488
+ return match ? normalizeExitCode(match[1]) : null;
489
+ }
490
+
491
+ function createSessionLookup(sessionTasks) {
492
+ const lookup = new Map();
493
+ const merge = (key, task) => {
494
+ if (!key) return;
495
+ const existing = lookup.get(key) || {};
496
+ lookup.set(key, {
497
+ ...existing,
498
+ ...task,
499
+ status: task.status || existing.status || null,
500
+ exitCode: task.exitCode ?? existing.exitCode ?? null,
501
+ isolation: task.isolation || existing.isolation || null,
502
+ sessionId: task.sessionId || existing.sessionId || null,
503
+ sessionName: task.sessionName || existing.sessionName || null,
504
+ workspace: task.workspace || existing.workspace || null,
505
+ });
506
+ };
507
+
508
+ for (const task of sessionTasks || []) {
509
+ if (!task) continue;
510
+ merge(task.sessionId, task);
511
+ merge(task.sessionName, task);
512
+ }
513
+ return lookup;
514
+ }
515
+
516
+ function normalizeDockerContainer(container) {
517
+ const name = String(container?.name || container?.Names || container?.Name || '')
518
+ .replace(/^\//, '')
519
+ .trim();
520
+ const state = normalizeText(container?.state || container?.State);
521
+ const statusText = String(container?.status ?? container?.Status ?? '').trim();
522
+ const exitCode = normalizeExitCode(container?.exitCode ?? container?.ExitCode ?? parseDockerContainerExitCode(statusText));
523
+ const running = container?.running === true || state === 'running' || /^Up\b/i.test(statusText);
524
+ return {
525
+ ...container,
526
+ id: container?.id || container?.ID || null,
527
+ image: container?.image || container?.Image || null,
528
+ name,
529
+ state,
530
+ status: statusText,
531
+ exitCode,
532
+ running,
533
+ };
534
+ }
535
+
536
+ function inferDockerContainerOutcome(container, session) {
537
+ const sessionStatus = normalizeText(session?.status);
538
+ const exitCode = normalizeExitCode(container.exitCode ?? session?.exitCode);
539
+ const running = container.running || isExecutingSessionStatus(sessionStatus);
540
+ const containerTerminal = ['exited', 'dead', 'removing'].includes(container.state) || /^Exited\b/i.test(container.status) || exitCode !== null;
541
+ const sessionTerminal = isTerminalSessionStatus(sessionStatus);
542
+ const terminal = !running && (sessionTerminal || containerTerminal);
543
+ const exitStatus = classifyExitStatus(exitCode);
544
+ const failed = terminal && (isFailureSessionStatus(sessionStatus) || isFailureSessionStatus(exitStatus) || (exitCode !== null && exitCode !== 0));
545
+ const successful = terminal && !failed && exitCode === 0;
546
+ const unknown = terminal && !successful && !failed;
547
+
548
+ return {
549
+ running,
550
+ terminal,
551
+ exitCode,
552
+ successful,
553
+ failed,
554
+ unknown,
555
+ };
556
+ }
557
+
558
+ function dockerCleanupRecord(container, session, outcome, reason) {
559
+ return {
560
+ ...container,
561
+ ...outcome,
562
+ session: session || null,
563
+ reason,
564
+ command: `docker rm -f ${container.name}`,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Plan selective cleanup for Docker-isolation task containers.
570
+ *
571
+ * The input containers are expected to be Docker ps records already filtered to
572
+ * session UUID names, but the planner filters defensively too. Running
573
+ * containers are never removed, regardless of mode.
574
+ *
575
+ * @param {Object} options
576
+ * @param {Array} options.containers
577
+ * @param {Array} [options.sessionTasks]
578
+ * @param {string} [options.mode]
579
+ * @returns {{keep: Array, remove: Array, mode: string}}
580
+ */
581
+ export function planDockerIsolationCleanup(options = {}) {
582
+ const mode = normalizeDockerIsolationCleanupMode(options.mode);
583
+ const sessions = createSessionLookup(options.sessionTasks || []);
584
+ const keep = [];
585
+ const remove = [];
586
+
587
+ for (const rawContainer of options.containers || []) {
588
+ const container = normalizeDockerContainer(rawContainer);
589
+ if (!isDockerIsolationSessionName(container.name)) continue;
590
+
591
+ const session = sessions.get(container.name) || null;
592
+ const outcome = inferDockerContainerOutcome(container, session);
593
+ let reason;
594
+ let action = 'keep';
595
+
596
+ if (mode === 'none') {
597
+ reason = 'disabled';
598
+ } else if (outcome.running) {
599
+ reason = 'active-container';
600
+ } else if (!outcome.terminal) {
601
+ reason = 'unknown-container-state';
602
+ } else if (mode === 'all') {
603
+ reason = 'finished-container';
604
+ action = 'remove';
605
+ } else if (outcome.successful) {
606
+ reason = 'successful-container';
607
+ action = 'remove';
608
+ } else if (outcome.failed) {
609
+ reason = 'failed-container-kept';
610
+ } else {
611
+ reason = 'unknown-outcome-kept';
612
+ }
613
+
614
+ const record = dockerCleanupRecord(container, session, outcome, reason);
615
+ if (action === 'remove') remove.push(record);
616
+ else keep.push(record);
617
+ }
618
+
619
+ return { keep, remove, mode };
620
+ }
621
+
622
+ /**
623
+ * Human-readable docker-isolation cleanup reason.
624
+ *
625
+ * @param {string} reason
626
+ * @returns {string}
627
+ */
628
+ export function describeDockerIsolationReason(reason) {
629
+ const map = {
630
+ disabled: 'docker-isolation cleanup disabled',
631
+ 'active-container': 'running docker-isolation task',
632
+ 'unknown-container-state': 'container state is not terminal',
633
+ 'successful-container': 'successful docker-isolation task container',
634
+ 'finished-container': 'finished docker-isolation task container',
635
+ 'failed-container-kept': 'failed docker-isolation task kept for debugging',
636
+ 'unknown-outcome-kept': 'docker-isolation task outcome unknown',
637
+ 'all-mode': 'non-running docker-isolation container',
638
+ };
639
+ return map[reason] || reason;
640
+ }
641
+
642
+ /**
643
+ * Format a docker-isolation container record for CLI logs.
644
+ *
645
+ * @param {Object} item
646
+ * @returns {string}
647
+ */
648
+ export function formatDockerIsolationContainerSummary(item) {
649
+ if (!item) return '';
650
+ const parts = [`session ${item.name}`];
651
+ if (item.image) parts.push(`image ${item.image}`);
652
+ if (item.state) parts.push(`state ${item.state}`);
653
+ if (item.status) parts.push(`status ${item.status}`);
654
+ if (item.exitCode !== null && item.exitCode !== undefined) parts.push(`exit ${item.exitCode}`);
655
+ if (item.session) parts.push(formatTaskSummary(item.session));
656
+ if (item.command && ['disabled', 'failed-container-kept', 'unknown-outcome-kept'].includes(item.reason)) {
657
+ parts.push(`remove when done: ${item.command}`);
658
+ }
659
+ return parts.join(', ');
660
+ }
package/src/cleanup.mjs CHANGED
@@ -23,6 +23,7 @@
23
23
  * --no-keep-dirty allow deleting clones with unpushed changes
24
24
  * --processes map claude/codex/etc. PIDs to task sessions
25
25
  * --kill-orphaned-agents signal orphaned terminal-session agents
26
+ * --docker-isolation[=<mode>] cleanup task containers by session UUID
26
27
  * --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
27
28
  * --system shorthand for --apt --journal --npm
28
29
  * --sudo prefix package-manager commands with sudo
@@ -35,8 +36,8 @@ import path from 'node:path';
35
36
  import { promises as fsp } from 'node:fs';
36
37
 
37
38
  import { isConfirmationYes, readConfirmationLine } from './confirmation.lib.mjs';
38
- import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary } from './cleanup.lib.mjs';
39
- import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees } from './cleanup.os.lib.mjs';
39
+ import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary, DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE, describeDockerIsolationReason, formatDockerIsolationContainerSummary, normalizeDockerIsolationCleanupMode, planDockerIsolationCleanup } from './cleanup.lib.mjs';
40
+ import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees, listDockerIsolationContainers, removeDockerContainer } from './cleanup.os.lib.mjs';
40
41
  import { formatProcessDebugReport } from './process-debug.lib.mjs';
41
42
 
42
43
  const args = process.argv.slice(2);
@@ -71,6 +72,17 @@ function parsePidList(values) {
71
72
  .filter(value => Number.isInteger(value) && value > 0);
72
73
  }
73
74
 
75
+ function parseDockerIsolationMode() {
76
+ if (hasFlag('--no-docker-isolation')) return 'none';
77
+ const configured = getFlagValue('--docker-isolation') ?? process.env.HIVE_MIND_CLEANUP_DOCKER_ISOLATION ?? process.env.HIVE_CLEANUP_DOCKER_ISOLATION ?? DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE;
78
+ try {
79
+ return normalizeDockerIsolationCleanupMode(configured);
80
+ } catch (error) {
81
+ console.error(`Error: ${error.message}`);
82
+ process.exit(1);
83
+ }
84
+ }
85
+
74
86
  // ---------------------------------------------------------------------------
75
87
  // Early --version / --help handling (no heavy imports).
76
88
  // ---------------------------------------------------------------------------
@@ -117,11 +129,17 @@ Process diagnostics:
117
129
  System / Ubuntu cleanup (opt-in):
118
130
  --apt apt-get clean / autoclean / autoremove
119
131
  --journal journalctl --vacuum-time=2weeks
120
- --docker docker system prune -f
132
+ --docker docker system prune -f (host-wide Docker cleanup)
121
133
  --npm npm cache clean --force
122
134
  --system Shorthand for --apt --journal --npm
123
135
  --sudo Prefix package-manager commands with sudo
124
136
 
137
+ Docker isolation cleanup:
138
+ --docker-isolation[=<mode>] Clean task containers named by session UUID
139
+ [default: ${DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE}]
140
+ modes: succeeded, all, none
141
+ --no-docker-isolation Disable Docker-isolation task container cleanup
142
+
125
143
  --verbose, -v Verbose logging
126
144
  --version Show version number
127
145
  --help, -h Show this help
@@ -150,6 +168,7 @@ const options = {
150
168
  apt: hasFlag('--apt', '--system'),
151
169
  journal: hasFlag('--journal', '--system'),
152
170
  docker: hasFlag('--docker'),
171
+ dockerIsolationMode: parseDockerIsolationMode(),
153
172
  npm: hasFlag('--npm', '--system'),
154
173
  sudo: hasFlag('--sudo'),
155
174
  };
@@ -311,16 +330,47 @@ async function main() {
311
330
  await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}${formatEntryContext(item)}`);
312
331
  }
313
332
 
314
- await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)})`);
333
+ let dockerIsolationPlan = { keep: [], remove: [], mode: options.dockerIsolationMode };
334
+ if (options.dockerIsolationMode === 'none') {
335
+ await log('\n🐳 Docker isolation containers: disabled (--docker-isolation=none)');
336
+ } else {
337
+ const dockerIsolationContainers = listDockerIsolationContainers();
338
+ dockerIsolationPlan = planDockerIsolationCleanup({
339
+ containers: dockerIsolationContainers,
340
+ sessionTasks,
341
+ mode: options.dockerIsolationMode,
342
+ });
343
+
344
+ await log(`\n🐳 Docker isolation containers (${dockerIsolationPlan.mode}):`);
345
+ if (dockerIsolationPlan.keep.length === 0 && dockerIsolationPlan.remove.length === 0) {
346
+ await log(' (none detected)');
347
+ } else {
348
+ await log(' KEPT:');
349
+ if (dockerIsolationPlan.keep.length === 0) await log(' (none)');
350
+ for (const item of dockerIsolationPlan.keep) {
351
+ await log(` ${formatDockerIsolationContainerSummary(item)} — ${describeDockerIsolationReason(item.reason)}`);
352
+ }
353
+
354
+ await log(` ${options.dryRun ? 'WOULD REMOVE' : 'TO REMOVE'}:`);
355
+ if (dockerIsolationPlan.remove.length === 0) await log(' (none)');
356
+ for (const item of dockerIsolationPlan.remove) {
357
+ await log(` ${formatDockerIsolationContainerSummary(item)} — ${describeDockerIsolationReason(item.reason)}`);
358
+ }
359
+ }
360
+ }
361
+
362
+ await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)}), docker keep ${dockerIsolationPlan.keep.length}, docker remove ${dockerIsolationPlan.remove.length}`);
315
363
 
316
364
  // 7. Execute deletion (unless dry-run).
365
+ const hasTempRemovals = classified.remove.length > 0;
366
+ const hasDockerRemovals = dockerIsolationPlan.remove.length > 0;
317
367
  if (options.dryRun) {
318
368
  await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
319
- } else if (classified.remove.length === 0) {
369
+ } else if (!hasTempRemovals && !hasDockerRemovals) {
320
370
  await log('\n✅ Nothing to delete.');
321
371
  } else {
322
372
  if (!options.force) {
323
- console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}).`);
373
+ console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}) and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
324
374
  console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
325
375
  let answer = '';
326
376
  try {
@@ -335,20 +385,39 @@ async function main() {
335
385
  }
336
386
  }
337
387
 
338
- await log('\n🗑️ Deleting...');
339
- let deleted = 0;
340
- let failed = 0;
341
- for (const item of classified.remove) {
342
- const ok = removePath(item.path);
343
- if (ok) {
344
- deleted++;
345
- await vlog(` removed ${item.path}`);
346
- } else {
347
- failed++;
348
- await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
388
+ if (hasTempRemovals) {
389
+ await log('\n🗑️ Deleting...');
390
+ let deleted = 0;
391
+ let failed = 0;
392
+ for (const item of classified.remove) {
393
+ const ok = removePath(item.path);
394
+ if (ok) {
395
+ deleted++;
396
+ await vlog(` removed ${item.path}`);
397
+ } else {
398
+ failed++;
399
+ await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
400
+ }
401
+ }
402
+ await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
403
+ }
404
+
405
+ if (hasDockerRemovals) {
406
+ await log('\n🐳 Removing Docker isolation containers...');
407
+ let removed = 0;
408
+ let failed = 0;
409
+ for (const item of dockerIsolationPlan.remove) {
410
+ const ok = removeDockerContainer(item.name);
411
+ if (ok) {
412
+ removed++;
413
+ await log(` ✓ ${item.command}`);
414
+ } else {
415
+ failed++;
416
+ await log(` ⚠️ failed: ${item.command}`, { level: 'warn' });
417
+ }
349
418
  }
419
+ await log(`\n✅ Removed ${removed} Docker isolation containers${failed ? `, ${failed} failed` : ''}.`);
350
420
  }
351
- await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
352
421
  }
353
422
 
354
423
  // 8. System / Ubuntu cleanup (opt-in).
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  import os from 'node:os';
20
20
  import { execFileSync } from 'node:child_process';
21
21
 
22
- import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
22
+ import { extractTaskRefsFromCommand, isDockerIsolationSessionName, parseDockerContainerExitCode, parseRemoteUrl } from './cleanup.lib.mjs';
23
23
  import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
24
24
 
25
25
  /** Run a command, returning trimmed stdout or null on any failure. */
@@ -303,6 +303,73 @@ export function listScreenSessions() {
303
303
  return sessions;
304
304
  }
305
305
 
306
+ function splitDockerNames(value) {
307
+ return String(value || '')
308
+ .split(',')
309
+ .map(name => name.trim().replace(/^\/+/, ''))
310
+ .filter(Boolean);
311
+ }
312
+
313
+ /**
314
+ * Parse `docker ps -a --format '{{json .}}'` output into docker-isolation task
315
+ * containers. start-command names native Docker isolation containers after the
316
+ * session UUID, so unrelated host containers are ignored.
317
+ *
318
+ * @param {string} output
319
+ * @returns {Array<{id: string|null, name: string, image: string|null, state: string, status: string, exitCode: number|null, running: boolean}>}
320
+ */
321
+ export function parseDockerPsJsonLines(output) {
322
+ const containers = [];
323
+ const seen = new Set();
324
+
325
+ for (const line of String(output || '').split('\n')) {
326
+ const trimmed = line.trim();
327
+ if (!trimmed) continue;
328
+
329
+ let data;
330
+ try {
331
+ data = JSON.parse(trimmed);
332
+ } catch {
333
+ continue;
334
+ }
335
+
336
+ const state = String(data.State || data.state || '')
337
+ .trim()
338
+ .toLowerCase();
339
+ const status = String(data.Status || data.status || '').trim();
340
+ const exitCode = parseDockerContainerExitCode(status);
341
+ const running = state === 'running' || /^Up\b/i.test(status);
342
+
343
+ for (const name of splitDockerNames(data.Names || data.Name || data.names || data.name)) {
344
+ if (!isDockerIsolationSessionName(name)) continue;
345
+ if (seen.has(name)) continue;
346
+ seen.add(name);
347
+ containers.push({
348
+ id: data.ID || data.Id || data.id || null,
349
+ name,
350
+ image: data.Image || data.image || null,
351
+ state,
352
+ status,
353
+ exitCode,
354
+ running,
355
+ });
356
+ }
357
+ }
358
+
359
+ return containers;
360
+ }
361
+
362
+ /**
363
+ * Enumerate Docker-isolation task containers from the local Docker daemon.
364
+ * Returns an empty list when docker is unavailable.
365
+ *
366
+ * @returns {Array}
367
+ */
368
+ export function listDockerIsolationContainers() {
369
+ const out = tryExec('docker', ['ps', '-a', '--format', '{{json .}}']);
370
+ return out ? parseDockerPsJsonLines(out) : [];
371
+ }
372
+
306
373
  function listStartCommandLogFiles(logRoot, maxFiles) {
307
374
  const files = [];
308
375
  const stack = [logRoot];
@@ -677,7 +744,7 @@ export function resolvePrHeadBranch(ref) {
677
744
  * @param {Object} [options]
678
745
  * @param {boolean} [options.verbose=false]
679
746
  * @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}>>}
747
+ * @returns {Promise<Array<{owner, repo, type, number, branch: string|null, sessionId: string|null, sessionName: string|null, status: string|null, exitCode: number|null, isolation: string|null, workspace: string|null, terminal: boolean, startTime: string|null}>>}
681
748
  */
682
749
  export async function listSessionTasks(options = {}) {
683
750
  const { verbose = false, resolveBranches = false } = options;
@@ -711,6 +778,8 @@ export async function listSessionTasks(options = {}) {
711
778
  sessionId: session.uuid || null,
712
779
  sessionName: session.sessionName || null,
713
780
  status: session.status || null,
781
+ exitCode: session.exitCode ?? null,
782
+ isolation: session.isolation || null,
714
783
  workspace: session.workingDirectory || null,
715
784
  terminal,
716
785
  startTime: session.startTime || null,
@@ -784,6 +853,18 @@ export function removePath(targetPath) {
784
853
  }
785
854
  }
786
855
 
856
+ /**
857
+ * Remove a Docker-isolation task container by session UUID. Returns false for
858
+ * invalid names, missing docker, missing containers, or docker errors.
859
+ *
860
+ * @param {string} containerName
861
+ * @returns {boolean}
862
+ */
863
+ export function removeDockerContainer(containerName) {
864
+ if (!isDockerIsolationSessionName(containerName)) return false;
865
+ return tryExec('docker', ['rm', '-f', containerName], { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] }) !== null;
866
+ }
867
+
787
868
  /**
788
869
  * System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
789
870
  * are only described, never executed.
@@ -111,7 +111,7 @@ export const githubLimits = {
111
111
 
112
112
  // Memory and disk configurations
113
113
  export const systemLimits = {
114
- minDiskSpaceMb: parseIntWithDefault('HIVE_MIND_MIN_DISK_SPACE_MB', 2048),
114
+ minDiskSpaceMb: parseIntWithDefault('HIVE_MIND_MIN_DISK_SPACE_MB', 10240),
115
115
  defaultPageSizeKb: parseIntWithDefault('HIVE_MIND_DEFAULT_PAGE_SIZE_KB', 16),
116
116
  };
117
117
 
package/src/hive.mjs CHANGED
@@ -1444,7 +1444,7 @@ if (isRunningDirectly) {
1444
1444
  } else {
1445
1445
  const systemCheck = await checkSystem(
1446
1446
  {
1447
- minDiskSpaceMB: argv.minDiskSpace || 2048,
1447
+ minDiskSpaceMB: argv.minDiskSpace || 10240,
1448
1448
  minMemoryMB: 256,
1449
1449
  exitOnFailure: true,
1450
1450
  },
@@ -488,7 +488,7 @@ export function readSessionExitFromLog(logPath, options = {}) {
488
488
  */
489
489
  async function findStartCommandBinary() {
490
490
  try {
491
- const result = await $`which $`;
491
+ const result = await $({ mirror: false })`which $`;
492
492
  const path = result.stdout?.toString().trim() || '';
493
493
  return path || null;
494
494
  } catch {
@@ -26,7 +26,7 @@ const lib = await import('./lib.mjs');
26
26
  const { log: libLog, setLogFile } = lib;
27
27
 
28
28
  // Function to check available disk space
29
- export const checkDiskSpace = async (minSpaceMB = 2048, options = {}) => {
29
+ export const checkDiskSpace = async (minSpaceMB = 10240, options = {}) => {
30
30
  const log = options.log || libLog;
31
31
 
32
32
  try {
@@ -289,7 +289,7 @@ export const getResourceSnapshot = async () => {
289
289
 
290
290
  // Combined system check function
291
291
  export const checkSystem = async (requirements = {}, options = {}) => {
292
- const { minMemoryMB = 256, minDiskSpaceMB = 2048, exitOnFailure = false } = requirements;
292
+ const { minMemoryMB = 256, minDiskSpaceMB = 10240, exitOnFailure = false } = requirements;
293
293
 
294
294
  // Note: log is passed through options to checkDiskSpace and checkRAM
295
295
  const results = {
@@ -334,7 +334,7 @@ const createMemoryCheckYargsConfig = yargsInstance =>
334
334
  alias: 'd',
335
335
  type: 'number',
336
336
  description: 'Minimum required disk space in MB',
337
- default: 2048,
337
+ default: 10240,
338
338
  })
339
339
  .option('exit-on-failure', {
340
340
  alias: 'e',
@@ -22,6 +22,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
22
22
  *
23
23
  * @see https://github.com/link-assistant/hive-mind/issues/1242
24
24
  * @see https://github.com/link-assistant/hive-mind/issues/1253
25
+ * @see https://github.com/link-assistant/hive-mind/issues/1981
25
26
  */
26
27
 
27
28
  // Use use-m to dynamically import modules
@@ -236,9 +237,8 @@ function getThresholdConfig(linoKey, envVarThreshold, envVarStrategy, defaultThr
236
237
  * - 'enqueue': Block and wait in queue
237
238
  * - 'dequeue-one-at-a-time': Allow one command, block subsequent
238
239
  *
239
- * BREAKING CHANGE: Disk threshold default strategy changed from 'dequeue-one-at-a-time' to 'reject'
240
- * because the queue is lost on server restart anyway, so there's no point in queueing.
241
- * To restore old behavior: HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time
240
+ * Issue #1981: disk now defaults to the normal wait/enqueue path at 80% used.
241
+ * Operators can still choose immediate rejection with HIVE_MIND_DISK_STRATEGY=reject.
242
242
  */
243
243
  export const QUEUE_CONFIG = {
244
244
  // Threshold configurations with value and strategy
@@ -246,10 +246,8 @@ export const QUEUE_CONFIG = {
246
246
  thresholds: {
247
247
  ram: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue'),
248
248
  cpu: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue'),
249
- // BREAKING: disk default changed from 'dequeue-one-at-a-time' to 'reject'
250
- // Queue is in RAM and lost on restart - no point enlarging it when disk is full
251
- // See: https://github.com/link-assistant/hive-mind/issues/1253
252
- disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject'),
249
+ // Issue #1981: wait instead of immediately rejecting when disk crosses 80%.
250
+ disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.8, 'enqueue'),
253
251
  claude5Hour: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
254
252
  claudeWeekly: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time'),
255
253
  codex5Hour: getThresholdConfig('codex5Hour', 'HIVE_MIND_CODEX_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CODEX_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
@@ -265,7 +263,7 @@ export const QUEUE_CONFIG = {
265
263
  // These are derived from thresholds.{metric}.value
266
264
  RAM_THRESHOLD: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue').value,
267
265
  CPU_THRESHOLD: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue').value,
268
- DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject').value,
266
+ DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.8, 'enqueue').value,
269
267
  CLAUDE_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
270
268
  CLAUDE_WEEKLY_THRESHOLD: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time').value,
271
269
  CODEX_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('codex5Hour', 'HIVE_MIND_CODEX_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CODEX_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
@@ -289,8 +289,8 @@ export const SOLVE_OPTION_DEFINITIONS = {
289
289
  },
290
290
  'min-disk-space': {
291
291
  type: 'number',
292
- description: 'Minimum required disk space in MB (default: 2048)',
293
- default: 2048,
292
+ description: 'Minimum required disk space in MB (default: 10240)',
293
+ default: 10240,
294
294
  },
295
295
  'log-dir': {
296
296
  type: 'string',
package/src/solve.mjs CHANGED
@@ -248,7 +248,7 @@ if (argv.planModel) {
248
248
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
249
249
  const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
250
250
  await cascadePlaywrightMcpDisable(argv, log);
251
- if (!(await performSystemChecks(argv.minDiskSpace || 2048, skipToolConnectionCheck, argv.model, argv))) {
251
+ if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
252
252
  await safeExit(1, 'System checks failed');
253
253
  }
254
254
  // Playwright MCP preflight is local/free and stays independent from paid tool connection checks.
@@ -54,7 +54,7 @@ const { parseResetTime: parseResetTimeToDate } = usageLimitLib;
54
54
  const { validateClaudeConnection } = claudeLib;
55
55
 
56
56
  // Wrapper function for disk space check using imported module
57
- const checkDiskSpace = async (minSpaceMB = 2048) => {
57
+ const checkDiskSpace = async (minSpaceMB = 10240) => {
58
58
  const result = await memoryCheck.checkDiskSpace(minSpaceMB, { log });
59
59
  return result.success;
60
60
  };
@@ -216,7 +216,7 @@ export const validateContinueOnlyOnFeedback = async (argv, isPrUrl, isIssueUrl)
216
216
  // Perform all system checks (disk space, memory, tool connection, GitHub permissions)
217
217
  // Note: skipToolConnection only skips the connection check, not model validation
218
218
  // Model validation should be done separately before calling this function
219
- export const performSystemChecks = async (minDiskSpace = 2048, skipToolConnection = false, model = 'sonnet', argv = {}) => {
219
+ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnection = false, model = 'sonnet', argv = {}) => {
220
220
  // Check disk space before proceeding
221
221
  const hasEnoughSpace = await checkDiskSpace(minDiskSpace);
222
222
  if (!hasEnoughSpace) {
@@ -61,7 +61,7 @@ export const createYargsConfig = yargsInstance =>
61
61
  type: 'string',
62
62
  description: 'agent-commander isolation mode',
63
63
  choices: ['screen', 'none', 'docker'],
64
- default: 'screen',
64
+ default: 'docker',
65
65
  })
66
66
  .option('screen-name', {
67
67
  type: 'string',
package/src/task.mjs CHANGED
@@ -35,7 +35,7 @@ if (earlyArgs.length === 0 || earlyArgs.includes('--help') || earlyArgs.includes
35
35
  console.log(' --split-count Number of issues to split into [default: 2]');
36
36
  console.log(' --tool AI tool for agent-commander read-only mode (claude, codex, opencode, agent, qwen, gemini) [default: claude]');
37
37
  console.log(' --model, -m Model to use');
38
- console.log(' --isolation agent-commander isolation mode [default: screen]');
38
+ console.log(' --isolation agent-commander isolation mode [default: docker]');
39
39
  console.log(' --dry-run Print split output without creating GitHub issues');
40
40
  console.log(' --verbose, -v Enable verbose logging');
41
41
  console.log(' --output-format Output format (text or json) [default: text]');
@@ -102,7 +102,7 @@ const config = yargs(hideBin(process.argv))
102
102
  .option('autoStartScreenWatchMessage', { type: 'boolean', description: 'Experimental: auto-start separate /terminal_watch messages for public /solve sessions', alias: 'auto-start-screen-watch-message', default: getenv('TELEGRAM_AUTO_START_SCREEN_WATCH_MESSAGE', getenv('TELEGRAM_AUTO_WATCH_MESSAGE', 'false')) === 'true' })
103
103
  // Issue #594: bot-owner toggle for --show-limits virtual option in /solve and /hive.
104
104
  .option('showLimits', { type: 'boolean', description: 'Experimental: allow /solve and /hive callers to use --show-limits to embed Claude/Codex usage at start, end, and delta in the completion message', alias: 'show-limits', default: getenv('TELEGRAM_SHOW_LIMITS', 'true') !== 'false' })
105
- .option('isolation', { type: 'string', description: "Isolation backend (screen/tmux/docker). Defaults to 'screen' so Telegram-bot work sessions survive bot restarts; pass --isolation '' (or set TELEGRAM_ISOLATION='') to disable.", default: getenv('TELEGRAM_ISOLATION', 'screen') })
105
+ .option('isolation', { type: 'string', description: "Isolation backend (screen/tmux/docker). Defaults to 'docker' so Telegram-bot work sessions run in Docker isolation; pass --isolation '' (or set TELEGRAM_ISOLATION='') to disable.", default: getenv('TELEGRAM_ISOLATION', 'docker') })
106
106
  .help('h')
107
107
  .alias('h', 'help')
108
108
  .parserConfiguration({
@@ -724,10 +724,11 @@ export class SolveQueue {
724
724
  * Default strategies:
725
725
  * - RAM: enqueue
726
726
  * - CPU: enqueue
727
- * - DISK: reject (changed from dequeue-one-at-a-time - queue lost on restart)
727
+ * - DISK: enqueue (waits until disk drops below the threshold)
728
728
  *
729
729
  * See: https://github.com/link-assistant/hive-mind/issues/1155
730
730
  * See: https://github.com/link-assistant/hive-mind/issues/1253
731
+ * See: https://github.com/link-assistant/hive-mind/issues/1981
731
732
  *
732
733
  * @param {number} totalProcessing - Total processing count (queue + external claude processes)
733
734
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
@@ -584,7 +584,7 @@ export function registerStartStopCommands(bot, options) {
584
584
  // immediately and was dispatched to a detached session) but the session
585
585
  // monitor still tracks a running isolated session for this URL, forward
586
586
  // CTRL+C to its start-command UUID. This is the common case for tasks
587
- // that begin executing right away with `--isolation screen`.
587
+ // that begin executing right away with an isolation backend.
588
588
  const queueHasTask = lookup.action === 'cancel-queued' || lookup.action === 'stop-running';
589
589
  if (!queueHasTask && runningSession?.stoppable && runningSession.sessionId) {
590
590
  VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to tracked session ${runningSession.sessionId} for ${url} (queue action=${lookup.action})`);
@@ -597,7 +597,7 @@ export function registerStartStopCommands(bot, options) {
597
597
  // running-but-non-stoppable (non-isolation) session, say so; otherwise
598
598
  // fall back to the UUID hint.
599
599
  if (runningSession) {
600
- await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
600
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
601
601
  parse_mode: 'Markdown',
602
602
  reply_to_message_id: message.message_id,
603
603
  });
@@ -615,13 +615,13 @@ export function registerStartStopCommands(bot, options) {
615
615
  // have forwarded CTRL+C above). If it tracked a non-isolation session,
616
616
  // explain why it can't be stopped; otherwise report not found.
617
617
  if (runningSession) {
618
- await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
618
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
619
619
  parse_mode: 'Markdown',
620
620
  reply_to_message_id: message.message_id,
621
621
  });
622
622
  return;
623
623
  }
624
- await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
624
+ await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with an isolation backend, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
625
625
  parse_mode: 'Markdown',
626
626
  reply_to_message_id: message.message_id,
627
627
  });
@@ -655,7 +655,7 @@ export function registerStartStopCommands(bot, options) {
655
655
  // running-not-isolated: a started, non-isolated screen session. We
656
656
  // could shell out to `screen -X -S <name> stuff $'\003'`, but that's
657
657
  // brittle and out of scope for #1780. Tell the user how to recover.
658
- await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
658
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
659
659
  parse_mode: 'Markdown',
660
660
  reply_to_message_id: message.message_id,
661
661
  });