@link-assistant/hive-mind 1.73.9 → 1.74.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,32 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b00a51c: feat(cleanup): add a task-aware `cleanup` command to free disk space safely (#1848)
8
+
9
+ Adds a new `cleanup` bin that removes stale hive-mind temporary
10
+ directories/files under the system temp dir while preserving folders that belong
11
+ to currently-running tasks, protected system paths, and any clone with
12
+ uncommitted or unpushed work.
13
+
14
+ Highlights:
15
+ - `--dry-run` / `-n` prints the full list of kept folders and folders that would
16
+ be deleted (with sizes and reasons), deleting nothing.
17
+ - `--keep-active-tasks-folders` (default on) detects active tasks from running
18
+ processes (`/proc`) and live isolation sessions (`screen`/`tmux` +
19
+ `$ --status`), and matches clones to tasks by branch name using the same logic
20
+ as `solve` (issue → `issue-{n}-{hex}` scoped to the repo; PR → its resolved
21
+ head branch). Disable with `--no-keep-active-tasks-folders`.
22
+ - Keeps `/tmp/start-command/` and system-owned temp entries by default;
23
+ `--force-start-command` allows deleting `/tmp/start-command` when needed.
24
+ - Optional Ubuntu/system cleanup behind explicit flags: `--apt`, `--journal`,
25
+ `--docker`, `--npm` (and `--system` shorthand), with `--sudo`.
26
+ - Safe by default: keeps unrecognised entries unless `--all`, never deletes
27
+ paths held open by a running process or used by the cleanup process itself,
28
+ and requires confirmation unless `--force`.
29
+
3
30
  ## 1.73.9
4
31
 
5
32
  ### Patch Changes
package/README.hi.md CHANGED
@@ -717,6 +717,42 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
717
717
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
718
718
  ```
719
719
 
720
+ ### डिस्क क्लीनअप
721
+
722
+ `cleanup` पुरानी hive-mind अस्थायी डायरेक्टरी/फ़ाइलों (जैसे प्रति-कार्य क्लोन
723
+ `/tmp/gh-issue-solver-*`, MCP कॉन्फ़िग फ़ाइलें, लॉग डाउनलोड डायरेक्टरी आदि) को हटाकर
724
+ डिस्क स्थान खाली करता है, जबकि **वर्तमान में चल रहे कार्यों से संबंधित फ़ोल्डर**,
725
+ सुरक्षित सिस्टम पथ, और बिना कमिट या बिना पुश किए बदलावों वाले किसी भी क्लोन को बनाए
726
+ रखता है। यह चल रही प्रक्रियाओं और लाइव आइसोलेशन सेशन से सक्रिय कार्यों का पता लगाता है
727
+ और `solve` के समान लॉजिक का उपयोग करते हुए शाखा नाम द्वारा क्लोन को कार्यों से मिलाता
728
+ है (issue → `issue-{n}-{hex}`; PR → इसकी हल की गई head शाखा)।
729
+
730
+ ```bash
731
+ # पूर्वावलोकन: रखे जाने वाले और हटाए जाने वाले फ़ोल्डरों की सूची (कुछ भी नहीं हटाता)
732
+ cleanup --dry-run
733
+
734
+ # पुरानी अस्थायी फ़ाइलें वास्तव में हटाएँ (पहले पुष्टि माँगता है)
735
+ cleanup
736
+
737
+ # पुष्टि प्रॉम्प्ट के बिना हटाएँ
738
+ cleanup --force
739
+
740
+ # गैर-hive-mind अस्थायी प्रविष्टियों पर भी विचार करें (अधिक आक्रामक)
741
+ cleanup --all --dry-run
742
+
743
+ # /tmp/start-command को हटाने की अनुमति दें (डिफ़ॉल्ट रूप से रखा जाता है; इसमें आइसोलेशन लॉग होते हैं)
744
+ cleanup --force-start-command
745
+
746
+ # Ubuntu / सिस्टम क्लीनअप (apt कैश, journald लॉग, npm कैश)
747
+ cleanup --system --sudo
748
+
749
+ # सक्रिय-कार्य पहचान अक्षम करें (केवल सुरक्षित पथ रखे जाते हैं)
750
+ cleanup --no-keep-active-tasks-folders --dry-run
751
+ ```
752
+
753
+ विकल्पों की पूरी सूची के लिए `cleanup --help` चलाएँ। यह कमांड dry-run के अनुकूल है और
754
+ हर रन के लिए टाइमस्टैम्प वाला `cleanup-*.log` लिखता है।
755
+
720
756
  ## 🔍 निगरानी और लॉगिंग
721
757
 
722
758
  लॉग में resume कमांड खोजें:
package/README.md CHANGED
@@ -737,6 +737,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
737
737
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
738
738
  ```
739
739
 
740
+ ### Disk Cleanup
741
+
742
+ `cleanup` frees disk space by removing stale hive-mind temporary
743
+ directories/files (per-task clones like `/tmp/gh-issue-solver-*`, MCP config
744
+ files, log download dirs, …) while **keeping folders that belong to
745
+ currently-running tasks**, protected system paths, and any clone with
746
+ uncommitted or unpushed work. It detects active tasks from running processes and
747
+ live isolation sessions and matches clones to tasks by branch name using the
748
+ same logic as `solve` (issue → `issue-{n}-{hex}`; PR → its resolved head
749
+ branch).
750
+
751
+ ```bash
752
+ # Preview: list kept folders and folders that would be deleted (deletes nothing)
753
+ cleanup --dry-run
754
+
755
+ # Actually delete stale temp artifacts (asks for confirmation first)
756
+ cleanup
757
+
758
+ # Delete without the confirmation prompt
759
+ cleanup --force
760
+
761
+ # Also consider non-hive-mind temp entries (more aggressive)
762
+ cleanup --all --dry-run
763
+
764
+ # Allow deleting /tmp/start-command (kept by default; holds isolation logs)
765
+ cleanup --force-start-command
766
+
767
+ # Ubuntu / system cleanup (apt caches, journald logs, npm cache)
768
+ cleanup --system --sudo
769
+
770
+ # Disable active-task detection (only protected paths are kept)
771
+ cleanup --no-keep-active-tasks-folders --dry-run
772
+ ```
773
+
774
+ Run `cleanup --help` for the full list of options. The command is dry-run
775
+ friendly and writes a timestamped `cleanup-*.log` for every run.
776
+
740
777
  ## 🔍 Monitoring & Logging
741
778
 
742
779
  Find resume commands in logs:
package/README.ru.md CHANGED
@@ -719,6 +719,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
719
719
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
720
720
  ```
721
721
 
722
+ ### Очистка диска
723
+
724
+ `cleanup` освобождает место на диске, удаляя устаревшие временные каталоги/файлы
725
+ hive-mind (клоны для каждой задачи вида `/tmp/gh-issue-solver-*`, файлы
726
+ конфигурации MCP, каталоги загрузки логов и т. д.), при этом **сохраняя папки,
727
+ относящиеся к выполняющимся в данный момент задачам**, защищённые системные пути и
728
+ любой клон с незакоммиченными или неотправленными изменениями. Он обнаруживает
729
+ активные задачи по запущенным процессам и активным сессиям изоляции и сопоставляет
730
+ клоны с задачами по имени ветки, используя ту же логику, что и `solve`
731
+ (issue → `issue-{n}-{hex}`; PR → его разрешённая head-ветка).
732
+
733
+ ```bash
734
+ # Предпросмотр: список сохраняемых и удаляемых папок (ничего не удаляет)
735
+ cleanup --dry-run
736
+
737
+ # Реально удалить устаревшие временные файлы (сначала запросит подтверждение)
738
+ cleanup
739
+
740
+ # Удалить без запроса подтверждения
741
+ cleanup --force
742
+
743
+ # Учитывать также не-hive-mind временные записи (более агрессивно)
744
+ cleanup --all --dry-run
745
+
746
+ # Разрешить удаление /tmp/start-command (по умолчанию сохраняется; хранит логи изоляции)
747
+ cleanup --force-start-command
748
+
749
+ # Очистка Ubuntu / системы (кэши apt, логи journald, кэш npm)
750
+ cleanup --system --sudo
751
+
752
+ # Отключить обнаружение активных задач (сохраняются только защищённые пути)
753
+ cleanup --no-keep-active-tasks-folders --dry-run
754
+ ```
755
+
756
+ Запустите `cleanup --help`, чтобы увидеть полный список опций. Команда удобна для
757
+ режима dry-run и записывает лог `cleanup-*.log` с меткой времени при каждом запуске.
758
+
722
759
  ## 🔍 Мониторинг и логирование
723
760
 
724
761
  Найдите команды возобновления в логах:
package/README.zh.md CHANGED
@@ -713,6 +713,41 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
713
713
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
714
714
  ```
715
715
 
716
+ ### 磁盘清理
717
+
718
+ `cleanup` 通过删除过时的 hive-mind 临时目录/文件(如每个任务的克隆
719
+ `/tmp/gh-issue-solver-*`、MCP 配置文件、日志下载目录等)来释放磁盘空间,同时
720
+ **保留属于当前正在运行任务的文件夹**、受保护的系统路径,以及任何包含未提交或未推送
721
+ 更改的克隆。它通过运行中的进程和实时隔离会话检测活动任务,并使用与 `solve` 相同的
722
+ 逻辑按分支名称将克隆与任务匹配(issue → `issue-{n}-{hex}`;PR → 其解析出的 head
723
+ 分支)。
724
+
725
+ ```bash
726
+ # 预览:列出保留的文件夹和将被删除的文件夹(不删除任何内容)
727
+ cleanup --dry-run
728
+
729
+ # 实际删除过时的临时文件(会先要求确认)
730
+ cleanup
731
+
732
+ # 删除时不显示确认提示
733
+ cleanup --force
734
+
735
+ # 同时考虑非 hive-mind 临时项(更激进)
736
+ cleanup --all --dry-run
737
+
738
+ # 允许删除 /tmp/start-command(默认保留;其中存放隔离日志)
739
+ cleanup --force-start-command
740
+
741
+ # Ubuntu / 系统清理(apt 缓存、journald 日志、npm 缓存)
742
+ cleanup --system --sudo
743
+
744
+ # 禁用活动任务检测(仅保留受保护的路径)
745
+ cleanup --no-keep-active-tasks-folders --dry-run
746
+ ```
747
+
748
+ 运行 `cleanup --help` 查看完整的选项列表。该命令对 dry-run 友好,并为每次运行写入
749
+ 带时间戳的 `cleanup-*.log` 日志。
750
+
716
751
  ## 🔍 监控与日志
717
752
 
718
753
  在日志中查找恢复命令:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.73.9",
3
+ "version": "1.74.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",
@@ -8,6 +8,7 @@
8
8
  "hive": "./src/hive.mjs",
9
9
  "solve": "./src/solve.mjs",
10
10
  "task": "./src/task.mjs",
11
+ "cleanup": "./src/cleanup.mjs",
11
12
  "review": "./src/review.mjs",
12
13
  "configure-claude": "./src/configure-claude.mjs",
13
14
  "start-screen": "./src/start-screen.mjs",
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Core, offline-testable logic for the `cleanup` command (issue #1848).
3
+ *
4
+ * This module deliberately avoids any top-level network access (no `use-m`
5
+ * fetch) and any side effects so it can be unit-tested without a network
6
+ * connection or a real filesystem. All OS interaction (reading /tmp, querying
7
+ * `$ --status`, scanning /proc, deleting paths, apt cleanup) lives in
8
+ * `cleanup.os.lib.mjs` / `cleanup.mjs`; this module only contains pure
9
+ * classification, parsing and formatting helpers.
10
+ *
11
+ * The classification mirrors the manual workflow described in the issue: list
12
+ * the temporary directories under the tmp root, figure out which ones belong to
13
+ * currently-running solve tasks (by branch name, the same way solve.mjs derives
14
+ * branches), and keep those while removing the rest. Protected system paths such
15
+ * as `/tmp/start-command/` are always preserved unless explicitly forced.
16
+ *
17
+ * @see https://github.com/link-assistant/hive-mind/issues/1848
18
+ */
19
+
20
+ import { isValidIssueBranchName } from './solve.branch.lib.mjs';
21
+
22
+ /**
23
+ * Directory names directly under the tmp root that must never be removed by
24
+ * default because deleting them would interfere with the system's ability to
25
+ * run or be debugged. `start-command` holds the isolation session logs that
26
+ * `$ --status`, /log and /terminal_watch rely on.
27
+ */
28
+ export const DEFAULT_PROTECTED_NAMES = ['start-command'];
29
+
30
+ /**
31
+ * System-owned temp entries that we never touch even in `--all` mode unless the
32
+ * user explicitly opts in. These are created by the OS / desktop / language
33
+ * runtimes and removing them mid-flight can break unrelated processes.
34
+ */
35
+ export const SYSTEM_PROTECTED_PATTERNS = [/^\.X11-unix$/, /^\.XIM-unix$/, /^\.ICE-unix$/, /^\.font-unix$/, /^\.Test-unix$/, /^systemd-private-/, /^snap-private-tmp$/, /^snap\./, /^\.snap/, /^dbus-/, /^ssh-/, /^hsperfdata_/, /^\.org\.chromium\./, /^\.com\.google\.Chrome\./];
36
+
37
+ /**
38
+ * Patterns for temporary entries that are unambiguously created by hive-mind
39
+ * (solve.mjs, github.lib.mjs, claude.lib.mjs, telegram-*, etc.). These are safe
40
+ * to delete when they are not tied to an active task. Each entry has a `name`
41
+ * (for reporting) and a `regex` matched against the basename under the tmp root.
42
+ *
43
+ * Sources are referenced inline so future maintainers can keep this list in
44
+ * sync with the code that produces the files.
45
+ */
46
+ export const HIVE_MIND_TEMP_PATTERNS = [
47
+ // solve.repository.lib.mjs / solve.execution.lib.mjs workspace clones
48
+ { name: 'solve workspace clone', regex: /^gh-issue-solver-\d+$/ },
49
+ { name: 'solve resume workspace', regex: /^gh-issue-solver-resume-.+$/ },
50
+ // solve.repository.lib.mjs buildWorkspacePath parent dir
51
+ { name: 'solve workspace root', regex: /^hive-mind-solve-gh-/ },
52
+ // github.lib.mjs log download working dirs
53
+ { name: 'solution draft log dir', regex: /^log-tmp-solution-draft-log-/ },
54
+ // claude.lib.mjs MCP config temp files
55
+ { name: 'claude MCP config', regex: /^claude-mcp-no-useless-.+\.json$/ },
56
+ { name: 'claude MCP config', regex: /^claude-mcp-.+\.json$/ },
57
+ // github.lib.mjs comment / body temp files
58
+ { name: 'solution draft log', regex: /^solution-draft-log-.+\.txt$/ },
59
+ { name: 'log upload comment', regex: /^log-upload-comment-.+\.md$/ },
60
+ { name: 'log comment', regex: /^log-comment-.+\.md$/ },
61
+ // github-error-reporter.lib.mjs
62
+ { name: 'issue body temp', regex: /^hive-mind-issue-body-.+\.md$/ },
63
+ // solve.auto-pr.lib.mjs / solve.results.lib.mjs
64
+ { name: 'PR body temp', regex: /^pr-body-.+$/ },
65
+ { name: 'PR title temp', regex: /^pr-title-.+\.txt$/ },
66
+ // solve.progress-monitoring.lib.mjs
67
+ { name: 'PR progress temp', regex: /^pr-progress-.+$/ },
68
+ // telegram-top-command.lib.mjs
69
+ { name: 'telegram top output', regex: /^top-output-.+\.txt$/ },
70
+ // start-screen.mjs
71
+ { name: 'screen ready marker', regex: /^screen-ready-.+\.marker$/ },
72
+ ];
73
+
74
+ /**
75
+ * Parse a GitHub issue/PR URL out of an arbitrary string (e.g. a solve command
76
+ * line). Self-contained so this module stays offline-safe (github.lib.mjs is
77
+ * not import-safe because of its top-level use-m fetch).
78
+ *
79
+ * @param {string} url
80
+ * @returns {{owner: string, repo: string, type: 'issue'|'pull', number: number}|null}
81
+ */
82
+ export function parseTaskUrl(url) {
83
+ if (!url || typeof url !== 'string') return null;
84
+ const match = url.match(/github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/i);
85
+ if (!match) return null;
86
+ const type = /^pull/i.test(match[3]) ? 'pull' : 'issue';
87
+ return {
88
+ owner: match[1],
89
+ repo: match[2],
90
+ type,
91
+ number: Number(match[4]),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Extract all GitHub issue/PR references from a command line string. A solve
97
+ * command typically takes the URL as its first positional argument, but we scan
98
+ * the whole string to be tolerant of flag ordering.
99
+ *
100
+ * @param {string} command
101
+ * @returns {Array<{owner: string, repo: string, type: 'issue'|'pull', number: number}>}
102
+ */
103
+ export function extractTaskRefsFromCommand(command) {
104
+ if (!command || typeof command !== 'string') return [];
105
+ const refs = [];
106
+ const seen = new Set();
107
+ const re = /github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/gi;
108
+ let m;
109
+ while ((m = re.exec(command)) !== null) {
110
+ const ref = parseTaskUrl(m[0]);
111
+ if (!ref) continue;
112
+ const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
113
+ if (seen.has(key)) continue;
114
+ seen.add(key);
115
+ refs.push(ref);
116
+ }
117
+ return refs;
118
+ }
119
+
120
+ /**
121
+ * Normalise an owner/repo pair extracted from a git remote URL.
122
+ *
123
+ * @param {string} remoteUrl
124
+ * @returns {{owner: string, repo: string}|null}
125
+ */
126
+ export function parseRemoteUrl(remoteUrl) {
127
+ if (!remoteUrl || typeof remoteUrl !== 'string') return null;
128
+ // git@github.com:owner/repo.git OR https://github.com/owner/repo(.git)
129
+ const sshMatch = remoteUrl.match(/^[^@]+@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
130
+ if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
131
+ const httpMatch = remoteUrl.match(/^[a-z]+:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/i);
132
+ if (httpMatch) return { owner: httpMatch[1], repo: httpMatch[2] };
133
+ return null;
134
+ }
135
+
136
+ function sameRepo(a, b) {
137
+ if (!a || !b) return false;
138
+ return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase();
139
+ }
140
+
141
+ /**
142
+ * Build the set of "active task matchers" from the running session task list.
143
+ * For PR tasks the resolved head branch is matched exactly; for issue tasks we
144
+ * fall back to the `issue-{number}-{hex}` prefix (the random hex is unknown from
145
+ * the URL alone) combined with a repo match.
146
+ *
147
+ * @param {Array<{owner, repo, type, number, branch?: string|null}>} activeTasks
148
+ * @returns {Array<{owner, repo, type, issueNumber: number|null, branch: string|null}>}
149
+ */
150
+ export function buildActiveMatchers(activeTasks) {
151
+ const matchers = [];
152
+ for (const task of activeTasks || []) {
153
+ if (!task) continue;
154
+ matchers.push({
155
+ owner: task.owner,
156
+ repo: task.repo,
157
+ type: task.type,
158
+ issueNumber: task.type === 'issue' ? task.number : (task.issueNumber ?? null),
159
+ branch: task.branch || null,
160
+ });
161
+ }
162
+ return matchers;
163
+ }
164
+
165
+ /**
166
+ * Decide whether a folder's git info matches one of the active task matchers.
167
+ *
168
+ * @param {{branch: string|null, remotes: Array<{owner, repo}>}|null} gitInfo
169
+ * @param {Array} matchers - from buildActiveMatchers
170
+ * @returns {Object|null} the matched matcher, or null
171
+ */
172
+ export function folderMatchesActiveTask(gitInfo, matchers) {
173
+ if (!gitInfo || !Array.isArray(matchers)) return null;
174
+ const remotes = gitInfo.remotes || [];
175
+ for (const m of matchers) {
176
+ // 1. Exact branch match (covers PR continue-mode and any known branch).
177
+ if (m.branch && gitInfo.branch && gitInfo.branch === m.branch) {
178
+ return m;
179
+ }
180
+ // 2. issue-{number}-{hex} prefix match scoped to the same repository.
181
+ if (m.issueNumber != null && gitInfo.branch && isValidIssueBranchName(gitInfo.branch, m.issueNumber)) {
182
+ const repoMatches = remotes.length === 0 || remotes.some(r => sameRepo(r, m));
183
+ if (repoMatches) return m;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function matchesAny(name, patterns) {
190
+ return patterns.some(p => (p.regex || p).test(name));
191
+ }
192
+
193
+ /**
194
+ * Identify the hive-mind temp pattern a name matches, if any.
195
+ *
196
+ * @param {string} name
197
+ * @returns {{name: string}|null}
198
+ */
199
+ export function matchHiveMindPattern(name) {
200
+ return HIVE_MIND_TEMP_PATTERNS.find(p => p.regex.test(name)) || null;
201
+ }
202
+
203
+ /**
204
+ * Pure classification of a single temp entry into keep/remove with a reason.
205
+ *
206
+ * Reason precedence (highest first): protected > self > active-process >
207
+ * active-task > dirty-worktree > hive-mind-temp (remove) > all-mode (remove) >
208
+ * unrecognized (keep).
209
+ *
210
+ * @param {{name: string, path: string, isDirectory: boolean}} entry
211
+ * @param {Object} ctx
212
+ * @param {string[]} ctx.protectedNames
213
+ * @param {boolean} ctx.forceStartCommand
214
+ * @param {boolean} ctx.includeSystem - allow classifying system entries in --all
215
+ * @param {boolean} ctx.includeAll - consider non-hive-mind entries for removal
216
+ * @param {boolean} ctx.keepDirty
217
+ * @param {Set<string>} ctx.selfPaths - absolute paths the cleanup process itself uses
218
+ * @param {Set<string>} ctx.heldPaths - absolute paths held by running processes
219
+ * @param {Array} ctx.matchers - active task matchers
220
+ * @param {Map<string,{branch, remotes, dirty}>} ctx.gitInfoByPath
221
+ * @returns {{action: 'keep'|'remove', reason: string}}
222
+ */
223
+ export function classifyEntry(entry, ctx) {
224
+ const { protectedNames = DEFAULT_PROTECTED_NAMES, forceStartCommand = false, includeSystem = false, includeAll = false, keepDirty = true, selfPaths = new Set(), heldPaths = new Set(), matchers = [], gitInfoByPath = new Map() } = ctx || {};
225
+
226
+ const name = entry.name;
227
+
228
+ // 1. Protected names (start-command can be forced).
229
+ const isStartCommand = name === 'start-command';
230
+ if (protectedNames.includes(name)) {
231
+ if (isStartCommand && forceStartCommand) {
232
+ // fall through to deletion logic below
233
+ } else {
234
+ return { action: 'keep', reason: 'protected' };
235
+ }
236
+ }
237
+
238
+ // 1b. System-owned entries are protected unless explicitly included.
239
+ if (!includeSystem && matchesAny(name, SYSTEM_PROTECTED_PATTERNS)) {
240
+ return { action: 'keep', reason: 'system-protected' };
241
+ }
242
+
243
+ // 2. Paths the cleanup process itself depends on (its own clone / cwd).
244
+ if (selfPaths.has(entry.path)) {
245
+ return { action: 'keep', reason: 'self' };
246
+ }
247
+
248
+ // 3. Held open / used as cwd by a running process.
249
+ if (heldPaths.has(entry.path)) {
250
+ return { action: 'keep', reason: 'active-process' };
251
+ }
252
+
253
+ // 4. Matches an active solve task by branch / repo.
254
+ const gitInfo = gitInfoByPath.get(entry.path);
255
+ const matched = folderMatchesActiveTask(gitInfo, matchers);
256
+ if (matched) {
257
+ return { action: 'keep', reason: 'active-task' };
258
+ }
259
+
260
+ // 5. Dirty / unpushed worktree: keep by default to avoid losing work.
261
+ if (keepDirty && gitInfo && gitInfo.dirty) {
262
+ return { action: 'keep', reason: 'dirty-worktree' };
263
+ }
264
+
265
+ // 6. Recognised hive-mind temp artifact -> safe to remove.
266
+ if (matchHiveMindPattern(name)) {
267
+ return { action: 'remove', reason: isStartCommand ? 'forced-start-command' : 'hive-mind-temp' };
268
+ }
269
+
270
+ // start-command forced but not a hive-mind pattern: still remove when forced.
271
+ if (isStartCommand && forceStartCommand) {
272
+ return { action: 'remove', reason: 'forced-start-command' };
273
+ }
274
+
275
+ // 7. --all mode removes anything not otherwise kept.
276
+ if (includeAll) {
277
+ return { action: 'remove', reason: 'all-mode' };
278
+ }
279
+
280
+ // 8. Default: leave unrecognised entries alone.
281
+ return { action: 'keep', reason: 'unrecognized' };
282
+ }
283
+
284
+ /**
285
+ * Classify a list of temp entries.
286
+ *
287
+ * @param {Array<{name, path, isDirectory, size?: number}>} entries
288
+ * @param {Object} ctx - see classifyEntry
289
+ * @returns {{keep: Array, remove: Array}} each item: {name, path, size, reason}
290
+ */
291
+ export function classifyEntries(entries, ctx) {
292
+ const keep = [];
293
+ const remove = [];
294
+ for (const entry of entries || []) {
295
+ const { action, reason } = classifyEntry(entry, ctx);
296
+ const record = { name: entry.name, path: entry.path, size: entry.size ?? null, reason };
297
+ if (action === 'remove') remove.push(record);
298
+ else keep.push(record);
299
+ }
300
+ return { keep, remove };
301
+ }
302
+
303
+ /**
304
+ * Human-readable, base-1024 byte formatting (matches `du -h` style closely
305
+ * enough for reporting).
306
+ *
307
+ * @param {number|null|undefined} bytes
308
+ * @returns {string}
309
+ */
310
+ export function formatBytes(bytes) {
311
+ if (bytes == null || Number.isNaN(bytes)) return '?';
312
+ if (bytes < 1024) return `${bytes}B`;
313
+ const units = ['K', 'M', 'G', 'T', 'P'];
314
+ let value = bytes / 1024;
315
+ let unit = 0;
316
+ while (value >= 1024 && unit < units.length - 1) {
317
+ value /= 1024;
318
+ unit++;
319
+ }
320
+ const rounded = value >= 10 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10;
321
+ return `${rounded}${units[unit]}`;
322
+ }
323
+
324
+ /**
325
+ * Aggregate totals for a classification result.
326
+ *
327
+ * @param {{keep: Array, remove: Array}} classified
328
+ * @returns {{keepCount, removeCount, keepBytes, removeBytes}}
329
+ */
330
+ export function summarize(classified) {
331
+ const sum = list => list.reduce((acc, item) => acc + (item.size || 0), 0);
332
+ return {
333
+ keepCount: classified.keep.length,
334
+ removeCount: classified.remove.length,
335
+ keepBytes: sum(classified.keep),
336
+ removeBytes: sum(classified.remove),
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Human-readable label for a keep/remove reason code.
342
+ * @param {string} reason
343
+ * @returns {string}
344
+ */
345
+ export function describeReason(reason) {
346
+ const map = {
347
+ protected: 'protected path',
348
+ 'system-protected': 'system-owned temp',
349
+ self: 'used by this cleanup process',
350
+ 'active-process': 'in use by a running process',
351
+ 'active-task': 'belongs to an active task',
352
+ 'dirty-worktree': 'has uncommitted/unpushed changes',
353
+ 'hive-mind-temp': 'hive-mind temporary artifact',
354
+ 'forced-start-command': 'start-command (forced)',
355
+ 'all-mode': 'removed by --all',
356
+ unrecognized: 'not a recognised hive-mind artifact',
357
+ };
358
+ return map[reason] || reason;
359
+ }
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `cleanup` — free disk space by removing stale hive-mind temporary
4
+ * directories/files while preserving folders that belong to currently-running
5
+ * (active) tasks, protected system paths and any work that is not yet pushed.
6
+ *
7
+ * This is the standalone command requested in issue #1848. It reproduces, in a
8
+ * safe and automated way, the manual workflow the maintainer used to reclaim
9
+ * space without restarting the server:
10
+ * - list temp entries (like `du -sh /tmp/*`),
11
+ * - figure out which clones belong to active solve tasks (by branch name, the
12
+ * same way solve.mjs derives branches), keeping those,
13
+ * - keep protected paths such as `/tmp/start-command/`,
14
+ * - delete the rest.
15
+ *
16
+ * Modes:
17
+ * --dry-run show kept + deleted lists, delete nothing
18
+ * --keep-active-tasks-folders keep folders of running tasks (default: on)
19
+ * --force / -f skip the confirmation prompt
20
+ * --all also consider non-hive-mind temp entries
21
+ * --force-start-command allow deleting /tmp/start-command
22
+ * --include-system also consider system-owned temp entries
23
+ * --no-keep-dirty allow deleting clones with unpushed changes
24
+ * --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
25
+ * --system shorthand for --apt --journal --npm
26
+ * --sudo prefix package-manager commands with sudo
27
+ * --verbose / -v
28
+ *
29
+ * @see https://github.com/link-assistant/hive-mind/issues/1848
30
+ */
31
+
32
+ import path from 'node:path';
33
+ import { promises as fsp } from 'node:fs';
34
+ import { execSync } from 'node:child_process';
35
+
36
+ import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES } from './cleanup.lib.mjs';
37
+ import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, removePath, runSystemCleanup } from './cleanup.os.lib.mjs';
38
+
39
+ const args = process.argv.slice(2);
40
+
41
+ function hasFlag(...names) {
42
+ return names.some(n => args.includes(n));
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Early --version / --help handling (no heavy imports).
47
+ // ---------------------------------------------------------------------------
48
+ if (hasFlag('--version')) {
49
+ const { getVersion } = await import('./version.lib.mjs');
50
+ try {
51
+ console.log(await getVersion());
52
+ } catch {
53
+ console.error('Error: Unable to determine version');
54
+ process.exit(1);
55
+ }
56
+ process.exit(0);
57
+ }
58
+
59
+ if (hasFlag('--help', '-h')) {
60
+ console.log(`Usage: cleanup [options]
61
+
62
+ Free disk space by removing stale hive-mind temporary directories/files while
63
+ keeping folders that belong to active tasks and protected system paths.
64
+
65
+ Options:
66
+ --dry-run, -n Show what would be kept and deleted, delete nothing
67
+ --keep-active-tasks-folders Keep folders of currently-running tasks [default: on]
68
+ --no-keep-active-tasks-folders
69
+ Disable active-task detection (only protected paths kept)
70
+ --force, -f Delete without the interactive confirmation prompt
71
+ --all Also consider non-hive-mind temp entries for deletion
72
+ --include-system Also consider system-owned temp entries (.X11-unix, …)
73
+ --force-start-command Allow deleting /tmp/start-command (kept by default)
74
+ --no-keep-dirty Allow deleting clones with uncommitted/unpushed changes
75
+ --no-sessions Do not query '$ --status' for active sessions
76
+ --no-resolve-branches Do not resolve PR head branches via gh
77
+
78
+ System / Ubuntu cleanup (opt-in):
79
+ --apt apt-get clean / autoclean / autoremove
80
+ --journal journalctl --vacuum-time=2weeks
81
+ --docker docker system prune -f
82
+ --npm npm cache clean --force
83
+ --system Shorthand for --apt --journal --npm
84
+ --sudo Prefix package-manager commands with sudo
85
+
86
+ --verbose, -v Verbose logging
87
+ --version Show version number
88
+ --help, -h Show this help
89
+ `);
90
+ process.exit(0);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Options.
95
+ // ---------------------------------------------------------------------------
96
+ const options = {
97
+ dryRun: hasFlag('--dry-run', '-n'),
98
+ keepActiveTasks: !hasFlag('--no-keep-active-tasks-folders'),
99
+ force: hasFlag('--force', '-f'),
100
+ includeAll: hasFlag('--all'),
101
+ includeSystem: hasFlag('--include-system'),
102
+ forceStartCommand: hasFlag('--force-start-command'),
103
+ keepDirty: !hasFlag('--no-keep-dirty'),
104
+ useSessions: !hasFlag('--no-sessions'),
105
+ resolveBranches: !hasFlag('--no-resolve-branches'),
106
+ verbose: hasFlag('--verbose', '-v'),
107
+ apt: hasFlag('--apt', '--system'),
108
+ journal: hasFlag('--journal', '--system'),
109
+ docker: hasFlag('--docker'),
110
+ npm: hasFlag('--npm', '--system'),
111
+ sudo: hasFlag('--sudo'),
112
+ };
113
+
114
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
115
+ const scriptDir = path.dirname(process.argv[1]);
116
+ const logFile = path.join(scriptDir, `cleanup-${timestamp}.log`);
117
+
118
+ async function log(message, { level = 'info' } = {}) {
119
+ await fsp.appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`).catch(() => {});
120
+ if (level === 'error') console.error(message);
121
+ else if (level === 'warn' || level === 'warning') console.warn(message);
122
+ else console.log(message);
123
+ }
124
+
125
+ function vlog(message) {
126
+ if (options.verbose) return log(message);
127
+ return fsp.appendFile(logFile, `[${new Date().toISOString()}] [DEBUG] ${message}\n`).catch(() => {});
128
+ }
129
+
130
+ /**
131
+ * Compute the set of absolute top-level tmp entries that the cleanup process
132
+ * itself depends on, so we never delete our own running clone.
133
+ */
134
+ function computeSelfPaths(tempRoot) {
135
+ const selfPaths = new Set();
136
+ const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
137
+ const add = candidate => {
138
+ if (candidate && (candidate === tempRoot || candidate.startsWith(normalizedRoot))) {
139
+ const first = candidate.slice(normalizedRoot.length).split(path.sep)[0];
140
+ if (first) selfPaths.add(path.join(tempRoot, first));
141
+ }
142
+ };
143
+ add(process.cwd());
144
+ add(path.resolve(scriptDir));
145
+ add(path.resolve(process.argv[1] || ''));
146
+ return selfPaths;
147
+ }
148
+
149
+ async function main() {
150
+ await fsp.writeFile(logFile, `# Cleanup Log - ${new Date().toISOString()}\n\n`).catch(() => {});
151
+
152
+ const tempRoot = getTempRoot();
153
+ await log('🧹 hive-mind cleanup');
154
+ await log('====================\n');
155
+ await log(`📂 Temp root: ${tempRoot}`);
156
+ if (options.dryRun) await log('📝 DRY RUN — nothing will be deleted\n');
157
+ else if (options.force) await log('⚠️ FORCE — deleting without confirmation\n');
158
+
159
+ // 1. Enumerate candidate entries.
160
+ const entries = listTempEntries(tempRoot);
161
+ await log(`🔍 Found ${entries.length} entries under ${tempRoot}`);
162
+
163
+ // 2. Gather signals for active-task detection.
164
+ const heldPaths = listProcessHeldPaths(tempRoot);
165
+ await vlog(`Process-held paths: ${[...heldPaths].join(', ') || '(none)'}`);
166
+
167
+ let matchers = [];
168
+ if (options.keepActiveTasks) {
169
+ const activeTasks = await getActiveTasks({ useSessions: options.useSessions, resolveBranches: options.resolveBranches });
170
+ matchers = buildActiveMatchers(activeTasks);
171
+ if (activeTasks.length > 0) {
172
+ await log(`🏃 Active tasks detected: ${activeTasks.length}`);
173
+ for (const t of activeTasks) {
174
+ await log(` • ${t.owner}/${t.repo} ${t.type} #${t.number}${t.branch ? ` (branch ${t.branch})` : ''}`);
175
+ }
176
+ } else {
177
+ await log('🏃 No active tasks detected from running processes/sessions');
178
+ }
179
+ } else {
180
+ await log('⚠️ Active-task detection disabled (--no-keep-active-tasks-folders)');
181
+ }
182
+
183
+ // 3. Read git info for directory entries (used by branch / dirty matching).
184
+ const gitInfoByPath = new Map();
185
+ for (const entry of entries) {
186
+ if (!entry.isDirectory) continue;
187
+ const info = readFolderGitInfo(entry.path);
188
+ if (info) gitInfoByPath.set(entry.path, info);
189
+ }
190
+
191
+ const selfPaths = computeSelfPaths(tempRoot);
192
+ await vlog(`Self paths: ${[...selfPaths].join(', ') || '(none)'}`);
193
+
194
+ // 4. Classify.
195
+ const ctx = {
196
+ protectedNames: DEFAULT_PROTECTED_NAMES,
197
+ forceStartCommand: options.forceStartCommand,
198
+ includeSystem: options.includeSystem,
199
+ includeAll: options.includeAll,
200
+ keepDirty: options.keepDirty,
201
+ selfPaths,
202
+ heldPaths,
203
+ matchers,
204
+ gitInfoByPath,
205
+ };
206
+ const classified = classifyEntries(entries, ctx);
207
+
208
+ // 5. Compute sizes (only for what we report, to keep it reasonably fast).
209
+ for (const item of [...classified.keep, ...classified.remove]) {
210
+ item.size = getPathSize(item.path);
211
+ }
212
+ const totals = summarize(classified);
213
+
214
+ // 6. Report.
215
+ await log('\n🟢 KEPT folders/files:');
216
+ if (classified.keep.length === 0) await log(' (none)');
217
+ for (const item of classified.keep.sort((a, b) => (b.size || 0) - (a.size || 0))) {
218
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
219
+ }
220
+
221
+ await log(`\n🗑️ ${options.dryRun ? 'WOULD DELETE' : 'TO DELETE'} folders/files:`);
222
+ if (classified.remove.length === 0) await log(' (none)');
223
+ for (const item of classified.remove.sort((a, b) => (b.size || 0) - (a.size || 0))) {
224
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}`);
225
+ }
226
+
227
+ await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)})`);
228
+
229
+ // 7. Execute deletion (unless dry-run).
230
+ if (options.dryRun) {
231
+ await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
232
+ } else if (classified.remove.length === 0) {
233
+ await log('\n✅ Nothing to delete.');
234
+ } else {
235
+ if (!options.force) {
236
+ console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}).`);
237
+ console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
238
+ process.stdout.write('> ');
239
+ let answer = '';
240
+ try {
241
+ answer = execSync('read answer && echo $answer', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], shell: '/bin/bash' }).trim();
242
+ } catch {
243
+ await log('\n❌ Cancelled');
244
+ return;
245
+ }
246
+ if (answer.toLowerCase() !== 'yes') {
247
+ await log('\n❌ Cancelled');
248
+ return;
249
+ }
250
+ }
251
+
252
+ await log('\n🗑️ Deleting...');
253
+ let deleted = 0;
254
+ let failed = 0;
255
+ for (const item of classified.remove) {
256
+ const ok = removePath(item.path);
257
+ if (ok) {
258
+ deleted++;
259
+ await vlog(` removed ${item.path}`);
260
+ } else {
261
+ failed++;
262
+ await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
263
+ }
264
+ }
265
+ await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
266
+ }
267
+
268
+ // 8. System / Ubuntu cleanup (opt-in).
269
+ if (options.apt || options.journal || options.docker || options.npm) {
270
+ await log('\n🧴 System cleanup:');
271
+ runSystemCleanup({
272
+ apt: options.apt,
273
+ journal: options.journal,
274
+ docker: options.docker,
275
+ npm: options.npm,
276
+ dryRun: options.dryRun,
277
+ useSudo: options.sudo,
278
+ logFn: msg => log(msg),
279
+ });
280
+ }
281
+
282
+ await log(`\n📁 Log file: ${logFile}`);
283
+ }
284
+
285
+ main().catch(async error => {
286
+ await log(`❌ Error: ${error.message}`, { level: 'error' });
287
+ process.exit(1);
288
+ });
@@ -0,0 +1,404 @@
1
+ /**
2
+ * OS-interaction layer for the `cleanup` command (issue #1848).
3
+ *
4
+ * Everything that touches the real filesystem, the process table (/proc),
5
+ * isolation session state (`$ --status` from start-command), git metadata of a
6
+ * clone, GitHub (`gh`) and system package caches lives here. The pure
7
+ * classification logic lives in `cleanup.lib.mjs` and is unit-tested without any
8
+ * of this.
9
+ *
10
+ * Implemented with `node:` built-ins + `node:child_process` so it does not
11
+ * depend on `use-m` / `command-stream` being reachable, except for the optional
12
+ * `$ --status` session query which reuses isolation-runner.lib.mjs.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/1848
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import { execFileSync } from 'node:child_process';
21
+
22
+ import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
23
+
24
+ /** Run a command, returning trimmed stdout or null on any failure. */
25
+ function tryExec(cmd, args, options = {}) {
26
+ try {
27
+ return execFileSync(cmd, args, {
28
+ encoding: 'utf8',
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ timeout: options.timeout ?? 20000,
31
+ maxBuffer: 64 * 1024 * 1024,
32
+ ...options,
33
+ }).trim();
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** The tmp root cleanup operates on (honours TMPDIR via os.tmpdir()). */
40
+ export function getTempRoot() {
41
+ return os.tmpdir();
42
+ }
43
+
44
+ /**
45
+ * List immediate children of the tmp root as candidate entries.
46
+ *
47
+ * @param {string} tempRoot
48
+ * @returns {Array<{name: string, path: string, isDirectory: boolean}>}
49
+ */
50
+ export function listTempEntries(tempRoot) {
51
+ let dirents;
52
+ try {
53
+ dirents = fs.readdirSync(tempRoot, { withFileTypes: true });
54
+ } catch {
55
+ return [];
56
+ }
57
+ return dirents.map(d => {
58
+ const full = path.join(tempRoot, d.name);
59
+ let isDirectory = d.isDirectory();
60
+ // Resolve symlinks defensively (don't follow into them for deletion though).
61
+ if (d.isSymbolicLink()) {
62
+ try {
63
+ isDirectory = fs.statSync(full).isDirectory();
64
+ } catch {
65
+ isDirectory = false;
66
+ }
67
+ }
68
+ return { name: d.name, path: full, isDirectory };
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Size of a path in bytes. Uses `du -sk` (fast, handles dirs) with a small
74
+ * fs.statSync fallback for plain files.
75
+ *
76
+ * @param {string} targetPath
77
+ * @returns {number|null}
78
+ */
79
+ export function getPathSize(targetPath) {
80
+ const out = tryExec('du', ['-sk', targetPath]);
81
+ if (out) {
82
+ const kb = parseInt(out.split(/\s+/)[0], 10);
83
+ if (!Number.isNaN(kb)) return kb * 1024;
84
+ }
85
+ try {
86
+ return fs.statSync(targetPath).size;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read the git branch, remotes and dirty state of a clone directory.
94
+ *
95
+ * @param {string} dir
96
+ * @returns {{branch: string|null, remotes: Array<{owner, repo, url}>, dirty: boolean}|null}
97
+ */
98
+ export function readFolderGitInfo(dir) {
99
+ // Cheap check: is it a git work tree?
100
+ const isRepo = tryExec('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree']);
101
+ if (isRepo !== 'true') return null;
102
+
103
+ const branch = tryExec('git', ['-C', dir, 'branch', '--show-current']) || null;
104
+
105
+ const remotesRaw = tryExec('git', ['-C', dir, 'remote', '-v']) || '';
106
+ const remotes = [];
107
+ const seen = new Set();
108
+ for (const line of remotesRaw.split('\n')) {
109
+ const m = line.match(/^\S+\s+(\S+)\s+\((?:fetch|push)\)/);
110
+ if (!m) continue;
111
+ const parsed = parseRemoteUrl(m[1]);
112
+ if (parsed) {
113
+ const key = `${parsed.owner}/${parsed.repo}`.toLowerCase();
114
+ if (!seen.has(key)) {
115
+ seen.add(key);
116
+ remotes.push({ ...parsed, url: m[1] });
117
+ }
118
+ }
119
+ }
120
+
121
+ // Dirty if there are uncommitted changes OR commits not present on any remote.
122
+ const status = tryExec('git', ['-C', dir, 'status', '--porcelain']);
123
+ let dirty = Boolean(status && status.length > 0);
124
+ if (!dirty && branch) {
125
+ // Unpushed local commits: branch exists but has no upstream, or is ahead.
126
+ const upstream = tryExec('git', ['-C', dir, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
127
+ if (!upstream) {
128
+ // No upstream tracking: check whether the branch commit exists on a remote.
129
+ const head = tryExec('git', ['-C', dir, 'rev-parse', 'HEAD']);
130
+ const onRemote = head ? tryExec('git', ['-C', dir, 'branch', '-r', '--contains', head]) : null;
131
+ dirty = !onRemote;
132
+ } else {
133
+ const counts = tryExec('git', ['-C', dir, 'rev-list', '--left-right', '--count', `${upstream}...HEAD`]);
134
+ if (counts) {
135
+ const ahead = parseInt(counts.split(/\s+/)[1] || '0', 10);
136
+ dirty = ahead > 0;
137
+ }
138
+ }
139
+ }
140
+
141
+ return { branch, remotes, dirty };
142
+ }
143
+
144
+ /**
145
+ * Scan /proc to find paths under tempRoot that are the cwd of, or an open fd /
146
+ * mapped file of, a running process. Linux-only; returns an empty set elsewhere.
147
+ *
148
+ * @param {string} tempRoot
149
+ * @returns {Set<string>} absolute top-level entry paths under tempRoot
150
+ */
151
+ export function listProcessHeldPaths(tempRoot) {
152
+ const held = new Set();
153
+ let pids;
154
+ try {
155
+ pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
156
+ } catch {
157
+ return held; // Not Linux / no procfs.
158
+ }
159
+
160
+ const normalizedRoot = tempRoot.endsWith(path.sep) ? tempRoot : tempRoot + path.sep;
161
+ const recordIfUnderRoot = target => {
162
+ if (!target) return;
163
+ if (target === tempRoot || target.startsWith(normalizedRoot)) {
164
+ // Reduce to the top-level entry directly under tempRoot.
165
+ const rest = target.slice(normalizedRoot.length);
166
+ const first = rest.split(path.sep)[0];
167
+ if (first) held.add(path.join(tempRoot, first));
168
+ }
169
+ };
170
+
171
+ for (const pid of pids) {
172
+ // cwd of the process (covers git/claude children that chdir into the clone).
173
+ try {
174
+ recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/cwd`));
175
+ } catch {
176
+ /* process gone or permission denied */
177
+ }
178
+ // open file descriptors.
179
+ try {
180
+ for (const fd of fs.readdirSync(`/proc/${pid}/fd`)) {
181
+ try {
182
+ recordIfUnderRoot(fs.readlinkSync(`/proc/${pid}/fd/${fd}`));
183
+ } catch {
184
+ /* fd vanished */
185
+ }
186
+ }
187
+ } catch {
188
+ /* no fd dir / permission */
189
+ }
190
+ }
191
+ return held;
192
+ }
193
+
194
+ /**
195
+ * Collect task references (owner/repo/number/type) from running solve/hive
196
+ * processes by scanning /proc/<pid>/cmdline.
197
+ *
198
+ * @returns {Array<{owner, repo, type, number}>}
199
+ */
200
+ export function listActiveTaskRefsFromProc() {
201
+ const refs = [];
202
+ const seen = new Set();
203
+ let pids;
204
+ try {
205
+ pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
206
+ } catch {
207
+ return refs;
208
+ }
209
+ for (const pid of pids) {
210
+ let cmdline;
211
+ try {
212
+ cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (!cmdline || !/github\.com/.test(cmdline)) continue;
217
+ for (const ref of extractTaskRefsFromCommand(cmdline)) {
218
+ const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
219
+ if (!seen.has(key)) {
220
+ seen.add(key);
221
+ refs.push(ref);
222
+ }
223
+ }
224
+ }
225
+ return refs;
226
+ }
227
+
228
+ /**
229
+ * Discover currently-running isolation session UUIDs from start-command's live
230
+ * session managers (screen / tmux). These names are the session UUIDs.
231
+ *
232
+ * @returns {string[]}
233
+ */
234
+ export function listLiveSessionIds() {
235
+ const ids = new Set();
236
+
237
+ const screenOut = tryExec('screen', ['-ls']);
238
+ if (screenOut) {
239
+ for (const m of screenOut.matchAll(/^\s*\d+\.([0-9a-f-]{8,})\s/gim)) {
240
+ ids.add(m[1]);
241
+ }
242
+ }
243
+
244
+ const tmuxOut = tryExec('tmux', ['ls', '-F', '#{session_name}']);
245
+ if (tmuxOut) {
246
+ for (const line of tmuxOut.split('\n')) {
247
+ const name = line.trim();
248
+ if (/^[0-9a-f-]{8,}$/i.test(name)) ids.add(name);
249
+ }
250
+ }
251
+
252
+ return [...ids];
253
+ }
254
+
255
+ /**
256
+ * Query `$ --status <uuid>` for each live session and extract task references
257
+ * from executing sessions' command lines. Optional; reuses isolation-runner.
258
+ *
259
+ * @param {string[]} sessionIds
260
+ * @returns {Promise<Array<{owner, repo, type, number}>>}
261
+ */
262
+ export async function listActiveTaskRefsFromSessions(sessionIds) {
263
+ if (!sessionIds || sessionIds.length === 0) return [];
264
+ let querySessionStatus;
265
+ let isTerminalSessionStatus;
266
+ try {
267
+ ({ querySessionStatus, isTerminalSessionStatus } = await import('./isolation-runner.lib.mjs'));
268
+ } catch {
269
+ return [];
270
+ }
271
+ const refs = [];
272
+ const seen = new Set();
273
+ for (const id of sessionIds) {
274
+ let status;
275
+ try {
276
+ status = await querySessionStatus(id);
277
+ } catch {
278
+ continue;
279
+ }
280
+ if (!status || !status.exists) continue;
281
+ if (status.status && isTerminalSessionStatus(status.status)) continue;
282
+ if (!status.command) continue;
283
+ for (const ref of extractTaskRefsFromCommand(status.command)) {
284
+ const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
285
+ if (!seen.has(key)) {
286
+ seen.add(key);
287
+ refs.push(ref);
288
+ }
289
+ }
290
+ }
291
+ return refs;
292
+ }
293
+
294
+ /**
295
+ * Resolve the head branch of a PR via `gh pr view`. Returns null on failure
296
+ * (offline, no gh, not found) — callers fall back to issue-prefix matching.
297
+ *
298
+ * @param {{owner, repo, number}} ref
299
+ * @returns {string|null}
300
+ */
301
+ export function resolvePrHeadBranch(ref) {
302
+ const out = tryExec('gh', ['pr', 'view', String(ref.number), '--repo', `${ref.owner}/${ref.repo}`, '--json', 'headRefName', '--jq', '.headRefName']);
303
+ return out || null;
304
+ }
305
+
306
+ /**
307
+ * Build the full active-task list, resolving PR head branches where possible.
308
+ *
309
+ * @param {Object} [options]
310
+ * @param {boolean} [options.useSessions=true] - also query `$ --status`
311
+ * @param {boolean} [options.resolveBranches=true] - resolve PR head branches via gh
312
+ * @returns {Promise<Array<{owner, repo, type, number, branch: string|null}>>}
313
+ */
314
+ export async function getActiveTasks(options = {}) {
315
+ const { useSessions = true, resolveBranches = true } = options;
316
+ const refs = [...listActiveTaskRefsFromProc()];
317
+ const seen = new Set(refs.map(r => `${r.owner}/${r.repo}#${r.number}:${r.type}`));
318
+
319
+ if (useSessions) {
320
+ const sessionRefs = await listActiveTaskRefsFromSessions(listLiveSessionIds());
321
+ for (const ref of sessionRefs) {
322
+ const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
323
+ if (!seen.has(key)) {
324
+ seen.add(key);
325
+ refs.push(ref);
326
+ }
327
+ }
328
+ }
329
+
330
+ return refs.map(ref => {
331
+ let branch = null;
332
+ if (ref.type === 'pull' && resolveBranches) {
333
+ branch = resolvePrHeadBranch(ref);
334
+ }
335
+ return { ...ref, branch };
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Permanently remove a path (recursive, force). Returns true on success.
341
+ *
342
+ * @param {string} targetPath
343
+ * @returns {boolean}
344
+ */
345
+ export function removePath(targetPath) {
346
+ try {
347
+ fs.rmSync(targetPath, { recursive: true, force: true });
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
356
+ * are only described, never executed.
357
+ *
358
+ * @param {Object} options
359
+ * @param {boolean} [options.apt] - apt-get clean / autoclean / autoremove
360
+ * @param {boolean} [options.journal] - journalctl --vacuum-time
361
+ * @param {boolean} [options.docker] - docker system prune
362
+ * @param {boolean} [options.npm] - npm cache clean --force
363
+ * @param {string} [options.journalVacuumTime='2weeks']
364
+ * @param {boolean} [options.dryRun]
365
+ * @param {boolean} [options.useSudo] - prefix package commands with sudo
366
+ * @param {(msg: string) => void} [options.logFn]
367
+ * @returns {Array<{command: string, executed: boolean, ok: boolean|null}>}
368
+ */
369
+ export function runSystemCleanup(options = {}) {
370
+ const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {} } = options;
371
+
372
+ const plan = [];
373
+ const sudo = useSudo ? ['sudo'] : [];
374
+ if (apt) {
375
+ plan.push([...sudo, 'apt-get', 'clean']);
376
+ plan.push([...sudo, 'apt-get', 'autoclean', '-y']);
377
+ plan.push([...sudo, 'apt-get', 'autoremove', '-y']);
378
+ }
379
+ if (journal) {
380
+ plan.push([...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`]);
381
+ }
382
+ if (docker) {
383
+ plan.push(['docker', 'system', 'prune', '-f']);
384
+ }
385
+ if (npm) {
386
+ plan.push(['npm', 'cache', 'clean', '--force']);
387
+ }
388
+
389
+ const results = [];
390
+ for (const argv of plan) {
391
+ const display = argv.join(' ');
392
+ if (dryRun) {
393
+ logFn(` [dry-run] would run: ${display}`);
394
+ results.push({ command: display, executed: false, ok: null });
395
+ continue;
396
+ }
397
+ logFn(` running: ${display}`);
398
+ const out = tryExec(argv[0], argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
399
+ const ok = out !== null;
400
+ logFn(ok ? ` ✓ ${display}` : ` ✗ ${display} (failed or unavailable)`);
401
+ results.push({ command: display, executed: true, ok });
402
+ }
403
+ return results;
404
+ }