@link-assistant/hive-mind 1.74.0 → 1.74.2

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.
@@ -20,6 +20,7 @@ import os from 'node:os';
20
20
  import { execFileSync } from 'node:child_process';
21
21
 
22
22
  import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
23
+ import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
23
24
 
24
25
  /** Run a command, returning trimmed stdout or null on any failure. */
25
26
  function tryExec(cmd, args, options = {}) {
@@ -191,6 +192,346 @@ export function listProcessHeldPaths(tempRoot) {
191
192
  return held;
192
193
  }
193
194
 
195
+ function parseProcStat(raw) {
196
+ if (!raw) return null;
197
+ const open = raw.indexOf('(');
198
+ const close = raw.lastIndexOf(')');
199
+ if (open < 0 || close < open) return null;
200
+ const commandName = raw.slice(open + 1, close);
201
+ const fields = raw
202
+ .slice(close + 2)
203
+ .trim()
204
+ .split(/\s+/);
205
+ return {
206
+ commandName,
207
+ state: fields[0] || null,
208
+ ppid: Number(fields[1]) || 0,
209
+ pgid: Number(fields[2]) || null,
210
+ sid: Number(fields[3]) || null,
211
+ };
212
+ }
213
+
214
+ function readProcText(filePath) {
215
+ try {
216
+ return fs.readFileSync(filePath, 'utf8');
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function readProcLink(filePath) {
223
+ try {
224
+ return fs.readlinkSync(filePath);
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function readProcCmdline(pid, fallbackName) {
231
+ const raw = readProcText(`/proc/${pid}/cmdline`);
232
+ const cmdline = raw ? raw.replace(/\0/g, ' ').trim() : '';
233
+ return cmdline || fallbackName || '';
234
+ }
235
+
236
+ function readProcScreenSessionName(pid) {
237
+ const raw = readProcText(`/proc/${pid}/environ`);
238
+ if (!raw) return null;
239
+ const sty = raw
240
+ .split('\0')
241
+ .find(line => line.startsWith('STY='))
242
+ ?.slice(4)
243
+ ?.trim();
244
+ if (!sty) return null;
245
+ return sty.replace(/^\d+\./, '') || sty;
246
+ }
247
+
248
+ /**
249
+ * Snapshot Linux process records used by process diagnostics and orphan
250
+ * cleanup. Returns an empty list when procfs is unavailable.
251
+ *
252
+ * @returns {Array<{pid: number, ppid: number, pgid: number|null, sid: number|null, state: string|null, commandName: string|null, cmdline: string, cwd: string|null, exe: string|null, screenSessionName: string|null}>}
253
+ */
254
+ export function listProcessRecords() {
255
+ let pids;
256
+ try {
257
+ pids = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name));
258
+ } catch {
259
+ return [];
260
+ }
261
+
262
+ const records = [];
263
+ for (const pidText of pids) {
264
+ const pid = Number(pidText);
265
+ const stat = parseProcStat(readProcText(`/proc/${pid}/stat`));
266
+ if (!stat) continue;
267
+ records.push({
268
+ pid,
269
+ ppid: stat.ppid,
270
+ pgid: stat.pgid,
271
+ sid: stat.sid,
272
+ state: stat.state,
273
+ commandName: stat.commandName,
274
+ cmdline: readProcCmdline(pid, stat.commandName),
275
+ cwd: readProcLink(`/proc/${pid}/cwd`),
276
+ exe: readProcLink(`/proc/${pid}/exe`),
277
+ screenSessionName: readProcScreenSessionName(pid),
278
+ });
279
+ }
280
+ return records;
281
+ }
282
+
283
+ /**
284
+ * Discover GNU screen sessions and their backing screen PIDs.
285
+ *
286
+ * @returns {Array<{screenPid: number, sessionName: string, displayName: string, attached: boolean, live: boolean}>}
287
+ */
288
+ export function listScreenSessions() {
289
+ const out = tryExec('screen', ['-ls']);
290
+ if (!out) return [];
291
+ const sessions = [];
292
+ for (const line of out.split('\n')) {
293
+ const match = line.match(/^\s*(\d+)\.([^\s]+)\s+\((Attached|Detached)\)/i);
294
+ if (!match) continue;
295
+ sessions.push({
296
+ screenPid: Number(match[1]),
297
+ sessionName: match[2],
298
+ displayName: `${match[1]}.${match[2]}`,
299
+ attached: match[3].toLowerCase() === 'attached',
300
+ live: true,
301
+ });
302
+ }
303
+ return sessions;
304
+ }
305
+
306
+ function listStartCommandLogFiles(logRoot, maxFiles) {
307
+ const files = [];
308
+ const stack = [logRoot];
309
+ while (stack.length > 0) {
310
+ const current = stack.pop();
311
+ let stat;
312
+ try {
313
+ stat = fs.statSync(current);
314
+ } catch {
315
+ continue;
316
+ }
317
+ if (stat.isDirectory()) {
318
+ let entries;
319
+ try {
320
+ entries = fs.readdirSync(current);
321
+ } catch {
322
+ continue;
323
+ }
324
+ for (const entry of entries) stack.push(path.join(current, entry));
325
+ } else if (stat.isFile() && current.endsWith('.log')) {
326
+ files.push({ path: current, mtimeMs: stat.mtimeMs });
327
+ }
328
+ }
329
+ return files
330
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
331
+ .slice(0, maxFiles)
332
+ .map(file => file.path);
333
+ }
334
+
335
+ function readFilePrefix(filePath, maxBytes) {
336
+ let fd;
337
+ try {
338
+ fd = fs.openSync(filePath, 'r');
339
+ const buffer = Buffer.alloc(maxBytes);
340
+ const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
341
+ return buffer.subarray(0, bytesRead).toString('utf8');
342
+ } catch {
343
+ return '';
344
+ } finally {
345
+ if (fd !== undefined) {
346
+ try {
347
+ fs.closeSync(fd);
348
+ } catch {
349
+ /* ignore */
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ function mergeSession(map, session) {
356
+ if (!session) return;
357
+ const key = session.sessionId || session.uuid || session.sessionName || session.screenSessionName || session.logPath;
358
+ if (!key) return;
359
+ const existing = map.get(key) || {};
360
+ const mergedProcessIds = { ...(existing.processIds || {}), ...(session.processIds || {}) };
361
+ map.set(key, {
362
+ ...existing,
363
+ ...session,
364
+ processIds: mergedProcessIds,
365
+ sessionId: session.sessionId || existing.sessionId || session.uuid || existing.uuid || null,
366
+ uuid: session.uuid || existing.uuid || session.sessionId || existing.sessionId || null,
367
+ sessionName: session.sessionName || existing.sessionName || session.screenSessionName || existing.screenSessionName || null,
368
+ screenSessionName: session.screenSessionName || existing.screenSessionName || session.sessionName || existing.sessionName || null,
369
+ live: session.live === true || existing.live === true,
370
+ command: session.command || existing.command || null,
371
+ taskUrl: session.taskUrl || existing.taskUrl || null,
372
+ workspace: session.workspace || existing.workspace || session.workingDirectory || existing.workingDirectory || null,
373
+ logPath: session.logPath || existing.logPath || null,
374
+ tool: session.tool || existing.tool || null,
375
+ status: session.status || existing.status || null,
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Collect start-command session/task metadata from logs, live screen sessions,
381
+ * and optional `$ --status` lookups.
382
+ *
383
+ * @param {Object} [options]
384
+ * @param {string} [options.logRoot='/tmp/start-command/logs']
385
+ * @param {number} [options.maxLogFiles=500]
386
+ * @param {number} [options.maxLogBytes=262144]
387
+ * @param {number} [options.maxStatusQueries=200]
388
+ * @param {boolean} [options.useSessions=true]
389
+ * @returns {Promise<Array>}
390
+ */
391
+ export async function collectProcessDebugSessions(options = {}) {
392
+ const { logRoot = '/tmp/start-command/logs', maxLogFiles = 500, maxLogBytes = 256 * 1024, maxStatusQueries = 200, useSessions = true } = options;
393
+
394
+ const sessions = new Map();
395
+
396
+ for (const logPath of listStartCommandLogFiles(logRoot, maxLogFiles)) {
397
+ const metadata = parseStartCommandLogMetadata({
398
+ logPath,
399
+ text: readFilePrefix(logPath, maxLogBytes),
400
+ });
401
+ mergeSession(sessions, metadata);
402
+ }
403
+
404
+ for (const screenSession of listScreenSessions()) {
405
+ mergeSession(sessions, {
406
+ sessionId: screenSession.sessionName,
407
+ sessionName: screenSession.sessionName,
408
+ screenSessionName: screenSession.sessionName,
409
+ processIds: { screenPid: screenSession.screenPid },
410
+ live: true,
411
+ });
412
+ }
413
+
414
+ if (!useSessions || sessions.size === 0) return [...sessions.values()];
415
+
416
+ let querySessionStatus;
417
+ try {
418
+ ({ querySessionStatus } = await import('./isolation-runner.lib.mjs'));
419
+ } catch {
420
+ return [...sessions.values()];
421
+ }
422
+
423
+ const queryCandidates = [...sessions.values()].sort((a, b) => (b.live === true) - (a.live === true)).slice(0, maxStatusQueries);
424
+
425
+ for (const session of queryCandidates) {
426
+ const id = session.sessionId || session.uuid || session.sessionName;
427
+ if (!id) continue;
428
+ let status;
429
+ try {
430
+ status = await querySessionStatus(id);
431
+ } catch {
432
+ continue;
433
+ }
434
+ if (!status?.exists) continue;
435
+ mergeSession(sessions, {
436
+ sessionId: status.uuid || id,
437
+ uuid: status.uuid || id,
438
+ status: status.status || session.status || null,
439
+ command: status.command ? redactProcessText(status.command) : session.command || null,
440
+ taskUrl: status.command ? extractTaskRefsFromCommand(status.command).map(ref => `https://github.com/${ref.owner}/${ref.repo}/${ref.type === 'pull' ? 'pull' : 'issues'}/${ref.number}`)[0] : session.taskUrl || null,
441
+ workspace: status.workingDirectory || session.workspace || null,
442
+ workingDirectory: status.workingDirectory || null,
443
+ logPath: status.logPath || session.logPath || null,
444
+ sessionName: status.sessionName || session.sessionName || id,
445
+ screenSessionName: status.sessionName || session.screenSessionName || session.sessionName || id,
446
+ processIds: status.processIds || {},
447
+ live: session.live === true || status.status === 'executing' || status.status === 'running',
448
+ });
449
+ }
450
+
451
+ return [...sessions.values()];
452
+ }
453
+
454
+ /**
455
+ * Build a redacted process debug report from the real OS state.
456
+ *
457
+ * @param {Object} [options]
458
+ * @returns {Promise<{items: Array, orphans: Array, sessions: Array}>}
459
+ */
460
+ export async function collectProcessDebugReport(options = {}) {
461
+ const processes = listProcessRecords();
462
+ const sessions = await collectProcessDebugSessions(options);
463
+ const report = correlateProcesses({ processes, sessions, currentPid: process.pid, targetPids: options.targetPids || [] });
464
+ return {
465
+ ...report,
466
+ processCount: processes.length,
467
+ sessionCount: sessions.length,
468
+ };
469
+ }
470
+
471
+ function buildChildrenMap(processes) {
472
+ const children = new Map();
473
+ for (const record of processes || []) {
474
+ if (!record?.pid || !record?.ppid) continue;
475
+ if (!children.has(record.ppid)) children.set(record.ppid, []);
476
+ children.get(record.ppid).push(record.pid);
477
+ }
478
+ return children;
479
+ }
480
+
481
+ function collectProcessTree(rootPid, children) {
482
+ const seen = new Set();
483
+ const ordered = [];
484
+ const visit = pid => {
485
+ if (!pid || seen.has(pid)) return;
486
+ seen.add(pid);
487
+ for (const child of children.get(pid) || []) visit(child);
488
+ ordered.push(pid);
489
+ };
490
+ visit(rootPid);
491
+ return ordered;
492
+ }
493
+
494
+ /**
495
+ * Send a signal to a process tree, children first.
496
+ *
497
+ * @param {number} rootPid
498
+ * @param {Array} processes
499
+ * @param {{signal?: string, currentPid?: number}} [options]
500
+ * @returns {Array<{pid: number, signal: string, ok: boolean, error?: string}>}
501
+ */
502
+ export function signalProcessTree(rootPid, processes, options = {}) {
503
+ const signal = options.signal || 'SIGTERM';
504
+ const currentPid = options.currentPid || process.pid;
505
+ const children = buildChildrenMap(processes);
506
+ const targets = collectProcessTree(Number(rootPid), children).filter(pid => pid !== currentPid && pid > 1);
507
+ const results = [];
508
+
509
+ for (const pid of targets) {
510
+ try {
511
+ process.kill(pid, signal);
512
+ results.push({ pid, signal, ok: true });
513
+ } catch (error) {
514
+ results.push({ pid, signal, ok: false, error: error.message });
515
+ }
516
+ }
517
+ return results;
518
+ }
519
+
520
+ /**
521
+ * Signal every orphaned agent tree from a previously collected report.
522
+ *
523
+ * @param {{orphans?: Array}} report
524
+ * @param {Object} [options]
525
+ * @returns {Array<{rootPid: number, results: Array}>}
526
+ */
527
+ export function signalOrphanedAgentTrees(report, options = {}) {
528
+ const processes = listProcessRecords();
529
+ return (report.orphans || []).map(orphan => ({
530
+ rootPid: orphan.pid,
531
+ results: signalProcessTree(orphan.pid, processes, options),
532
+ }));
533
+ }
534
+
194
535
  /**
195
536
  * Collect task references (owner/repo/number/type) from running solve/hive
196
537
  * processes by scanning /proc/<pid>/cmdline.
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 });
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive Mode Image Rendering (Issue #1843)
4
+ *
5
+ * Bridges the raw image uploader (interactive-image-upload.lib.mjs) and the
6
+ * Markdown formatter (interactive-mode.shared.lib.mjs) so interactive-mode.lib.mjs
7
+ * can turn the base64 images Claude/Codex read or wrote into an inline
8
+ * `### 🖼️ Images` section with a single call. Kept in its own module so the main
9
+ * interactive-mode handler stays under the 1500-line file limit (issue #1730).
10
+ *
11
+ * @module interactive-image-render.lib.mjs
12
+ * @experimental
13
+ */
14
+
15
+ import { formatImageEmbeds } from './interactive-mode.shared.lib.mjs';
16
+ import { createImageUploader, extractImagePayload, isImageNode } from './interactive-image-upload.lib.mjs';
17
+
18
+ // Re-exported so the main handler can import its image helpers from one place.
19
+ export { extractImagePayload, isImageNode };
20
+
21
+ /**
22
+ * Collect normalized image payloads from tool-result-like objects, de-duplicated
23
+ * by base64 content. Scans arrays directly, an array `content` (Claude/MCP), and
24
+ * the node itself (Claude Read `tool_use_result`). Enriches a missing
25
+ * `originalSize` from a later sibling payload with the same bytes.
26
+ *
27
+ * @param {...*} sources - candidate nodes/containers to scan
28
+ * @returns {Array<{ base64: string, mediaType?: string, originalSize?: number }>}
29
+ */
30
+ export const collectImagePayloads = (...sources) => {
31
+ const payloads = [];
32
+ const seen = new Set();
33
+ const add = node => {
34
+ const payload = extractImagePayload(node);
35
+ if (!payload) return;
36
+ const key = payload.base64.slice(0, 64) + ':' + payload.base64.length;
37
+ if (seen.has(key)) {
38
+ // Enrich an already-collected payload with size metadata if we now have it.
39
+ if (payload.originalSize) {
40
+ const existing = payloads.find(p => p.base64.slice(0, 64) + ':' + p.base64.length === key);
41
+ if (existing && !existing.originalSize) existing.originalSize = payload.originalSize;
42
+ }
43
+ return;
44
+ }
45
+ seen.add(key);
46
+ payloads.push(payload);
47
+ };
48
+ const scan = source => {
49
+ if (!source) return;
50
+ if (Array.isArray(source)) {
51
+ source.forEach(add);
52
+ } else if (typeof source === 'object') {
53
+ if (Array.isArray(source.content)) source.content.forEach(add);
54
+ add(source);
55
+ }
56
+ };
57
+ sources.forEach(scan);
58
+ return payloads;
59
+ };
60
+
61
+ /**
62
+ * Create an image renderer bound to a PR. Wraps an image uploader (built here, or
63
+ * injected via `options.uploader` for tests) and produces the Markdown image
64
+ * section for a set of tool-result sources. Upload failures / disabled uploads
65
+ * degrade to a metadata note inside `formatImageEmbeds` rather than dumping base64.
66
+ *
67
+ * @param {Object} [options]
68
+ * @param {Object} [options.uploader] - injected uploader (tests); otherwise built from the remaining options
69
+ * @param {Object} [options.state] - handler state, used to label images by tool name
70
+ * @param {Function} [options.log] - async logging function
71
+ * @param {boolean} [options.verbose=false]
72
+ * @param {string} [options.owner]
73
+ * @param {string} [options.repo]
74
+ * @param {number|string} [options.prNumber]
75
+ * @param {string} [options.mediaRef]
76
+ * @param {string} [options.refNamespace]
77
+ * @param {Function} [options.execFile]
78
+ * @param {boolean} [options.enabled=true]
79
+ * @returns {{ uploader: Object, collect: Function, render: Function, toolLabel: Function, section: Function }}
80
+ */
81
+ export const createImageRenderer = (options = {}) => {
82
+ const { uploader: injectedUploader, state, log = async () => {}, verbose = false, owner, repo, prNumber, mediaRef, refNamespace, execFile, enabled = true } = options;
83
+
84
+ const uploader = injectedUploader !== undefined ? injectedUploader : createImageUploader({ owner, repo, prNumber, mediaRef, refNamespace, log, verbose, execFile, enabled });
85
+
86
+ /**
87
+ * Upload normalized payloads and render the `### 🖼️ Images` Markdown section.
88
+ * Always returns a string (empty when there are no images).
89
+ * @param {Array<{ base64: string, mediaType?: string, originalSize?: number }>} payloads
90
+ * @param {string} [label] - human label prefix for captions / commit messages
91
+ * @returns {Promise<string>}
92
+ */
93
+ const render = async (payloads, label = 'image') => {
94
+ if (!Array.isArray(payloads) || payloads.length === 0) return '';
95
+ const rendered = [];
96
+ for (let i = 0; i < payloads.length; i++) {
97
+ const payload = payloads[i];
98
+ const name = payloads.length > 1 ? `${label} ${i + 1}` : label;
99
+ let url = null;
100
+ try {
101
+ if (uploader && typeof uploader.uploadImage === 'function') {
102
+ url = await uploader.uploadImage({ base64: payload.base64, mediaType: payload.mediaType, name });
103
+ }
104
+ } catch (err) {
105
+ if (verbose) await log(`⚠️ Interactive mode: image upload threw: ${err.message}`, { verbose: true });
106
+ url = null;
107
+ }
108
+ rendered.push({ url, mediaType: payload.mediaType, originalSize: payload.originalSize, name });
109
+ }
110
+ return formatImageEmbeds(rendered);
111
+ };
112
+
113
+ /**
114
+ * A human label for image captions, derived from the tool name in the
115
+ * pending-call / registry maps (e.g. "Read image"). Falls back to "image".
116
+ * @param {string} toolUseId
117
+ * @returns {string}
118
+ */
119
+ const toolLabel = toolUseId => {
120
+ const name = state?.pendingToolCalls?.get(toolUseId)?.toolName || state?.toolUseRegistry?.get(toolUseId)?.toolName;
121
+ return name ? `${name} image` : 'image';
122
+ };
123
+
124
+ /**
125
+ * Convenience: collect images from `sources` and render them in one call.
126
+ * @param {Array<*>} sources - array of candidate nodes/containers
127
+ * @param {string} [label]
128
+ * @returns {Promise<string>}
129
+ */
130
+ const section = async (sources, label) => render(collectImagePayloads(...(Array.isArray(sources) ? sources : [sources])), label);
131
+
132
+ return { uploader, collect: collectImagePayloads, render, toolLabel, section };
133
+ };
134
+
135
+ export default {
136
+ collectImagePayloads,
137
+ createImageRenderer,
138
+ extractImagePayload,
139
+ isImageNode,
140
+ };