@link-assistant/hive-mind 1.74.1 → 1.74.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 741752e: Bump the Docker-in-Docker base image to `konard/box-dind:2.1.4` so `docker exec` sessions default to the `box` user with `/home/box` while dockerd still starts correctly.
8
+
9
+ ## 1.74.2
10
+
11
+ ### Patch Changes
12
+
13
+ - d726744: Add cleanup process diagnostics for mapping agent PIDs to task sessions and stopping orphaned terminal-session agents.
14
+
3
15
  ## 1.74.1
4
16
 
5
17
  ### Patch Changes
package/README.hi.md CHANGED
@@ -746,12 +746,25 @@ cleanup --force-start-command
746
746
  # Ubuntu / सिस्टम क्लीनअप (apt कैश, journald लॉग, npm कैश)
747
747
  cleanup --system --sudo
748
748
 
749
+ # लाइव/अटके हुए agent PID को hive/start-command कार्य सत्रों से मिलाएँ
750
+ cleanup --processes
751
+
752
+ # किसी खास non-agent PID को ट्रेस करें, जैसे browser child या shell
753
+ cleanup --pid 94445
754
+
755
+ # रोके जा सकने वाले orphaned agents का पूर्वावलोकन करें
756
+ cleanup --kill-orphaned-agents --dry-run
757
+
758
+ # पूर्वावलोकन जाँचने के बाद orphaned agent process trees रोकें
759
+ cleanup --kill-orphaned-agents --force
760
+
749
761
  # सक्रिय-कार्य पहचान अक्षम करें (केवल सुरक्षित पथ रखे जाते हैं)
750
762
  cleanup --no-keep-active-tasks-folders --dry-run
751
763
  ```
752
764
 
753
765
  विकल्पों की पूरी सूची के लिए `cleanup --help` चलाएँ। यह कमांड dry-run के अनुकूल है और
754
- हर रन के लिए टाइमस्टैम्प वाला `cleanup-*.log` लिखता है।
766
+ हर रन के लिए टाइमस्टैम्प वाला `cleanup-*.log` लिखता है। प्रक्रिया डायग्नोस्टिक आउटपुट
767
+ कमांड लाइन प्रिंट करने से पहले सामान्य token आकारों को छिपाता है।
755
768
 
756
769
  ## 🔍 निगरानी और लॉगिंग
757
770
 
@@ -802,7 +815,22 @@ find docs/ -name "*.md" -exec wc -l {} + | awk '$1 > 1000 {print "ERROR: " $2 "
802
815
 
803
816
  ## सर्वर डायग्नोस्टिक्स
804
817
 
805
- उन स्क्रीन की पहचान करें जो संसाधन खपत करने वाली प्रक्रियाओं के पैरेंट हैं
818
+ किसी व्यस्त `claude`, `codex`, `gemini`, `qwen`, या `opencode` PID को उसे शुरू करने
819
+ वाले hive कार्य से जोड़ने के लिए पहले built-in process diagnostic command उपयोग करें:
820
+
821
+ ```bash
822
+ # agent PID, start-command session ID, GitHub task URL, workspace, match reasons और संभावित orphaned agents दिखाएँ।
823
+ cleanup --processes
824
+
825
+ # उसी report में कोई भी PID शामिल करें।
826
+ cleanup --pid 62220
827
+
828
+ # केवल terminal task वाले orphaned agents रोकें।
829
+ cleanup --kill-orphaned-agents --dry-run
830
+ cleanup --kill-orphaned-agents --force
831
+ ```
832
+
833
+ Manual fallback: उन स्क्रीन की पहचान करें जो संसाधन खपत करने वाली प्रक्रियाओं के पैरेंट हैं।
806
834
 
807
835
  ```bash
808
836
  TARGETS="62220 65988 63094 66606 1028071 4127023"
package/README.md CHANGED
@@ -767,12 +767,25 @@ cleanup --force-start-command
767
767
  # Ubuntu / system cleanup (apt caches, journald logs, npm cache)
768
768
  cleanup --system --sudo
769
769
 
770
+ # Map live/stuck agent PIDs back to hive/start-command task sessions
771
+ cleanup --processes
772
+
773
+ # Trace a specific non-agent PID, for example a browser child or shell
774
+ cleanup --pid 94445
775
+
776
+ # Preview orphaned terminal-session agents that can be stopped
777
+ cleanup --kill-orphaned-agents --dry-run
778
+
779
+ # Stop orphaned agent process trees after reviewing the preview
780
+ cleanup --kill-orphaned-agents --force
781
+
770
782
  # Disable active-task detection (only protected paths are kept)
771
783
  cleanup --no-keep-active-tasks-folders --dry-run
772
784
  ```
773
785
 
774
786
  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.
787
+ friendly and writes a timestamped `cleanup-*.log` for every run. Process
788
+ diagnostic output redacts common token shapes before printing command lines.
776
789
 
777
790
  ## 🔍 Monitoring & Logging
778
791
 
@@ -823,7 +836,25 @@ find docs/ -name "*.md" -exec wc -l {} + | awk '$1 > 1000 {print "ERROR: " $2 "
823
836
 
824
837
  ## Server diagnostics
825
838
 
826
- Identify screens that are parents of processes that eating the resources
839
+ Prefer the built-in process diagnostic command when connecting a busy
840
+ `claude`, `codex`, `gemini`, `qwen`, or `opencode` PID back to the hive task
841
+ that launched it:
842
+
843
+ ```bash
844
+ # Show agent PIDs, start-command session IDs, GitHub task URLs, workspaces,
845
+ # match reasons, and possible orphaned terminal-session agents.
846
+ cleanup --processes
847
+
848
+ # Include an arbitrary PID in the same report.
849
+ cleanup --pid 62220
850
+
851
+ # Kill only agents whose matched start-command task is already terminal.
852
+ cleanup --kill-orphaned-agents --dry-run
853
+ cleanup --kill-orphaned-agents --force
854
+ ```
855
+
856
+ Manual fallback: identify screens that are parents of processes that are eating
857
+ the resources.
827
858
 
828
859
  ```bash
829
860
  TARGETS="62220 65988 63094 66606 1028071 4127023"
package/README.ru.md CHANGED
@@ -749,12 +749,26 @@ cleanup --force-start-command
749
749
  # Очистка Ubuntu / системы (кэши apt, логи journald, кэш npm)
750
750
  cleanup --system --sudo
751
751
 
752
+ # Сопоставить живые/зависшие agent PID с задачами hive/start-command
753
+ cleanup --processes
754
+
755
+ # Отследить конкретный не-agent PID, например дочерний процесс браузера или shell
756
+ cleanup --pid 94445
757
+
758
+ # Предпросмотр orphaned agents, которые можно остановить
759
+ cleanup --kill-orphaned-agents --dry-run
760
+
761
+ # Остановить деревья orphaned agent процессов после просмотра предпросмотра
762
+ cleanup --kill-orphaned-agents --force
763
+
752
764
  # Отключить обнаружение активных задач (сохраняются только защищённые пути)
753
765
  cleanup --no-keep-active-tasks-folders --dry-run
754
766
  ```
755
767
 
756
768
  Запустите `cleanup --help`, чтобы увидеть полный список опций. Команда удобна для
757
769
  режима dry-run и записывает лог `cleanup-*.log` с меткой времени при каждом запуске.
770
+ Диагностический вывод процессов скрывает распространённые формы token перед печатью
771
+ командных строк.
758
772
 
759
773
  ## 🔍 Мониторинг и логирование
760
774
 
@@ -805,7 +819,24 @@ find docs/ -name "*.md" -exec wc -l {} + | awk '$1 > 1000 {print "ERROR: " $2 "
805
819
 
806
820
  ## Диагностика сервера
807
821
 
808
- Определите screen-сессии, являющиеся родительскими для процессов, потребляющих ресурсы
822
+ Чтобы связать загруженный `claude`, `codex`, `gemini`, `qwen` или `opencode` PID с
823
+ запустившей его задачей hive, сначала используйте встроенную диагностику процессов:
824
+
825
+ ```bash
826
+ # Показать agent PID, start-command session ID, GitHub task URL, workspace,
827
+ # причины совпадения и возможные orphaned agents.
828
+ cleanup --processes
829
+
830
+ # Включить произвольный PID в тот же отчёт.
831
+ cleanup --pid 62220
832
+
833
+ # Остановить только orphaned agents, чья задача уже завершена.
834
+ cleanup --kill-orphaned-agents --dry-run
835
+ cleanup --kill-orphaned-agents --force
836
+ ```
837
+
838
+ Ручной fallback: определите screen-сессии, являющиеся родительскими для процессов,
839
+ потребляющих ресурсы.
809
840
 
810
841
  ```bash
811
842
  TARGETS="62220 65988 63094 66606 1028071 4127023"
package/README.zh.md CHANGED
@@ -741,12 +741,25 @@ cleanup --force-start-command
741
741
  # Ubuntu / 系统清理(apt 缓存、journald 日志、npm 缓存)
742
742
  cleanup --system --sudo
743
743
 
744
+ # 将实时/卡住的 agent PID 映射回 hive/start-command 任务会话
745
+ cleanup --processes
746
+
747
+ # 跟踪指定的非 agent PID,例如浏览器子进程或 shell
748
+ cleanup --pid 94445
749
+
750
+ # 预览可以停止的孤立 agent
751
+ cleanup --kill-orphaned-agents --dry-run
752
+
753
+ # 审查预览后停止孤立 agent 进程树
754
+ cleanup --kill-orphaned-agents --force
755
+
744
756
  # 禁用活动任务检测(仅保留受保护的路径)
745
757
  cleanup --no-keep-active-tasks-folders --dry-run
746
758
  ```
747
759
 
748
760
  运行 `cleanup --help` 查看完整的选项列表。该命令对 dry-run 友好,并为每次运行写入
749
- 带时间戳的 `cleanup-*.log` 日志。
761
+ 带时间戳的 `cleanup-*.log` 日志。进程诊断输出会在打印命令行前遮蔽常见 token
762
+ 格式。
750
763
 
751
764
  ## 🔍 监控与日志
752
765
 
@@ -797,7 +810,22 @@ find docs/ -name "*.md" -exec wc -l {} + | awk '$1 > 1000 {print "ERROR: " $2 "
797
810
 
798
811
  ## 服务器诊断
799
812
 
800
- 识别消耗资源的进程所属的 Screen 会话:
813
+ 将繁忙的 `claude`、`codex`、`gemini`、`qwen` 或 `opencode` PID 关联回启动它的
814
+ hive 任务时,优先使用内置进程诊断命令:
815
+
816
+ ```bash
817
+ # 显示 agent PID、start-command 会话 ID、GitHub 任务 URL、工作区、匹配原因和可能的孤立 agent。
818
+ cleanup --processes
819
+
820
+ # 在同一报告中包含任意 PID。
821
+ cleanup --pid 62220
822
+
823
+ # 只停止已完成任务中的孤立 agent。
824
+ cleanup --kill-orphaned-agents --dry-run
825
+ cleanup --kill-orphaned-agents --force
826
+ ```
827
+
828
+ 手动兜底:识别消耗资源的进程所属的 Screen 会话。
801
829
 
802
830
  ```bash
803
831
  TARGETS="62220 65988 63094 66606 1028071 4127023"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.1",
3
+ "version": "1.74.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/cleanup.mjs CHANGED
@@ -21,6 +21,8 @@
21
21
  * --force-start-command allow deleting /tmp/start-command
22
22
  * --include-system also consider system-owned temp entries
23
23
  * --no-keep-dirty allow deleting clones with unpushed changes
24
+ * --processes map claude/codex/etc. PIDs to task sessions
25
+ * --kill-orphaned-agents signal orphaned terminal-session agents
24
26
  * --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
25
27
  * --system shorthand for --apt --journal --npm
26
28
  * --sudo prefix package-manager commands with sudo
@@ -34,7 +36,8 @@ import { promises as fsp } from 'node:fs';
34
36
  import { execSync } from 'node:child_process';
35
37
 
36
38
  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';
39
+ import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees } from './cleanup.os.lib.mjs';
40
+ import { formatProcessDebugReport } from './process-debug.lib.mjs';
38
41
 
39
42
  const args = process.argv.slice(2);
40
43
 
@@ -42,6 +45,32 @@ function hasFlag(...names) {
42
45
  return names.some(n => args.includes(n));
43
46
  }
44
47
 
48
+ function getFlagValue(name) {
49
+ const exact = args.indexOf(name);
50
+ if (exact >= 0 && args[exact + 1] && !args[exact + 1].startsWith('-')) return args[exact + 1];
51
+ const prefix = `${name}=`;
52
+ const withEquals = args.find(arg => arg.startsWith(prefix));
53
+ return withEquals ? withEquals.slice(prefix.length) : null;
54
+ }
55
+
56
+ function getFlagValues(name) {
57
+ const values = [];
58
+ const prefix = `${name}=`;
59
+ for (let i = 0; i < args.length; i++) {
60
+ const arg = args[i];
61
+ if (arg === name && args[i + 1] && !args[i + 1].startsWith('-')) values.push(args[i + 1]);
62
+ else if (arg.startsWith(prefix)) values.push(arg.slice(prefix.length));
63
+ }
64
+ return values;
65
+ }
66
+
67
+ function parsePidList(values) {
68
+ return values
69
+ .flatMap(value => String(value || '').split(','))
70
+ .map(value => Number(value.trim()))
71
+ .filter(value => Number.isInteger(value) && value > 0);
72
+ }
73
+
45
74
  // ---------------------------------------------------------------------------
46
75
  // Early --version / --help handling (no heavy imports).
47
76
  // ---------------------------------------------------------------------------
@@ -75,6 +104,16 @@ Options:
75
104
  --no-sessions Do not query '$ --status' for active sessions
76
105
  --no-resolve-branches Do not resolve PR head branches via gh
77
106
 
107
+ Process diagnostics:
108
+ --processes, --debug-processes
109
+ Map claude/codex/gemini/qwen/opencode PIDs to
110
+ hive-mind task sessions and workspaces
111
+ --pid <pid[,pid...]> Include specific non-agent PIDs in the report
112
+ --kill-orphaned-agents Signal orphaned agent processes whose task
113
+ session has already reached a terminal status
114
+ (dry-run unless --force is also set)
115
+ --signal <name> Signal for --kill-orphaned-agents [SIGTERM]
116
+
78
117
  System / Ubuntu cleanup (opt-in):
79
118
  --apt apt-get clean / autoclean / autoremove
80
119
  --journal journalctl --vacuum-time=2weeks
@@ -103,6 +142,10 @@ const options = {
103
142
  keepDirty: !hasFlag('--no-keep-dirty'),
104
143
  useSessions: !hasFlag('--no-sessions'),
105
144
  resolveBranches: !hasFlag('--no-resolve-branches'),
145
+ debugProcesses: hasFlag('--processes', '--debug-processes'),
146
+ killOrphanedAgents: hasFlag('--kill-orphaned-agents'),
147
+ targetPids: parsePidList(getFlagValues('--pid')),
148
+ signal: getFlagValue('--signal') || 'SIGTERM',
106
149
  verbose: hasFlag('--verbose', '-v'),
107
150
  apt: hasFlag('--apt', '--system'),
108
151
  journal: hasFlag('--journal', '--system'),
@@ -110,6 +153,7 @@ const options = {
110
153
  npm: hasFlag('--npm', '--system'),
111
154
  sudo: hasFlag('--sudo'),
112
155
  };
156
+ if (options.targetPids.length > 0) options.debugProcesses = true;
113
157
 
114
158
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
115
159
  const scriptDir = path.dirname(process.argv[1]);
@@ -156,6 +200,36 @@ async function main() {
156
200
  if (options.dryRun) await log('📝 DRY RUN — nothing will be deleted\n');
157
201
  else if (options.force) await log('⚠️ FORCE — deleting without confirmation\n');
158
202
 
203
+ if (options.debugProcesses || options.killOrphanedAgents) {
204
+ const report = await collectProcessDebugReport({ useSessions: options.useSessions, targetPids: options.targetPids });
205
+ await log('');
206
+ await log(formatProcessDebugReport(report));
207
+
208
+ if (options.killOrphanedAgents) {
209
+ if (report.orphans.length === 0) {
210
+ await log('\n✅ No orphaned terminal-session agent processes found.');
211
+ } else if (options.dryRun || !options.force) {
212
+ await log(`\n📝 Dry run: would send ${options.signal} to orphaned agent roots: ${report.orphans.map(item => item.pid).join(', ')}`);
213
+ await log('Re-run with --force to signal these process trees.');
214
+ } else {
215
+ await log(`\n🧯 Sending ${options.signal} to orphaned agent process trees...`);
216
+ const killed = signalOrphanedAgentTrees(report, { signal: options.signal, currentPid: process.pid });
217
+ let ok = 0;
218
+ let failed = 0;
219
+ for (const tree of killed) {
220
+ const pids = tree.results.map(result => `${result.pid}${result.ok ? '' : ' (failed)'}`).join(', ') || '(none)';
221
+ await log(` root ${tree.rootPid}: ${pids}`);
222
+ ok += tree.results.filter(result => result.ok).length;
223
+ failed += tree.results.filter(result => !result.ok).length;
224
+ }
225
+ await log(`\n✅ Signalled ${ok} processes${failed ? `, ${failed} failed` : ''}.`);
226
+ }
227
+ }
228
+
229
+ await log(`\n📁 Log file: ${logFile}`);
230
+ return;
231
+ }
232
+
159
233
  // 1. Enumerate candidate entries.
160
234
  const entries = listTempEntries(tempRoot);
161
235
  await log(`🔍 Found ${entries.length} entries under ${tempRoot}`);
@@ -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.
@@ -25,6 +25,16 @@ const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
25
  const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
26
26
  const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
27
27
 
28
+ function normalizeProcessIds(value) {
29
+ if (!value || typeof value !== 'object') return {};
30
+ const out = {};
31
+ for (const [key, raw] of Object.entries(value)) {
32
+ const number = Number(raw);
33
+ if (Number.isInteger(number) && number > 0) out[key] = number;
34
+ }
35
+ return out;
36
+ }
37
+
28
38
  /**
29
39
  * Generate a UUID v4 for unique session identification
30
40
  * @returns {string} UUID v4 string
@@ -41,12 +51,12 @@ export function generateSessionId() {
41
51
  * Keep the parser tolerant so completion monitoring survives either format.
42
52
  *
43
53
  * @param {string} output - Raw stdout from `$ --status`
44
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, raw: string}}
54
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
45
55
  */
46
56
  export function parseSessionStatusOutput(output) {
47
57
  const raw = (output || '').trim();
48
58
  if (!raw) {
49
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
59
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
50
60
  }
51
61
 
52
62
  try {
@@ -58,6 +68,9 @@ export function parseSessionStatusOutput(output) {
58
68
  // field — keep accepting all three so we are tolerant of future renames.
59
69
  // See https://github.com/link-assistant/hive-mind/issues/1700.
60
70
  const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
71
+ const topPid = Number(data?.pid);
72
+ const processIds = normalizeProcessIds(data?.processIds);
73
+ if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
61
74
  return {
62
75
  exists: true,
63
76
  uuid: data?.uuid || null,
@@ -70,6 +83,8 @@ export function parseSessionStatusOutput(output) {
70
83
  command: data?.command || null,
71
84
  isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
72
85
  workingDirectory: data?.workingDirectory || null,
86
+ sessionName: data?.sessionName || data?.options?.sessionName || null,
87
+ processIds,
73
88
  raw,
74
89
  };
75
90
  } catch {
@@ -95,6 +110,12 @@ export function parseSessionStatusOutput(output) {
95
110
  // returned null for every real session and made /log + /terminal_watch
96
111
  // reject screen/tmux/docker sessions. See issue #1700.
97
112
  const isolationText = readField('isolated') || readField('isolation');
113
+ const processIds = {};
114
+ for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
115
+ const value = readField(name);
116
+ const number = Number(value);
117
+ if (Number.isInteger(number) && number > 0) processIds[name] = number;
118
+ }
98
119
 
99
120
  return {
100
121
  exists: Boolean(status || firstLine),
@@ -108,6 +129,8 @@ export function parseSessionStatusOutput(output) {
108
129
  command: readField('command'),
109
130
  isolation: isolationText?.toLowerCase() || null,
110
131
  workingDirectory: readField('workingDirectory'),
132
+ sessionName: readField('sessionName'),
133
+ processIds,
111
134
  raw,
112
135
  };
113
136
  }
@@ -234,7 +257,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
234
257
  if (verbose) {
235
258
  console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
236
259
  }
237
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
260
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
238
261
  }
239
262
 
240
263
  try {
@@ -251,7 +274,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
251
274
  if (verbose) {
252
275
  console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
253
276
  }
254
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
277
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
255
278
  }
256
279
  }
257
280
 
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Pure process/session correlation helpers for cleanup process diagnostics
3
+ * (issue #1851).
4
+ *
5
+ * This module intentionally avoids /proc, screen, filesystem, and network
6
+ * access. The OS layer supplies process records and start-command session
7
+ * metadata; this file only parses, matches, redacts, and formats.
8
+ */
9
+
10
+ import path from 'node:path';
11
+
12
+ import { extractTaskRefsFromCommand } from './cleanup.lib.mjs';
13
+
14
+ const AGENT_KINDS = ['claude', 'codex', 'gemini', 'qwen', 'opencode'];
15
+ const RUNNING_STATUSES = new Set(['executing', 'running']);
16
+ const TERMINAL_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
17
+
18
+ function taskUrlFromRef(ref) {
19
+ if (!ref) return null;
20
+ const kind = ref.type === 'pull' ? 'pull' : 'issues';
21
+ return `https://github.com/${ref.owner}/${ref.repo}/${kind}/${ref.number}`;
22
+ }
23
+
24
+ function firstTaskUrl(text) {
25
+ const refs = extractTaskRefsFromCommand(text || '');
26
+ return taskUrlFromRef(refs[0]);
27
+ }
28
+
29
+ function normalizeWhitespace(value) {
30
+ return String(value || '')
31
+ .replace(/\s+/g, ' ')
32
+ .trim();
33
+ }
34
+
35
+ function toPositiveInteger(value) {
36
+ const number = Number(value);
37
+ return Number.isInteger(number) && number > 0 ? number : null;
38
+ }
39
+
40
+ function normalizeProcessIds(value) {
41
+ const out = {};
42
+ if (!value || typeof value !== 'object') return out;
43
+ for (const [key, raw] of Object.entries(value)) {
44
+ const number = toPositiveInteger(raw);
45
+ if (number) out[key] = number;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function normalizePath(value) {
51
+ if (!value || typeof value !== 'string') return null;
52
+ return value.trim().replace(/\/+$/, '') || null;
53
+ }
54
+
55
+ function isPathInside(candidate, parent) {
56
+ const child = normalizePath(candidate);
57
+ const root = normalizePath(parent);
58
+ if (!child || !root) return false;
59
+ return child === root || child.startsWith(root + path.sep);
60
+ }
61
+
62
+ function containsToken(text, token) {
63
+ return Boolean(text && token && String(text).includes(String(token)));
64
+ }
65
+
66
+ function readFlagValue(command, flag) {
67
+ if (!command) return null;
68
+ const re = new RegExp(`(?:^|\\s)${flag}(?:=|\\s+)("([^"]+)"|'([^']+)'|\\S+)`, 'i');
69
+ const match = command.match(re);
70
+ return match ? (match[2] || match[3] || match[1] || '').replace(/^["']|["']$/g, '') : null;
71
+ }
72
+
73
+ function extractWorkspace(text) {
74
+ if (!text) return null;
75
+ const clean = value => {
76
+ const raw = String(value || '')
77
+ .trim()
78
+ .replace(/^["']|["']$/g, '');
79
+ const tmpMatch = raw.match(/\/tmp\/gh-issue-solver-[A-Za-z0-9._-]+/);
80
+ if (tmpMatch) return normalizePath(tmpMatch[0]);
81
+ return normalizePath(raw.split(/\\n|\s/)[0]);
82
+ };
83
+ const patterns = [/Your prepared working directory:\s*([^\r\n]+)/i, /Creating temporary directory:\s*([^\r\n]+)/i, /Cloning into ['"]([^'"]+)['"]/i, /\bworking directory:\s*([^\r\n]+)/i, /\bworkspace(?: directory)?:\s*([^\r\n]+)/i, /\((?:cd|pushd)\s+["']?([^"')\s]+)["']?\s+&&/i, /(\/tmp\/gh-issue-solver-[A-Za-z0-9._-]+)/];
84
+
85
+ for (const pattern of patterns) {
86
+ const match = text.match(pattern);
87
+ const value = match?.[1] ? clean(match[1]) : null;
88
+ if (value) return normalizePath(value);
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function detectTool(command, text) {
94
+ const fromFlag = readFlagValue(command, '--tool');
95
+ if (fromFlag) return String(fromFlag).toLowerCase();
96
+ const haystack = `${command || ''}\n${text || ''}`.toLowerCase();
97
+ for (const kind of AGENT_KINDS) {
98
+ if (new RegExp(`\\b${kind}\\b`, 'i').test(haystack)) return kind;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export function detectAgentKind(processRecord) {
104
+ const commandName = String(processRecord?.commandName || '').toLowerCase();
105
+ const exeBase = path.basename(String(processRecord?.exe || '')).toLowerCase();
106
+ const cmdline = String(processRecord?.cmdline || '').toLowerCase();
107
+ const combined = `${commandName} ${exeBase} ${cmdline}`;
108
+
109
+ for (const kind of AGENT_KINDS) {
110
+ if (commandName === kind || exeBase === kind) return kind;
111
+ if (new RegExp(`(?:^|[\\s/.-])${kind}(?:$|[\\s/.-])`).test(combined)) return kind;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Synchronous redaction for process command lines and log snippets. Process
118
+ * debugging runs inside cleanup, so it cannot rely on async full-log
119
+ * sanitizers. Keep this conservative and token-shape based.
120
+ *
121
+ * @param {string} text
122
+ * @returns {string}
123
+ */
124
+ export function redactProcessText(text) {
125
+ let out = String(text ?? '');
126
+ out = out.replace(/\b(\d{6,12}):([A-Za-z0-9_-]{20,})\b/g, '$1:[REDACTED]');
127
+ out = out.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github_pat_[REDACTED]');
128
+ out = out.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, match => `${match.slice(0, 4)}[REDACTED]`);
129
+ out = out.replace(/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, 'sk-ant-[REDACTED]');
130
+ out = out.replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, 'sk-[REDACTED]');
131
+ out = out.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, match => `${match.slice(0, 5)}[REDACTED]`);
132
+ out = out.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, 'npm_[REDACTED]');
133
+ out = out.replace(/\bhf_[A-Za-z0-9]{20,}\b/g, 'hf_[REDACTED]');
134
+ out = out.replace(/(Authorization:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{10,})/gi, '$1[REDACTED]');
135
+ out = out.replace(/((?:api[_-]?key|token|secret|password|bot[_-]?token)\s*[:=]\s*['"]?)([A-Za-z0-9._~+/:=-]{12,})(['"]?)/gi, '$1[REDACTED]$3');
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * Extract useful metadata from a start-command isolation log. The logs commonly
141
+ * contain the original command, the temporary worktree path, and the command
142
+ * that launches the selected agent.
143
+ *
144
+ * @param {{logPath?: string|null, text?: string|null, sessionId?: string|null}} input
145
+ * @returns {{sessionId: string|null, command: string|null, taskUrl: string|null, workspace: string|null, tool: string|null, logPath: string|null}}
146
+ */
147
+ export function parseStartCommandLogMetadata(input = {}) {
148
+ const logPath = input.logPath || null;
149
+ const text = String(input.text || '');
150
+ const command = text.match(/^Command:\s*(.+)$/im)?.[1]?.trim() || null;
151
+ const sessionId = input.sessionId || (logPath ? path.basename(logPath).replace(/\.[^.]+$/, '') : null) || null;
152
+
153
+ const taskUrl = firstTaskUrl(command) || firstTaskUrl(text);
154
+ const workspace = extractWorkspace(text);
155
+ const tool = detectTool(command, text);
156
+
157
+ return {
158
+ sessionId,
159
+ command: command ? redactProcessText(command) : null,
160
+ taskUrl,
161
+ workspace,
162
+ tool,
163
+ logPath,
164
+ };
165
+ }
166
+
167
+ function normalizeSession(input) {
168
+ const command = input?.command ? String(input.command) : null;
169
+ const status = input?.status ? String(input.status).toLowerCase() : null;
170
+ const processIds = normalizeProcessIds(input?.processIds);
171
+ const sessionId = input?.sessionId || input?.uuid || input?.id || null;
172
+ const sessionName = input?.sessionName || input?.screenSessionName || sessionId || null;
173
+ const taskUrl = input?.taskUrl || firstTaskUrl(command);
174
+ const live = input?.live === true || RUNNING_STATUSES.has(status);
175
+
176
+ return {
177
+ ...input,
178
+ sessionId,
179
+ uuid: input?.uuid || sessionId,
180
+ sessionName,
181
+ screenSessionName: input?.screenSessionName || sessionName,
182
+ status,
183
+ command,
184
+ taskUrl,
185
+ workspace: normalizePath(input?.workspace || input?.workingDirectory || null),
186
+ tool: input?.tool ? String(input.tool).toLowerCase() : detectTool(command, command),
187
+ logPath: input?.logPath || null,
188
+ processIds,
189
+ live,
190
+ };
191
+ }
192
+
193
+ function normalizeProcess(input) {
194
+ return {
195
+ ...input,
196
+ pid: toPositiveInteger(input?.pid),
197
+ ppid: toPositiveInteger(input?.ppid),
198
+ pgid: toPositiveInteger(input?.pgid) || null,
199
+ sid: toPositiveInteger(input?.sid) || null,
200
+ state: input?.state || null,
201
+ commandName: input?.commandName || null,
202
+ cmdline: normalizeWhitespace(input?.cmdline || input?.command || ''),
203
+ cwd: normalizePath(input?.cwd || null),
204
+ exe: input?.exe || null,
205
+ screenSessionName: input?.screenSessionName || null,
206
+ };
207
+ }
208
+
209
+ function scoreSessionMatch(processRecord, session) {
210
+ const reasons = [];
211
+ const processIdValues = new Set(Object.values(session.processIds || {}).filter(Boolean));
212
+
213
+ if (processIdValues.has(processRecord.pid)) reasons.push('pid-session-process');
214
+ if (processIdValues.has(processRecord.ppid)) reasons.push('parent-session-process');
215
+ if (processIdValues.has(processRecord.pgid)) reasons.push('process-group-session-process');
216
+ if (processIdValues.has(processRecord.sid)) reasons.push('session-id-session-process');
217
+
218
+ if (processRecord.screenSessionName) {
219
+ const names = new Set([session.screenSessionName, session.sessionName, session.sessionId].filter(Boolean));
220
+ if (names.has(processRecord.screenSessionName)) reasons.push('screen-session');
221
+ }
222
+
223
+ if (session.workspace && processRecord.cwd && isPathInside(processRecord.cwd, session.workspace)) {
224
+ reasons.push('cwd-workspace');
225
+ }
226
+ if (session.workspace && containsToken(processRecord.cmdline, session.workspace)) {
227
+ reasons.push('cmd-workspace');
228
+ }
229
+ if (session.taskUrl && containsToken(processRecord.cmdline, session.taskUrl)) {
230
+ reasons.push('cmd-task-url');
231
+ }
232
+
233
+ const agentKind = detectAgentKind(processRecord);
234
+ if (agentKind && session.tool && agentKind === session.tool) reasons.push('agent-tool');
235
+ if (reasons.length === 1 && reasons[0] === 'agent-tool') {
236
+ return { score: 0, reasons: [] };
237
+ }
238
+
239
+ const weights = {
240
+ 'pid-session-process': 100,
241
+ 'parent-session-process': 90,
242
+ 'process-group-session-process': 80,
243
+ 'session-id-session-process': 80,
244
+ 'screen-session': 75,
245
+ 'cwd-workspace': 60,
246
+ 'cmd-workspace': 50,
247
+ 'cmd-task-url': 45,
248
+ 'agent-tool': 10,
249
+ };
250
+ const score = reasons.reduce((sum, reason) => sum + (weights[reason] || 1), 0);
251
+ return { score, reasons };
252
+ }
253
+
254
+ function chooseSession(processRecord, sessions) {
255
+ let best = null;
256
+ for (const session of sessions) {
257
+ const match = scoreSessionMatch(processRecord, session);
258
+ if (match.score <= 0) continue;
259
+ if (!best || match.score > best.score) {
260
+ best = { session, score: match.score, reasons: match.reasons };
261
+ }
262
+ }
263
+ return best;
264
+ }
265
+
266
+ function isOrphanedAgent(processRecord, session, agentKind, currentPid) {
267
+ if (!agentKind || !session || processRecord.pid === currentPid) return false;
268
+ const status = String(session.status || '').toLowerCase();
269
+ const terminal = TERMINAL_STATUSES.has(status);
270
+ if (!terminal) return false;
271
+ if (session.live) return false;
272
+ return processRecord.ppid === 1 || processRecord.ppid == null;
273
+ }
274
+
275
+ /**
276
+ * Correlate process records with start-command session/task records.
277
+ *
278
+ * @param {{processes: Array, sessions: Array, currentPid?: number|null}} input
279
+ * @returns {{items: Array, orphans: Array, sessions: Array}}
280
+ */
281
+ export function correlateProcesses(input = {}) {
282
+ const sessions = (input.sessions || []).map(normalizeSession).filter(session => session.sessionId || session.taskUrl || session.workspace);
283
+ const currentPid = toPositiveInteger(input.currentPid) || null;
284
+ const targetPids = new Set((input.targetPids || []).map(toPositiveInteger).filter(Boolean));
285
+ const items = [];
286
+
287
+ for (const rawProcess of input.processes || []) {
288
+ const processRecord = normalizeProcess(rawProcess);
289
+ if (!processRecord.pid) continue;
290
+
291
+ const agentKind = detectAgentKind(processRecord);
292
+ const match = chooseSession(processRecord, sessions);
293
+ const targeted = targetPids.has(processRecord.pid);
294
+ const strongMatch = match?.reasons?.some(reason => !['cwd-workspace', 'cmd-workspace', 'agent-tool'].includes(reason));
295
+ if (!agentKind && !targeted && !strongMatch) continue;
296
+
297
+ const session = match?.session || null;
298
+ const orphaned = isOrphanedAgent(processRecord, session, agentKind, currentPid);
299
+ items.push({
300
+ ...processRecord,
301
+ agentKind,
302
+ sessionId: session?.sessionId || null,
303
+ sessionName: session?.sessionName || null,
304
+ screenSessionName: processRecord.screenSessionName || session?.screenSessionName || null,
305
+ sessionStatus: session?.status || null,
306
+ sessionLive: session?.live === true,
307
+ taskUrl: session?.taskUrl || firstTaskUrl(processRecord.cmdline),
308
+ workspace: session?.workspace || processRecord.cwd || null,
309
+ logPath: session?.logPath || null,
310
+ matchReasons: match?.reasons || [],
311
+ orphaned,
312
+ });
313
+ }
314
+
315
+ items.sort((a, b) => {
316
+ if (a.orphaned !== b.orphaned) return a.orphaned ? -1 : 1;
317
+ return a.pid - b.pid;
318
+ });
319
+
320
+ return {
321
+ items,
322
+ orphans: items.filter(item => item.orphaned),
323
+ sessions,
324
+ };
325
+ }
326
+
327
+ function yesNo(value) {
328
+ return value ? 'yes' : 'no';
329
+ }
330
+
331
+ /**
332
+ * Render a console-safe diagnostic report. All command lines are redacted.
333
+ *
334
+ * @param {{items?: Array, orphans?: Array}} report
335
+ * @returns {string}
336
+ */
337
+ export function formatProcessDebugReport(report = {}) {
338
+ const items = report.items || [];
339
+ const orphans = report.orphans || [];
340
+ const lines = ['Process debug report', '====================', `Matched processes: ${items.length}`, `Orphaned terminal-session agents: ${orphans.length}`];
341
+
342
+ if (items.length === 0) {
343
+ lines.push('', 'No claude/codex/gemini/qwen/opencode processes were linked to hive-mind tasks.');
344
+ return lines.join('\n');
345
+ }
346
+
347
+ for (const item of items) {
348
+ lines.push('');
349
+ lines.push(`PID ${item.pid} ppid=${item.ppid ?? '?'} state=${item.state || '?'} agent=${item.agentKind || 'unknown'} orphan=${yesNo(item.orphaned)}`);
350
+ if (item.sessionId || item.sessionStatus) {
351
+ lines.push(` session: ${item.sessionId || '(unknown)'} status=${item.sessionStatus || '?'} live=${yesNo(item.sessionLive)}`);
352
+ }
353
+ if (item.taskUrl) lines.push(` task: ${item.taskUrl}`);
354
+ if (item.workspace) lines.push(` workspace: ${item.workspace}`);
355
+ if (item.logPath) lines.push(` log: ${item.logPath}`);
356
+ if (item.matchReasons?.length) lines.push(` match: ${item.matchReasons.join(', ')}`);
357
+ if (item.cmdline) lines.push(` cmd: ${redactProcessText(item.cmdline)}`);
358
+ }
359
+
360
+ return lines.join('\n');
361
+ }