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,183 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const crypto = require('crypto');
5
+ const { reportRecoverableError, runBestEffort } = require('./diagnostics');
6
+
7
+ const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
8
+ const ALLOWED_KEYS = new Set(['Enter', 'Escape', 'Tab', 'BSpace', 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage', 'C-c', 'C-d', 'C-l', 'C-z']);
9
+ const ALLOWED_LAYOUTS = new Set(['even-horizontal', 'even-vertical', 'main-horizontal', 'main-vertical', 'tiled']);
10
+
11
+ function clean(value, max = 200) {
12
+ const text = String(value == null ? '' : value).replace(/[\u0000\r\n]/g, '').trim();
13
+ if (!text || text.length > max) throw new Error('tmux 대상 값이 올바르지 않습니다.');
14
+ return text;
15
+ }
16
+
17
+ function safeName(value) {
18
+ const text = clean(value, 100);
19
+ if (!/^[\p{L}\p{N}_.-]+$/u.test(text)) throw new Error('이름에는 문자, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다.');
20
+ return text;
21
+ }
22
+
23
+ function safeTarget(value) {
24
+ const text = clean(value, 160);
25
+ if (!/^[\p{L}\p{N}_@%$.:+\/-]+$/u.test(text)) throw new Error('tmux 대상 형식이 올바르지 않습니다.');
26
+ return text;
27
+ }
28
+
29
+ function runProcess(file, args, options = {}) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn(file, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
32
+ const stdout = [];
33
+ const stderr = [];
34
+ let stdoutBytes = 0;
35
+ let stderrBytes = 0;
36
+ let settled = false;
37
+ const finish = (error, value) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ if (error) reject(error); else resolve(value);
42
+ };
43
+ const timer = setTimeout(() => {
44
+ runBestEffort('tmux-timeout-kill', () => child.kill());
45
+ finish(new Error('tmux 명령 시간이 초과되었습니다.'));
46
+ }, options.timeoutMs || 8_000);
47
+ child.stdout.on('data', chunk => {
48
+ stdoutBytes += chunk.length;
49
+ if (stdoutBytes <= MAX_OUTPUT_BYTES) stdout.push(chunk);
50
+ });
51
+ child.stderr.on('data', chunk => {
52
+ stderrBytes += chunk.length;
53
+ if (stderrBytes <= MAX_OUTPUT_BYTES) stderr.push(chunk);
54
+ });
55
+ child.on('error', error => finish(error));
56
+ child.on('exit', code => {
57
+ const out = Buffer.concat(stdout).toString('utf8');
58
+ const err = Buffer.concat(stderr).toString('utf8').trim();
59
+ if (code === 0) finish(null, { ok: true, stdout: out, stderr: err });
60
+ else finish(new Error(err || `tmux 명령이 종료 코드 ${code}로 실패했습니다.`));
61
+ });
62
+ if (options.input != null) child.stdin.end(String(options.input), 'utf8');
63
+ else child.stdin.end();
64
+ });
65
+ }
66
+
67
+ class TmuxController {
68
+ constructor(options = {}) {
69
+ this.run = options.run || runProcess;
70
+ this.platform = options.platform || process.platform;
71
+ }
72
+
73
+ execute(distro, args, options = {}) {
74
+ if (this.platform !== 'win32') return this.run('tmux', args.map(String), options);
75
+ // A cold WSL distro can take several seconds before tmux itself starts.
76
+ return this.run('wsl.exe', ['-d', clean(distro, 100), '--', 'tmux', ...args.map(String)], {
77
+ ...options,
78
+ timeoutMs: options.timeoutMs ?? 15_000,
79
+ });
80
+ }
81
+
82
+ async sendText(options = {}) {
83
+ const distro = clean(options.distro, 100);
84
+ const target = safeTarget(options.target);
85
+ const text = String(options.text == null ? '' : options.text);
86
+ if (!text || text.length > 128 * 1024) throw new Error('보낼 명령의 크기가 올바르지 않습니다.');
87
+ const bufferName = `loadtoagent-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
88
+ await this.execute(distro, ['load-buffer', '-b', bufferName, '-'], { input: text });
89
+ try {
90
+ await this.execute(distro, ['paste-buffer', '-b', bufferName, '-d', '-t', target]);
91
+ } catch (error) {
92
+ await this.execute(distro, ['delete-buffer', '-b', bufferName]).catch(cleanupError => {
93
+ reportRecoverableError('tmux-delete-failed-buffer', cleanupError);
94
+ });
95
+ throw error;
96
+ }
97
+ if (options.enter !== false) await this.execute(distro, ['send-keys', '-t', target, 'Enter']);
98
+ return { ok: true };
99
+ }
100
+
101
+ sendKey(options = {}) {
102
+ const key = clean(options.key, 20);
103
+ if (!ALLOWED_KEYS.has(key)) throw new Error('허용되지 않은 tmux 키입니다.');
104
+ return this.execute(options.distro, ['send-keys', '-t', safeTarget(options.target), key]).then(() => ({ ok: true }));
105
+ }
106
+
107
+ async capture(options = {}) {
108
+ const lines = Math.max(20, Math.min(5_000, Math.floor(Number(options.lines || 500))));
109
+ const result = await this.execute(options.distro, ['capture-pane', '-p', '-e', '-S', `-${lines}`, '-t', safeTarget(options.target)]);
110
+ return { ok: true, output: result.stdout };
111
+ }
112
+
113
+ async newSession(options = {}) {
114
+ const distro = clean(options.distro, 100);
115
+ const name = safeName(options.name);
116
+ const args = ['new-session', '-d', '-s', name];
117
+ if (options.cwd) args.push('-c', clean(options.cwd, 500));
118
+ await this.execute(distro, args);
119
+ if (String(options.command || '').trim()) await this.sendText({ distro, target: name, text: options.command, enter: true });
120
+ return { ok: true, name };
121
+ }
122
+
123
+ async newWindow(options = {}) {
124
+ const args = ['new-window', '-d', '-t', safeTarget(options.target)];
125
+ if (options.name) args.push('-n', safeName(options.name));
126
+ if (options.cwd) args.push('-c', clean(options.cwd, 500));
127
+ const result = await this.execute(options.distro, args);
128
+ return { ok: true, output: result.stdout };
129
+ }
130
+
131
+ async splitPane(options = {}) {
132
+ // WSL routes command arguments through a Linux command line; a bare tmux
133
+ // format beginning with # can be consumed as a shell comment. -P's default
134
+ // target format is stable and avoids that quoting boundary entirely.
135
+ if (!['horizontal', 'vertical'].includes(options.direction)) throw new Error('지원하지 않는 tmux 분할 방향입니다.');
136
+ const args = ['split-window', '-d', '-t', safeTarget(options.target), '-P'];
137
+ if (options.direction === 'horizontal') args.splice(1, 0, '-h');
138
+ if (options.cwd) args.push('-c', clean(options.cwd, 500));
139
+ const result = await this.execute(options.distro, args);
140
+ return { ok: true, paneId: result.stdout.trim() };
141
+ }
142
+
143
+ async renameSession(options = {}) {
144
+ await this.execute(options.distro, ['rename-session', '-t', safeTarget(options.target), safeName(options.name)]);
145
+ return { ok: true };
146
+ }
147
+
148
+ async renameWindow(options = {}) {
149
+ await this.execute(options.distro, ['rename-window', '-t', safeTarget(options.target), safeName(options.name)]);
150
+ return { ok: true };
151
+ }
152
+
153
+ async selectLayout(options = {}) {
154
+ const layout = clean(options.layout, 40);
155
+ if (!ALLOWED_LAYOUTS.has(layout)) throw new Error('지원하지 않는 tmux 레이아웃입니다.');
156
+ await this.execute(options.distro, ['select-layout', '-t', safeTarget(options.target), layout]);
157
+ return { ok: true };
158
+ }
159
+
160
+ async killPane(options = {}) {
161
+ await this.execute(options.distro, ['kill-pane', '-t', safeTarget(options.target)]);
162
+ return { ok: true };
163
+ }
164
+
165
+ async killWindow(options = {}) {
166
+ await this.execute(options.distro, ['kill-window', '-t', safeTarget(options.target)]);
167
+ return { ok: true };
168
+ }
169
+
170
+ async killSession(options = {}) {
171
+ await this.execute(options.distro, ['kill-session', '-t', safeTarget(options.target)]);
172
+ return { ok: true };
173
+ }
174
+ }
175
+
176
+ module.exports = {
177
+ TmuxController,
178
+ runProcess,
179
+ safeName,
180
+ safeTarget,
181
+ ALLOWED_KEYS,
182
+ ALLOWED_LAYOUTS,
183
+ };
@@ -0,0 +1,432 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ const FIELD_SEPARATOR = '|~|';
7
+ const DEFAULT_SCAN_TTL_MS = 5_000;
8
+ const DEFAULT_DISCOVERY_TTL_MS = 60_000;
9
+
10
+ function decodeCommandOutput(value) {
11
+ if (value == null) return '';
12
+ if (typeof value === 'string') return value.replace(/\u0000/g, '');
13
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
14
+ const hasNullBytes = buffer.subarray(0, Math.min(buffer.length, 200)).some(byte => byte === 0);
15
+ return buffer.toString(hasNullBytes ? 'utf16le' : 'utf8').replace(/^\uFEFF/, '').replace(/\u0000/g, '');
16
+ }
17
+
18
+ function normalizeWslList(value) {
19
+ return [...new Set(decodeCommandOutput(value)
20
+ .split(/\r?\n/)
21
+ .map(item => item.trim())
22
+ .filter(item => item && !/^docker-desktop(?:-data)?$/i.test(item)))];
23
+ }
24
+
25
+ function uncHomeFor(distro, linuxHome) {
26
+ const parts = String(linuxHome || '').split('/').filter(Boolean);
27
+ if (!distro || !parts.length) return '';
28
+ return `\\\\wsl.localhost\\${distro}\\${parts.join('\\')}`;
29
+ }
30
+
31
+ function linuxPathToUnc(distro, linuxPath) {
32
+ const parts = String(linuxPath || '').split('/').filter(Boolean);
33
+ if (!distro || !parts.length) return '';
34
+ return `\\\\wsl.localhost\\${distro}\\${parts.join('\\')}`;
35
+ }
36
+
37
+ function providerFromProcess(processInfo = {}) {
38
+ const command = String(processInfo.command || '').toLowerCase();
39
+ const args = String(processInfo.args || '').toLowerCase();
40
+ if (command === 'claude' || /(?:^|\/)claude(?:\s|$)/.test(args) || /@anthropic-ai\/claude-code/.test(args)) return 'claude';
41
+ if (/^codex(?:-|$)/.test(command) || /@openai\/codex/.test(args) || /(?:^|\/)bin\/codex(?:\s|$)/.test(args)) return 'codex';
42
+ if (/^gemini(?:-|$)/.test(command) || /@google\/gemini-cli/.test(args) || /(?:^|\/)bin\/gemini(?:\s|$)/.test(args)) return 'gemini';
43
+ if (/^grok(?:-|$)/.test(command) || /@xai-org\/grok/.test(args) || /(?:^|\/)bin\/grok(?:\s|$)/.test(args)) return 'grok';
44
+ return '';
45
+ }
46
+
47
+ function parseElapsedSeconds(value) {
48
+ const text = String(value || '').trim();
49
+ const match = text.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
50
+ if (!match) return Number(text || 0);
51
+ return Number(match[1] || 0) * 86400 + Number(match[2] || 0) * 3600 + Number(match[3] || 0) * 60 + Number(match[4] || 0);
52
+ }
53
+
54
+ function parseProcessLine(value) {
55
+ const match = String(value || '').trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/);
56
+ if (!match) return null;
57
+ return {
58
+ pid: Number(match[1]),
59
+ parentPid: Number(match[2]),
60
+ elapsedSeconds: parseElapsedSeconds(match[3]),
61
+ command: match[4],
62
+ args: match[5] || match[4],
63
+ };
64
+ }
65
+
66
+ function parseTmuxProbe(value, distro, now = Date.now(), kind = 'wsl') {
67
+ const output = decodeCommandOutput(value);
68
+ const meta = { linuxHome: '', tmuxVersion: '' };
69
+ const panes = [];
70
+ const processes = [];
71
+ const historyFiles = { claude: [], codex: [], gemini: [], grok: [] };
72
+ for (const rawLine of output.split(/\r?\n/)) {
73
+ if (!rawLine) continue;
74
+ const parts = rawLine.split(FIELD_SEPARATOR);
75
+ if (parts[0] === 'M') {
76
+ meta.linuxHome = parts[1] || '';
77
+ meta.tmuxVersion = parts[2] || '';
78
+ } else if (parts[0] === 'P' && parts.length >= 18) {
79
+ panes.push({
80
+ sessionNativeId: parts[1],
81
+ sessionName: parts[2],
82
+ sessionCreatedAt: Number(parts[3]) ? new Date(Number(parts[3]) * 1000).toISOString() : null,
83
+ sessionAttached: Number(parts[4]) > 0,
84
+ sessionWindows: Number(parts[5] || 0),
85
+ windowNativeId: parts[6],
86
+ windowIndex: Number(parts[7] || 0),
87
+ windowName: parts[8] || 'window',
88
+ windowActive: parts[9] === '1',
89
+ paneNativeId: parts[10],
90
+ paneIndex: Number(parts[11] || 0),
91
+ pid: Number(parts[12] || 0),
92
+ currentCommand: parts[13] || '',
93
+ cwd: parts[14] || '',
94
+ active: parts[15] === '1',
95
+ dead: parts[16] === '1',
96
+ title: parts[17] || '',
97
+ });
98
+ } else if (parts[0] === 'R') {
99
+ const processInfo = parseProcessLine(parts.slice(1).join(FIELD_SEPARATOR));
100
+ if (processInfo) {
101
+ processInfo.startedAt = new Date(now - processInfo.elapsedSeconds * 1000).toISOString();
102
+ processes.push(processInfo);
103
+ }
104
+ } else if (parts[0] === 'F' && historyFiles[parts[1]] && parts.length >= 5) {
105
+ const linuxPath = parts.slice(4).join(FIELD_SEPARATOR);
106
+ if (linuxPath) historyFiles[parts[1]].push({
107
+ file: linuxPath,
108
+ mtimeMs: Math.round(Number(parts[2] || 0) * 1000),
109
+ size: Number(parts[3] || 0),
110
+ });
111
+ }
112
+ }
113
+ return { distro, kind, ...meta, panes, processes, historyFiles };
114
+ }
115
+
116
+ function descendantProcesses(rootPid, processes) {
117
+ const byParent = new Map();
118
+ for (const item of processes) {
119
+ if (!byParent.has(item.parentPid)) byParent.set(item.parentPid, []);
120
+ byParent.get(item.parentPid).push(item);
121
+ }
122
+ const result = [];
123
+ const queue = [Number(rootPid || 0)];
124
+ const seen = new Set(queue);
125
+ while (queue.length) {
126
+ const parentPid = queue.shift();
127
+ for (const child of byParent.get(parentPid) || []) {
128
+ if (seen.has(child.pid)) continue;
129
+ seen.add(child.pid);
130
+ result.push(child);
131
+ queue.push(child.pid);
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ function selectAgentProcess(pane, processes) {
138
+ const direct = processes.find(item => item.pid === pane.pid);
139
+ const candidates = [direct, ...descendantProcesses(pane.pid, processes)].filter(Boolean);
140
+ const detected = candidates
141
+ .map(item => ({ ...item, provider: providerFromProcess(item) }))
142
+ .filter(item => item.provider);
143
+ if (!detected.length) return null;
144
+ return detected.sort((a, b) => {
145
+ const binaryA = a.command === a.provider ? 1 : 0;
146
+ const binaryB = b.command === b.provider ? 1 : 0;
147
+ return binaryB - binaryA || b.pid - a.pid;
148
+ })[0];
149
+ }
150
+
151
+ function buildDistroTopology(probe) {
152
+ const distroId = `${probe.kind || 'wsl'}:${probe.distro}`;
153
+ const sessionsById = new Map();
154
+ for (const rawPane of probe.panes) {
155
+ const sessionId = `${distroId}:tmux:${rawPane.sessionNativeId}`;
156
+ if (!sessionsById.has(sessionId)) {
157
+ sessionsById.set(sessionId, {
158
+ id: sessionId,
159
+ nativeId: rawPane.sessionNativeId,
160
+ name: rawPane.sessionName,
161
+ createdAt: rawPane.sessionCreatedAt,
162
+ attached: rawPane.sessionAttached,
163
+ windowCount: rawPane.sessionWindows,
164
+ windows: [],
165
+ });
166
+ }
167
+ const session = sessionsById.get(sessionId);
168
+ let window = session.windows.find(item => item.nativeId === rawPane.windowNativeId);
169
+ if (!window) {
170
+ window = {
171
+ id: `${sessionId}:window:${rawPane.windowNativeId}`,
172
+ nativeId: rawPane.windowNativeId,
173
+ index: rawPane.windowIndex,
174
+ name: rawPane.windowName,
175
+ active: rawPane.windowActive,
176
+ panes: [],
177
+ };
178
+ session.windows.push(window);
179
+ }
180
+ const agentProcess = selectAgentProcess(rawPane, probe.processes);
181
+ window.panes.push({
182
+ id: `${window.id}:pane:${rawPane.paneNativeId}`,
183
+ nativeId: rawPane.paneNativeId,
184
+ index: rawPane.paneIndex,
185
+ pid: rawPane.pid,
186
+ command: rawPane.currentCommand,
187
+ cwd: rawPane.cwd,
188
+ active: rawPane.active,
189
+ dead: rawPane.dead,
190
+ title: rawPane.title,
191
+ agentProcess: agentProcess ? {
192
+ provider: agentProcess.provider,
193
+ pid: agentProcess.pid,
194
+ parentPid: agentProcess.parentPid,
195
+ command: agentProcess.command,
196
+ args: String(agentProcess.args || '').slice(0, 500),
197
+ startedAt: agentProcess.startedAt,
198
+ } : null,
199
+ agent: null,
200
+ });
201
+ }
202
+ const sessions = [...sessionsById.values()]
203
+ .map(session => ({
204
+ ...session,
205
+ windows: session.windows
206
+ .map(window => ({ ...window, panes: window.panes.sort((a, b) => a.index - b.index) }))
207
+ .sort((a, b) => a.index - b.index),
208
+ }))
209
+ .sort((a, b) => a.name.localeCompare(b.name));
210
+ return {
211
+ id: distroId,
212
+ name: probe.distro,
213
+ kind: probe.kind || 'wsl',
214
+ linuxHome: probe.linuxHome,
215
+ uncHome: probe.kind === 'wsl' ? uncHomeFor(probe.distro, probe.linuxHome) : '',
216
+ tmuxInstalled: Boolean(probe.tmuxVersion),
217
+ tmuxVersion: probe.tmuxVersion,
218
+ historyFiles: probe.historyFiles,
219
+ error: '',
220
+ sessions,
221
+ };
222
+ }
223
+
224
+ function normalizeCwd(value) {
225
+ let text = String(value || '').trim().replace(/\\/g, '/');
226
+ const drive = text.match(/^([a-z]):\/(.*)$/i);
227
+ if (drive) text = `/mnt/${drive[1].toLowerCase()}/${drive[2]}`;
228
+ return text.replace(/\/{2,}/g, '/').replace(/\/$/, '').toLowerCase();
229
+ }
230
+
231
+ function sessionEnvironmentMatches(session, distro) {
232
+ if (!session.environment) return false;
233
+ if (distro.kind === 'local') return session.environment.kind === 'macos' || session.environment.kind === 'linux';
234
+ return session.environment.kind === 'wsl'
235
+ && String(session.environment.distro || '').toLowerCase() === String(distro.name || '').toLowerCase();
236
+ }
237
+
238
+ function linkScore(session, pane, distro, processInfo, now = Date.now()) {
239
+ if (!session || session.provider !== processInfo.provider) return -Infinity;
240
+ let score = 0;
241
+ const sessionCwd = normalizeCwd(session.cwd);
242
+ const paneCwd = normalizeCwd(pane.cwd);
243
+ if (sessionCwd && paneCwd && sessionCwd === paneCwd) score += 2_000;
244
+ else if (sessionCwd && paneCwd && (sessionCwd.startsWith(`${paneCwd}/`) || paneCwd.startsWith(`${sessionCwd}/`))) score += 500;
245
+ if (sessionEnvironmentMatches(session, distro)) score += 1_500;
246
+ else if (session.environment && session.environment.kind === 'wsl') score -= 2_000;
247
+ if (session.status === 'running') score += 500;
248
+ const updatedDeltaMinutes = Math.abs(now - Date.parse(session.updatedAt || 0)) / 60_000;
249
+ score += Math.max(0, 360 - updatedDeltaMinutes);
250
+ const processStarted = Date.parse(processInfo.startedAt || 0);
251
+ const sessionStarted = Date.parse(session.startedAt || 0);
252
+ if (Number.isFinite(processStarted) && Number.isFinite(sessionStarted)) {
253
+ score += Math.max(0, 240 - Math.abs(processStarted - sessionStarted) / 60_000);
254
+ }
255
+ return score;
256
+ }
257
+
258
+ function linkAgentSessions(snapshot, agentSessions, now = Date.now()) {
259
+ const result = structuredClone(snapshot);
260
+ const usedSessionIds = new Set();
261
+ let paneCount = 0;
262
+ let aiPaneCount = 0;
263
+ let linkedCount = 0;
264
+ let windowCount = 0;
265
+ let tmuxSessionCount = 0;
266
+ for (const distro of result.distros || []) {
267
+ for (const tmuxSession of distro.sessions || []) {
268
+ tmuxSessionCount += 1;
269
+ for (const window of tmuxSession.windows || []) {
270
+ windowCount += 1;
271
+ for (const pane of window.panes || []) {
272
+ paneCount += 1;
273
+ if (!pane.agentProcess) continue;
274
+ aiPaneCount += 1;
275
+ const ranked = (agentSessions || [])
276
+ .filter(session => !usedSessionIds.has(session.id))
277
+ .map(session => ({ session, score: linkScore(session, pane, distro, pane.agentProcess, now) }))
278
+ .filter(item => item.score >= 1_000)
279
+ .sort((a, b) => b.score - a.score);
280
+ const linked = ranked[0] && ranked[0].session;
281
+ if (linked) {
282
+ usedSessionIds.add(linked.id);
283
+ linkedCount += 1;
284
+ }
285
+ pane.agent = {
286
+ provider: pane.agentProcess.provider,
287
+ pid: pane.agentProcess.pid,
288
+ command: pane.agentProcess.command,
289
+ args: pane.agentProcess.args,
290
+ startedAt: pane.agentProcess.startedAt,
291
+ linkedSessionId: linked && linked.id || null,
292
+ title: linked && linked.title || `${pane.agentProcess.provider} tmux 작업`,
293
+ model: linked && linked.model || '',
294
+ status: pane.dead ? 'failed' : 'running',
295
+ statusDetail: linked && linked.statusDetail || `${pane.command || pane.agentProcess.command} 프로세스 실행 중`,
296
+ updatedAt: linked && linked.updatedAt || new Date(now).toISOString(),
297
+ context: linked && linked.context || { used: 0, window: 0, percent: 0, source: 'unknown' },
298
+ usage: linked && linked.usage || { input: 0, cachedInput: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 },
299
+ childIds: linked && linked.childIds || [],
300
+ agentName: linked && linked.agentName || '',
301
+ linkScore: ranked[0] && Math.round(ranked[0].score) || 0,
302
+ };
303
+ }
304
+ }
305
+ }
306
+ }
307
+ result.summary = {
308
+ distros: (result.distros || []).filter(item => item.tmuxInstalled).length,
309
+ sessions: tmuxSessionCount,
310
+ windows: windowCount,
311
+ panes: paneCount,
312
+ aiPanes: aiPaneCount,
313
+ linked: linkedCount,
314
+ };
315
+ return result;
316
+ }
317
+
318
+ class TmuxMonitor {
319
+ constructor(options = {}) {
320
+ this.execFileSync = options.execFileSync || execFileSync;
321
+ this.platform = options.platform || process.platform;
322
+ this.scanTtlMs = options.scanTtlMs ?? DEFAULT_SCAN_TTL_MS;
323
+ this.discoveryTtlMs = options.discoveryTtlMs ?? DEFAULT_DISCOVERY_TTL_MS;
324
+ this.lastDiscoveryAt = 0;
325
+ this.lastScanAt = 0;
326
+ this.distros = [];
327
+ this.lastGoodByDistro = new Map();
328
+ this.lastSnapshot = { generatedAt: new Date().toISOString(), available: false, status: '확인 중', distros: [], summary: { distros: 0, sessions: 0, windows: 0, panes: 0, aiPanes: 0, linked: 0 } };
329
+ }
330
+
331
+ discoverDistros(force = false) {
332
+ if (!force && Date.now() - this.lastDiscoveryAt < this.discoveryTtlMs) return this.distros;
333
+ this.lastDiscoveryAt = Date.now();
334
+ if (this.platform === 'darwin') {
335
+ this.distros = ['macOS'];
336
+ return this.distros;
337
+ }
338
+ if (this.platform !== 'win32') {
339
+ this.distros = ['로컬'];
340
+ return this.distros;
341
+ }
342
+ try {
343
+ const output = this.execFileSync('wsl.exe', ['--list', '--quiet'], { windowsHide: true, timeout: 5_000, maxBuffer: 256 * 1024 });
344
+ this.distros = normalizeWslList(output);
345
+ } catch (_wslUnavailable) {
346
+ // WSL absence is a supported runtime state; the next scan will probe again.
347
+ this.distros = [];
348
+ }
349
+ return this.distros;
350
+ }
351
+
352
+ probeDistro(distro) {
353
+ const common = [
354
+ 'printf "M|~|%s|~|" "$HOME"',
355
+ "tmux -V 2>/dev/null | tr ' ' '_' || true",
356
+ 'if command -v tmux >/dev/null 2>&1; then tmux list-panes -a -F "P|~|#{session_id}|~|#{session_name}|~|#{session_created}|~|#{session_attached}|~|#{session_windows}|~|#{window_id}|~|#{window_index}|~|#{window_name}|~|#{window_active}|~|#{pane_id}|~|#{pane_index}|~|#{pane_pid}|~|#{pane_current_command}|~|#{pane_current_path}|~|#{pane_active}|~|#{pane_dead}|~|#{pane_title}" 2>/dev/null || true; fi',
357
+ ];
358
+ const history = [
359
+ 'ps -eo pid=,ppid=,etimes=,comm=,args= 2>/dev/null | sed "s/^/R|~|/"',
360
+ 'find "$HOME/.claude/projects" -type f -name "*.jsonl" -printf "%T@ %s %p\\n" 2>/dev/null | sort -nr | head -80 | while read -r MT SZ FILE; do printf "F|~|claude|~|%s|~|%s|~|%s\\n" "\\$MT" "\\$SZ" "\\$FILE"; done',
361
+ 'find "$HOME/.codex/sessions" -type f -name "*.jsonl" -printf "%T@ %s %p\\n" 2>/dev/null | sort -nr | head -80 | while read -r MT SZ FILE; do printf "F|~|codex|~|%s|~|%s|~|%s\\n" "\\$MT" "\\$SZ" "\\$FILE"; done',
362
+ 'find "$HOME/.gemini/tmp" -type f \\( -name "*.json" -o -name "*.jsonl" \\) -printf "%T@ %s %p\\n" 2>/dev/null | sort -nr | head -50 | while read -r MT SZ FILE; do printf "F|~|gemini|~|%s|~|%s|~|%s\\n" "\\$MT" "\\$SZ" "\\$FILE"; done',
363
+ 'find "$HOME/.grok/sessions" -type f \\( -name "*.json" -o -name "*.jsonl" \\) -printf "%T@ %s %p\\n" 2>/dev/null | sort -nr | head -50 | while read -r MT SZ FILE; do printf "F|~|grok|~|%s|~|%s|~|%s\\n" "\\$MT" "\\$SZ" "\\$FILE"; done',
364
+ ];
365
+ const localProcess = 'ps -axo pid=,ppid=,etime=,comm=,args= 2>/dev/null | sed "s/^/R|~|/"';
366
+ const command = [...common, ...(this.platform === 'win32' ? history : [localProcess])].join('; ');
367
+ const file = this.platform === 'win32' ? 'wsl.exe' : (process.env.SHELL || '/bin/sh');
368
+ const args = this.platform === 'win32' ? ['-d', distro, '--', 'sh', '-lc', command] : ['-lc', command];
369
+ const output = this.execFileSync(file, args, {
370
+ encoding: 'utf8',
371
+ windowsHide: true,
372
+ timeout: 10_000,
373
+ maxBuffer: 2 * 1024 * 1024,
374
+ });
375
+ const topology = buildDistroTopology(parseTmuxProbe(output, distro, Date.now(), this.platform === 'win32' ? 'wsl' : 'local'));
376
+ topology.tmuxVersion = topology.tmuxVersion.replace(/_/g, ' ');
377
+ return topology;
378
+ }
379
+
380
+ scan(force = false) {
381
+ if (!force && Date.now() - this.lastScanAt < this.scanTtlMs) return this.lastSnapshot;
382
+ this.lastScanAt = Date.now();
383
+ const distros = this.discoverDistros(force);
384
+ const results = [];
385
+ for (const distro of distros) {
386
+ try {
387
+ const topology = this.probeDistro(distro);
388
+ if (topology.tmuxInstalled) {
389
+ this.lastGoodByDistro.set(distro, topology);
390
+ results.push(topology);
391
+ }
392
+ } catch (error) {
393
+ const previous = this.lastGoodByDistro.get(distro);
394
+ if (previous) results.push({ ...structuredClone(previous), error: String(error.message || error), stale: true });
395
+ }
396
+ }
397
+ this.lastSnapshot = {
398
+ generatedAt: new Date().toISOString(),
399
+ available: results.length > 0,
400
+ status: results.some(item => item.sessions.length) ? '연결됨' : (results.length ? 'tmux 설치됨 · 실행 세션 없음' : (distros.length ? 'tmux 미설치 또는 서버 없음' : (this.platform === 'win32' ? 'WSL 배포판 없음' : '로컬 환경 없음'))),
401
+ distros: results,
402
+ summary: { distros: results.length, sessions: 0, windows: 0, panes: 0, aiPanes: 0, linked: 0 },
403
+ };
404
+ return this.lastSnapshot;
405
+ }
406
+
407
+ historyHomes() {
408
+ return (this.lastSnapshot.distros || [])
409
+ .filter(item => item.kind === 'wsl' && item.uncHome)
410
+ .map(item => ({
411
+ home: item.uncHome,
412
+ kind: 'wsl',
413
+ distro: item.name,
414
+ label: `WSL · ${item.name}`,
415
+ files: Object.fromEntries(Object.entries(item.historyFiles || {}).map(([provider, files]) => [provider, files.slice(0, 40).map(info => ({ ...info, file: linuxPathToUnc(item.name, info.file) }))])),
416
+ }));
417
+ }
418
+ }
419
+
420
+ module.exports = {
421
+ TmuxMonitor,
422
+ normalizeWslList,
423
+ parseTmuxProbe,
424
+ parseProcessLine,
425
+ parseElapsedSeconds,
426
+ providerFromProcess,
427
+ buildDistroTopology,
428
+ linkAgentSessions,
429
+ normalizeCwd,
430
+ uncHomeFor,
431
+ linuxPathToUnc,
432
+ };