@link-assistant/hive-mind 1.73.9 → 1.74.1

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.
@@ -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
+ }
package/src/codex.lib.mjs CHANGED
@@ -805,6 +805,8 @@ export const executeCodexCommand = async params => {
805
805
  // comment-posting path can honor them. All default to false.
806
806
  skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
807
807
  skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
808
+ // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
809
+ imageUploadEnabled: argv['interactive-image-upload'] !== false,
808
810
  });
809
811
  } else if (argv.interactiveMode) {
810
812
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });