loadtoagent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.ko.md +175 -0
  2. package/README.md +175 -0
  3. package/README.zh-CN.md +175 -0
  4. package/bin/loadtoagent.js +171 -0
  5. package/docs/ARCHITECTURE.md +30 -0
  6. package/docs/PROVIDER-CONTRACTS.md +58 -0
  7. package/docs/assets/loadtoagent-dashboard.png +0 -0
  8. package/docs/assets/loadtoagent-demo.gif +0 -0
  9. package/main.js +493 -0
  10. package/package.json +133 -0
  11. package/preload.js +75 -0
  12. package/renderer/app-agent-actions.js +285 -0
  13. package/renderer/app-bootstrap.js +95 -0
  14. package/renderer/app-dashboard.js +361 -0
  15. package/renderer/app-drawer-content.js +203 -0
  16. package/renderer/app-drawer-data.js +41 -0
  17. package/renderer/app-drawer.js +183 -0
  18. package/renderer/app-events-dialogs.js +169 -0
  19. package/renderer/app-events-filters.js +74 -0
  20. package/renderer/app-events-navigation.js +96 -0
  21. package/renderer/app-events-sessions.js +195 -0
  22. package/renderer/app-events.js +16 -0
  23. package/renderer/app-graph-layout.js +71 -0
  24. package/renderer/app-graph-model.js +107 -0
  25. package/renderer/app-graph-orchestration.js +76 -0
  26. package/renderer/app-graph-view.js +544 -0
  27. package/renderer/app-run-modal.js +221 -0
  28. package/renderer/app-session-render.js +243 -0
  29. package/renderer/app-tmux-render.js +247 -0
  30. package/renderer/app.js +608 -0
  31. package/renderer/fonts/geist-ext.woff2 +0 -0
  32. package/renderer/fonts/geist.woff2 +0 -0
  33. package/renderer/i18n-messages.js +355 -0
  34. package/renderer/i18n.js +122 -0
  35. package/renderer/index.html +386 -0
  36. package/renderer/shared.js +26 -0
  37. package/renderer/styles-agent-map.css +401 -0
  38. package/renderer/styles-cards.css +600 -0
  39. package/renderer/styles-collaboration.css +534 -0
  40. package/renderer/styles-components.css +1293 -0
  41. package/renderer/styles-onboarding.css +344 -0
  42. package/renderer/styles-overlays.css +284 -0
  43. package/renderer/styles-product.css +686 -0
  44. package/renderer/styles-responsive-product.css +689 -0
  45. package/renderer/styles-responsive-runtime.css +718 -0
  46. package/renderer/styles-responsive-shell.css +533 -0
  47. package/renderer/styles-responsive-workflows.css +778 -0
  48. package/renderer/styles-run-composer.css +695 -0
  49. package/renderer/styles-settings.css +606 -0
  50. package/renderer/styles-terminal.css +1241 -0
  51. package/renderer/styles-tmux.css +1162 -0
  52. package/renderer/styles-workflow-map.css +513 -0
  53. package/renderer/styles-workflows.css +1217 -0
  54. package/renderer/styles.css +486 -0
  55. package/renderer/terminal-agent.js +152 -0
  56. package/renderer/terminal-events.js +226 -0
  57. package/renderer/terminal-workbench.js +464 -0
  58. package/renderer/terminal.js +497 -0
  59. package/src/agentMonitor/claudeParser.js +197 -0
  60. package/src/agentMonitor/codexCollaboration.js +95 -0
  61. package/src/agentMonitor/codexParser.js +447 -0
  62. package/src/agentMonitor/genericParser.js +244 -0
  63. package/src/agentMonitor/hierarchy.js +248 -0
  64. package/src/agentMonitor/sessionFiles.js +86 -0
  65. package/src/agentMonitor.js +591 -0
  66. package/src/agentRunner.js +465 -0
  67. package/src/bridgeServer.js +246 -0
  68. package/src/contracts.js +71 -0
  69. package/src/diagnostics.js +23 -0
  70. package/src/ipc/registerAgentIpc.js +12 -0
  71. package/src/ipc/registerAppIpc.js +20 -0
  72. package/src/ipc/registerTerminalIpc.js +50 -0
  73. package/src/ipc/registerTmuxIpc.js +30 -0
  74. package/src/ipc/registerWorkspaceIpc.js +14 -0
  75. package/src/monitorWorker.js +273 -0
  76. package/src/processMonitor.js +394 -0
  77. package/src/providerRegistry.js +120 -0
  78. package/src/terminalManager.js +438 -0
  79. package/src/tmuxController.js +183 -0
  80. package/src/tmuxMonitor.js +432 -0
  81. package/src/updateManager.js +339 -0
  82. package/src/workspaceStore.js +77 -0
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ const { parentPort, workerData } = require('worker_threads');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { AgentMonitor, buildSummary } = require('./agentMonitor');
7
+ const { TmuxMonitor, linkAgentSessions } = require('./tmuxMonitor');
8
+ const { ProcessMonitor, applyRuntimePresence } = require('./processMonitor');
9
+ const { reportRecoverableError } = require('./diagnostics');
10
+
11
+ const tmuxMonitor = new TmuxMonitor();
12
+ tmuxMonitor.scan();
13
+ const processMonitor = new ProcessMonitor();
14
+
15
+ const monitor = new AgentMonitor({
16
+ runsDir: workerData.runsDir,
17
+ home: workerData.home,
18
+ intervalMs: workerData.intervalMs || 1200,
19
+ });
20
+
21
+ monitor.setAvailability(workerData.availability || {});
22
+ let lastFingerprint = '';
23
+ let lastPublishedSessions = [];
24
+ let currentBridges = [];
25
+ const discoveryWatchers = [];
26
+
27
+ for (const root of [
28
+ path.join(workerData.home, '.claude', 'projects'),
29
+ path.join(workerData.home, '.codex', 'sessions'),
30
+ path.join(workerData.home, '.gemini', 'tmp'),
31
+ path.join(workerData.home, '.grok', 'sessions'),
32
+ ]) {
33
+ if (!fs.existsSync(root)) continue;
34
+ try {
35
+ const watcher = fs.watch(root, { recursive: process.platform === 'win32' || process.platform === 'darwin' }, (eventType, filename) => {
36
+ if (eventType === 'rename') return monitor.listCache.clear();
37
+ const changed = filename ? path.resolve(root, String(filename)) : '';
38
+ const known = changed && [...monitor.listCache.values()].some(entry => (entry.paths || []).some(file => path.resolve(file) === changed));
39
+ if (!known) monitor.listCache.clear();
40
+ });
41
+ discoveryWatchers.push(watcher);
42
+ } catch (error) {
43
+ reportRecoverableError(`session-watch:${root}`, error);
44
+ }
45
+ }
46
+
47
+ function clip(value, limit) {
48
+ const text = String(value == null ? '' : value).replace(/\u0000/g, '').trim();
49
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
50
+ }
51
+
52
+ /** Creates the compact renderer projection of a provider-neutral session message. */
53
+ function cardMessage(message) {
54
+ return {
55
+ id: message.id,
56
+ role: message.role,
57
+ type: message.type,
58
+ title: clip(message.title, 80),
59
+ text: clip(message.text, 420),
60
+ status: message.status,
61
+ timestamp: message.timestamp,
62
+ };
63
+ }
64
+
65
+ function cardLifecycle(event) {
66
+ return {
67
+ id: event.id,
68
+ type: event.type,
69
+ label: clip(event.label, 100),
70
+ detail: clip(event.detail, 180),
71
+ status: event.status,
72
+ timestamp: event.timestamp,
73
+ };
74
+ }
75
+
76
+ function selectCardMessages(messages) {
77
+ const list = messages || [];
78
+ const selected = new Set();
79
+ for (const role of ['user', 'assistant', 'tool']) {
80
+ for (let index = list.length - 1; index >= 0; index -= 1) {
81
+ if (list[index] && list[index].role === role) {
82
+ selected.add(index);
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ return [...selected].sort((a, b) => a - b).map(index => cardMessage(list[index]));
88
+ }
89
+
90
+ function cardCollaboration(value) {
91
+ const collaboration = value || {};
92
+ return {
93
+ capacity: collaboration.capacity || { totalThreads: 0, subagents: 0, source: 'unknown' },
94
+ retainedObserved: Boolean(collaboration.retainedObserved),
95
+ retainedAgents: (collaboration.retainedAgents || []).slice(-30).map(agent => ({
96
+ path: clip(agent.path, 180), taskName: clip(agent.taskName, 180), name: clip(agent.name, 120), status: agent.status, observedAt: agent.observedAt,
97
+ })),
98
+ metrics: collaboration.metrics || null,
99
+ spawns: (collaboration.spawns || []).slice(-160).map(record => ({
100
+ callId: record.callId,
101
+ taskName: clip(record.taskName, 180),
102
+ agentPath: clip(record.agentPath, 180),
103
+ childId: record.childId,
104
+ assignment: clip(record.assignment, 1200),
105
+ assignmentObserved: Boolean(record.assignmentObserved),
106
+ assignmentProtected: Boolean(record.assignmentProtected),
107
+ assignmentSource: clip(record.assignmentSource, 80),
108
+ sharedGoal: clip(record.sharedGoal, 1200),
109
+ status: record.status,
110
+ startedAt: record.startedAt,
111
+ completedAt: record.completedAt,
112
+ result: clip(record.result, 1200),
113
+ agentName: clip(record.agentName, 120),
114
+ currentlyRetained: Boolean(record.currentlyRetained),
115
+ inferred: Boolean(record.inferred),
116
+ })),
117
+ communications: (collaboration.communications || []).slice(-120).map(event => ({
118
+ id: event.id,
119
+ kind: event.kind,
120
+ label: clip(event.label, 100),
121
+ from: clip(event.from, 180),
122
+ to: clip(event.to, 180),
123
+ taskName: clip(event.taskName, 180),
124
+ childId: event.childId,
125
+ text: clip(event.text, 1200),
126
+ protected: Boolean(event.protected),
127
+ assignmentSource: clip(event.assignmentSource, 80),
128
+ timestamp: event.timestamp,
129
+ })),
130
+ };
131
+ }
132
+
133
+ function cardSession(session) {
134
+ return {
135
+ id: session.id,
136
+ externalId: session.externalId,
137
+ provider: session.provider,
138
+ parentId: session.parentId,
139
+ depth: session.depth,
140
+ agentName: session.agentName,
141
+ agentRole: session.agentRole,
142
+ agentPath: session.agentPath || '',
143
+ taskName: session.taskName || '',
144
+ sharedGoal: clip(session.sharedGoal, 1200),
145
+ environment: session.environment,
146
+ title: clip(session.title, 180),
147
+ model: session.model,
148
+ cwd: session.cwd,
149
+ branch: session.branch,
150
+ workspace: session.workspace,
151
+ projectless: Boolean(session.projectless),
152
+ source: session.source,
153
+ sourceLabel: session.sourceLabel,
154
+ clientKind: session.clientKind || '',
155
+ status: session.status,
156
+ statusDetail: clip(session.statusDetail, 180),
157
+ statusObserved: session.statusObserved,
158
+ startedAt: session.startedAt,
159
+ updatedAt: session.updatedAt,
160
+ endedAt: session.endedAt,
161
+ completedAt: session.completedAt,
162
+ completionObserved: Boolean(session.completionObserved),
163
+ result: clip(session.result, 1200),
164
+ delegation: session.delegation ? {
165
+ taskName: clip(session.delegation.taskName, 180),
166
+ assignment: clip(session.delegation.assignment, 1200),
167
+ assignmentObserved: Boolean(session.delegation.assignmentObserved),
168
+ assignmentProtected: Boolean(session.delegation.assignmentProtected),
169
+ assignmentSource: clip(session.delegation.assignmentSource, 80),
170
+ sharedGoal: clip(session.delegation.sharedGoal, 1200),
171
+ result: clip(session.delegation.result, 1200),
172
+ startedAt: session.delegation.startedAt,
173
+ completedAt: session.delegation.completedAt,
174
+ currentlyRetained: Boolean(session.delegation.currentlyRetained),
175
+ } : null,
176
+ truncated: session.truncated,
177
+ runId: session.runId,
178
+ usage: session.usage,
179
+ context: session.context,
180
+ childIds: session.childIds,
181
+ runtimePresence: session.runtimePresence || [],
182
+ collaboration: cardCollaboration(session.collaboration),
183
+ messages: selectCardMessages(session.messages),
184
+ lifecycle: (session.lifecycle || []).slice(-2).map(cardLifecycle),
185
+ };
186
+ }
187
+
188
+ function fingerprint(snapshot, tmux) {
189
+ const sessions = snapshot.sessions.map(session => [
190
+ session.id,
191
+ session.updatedAt,
192
+ session.status,
193
+ session.usage && session.usage.total,
194
+ session.context && session.context.used,
195
+ session.workspace,
196
+ Boolean(session.projectless),
197
+ session.childIds && session.childIds.length,
198
+ session.collaboration && session.collaboration.metrics && Object.values(session.collaboration.metrics).join(':'),
199
+ session.collaboration && session.collaboration.communications && session.collaboration.communications.length,
200
+ session.collaboration && session.collaboration.communications && session.collaboration.communications.at(-1) && session.collaboration.communications.at(-1).id,
201
+ (session.runtimePresence || []).map(item => `${item.id}:${item.pid}:${item.terminalId || ''}`).join(','),
202
+ ]);
203
+ const tmuxState = (tmux.distros || []).flatMap(distro => (distro.sessions || []).flatMap(tmuxSession => (tmuxSession.windows || []).flatMap(window => (window.panes || []).map(pane => [
204
+ distro.name,
205
+ tmuxSession.id,
206
+ window.id,
207
+ pane.id,
208
+ pane.pid,
209
+ pane.command,
210
+ pane.cwd,
211
+ pane.active,
212
+ pane.dead,
213
+ pane.agent && pane.agent.provider,
214
+ pane.agent && pane.agent.linkedSessionId,
215
+ pane.agent && pane.agent.updatedAt,
216
+ ]))));
217
+ return JSON.stringify([sessions, tmuxState]);
218
+ }
219
+
220
+ monitor.on('snapshot', snapshot => {
221
+ const tmuxBase = tmuxMonitor.scan();
222
+ monitor.setHistoryHomes(tmuxMonitor.historyHomes());
223
+ const tmux = linkAgentSessions(tmuxBase, snapshot.sessions);
224
+ const processSnapshot = processMonitor.scan();
225
+ const sessions = applyRuntimePresence(snapshot.sessions, tmux, processSnapshot, Date.now(), currentBridges);
226
+ const runtimeSnapshot = {
227
+ generatedAt: snapshot.generatedAt,
228
+ sessions,
229
+ summary: buildSummary(sessions, monitor.availability),
230
+ };
231
+ const nextFingerprint = fingerprint(runtimeSnapshot, tmux);
232
+ if (nextFingerprint === lastFingerprint) return;
233
+ lastFingerprint = nextFingerprint;
234
+ lastPublishedSessions = sessions;
235
+ parentPort.postMessage({
236
+ type: 'snapshot',
237
+ snapshot: {
238
+ generatedAt: snapshot.generatedAt,
239
+ sessions: sessions.map(cardSession),
240
+ summary: runtimeSnapshot.summary,
241
+ tmux,
242
+ runtime: {
243
+ localProcesses: processSnapshot.processes.length,
244
+ bridgeProcesses: currentBridges.length,
245
+ tmuxProcesses: tmux.summary.aiPanes,
246
+ },
247
+ },
248
+ });
249
+ });
250
+ parentPort.on('message', message => {
251
+ if (!message) return;
252
+ if (message.type === 'availability') monitor.setAvailability(message.availability || {});
253
+ if (message.type === 'scan') {
254
+ monitor.scanNow();
255
+ }
256
+ if (message.type === 'bridge-presence') {
257
+ currentBridges = Array.isArray(message.bridges) ? message.bridges : [];
258
+ monitor.scanNow();
259
+ }
260
+ if (message.type === 'detail') {
261
+ const runtime = lastPublishedSessions.find(item => item.id === message.sessionId) || null;
262
+ const stored = (monitor.lastSnapshot.sessions || []).find(item => item.id === message.sessionId) || null;
263
+ const session = stored && runtime
264
+ ? { ...stored, status: runtime.status, statusDetail: runtime.statusDetail, statusObserved: runtime.statusObserved, runtimePresence: runtime.runtimePresence || [] }
265
+ : (stored || runtime);
266
+ parentPort.postMessage({ type: 'detail-result', requestId: message.requestId, session });
267
+ }
268
+ if (message.type === 'stop') {
269
+ monitor.stop();
270
+ discoveryWatchers.forEach(watcher => watcher.close());
271
+ }
272
+ });
273
+ monitor.start();
@@ -0,0 +1,394 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const { blankUsage } = require('./providerRegistry');
5
+
6
+ const DEFAULT_SCAN_TTL_MS = 12_000;
7
+ const WMIC_QUERY = "Name='claude.exe' or Name='codex.exe' or Name='node.exe' or Name='gemini.exe' or Name='grok.exe'";
8
+
9
+ function parseCsvRows(value) {
10
+ const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value || '');
11
+ const rows = [];
12
+ let row = [];
13
+ let field = '';
14
+ let quoted = false;
15
+ for (let index = 0; index < text.length; index += 1) {
16
+ const char = text[index];
17
+ if (quoted) {
18
+ if (char === '"' && text[index + 1] === '"') {
19
+ field += '"';
20
+ index += 1;
21
+ } else if (char === '"') quoted = false;
22
+ else field += char;
23
+ continue;
24
+ }
25
+ if (char === '"') quoted = true;
26
+ else if (char === ',') {
27
+ row.push(field);
28
+ field = '';
29
+ } else if (char === '\n') {
30
+ row.push(field.replace(/\r$/, ''));
31
+ field = '';
32
+ if (row.some(item => item.trim())) rows.push(row);
33
+ row = [];
34
+ } else field += char;
35
+ }
36
+ if (field || row.length) {
37
+ row.push(field.replace(/\r$/, ''));
38
+ if (row.some(item => item.trim())) rows.push(row);
39
+ }
40
+ if (!rows.length) return [];
41
+ const headerIndex = rows.findIndex(item => item.some(fieldName => fieldName.replace(/^\uFEFF/, '').trim() === 'ProcessId'));
42
+ if (headerIndex < 0) return [];
43
+ const headers = rows[headerIndex].map(item => item.replace(/^\uFEFF/, '').trim());
44
+ return rows.slice(headerIndex + 1).filter(item => item.length >= headers.length).map(values => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])));
45
+ }
46
+
47
+ function wmiDateToIso(value) {
48
+ const match = String(value || '').match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{3})\d*([+-])(\d{3})$/);
49
+ if (!match) return null;
50
+ const offsetMinutes = Number(match[9] || 0);
51
+ const offsetHour = String(Math.floor(offsetMinutes / 60)).padStart(2, '0');
52
+ const offsetMinute = String(offsetMinutes % 60).padStart(2, '0');
53
+ const iso = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}.${match[7]}${match[8]}${offsetHour}:${offsetMinute}`;
54
+ const parsed = Date.parse(iso);
55
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
56
+ }
57
+
58
+ function providerFromWindowsProcess(processInfo = {}) {
59
+ const name = String(processInfo.name || processInfo.Name || '').toLowerCase();
60
+ const commandLine = String(processInfo.commandLine || processInfo.CommandLine || '');
61
+ const args = commandLine.toLowerCase().replace(/\\/g, '/');
62
+ if (name === 'claude.exe') {
63
+ if (args.includes('/windowsapps/claude_') || args.includes('--type=')) return null;
64
+ if (/^(?:"?[a-z]:)?[^\r\n]*\/\.local\/bin\/claude\.exe"?\s*$/i.test(args) || /^claude(?:\.exe)?\s*$/i.test(args)) return 'claude';
65
+ return null;
66
+ }
67
+ if (name === 'codex.exe') {
68
+ if (args.includes('/windowsapps/openai.codex_') || /\bapp-server\b/.test(args)) return null;
69
+ return 'codex';
70
+ }
71
+ if (name === 'gemini.exe') return args.includes('--type=') ? null : 'gemini';
72
+ if (name === 'grok.exe') return args.includes('--type=') ? null : 'grok';
73
+ if (name !== 'node.exe') return null;
74
+ if (/@openai[\\/]codex|@openai\/codex/.test(args)) return 'codex';
75
+ if (/@anthropic-ai[\\/]claude-code|@anthropic-ai\/claude-code/.test(args)) return 'claude';
76
+ if (/@google[\\/]gemini-cli|@google\/gemini-cli/.test(args)) return 'gemini';
77
+ if (/node_modules[\\/]grok(?:-cli)?/.test(args)) return 'grok';
78
+ return null;
79
+ }
80
+
81
+ function providerFromPosixProcess(processInfo = {}) {
82
+ const name = String(processInfo.name || processInfo.command || '').toLowerCase().split('/').pop();
83
+ const args = String(processInfo.commandLine || processInfo.args || '').toLowerCase();
84
+ if (name === 'claude') return /--type=|\/applications\/claude\.app/.test(args) ? null : 'claude';
85
+ if (name === 'codex' || /^codex-/.test(name)) return /\bapp-server\b|\/applications\/(?:chatgpt|codex)\.app/.test(args) ? null : 'codex';
86
+ if (name === 'gemini') return 'gemini';
87
+ if (name === 'grok') return 'grok';
88
+ if (name !== 'node') return null;
89
+ if (/@openai\/codex/.test(args)) return 'codex';
90
+ if (/@anthropic-ai\/claude-code/.test(args)) return 'claude';
91
+ if (/@google\/gemini-cli/.test(args)) return 'gemini';
92
+ if (/node_modules\/grok(?:-cli)?/.test(args)) return 'grok';
93
+ return null;
94
+ }
95
+
96
+ function elapsedSeconds(value) {
97
+ const text = String(value || '').trim();
98
+ const match = text.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
99
+ if (!match) {
100
+ const seconds = Number(text);
101
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
102
+ }
103
+ return Number(match[1] || 0) * 86400 + Number(match[2] || 0) * 3600 + Number(match[3] || 0) * 60 + Number(match[4] || 0);
104
+ }
105
+
106
+ function posixProcessRows(value, now = Date.now()) {
107
+ return String(value || '').split(/\r?\n/).map(line => {
108
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/);
109
+ if (!match) return null;
110
+ const age = elapsedSeconds(match[3]);
111
+ if (age == null) return null;
112
+ return {
113
+ pid: Number(match[1]),
114
+ parentPid: Number(match[2]),
115
+ name: match[4],
116
+ commandLine: match[5] || match[4],
117
+ startedAt: new Date(now - age * 1000).toISOString(),
118
+ };
119
+ }).filter(Boolean);
120
+ }
121
+
122
+ function processRows(value) {
123
+ return parseCsvRows(value).map(row => ({
124
+ pid: Number(row.ProcessId || 0),
125
+ parentPid: Number(row.ParentProcessId || 0),
126
+ name: row.Name || '',
127
+ commandLine: row.CommandLine || '',
128
+ startedAt: wmiDateToIso(row.CreationDate),
129
+ })).filter(item => item.pid > 0);
130
+ }
131
+
132
+ function selectAgentProcesses(rows, options = {}) {
133
+ const providerResolver = options.providerResolver || providerFromWindowsProcess;
134
+ const environment = options.environment || 'windows';
135
+ const candidates = rows.map(item => ({ ...item, provider: providerResolver(item) })).filter(item => item.provider);
136
+ const byParent = new Map();
137
+ for (const item of candidates) {
138
+ if (!byParent.has(item.parentPid)) byParent.set(item.parentPid, []);
139
+ byParent.get(item.parentPid).push(item);
140
+ }
141
+ const hasProviderDescendant = item => {
142
+ const queue = [item.pid];
143
+ const seen = new Set(queue);
144
+ while (queue.length) {
145
+ const pid = queue.shift();
146
+ for (const child of byParent.get(pid) || []) {
147
+ if (seen.has(child.pid)) continue;
148
+ if (child.provider === item.provider) return true;
149
+ seen.add(child.pid);
150
+ queue.push(child.pid);
151
+ }
152
+ }
153
+ return false;
154
+ };
155
+ return candidates.filter(item => !hasProviderDescendant(item)).map(item => ({
156
+ id: `${environment}:${item.provider}:${item.pid}`,
157
+ environment,
158
+ provider: item.provider,
159
+ pid: item.pid,
160
+ parentPid: item.parentPid,
161
+ command: String(item.name || '').replace(/\.exe$/i, '').split(/[\\/]/).pop(),
162
+ startedAt: item.startedAt,
163
+ })).sort((a, b) => a.provider.localeCompare(b.provider) || a.pid - b.pid);
164
+ }
165
+
166
+ function utilitySession(session) {
167
+ return /^(?:extract durable memory candidates|approved command prefix saved|you are a memory extraction)/i.test(String(session.title || '').trim());
168
+ }
169
+
170
+ function runtimeLinkScore(session, processInfo, now = Date.now()) {
171
+ if (!session || session.provider !== processInfo.provider) return -Infinity;
172
+ if (!session.environment || session.environment.kind !== processInfo.environment) return -Infinity;
173
+ let score = session.parentId ? -800 : 2_000;
174
+ if (utilitySession(session)) score -= 10_000;
175
+ if (session.status === 'running' || session.status === 'starting') score += 3_000;
176
+ else if (session.status === 'waiting') score += 1_000;
177
+ const ageMinutes = Math.max(0, (now - Date.parse(session.updatedAt || 0)) / 60_000);
178
+ score += Math.max(0, 2_880 - ageMinutes);
179
+ const sessionStart = Date.parse(session.startedAt || 0);
180
+ const processStart = Date.parse(processInfo.startedAt || 0);
181
+ if (Number.isFinite(sessionStart) && Number.isFinite(processStart)) {
182
+ const deltaMinutes = Math.abs(sessionStart - processStart) / 60_000;
183
+ if (deltaMinutes <= 3) score += 6_000;
184
+ else if (deltaMinutes <= 30) score += 2_500;
185
+ else if (deltaMinutes <= 180) score += 800;
186
+ }
187
+ return score;
188
+ }
189
+
190
+ function markRuntime(session, presence) {
191
+ const existing = Array.isArray(session.runtimePresence) ? session.runtimePresence : [];
192
+ if (!existing.some(item => item.id === presence.id)) existing.push(presence);
193
+ session.runtimePresence = existing;
194
+ session.status = 'running';
195
+ session.statusObserved = true;
196
+ session.statusDetail = presence.kind === 'tmux'
197
+ ? `tmux에서 AI 프로세스 실행 중 · PID ${presence.pid}`
198
+ : (presence.kind === 'bridge'
199
+ ? `안전하게 연결된 외부 터미널 · PID ${presence.pid}`
200
+ : `AI CLI 프로세스 실행 중 · PID ${presence.pid}`);
201
+ return session;
202
+ }
203
+
204
+ function syntheticRuntimeSession(processInfo, now = Date.now()) {
205
+ const label = processInfo.provider === 'claude' ? 'Claude' : (processInfo.provider === 'codex' ? 'GPT · Codex' : (processInfo.provider === 'gemini' ? 'Gemini' : 'Grok'));
206
+ const environmentLabel = processInfo.environment === 'macos' ? 'macOS' : (processInfo.environment === 'linux' ? 'Linux' : 'Windows');
207
+ const updatedAt = new Date(now).toISOString();
208
+ return {
209
+ id: `runtime:${processInfo.id}`,
210
+ externalId: `process-${processInfo.pid}`,
211
+ provider: processInfo.provider,
212
+ parentId: null,
213
+ depth: 0,
214
+ agentName: '',
215
+ agentRole: '',
216
+ environment: { kind: processInfo.environment || 'windows', distro: '', label: `${environmentLabel} 실행 프로세스`, home: '' },
217
+ title: `${label} CLI · PID ${processInfo.pid}`,
218
+ model: '',
219
+ cwd: '',
220
+ branch: '',
221
+ workspace: '작업 폴더 확인 중',
222
+ source: 'runtime-process',
223
+ sourceLabel: `${environmentLabel} 실행 프로세스`,
224
+ clientKind: 'external-cli',
225
+ status: 'running',
226
+ statusDetail: `AI CLI 프로세스 실행 중 · PID ${processInfo.pid}`,
227
+ statusObserved: true,
228
+ startedAt: processInfo.startedAt || updatedAt,
229
+ updatedAt,
230
+ endedAt: null,
231
+ truncated: false,
232
+ runId: null,
233
+ usage: blankUsage(),
234
+ turnUsage: blankUsage(),
235
+ context: { used: 0, window: 0, percent: 0, source: 'unknown' },
236
+ childIds: [],
237
+ runtimePresence: [{ ...processInfo, kind: processInfo.environment || 'windows', label: `${environmentLabel} CLI` }],
238
+ messages: [{ id: `runtime:${processInfo.pid}:notice`, role: 'system', type: 'notice', title: '프로세스 감지', text: '실행 중인 AI CLI는 확인했지만 연결할 대화 기록을 아직 찾지 못했습니다.', status: 'running', timestamp: updatedAt }],
239
+ lifecycle: [{ id: `runtime:${processInfo.pid}:start`, type: 'session-start', label: 'AI CLI 프로세스 감지', detail: `PID ${processInfo.pid}`, status: 'running', timestamp: processInfo.startedAt || updatedAt }],
240
+ };
241
+ }
242
+
243
+ function bridgeLinkScore(session, bridge, now = Date.now()) {
244
+ if (!session || session.provider !== bridge.provider) return -Infinity;
245
+ if (!session.environment || session.environment.kind !== bridge.environment) return -Infinity;
246
+ if (session.clientKind === 'codex-desktop' || session.clientKind === 'codex-ide' || session.clientKind === 'claude-desktop') return -Infinity;
247
+ let score = session.parentId ? -500 : 3_000;
248
+ const sessionCwd = String(session.cwd || '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
249
+ const bridgeCwd = String(bridge.cwd || '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
250
+ if (sessionCwd && bridgeCwd && sessionCwd === bridgeCwd) score += 8_000;
251
+ const sessionStart = Date.parse(session.startedAt || 0);
252
+ const bridgeStart = Date.parse(bridge.startedAt || 0);
253
+ if (!Number.isFinite(sessionStart) || !Number.isFinite(bridgeStart)) return -Infinity;
254
+ const delta = Math.abs(sessionStart - bridgeStart) / 60_000;
255
+ if (delta > 5) return -Infinity;
256
+ score += delta <= 1 ? 8_000 : 4_000;
257
+ const age = Math.max(0, (now - Date.parse(session.updatedAt || 0)) / 60_000);
258
+ return score + Math.max(0, 720 - age);
259
+ }
260
+
261
+ function syntheticBridgeSession(bridge, now = Date.now()) {
262
+ const session = syntheticRuntimeSession({ ...bridge, id: `bridge:${bridge.id}` }, now);
263
+ session.id = `bridge:${bridge.id}`;
264
+ session.externalId = bridge.id;
265
+ session.title = `${bridge.provider === 'codex' ? 'GPT · Codex' : bridge.provider} 외부 연결`;
266
+ session.cwd = bridge.cwd || '';
267
+ session.workspace = session.cwd ? session.cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() : '작업 폴더 확인 중';
268
+ session.source = 'loadtoagent-bridge';
269
+ session.sourceLabel = 'LoadToAgent 외부 터미널 브리지';
270
+ session.clientKind = 'loadtoagent-bridge';
271
+ session.runtimePresence = [{ ...bridge, kind: 'bridge', label: 'LoadToAgent 외부 터미널 브리지' }];
272
+ session.statusDetail = `안전하게 연결된 외부 터미널 · PID ${bridge.pid}`;
273
+ return session;
274
+ }
275
+
276
+ function applyRuntimePresence(agentSessions, tmuxSnapshot, processSnapshot, now = Date.now(), bridges = []) {
277
+ const sessions = structuredClone(agentSessions || []);
278
+ const byId = new Map(sessions.map(session => [session.id, session]));
279
+ const usedSessionIds = new Set();
280
+ const usedBridgeIds = new Set();
281
+ const bridgePairs = [];
282
+ for (const bridge of bridges || []) {
283
+ for (const session of sessions) {
284
+ const score = bridgeLinkScore(session, bridge, now);
285
+ if (score > 0) bridgePairs.push({ bridge, session, score });
286
+ }
287
+ }
288
+ bridgePairs.sort((a, b) => b.score - a.score);
289
+ for (const pair of bridgePairs) {
290
+ if (usedBridgeIds.has(pair.bridge.id) || usedSessionIds.has(pair.session.id)) continue;
291
+ usedBridgeIds.add(pair.bridge.id);
292
+ usedSessionIds.add(pair.session.id);
293
+ markRuntime(pair.session, { ...pair.bridge, kind: 'bridge', label: 'LoadToAgent 외부 터미널 브리지', linkScore: Math.round(pair.score) });
294
+ }
295
+ for (const distro of tmuxSnapshot && tmuxSnapshot.distros || []) {
296
+ for (const tmuxSession of distro.sessions || []) {
297
+ for (const window of tmuxSession.windows || []) {
298
+ for (const pane of window.panes || []) {
299
+ const agent = pane.agent;
300
+ const linked = agent && agent.linkedSessionId && byId.get(agent.linkedSessionId);
301
+ if (!linked) continue;
302
+ usedSessionIds.add(linked.id);
303
+ markRuntime(linked, {
304
+ id: `tmux:${distro.name}:${pane.nativeId}`,
305
+ kind: 'tmux',
306
+ label: `${distro.name} · ${tmuxSession.name} · pane ${pane.index}`,
307
+ provider: agent.provider,
308
+ pid: agent.pid,
309
+ startedAt: agent.startedAt,
310
+ cwd: pane.cwd,
311
+ });
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ const bridgePids = new Set((bridges || []).map(item => Number(item.pid || 0)).filter(Boolean));
318
+ const processes = (processSnapshot && processSnapshot.processes || []).filter(item => !bridgePids.has(Number(item.pid || 0)));
319
+ const pairs = [];
320
+ for (const processInfo of processes) {
321
+ for (const session of sessions) {
322
+ if (usedSessionIds.has(session.id)) continue;
323
+ const score = runtimeLinkScore(session, processInfo, now);
324
+ if (score > 0) pairs.push({ processInfo, session, score });
325
+ }
326
+ }
327
+ pairs.sort((a, b) => b.score - a.score);
328
+ const usedProcessIds = new Set();
329
+ for (const pair of pairs) {
330
+ if (usedProcessIds.has(pair.processInfo.id) || usedSessionIds.has(pair.session.id)) continue;
331
+ usedProcessIds.add(pair.processInfo.id);
332
+ usedSessionIds.add(pair.session.id);
333
+ const label = pair.processInfo.environment === 'macos' ? 'macOS CLI' : (pair.processInfo.environment === 'linux' ? 'Linux CLI' : 'Windows CLI');
334
+ markRuntime(pair.session, { ...pair.processInfo, kind: pair.processInfo.environment || 'windows', label, linkScore: Math.round(pair.score) });
335
+ }
336
+ for (const processInfo of processes) {
337
+ if (!usedProcessIds.has(processInfo.id)) sessions.push(syntheticRuntimeSession(processInfo, now));
338
+ }
339
+ for (const bridge of bridges || []) {
340
+ if (!usedBridgeIds.has(bridge.id)) sessions.push(syntheticBridgeSession(bridge, now));
341
+ }
342
+ return sessions.sort((a, b) => {
343
+ const liveA = a.status === 'running' || a.status === 'starting' ? 1 : 0;
344
+ const liveB = b.status === 'running' || b.status === 'starting' ? 1 : 0;
345
+ return liveB - liveA || Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0);
346
+ });
347
+ }
348
+
349
+ class ProcessMonitor {
350
+ constructor(options = {}) {
351
+ this.execFileSync = options.execFileSync || execFileSync;
352
+ this.platform = options.platform || process.platform;
353
+ this.scanTtlMs = options.scanTtlMs ?? DEFAULT_SCAN_TTL_MS;
354
+ this.lastScanAt = 0;
355
+ this.lastSnapshot = { generatedAt: new Date().toISOString(), available: false, processes: [], error: '' };
356
+ }
357
+
358
+ scan(force = false) {
359
+ if (!force && Date.now() - this.lastScanAt < this.scanTtlMs) return this.lastSnapshot;
360
+ this.lastScanAt = Date.now();
361
+ try {
362
+ let processes;
363
+ if (this.platform === 'win32') {
364
+ const output = this.execFileSync('wmic.exe', ['process', 'where', WMIC_QUERY, 'get', 'ProcessId,ParentProcessId,CreationDate,Name,CommandLine', '/format:csv'], {
365
+ encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 2 * 1024 * 1024,
366
+ });
367
+ processes = selectAgentProcesses(processRows(output));
368
+ } else {
369
+ const output = this.execFileSync('ps', ['-axo', 'pid=,ppid=,etime=,comm=,args='], { encoding: 'utf8', timeout: 8_000, maxBuffer: 2 * 1024 * 1024 });
370
+ const environment = this.platform === 'darwin' ? 'macos' : 'linux';
371
+ processes = selectAgentProcesses(posixProcessRows(output), { providerResolver: providerFromPosixProcess, environment });
372
+ }
373
+ this.lastSnapshot = { generatedAt: new Date().toISOString(), available: true, processes, error: '' };
374
+ } catch (error) {
375
+ this.lastSnapshot = { generatedAt: new Date().toISOString(), available: false, processes: [], error: String(error.message || error) };
376
+ }
377
+ return this.lastSnapshot;
378
+ }
379
+ }
380
+
381
+ module.exports = {
382
+ ProcessMonitor,
383
+ parseCsvRows,
384
+ processRows,
385
+ wmiDateToIso,
386
+ providerFromWindowsProcess,
387
+ providerFromPosixProcess,
388
+ posixProcessRows,
389
+ elapsedSeconds,
390
+ selectAgentProcesses,
391
+ runtimeLinkScore,
392
+ bridgeLinkScore,
393
+ applyRuntimePresence,
394
+ };