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.
- package/README.ko.md +175 -0
- package/README.md +175 -0
- package/README.zh-CN.md +175 -0
- package/bin/loadtoagent.js +171 -0
- package/docs/ARCHITECTURE.md +30 -0
- package/docs/PROVIDER-CONTRACTS.md +58 -0
- package/docs/assets/loadtoagent-dashboard.png +0 -0
- package/docs/assets/loadtoagent-demo.gif +0 -0
- package/main.js +493 -0
- package/package.json +133 -0
- package/preload.js +75 -0
- package/renderer/app-agent-actions.js +285 -0
- package/renderer/app-bootstrap.js +95 -0
- package/renderer/app-dashboard.js +361 -0
- package/renderer/app-drawer-content.js +203 -0
- package/renderer/app-drawer-data.js +41 -0
- package/renderer/app-drawer.js +183 -0
- package/renderer/app-events-dialogs.js +169 -0
- package/renderer/app-events-filters.js +74 -0
- package/renderer/app-events-navigation.js +96 -0
- package/renderer/app-events-sessions.js +195 -0
- package/renderer/app-events.js +16 -0
- package/renderer/app-graph-layout.js +71 -0
- package/renderer/app-graph-model.js +107 -0
- package/renderer/app-graph-orchestration.js +76 -0
- package/renderer/app-graph-view.js +544 -0
- package/renderer/app-run-modal.js +221 -0
- package/renderer/app-session-render.js +243 -0
- package/renderer/app-tmux-render.js +247 -0
- package/renderer/app.js +608 -0
- package/renderer/fonts/geist-ext.woff2 +0 -0
- package/renderer/fonts/geist.woff2 +0 -0
- package/renderer/i18n-messages.js +355 -0
- package/renderer/i18n.js +122 -0
- package/renderer/index.html +386 -0
- package/renderer/shared.js +26 -0
- package/renderer/styles-agent-map.css +401 -0
- package/renderer/styles-cards.css +600 -0
- package/renderer/styles-collaboration.css +534 -0
- package/renderer/styles-components.css +1293 -0
- package/renderer/styles-onboarding.css +344 -0
- package/renderer/styles-overlays.css +284 -0
- package/renderer/styles-product.css +686 -0
- package/renderer/styles-responsive-product.css +689 -0
- package/renderer/styles-responsive-runtime.css +718 -0
- package/renderer/styles-responsive-shell.css +533 -0
- package/renderer/styles-responsive-workflows.css +778 -0
- package/renderer/styles-run-composer.css +695 -0
- package/renderer/styles-settings.css +606 -0
- package/renderer/styles-terminal.css +1241 -0
- package/renderer/styles-tmux.css +1162 -0
- package/renderer/styles-workflow-map.css +513 -0
- package/renderer/styles-workflows.css +1217 -0
- package/renderer/styles.css +486 -0
- package/renderer/terminal-agent.js +152 -0
- package/renderer/terminal-events.js +226 -0
- package/renderer/terminal-workbench.js +464 -0
- package/renderer/terminal.js +497 -0
- package/src/agentMonitor/claudeParser.js +197 -0
- package/src/agentMonitor/codexCollaboration.js +95 -0
- package/src/agentMonitor/codexParser.js +447 -0
- package/src/agentMonitor/genericParser.js +244 -0
- package/src/agentMonitor/hierarchy.js +248 -0
- package/src/agentMonitor/sessionFiles.js +86 -0
- package/src/agentMonitor.js +591 -0
- package/src/agentRunner.js +465 -0
- package/src/bridgeServer.js +246 -0
- package/src/contracts.js +71 -0
- package/src/diagnostics.js +23 -0
- package/src/ipc/registerAgentIpc.js +12 -0
- package/src/ipc/registerAppIpc.js +20 -0
- package/src/ipc/registerTerminalIpc.js +50 -0
- package/src/ipc/registerTmuxIpc.js +30 -0
- package/src/ipc/registerWorkspaceIpc.js +14 -0
- package/src/monitorWorker.js +273 -0
- package/src/processMonitor.js +394 -0
- package/src/providerRegistry.js +120 -0
- package/src/terminalManager.js +438 -0
- package/src/tmuxController.js +183 -0
- package/src/tmuxMonitor.js +432 -0
- package/src/updateManager.js +339 -0
- package/src/workspaceStore.js +77 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PROVIDERS = Object.freeze({
|
|
4
|
+
claude: {
|
|
5
|
+
id: 'claude',
|
|
6
|
+
label: 'Claude',
|
|
7
|
+
company: 'Anthropic',
|
|
8
|
+
command: 'claude',
|
|
9
|
+
accent: '#d97757',
|
|
10
|
+
mark: 'CL',
|
|
11
|
+
historyHint: '~/.claude/projects',
|
|
12
|
+
docs: 'https://code.claude.com/docs/en/headless',
|
|
13
|
+
},
|
|
14
|
+
codex: {
|
|
15
|
+
id: 'codex',
|
|
16
|
+
label: 'GPT',
|
|
17
|
+
company: 'OpenAI · Codex',
|
|
18
|
+
command: 'codex',
|
|
19
|
+
accent: '#10a37f',
|
|
20
|
+
mark: 'GPT',
|
|
21
|
+
historyHint: '~/.codex/sessions',
|
|
22
|
+
docs: 'https://developers.openai.com/codex/codex-manual.md',
|
|
23
|
+
},
|
|
24
|
+
gemini: {
|
|
25
|
+
id: 'gemini',
|
|
26
|
+
label: 'Gemini',
|
|
27
|
+
company: 'Google',
|
|
28
|
+
command: 'gemini',
|
|
29
|
+
accent: '#8ab4f8',
|
|
30
|
+
mark: 'GM',
|
|
31
|
+
historyHint: '~/.gemini/tmp/*/chats',
|
|
32
|
+
docs: 'https://geminicli.com/docs/cli/headless/',
|
|
33
|
+
},
|
|
34
|
+
grok: {
|
|
35
|
+
id: 'grok',
|
|
36
|
+
label: 'Grok',
|
|
37
|
+
company: 'xAI',
|
|
38
|
+
command: 'grok',
|
|
39
|
+
accent: '#f4f4f5',
|
|
40
|
+
mark: 'X',
|
|
41
|
+
historyHint: '~/.grok/sessions',
|
|
42
|
+
docs: 'https://docs.x.ai/build/cli/headless-scripting',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function providerList() {
|
|
47
|
+
return Object.values(PROVIDERS).map(item => ({ ...item }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeProvider(value) {
|
|
51
|
+
const raw = String(value || '').toLowerCase();
|
|
52
|
+
if (raw.includes('anthropic') || raw.includes('claude')) return 'claude';
|
|
53
|
+
if (raw.includes('openai') || raw.includes('codex') || raw === 'gpt') return 'codex';
|
|
54
|
+
if (raw.includes('google') || raw.includes('gemini')) return 'gemini';
|
|
55
|
+
if (raw.includes('xai') || raw.includes('grok')) return 'grok';
|
|
56
|
+
return PROVIDERS[raw] ? raw : 'codex';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function modelContextWindow(provider, model, observed) {
|
|
60
|
+
const exact = Number(observed || 0);
|
|
61
|
+
if (Number.isFinite(exact) && exact > 0) return { tokens: exact, source: 'session' };
|
|
62
|
+
const id = String(model || '').toLowerCase();
|
|
63
|
+
|
|
64
|
+
if (provider === 'claude') {
|
|
65
|
+
if (/opus-4-[678]|sonnet-(?:4-6|5)|fable-5|mythos/.test(id)) {
|
|
66
|
+
return { tokens: 1_000_000, source: 'model-catalog' };
|
|
67
|
+
}
|
|
68
|
+
return { tokens: 200_000, source: 'model-catalog' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (provider === 'codex') {
|
|
72
|
+
if (/gpt-5\.(?:4|5)(?:$|-|\b)/.test(id)) return { tokens: 1_050_000, source: 'model-catalog' };
|
|
73
|
+
if (/gpt-5\.4-mini/.test(id)) return { tokens: 400_000, source: 'model-catalog' };
|
|
74
|
+
return { tokens: 0, source: 'unknown' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (provider === 'gemini') {
|
|
78
|
+
if (/gemini/.test(id)) return { tokens: 1_048_576, source: 'model-catalog' };
|
|
79
|
+
return { tokens: 0, source: 'unknown' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (provider === 'grok') {
|
|
83
|
+
if (/grok-build/.test(id)) return { tokens: 256_000, source: 'model-catalog' };
|
|
84
|
+
if (/grok-4\.5/.test(id)) return { tokens: 500_000, source: 'model-catalog' };
|
|
85
|
+
if (/grok-4\.(?:3|20)/.test(id)) return { tokens: 1_000_000, source: 'model-catalog' };
|
|
86
|
+
return { tokens: 0, source: 'unknown' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { tokens: 0, source: 'unknown' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function blankUsage() {
|
|
93
|
+
return {
|
|
94
|
+
input: 0,
|
|
95
|
+
cachedInput: 0,
|
|
96
|
+
cacheWrite: 0,
|
|
97
|
+
output: 0,
|
|
98
|
+
reasoning: 0,
|
|
99
|
+
total: 0,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function finalizeUsage(raw = {}) {
|
|
104
|
+
const usage = blankUsage();
|
|
105
|
+
for (const key of Object.keys(usage)) {
|
|
106
|
+
const value = Number(raw[key] || 0);
|
|
107
|
+
usage[key] = Number.isFinite(value) && value > 0 ? value : 0;
|
|
108
|
+
}
|
|
109
|
+
if (!usage.total) usage.total = usage.input + usage.output + usage.reasoning;
|
|
110
|
+
return usage;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
PROVIDERS,
|
|
115
|
+
providerList,
|
|
116
|
+
normalizeProvider,
|
|
117
|
+
modelContextWindow,
|
|
118
|
+
blankUsage,
|
|
119
|
+
finalizeUsage,
|
|
120
|
+
};
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { EventEmitter } = require('events');
|
|
8
|
+
const { spawn: spawnChild } = require('child_process');
|
|
9
|
+
const { runBestEffort } = require('./diagnostics');
|
|
10
|
+
|
|
11
|
+
const MAX_SESSIONS = 24;
|
|
12
|
+
const MAX_INPUT_CHARS = 128 * 1024;
|
|
13
|
+
const MAX_REPLAY_CHARS = 2 * 1024 * 1024;
|
|
14
|
+
const TERMINAL_TYPES = new Set(['powershell', 'cmd', 'shell', 'wsl', 'tmux', 'agent']);
|
|
15
|
+
const AGENT_PROVIDERS = Object.freeze({
|
|
16
|
+
claude: { command: 'claude', label: 'Claude' },
|
|
17
|
+
codex: { command: 'codex', label: 'GPT · Codex' },
|
|
18
|
+
gemini: { command: 'gemini', label: 'Gemini' },
|
|
19
|
+
grok: { command: 'grok', label: 'Grok' },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function cleanText(value, max = 200) {
|
|
23
|
+
return String(value == null ? '' : value).replace(/[\u0000\r\n]/g, ' ').trim().slice(0, max);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shellQuote(value) {
|
|
27
|
+
return `'${String(value == null ? '' : value).replace(/'/g, `'"'"'`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function numericDimension(value, fallback, min, max) {
|
|
31
|
+
const number = Math.floor(Number(value || fallback));
|
|
32
|
+
return Math.max(min, Math.min(max, Number.isFinite(number) ? number : fallback));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function terminalEnvironment(extra = {}) {
|
|
36
|
+
const env = {};
|
|
37
|
+
for (const [key, value] of Object.entries({ ...process.env, ...extra })) {
|
|
38
|
+
if (value != null) env[key] = String(value);
|
|
39
|
+
}
|
|
40
|
+
env.TERM = env.TERM || 'xterm-256color';
|
|
41
|
+
env.COLORTERM = env.COLORTERM || 'truecolor';
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function powershellExecutable() {
|
|
46
|
+
const modern = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe');
|
|
47
|
+
return fs.existsSync(modern) ? modern : 'powershell.exe';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isExecutableFile(file, fileSystem = fs) {
|
|
51
|
+
try {
|
|
52
|
+
if (!path.isAbsolute(file) || !fileSystem.statSync(file).isFile()) return false;
|
|
53
|
+
fileSystem.accessSync(file, fileSystem.constants?.X_OK ?? fs.constants.X_OK);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (_missingOrNonExecutableShell) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolvePosixShell(environment = process.env, platform = process.platform, fileSystem = fs) {
|
|
61
|
+
const configured = String(environment.SHELL || '').trim();
|
|
62
|
+
const platformDefaults = platform === 'darwin'
|
|
63
|
+
? ['/bin/zsh', '/bin/bash', '/bin/sh']
|
|
64
|
+
: ['/bin/bash', '/bin/zsh', '/bin/sh'];
|
|
65
|
+
const candidates = [...new Set([configured, ...platformDefaults].filter(Boolean))];
|
|
66
|
+
const shell = candidates.find(candidate => isExecutableFile(candidate, fileSystem));
|
|
67
|
+
if (!shell) throw new Error('실행 가능한 POSIX 셸을 찾지 못했습니다. SHELL 환경 변수와 /bin/sh 설치 상태를 확인하세요.');
|
|
68
|
+
return shell;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function windowsPathValue(env = process.env) {
|
|
72
|
+
const key = Object.keys(env).find(name => name.toLowerCase() === 'path');
|
|
73
|
+
return key ? String(env[key] || '') : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveWindowsCommand(command, env = process.env) {
|
|
77
|
+
const value = String(command || '').trim();
|
|
78
|
+
if (!value) return '';
|
|
79
|
+
const hasPath = /[\\/]/.test(value);
|
|
80
|
+
const directories = hasPath ? [''] : windowsPathValue(env).split(path.delimiter).filter(Boolean);
|
|
81
|
+
const extension = path.extname(value).toLowerCase();
|
|
82
|
+
const suffixes = extension ? [''] : ['.exe', '.com', '.ps1', '.cmd', '.bat'];
|
|
83
|
+
for (const directory of directories) {
|
|
84
|
+
for (const suffix of suffixes) {
|
|
85
|
+
const candidate = hasPath ? `${value}${suffix}` : path.join(directory, `${value}${suffix}`);
|
|
86
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function killPtyTree(handle, pid) {
|
|
93
|
+
if (!handle) return;
|
|
94
|
+
if (process.platform !== 'win32' || !Number.isFinite(Number(pid))) {
|
|
95
|
+
runBestEffort('terminal-kill', () => handle.kill());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const killer = spawnChild('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
stdio: 'ignore',
|
|
102
|
+
});
|
|
103
|
+
killer.once('exit', code => {
|
|
104
|
+
if (code === 0 || handle.__loadtoagentExited) return;
|
|
105
|
+
runBestEffort('terminal-kill-fallback', () => handle.kill());
|
|
106
|
+
});
|
|
107
|
+
killer.unref();
|
|
108
|
+
} catch (_treeKillUnavailable) {
|
|
109
|
+
// Fall back to the PTY handle when the platform process-tree command is unavailable.
|
|
110
|
+
runBestEffort('terminal-kill-spawn-fallback', () => handle.kill());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeLaunchOptions(options = {}, platform = process.platform) {
|
|
115
|
+
const fallbackType = platform === 'win32' ? 'powershell' : 'shell';
|
|
116
|
+
const type = TERMINAL_TYPES.has(options.type) ? options.type : fallbackType;
|
|
117
|
+
const suppliedCwd = String(options.cwd || '').trim();
|
|
118
|
+
const localCwd = suppliedCwd || os.homedir();
|
|
119
|
+
if (['powershell', 'cmd', 'shell', 'agent'].includes(type) && (!fs.existsSync(localCwd) || !fs.statSync(localCwd).isDirectory())) {
|
|
120
|
+
throw new Error(`작업 폴더를 찾을 수 없습니다: ${localCwd}`);
|
|
121
|
+
}
|
|
122
|
+
const distro = cleanText(options.distro, 100);
|
|
123
|
+
if ((type === 'wsl' || type === 'tmux') && !distro) throw new Error(type === 'tmux' ? 'tmux 환경을 선택하세요.' : 'WSL 배포판을 선택하세요.');
|
|
124
|
+
const tmuxSession = cleanText(options.tmuxSession, 100);
|
|
125
|
+
const tmuxPane = cleanText(options.tmuxPane, 100);
|
|
126
|
+
if (type === 'tmux' && !tmuxSession) throw new Error('연결할 tmux 세션이 필요합니다.');
|
|
127
|
+
const provider = cleanText(options.provider, 30).toLowerCase();
|
|
128
|
+
if (type === 'agent' && !AGENT_PROVIDERS[provider]) throw new Error('지원하지 않는 AI 제공사입니다.');
|
|
129
|
+
const args = Array.isArray(options.args)
|
|
130
|
+
? options.args.slice(0, 80).map(value => cleanText(value, 2_000))
|
|
131
|
+
: [];
|
|
132
|
+
return {
|
|
133
|
+
type,
|
|
134
|
+
cwd: ['powershell', 'cmd', 'shell', 'agent'].includes(type) ? path.resolve(localCwd) : suppliedCwd,
|
|
135
|
+
distro,
|
|
136
|
+
tmuxSession,
|
|
137
|
+
tmuxPane,
|
|
138
|
+
provider,
|
|
139
|
+
args,
|
|
140
|
+
bridgeId: cleanText(options.bridgeId, 100),
|
|
141
|
+
title: cleanText(options.title, 100),
|
|
142
|
+
cols: numericDimension(options.cols, 120, 20, 500),
|
|
143
|
+
rows: numericDimension(options.rows, 32, 5, 200),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function launchSpec(options, platform = process.platform, agentProviders = AGENT_PROVIDERS, runtime = {}) {
|
|
148
|
+
if (options.type === 'powershell') {
|
|
149
|
+
const file = powershellExecutable();
|
|
150
|
+
return { file, args: ['-NoLogo'], cwd: options.cwd, label: path.basename(file, '.exe') };
|
|
151
|
+
}
|
|
152
|
+
if (options.type === 'cmd') return { file: process.env.ComSpec || 'cmd.exe', args: ['/Q'], cwd: options.cwd, label: '명령 프롬프트' };
|
|
153
|
+
if (options.type === 'shell') {
|
|
154
|
+
const file = resolvePosixShell(runtime.env || process.env, platform, runtime.fileSystem || fs);
|
|
155
|
+
return { file, args: ['-l'], cwd: options.cwd, label: path.basename(file) };
|
|
156
|
+
}
|
|
157
|
+
if (options.type === 'agent') {
|
|
158
|
+
const provider = agentProviders[options.provider] || AGENT_PROVIDERS[options.provider];
|
|
159
|
+
if (platform === 'win32') {
|
|
160
|
+
const command = resolveWindowsCommand(provider.command);
|
|
161
|
+
if (path.extname(command).toLowerCase() === '.ps1') {
|
|
162
|
+
return {
|
|
163
|
+
file: powershellExecutable(),
|
|
164
|
+
args: ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command, ...(provider.args || []), ...options.args],
|
|
165
|
+
cwd: options.cwd,
|
|
166
|
+
label: provider.label,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (/\.(?:cmd|bat)$/i.test(command)) {
|
|
170
|
+
return { file: process.env.ComSpec || 'cmd.exe', args: ['/D', '/S', '/C', command, ...(provider.args || []), ...options.args], cwd: options.cwd, label: provider.label };
|
|
171
|
+
}
|
|
172
|
+
return { file: command, args: [...(provider.args || []), ...options.args], cwd: options.cwd, label: provider.label };
|
|
173
|
+
}
|
|
174
|
+
return { file: provider.command, args: [...(provider.args || []), ...options.args], cwd: options.cwd, label: provider.label };
|
|
175
|
+
}
|
|
176
|
+
if (options.type === 'wsl') {
|
|
177
|
+
const args = ['-d', options.distro];
|
|
178
|
+
if (options.cwd) args.push('--cd', options.cwd);
|
|
179
|
+
return { file: 'wsl.exe', args, cwd: os.homedir(), label: `${options.distro} 셸` };
|
|
180
|
+
}
|
|
181
|
+
const selectPane = options.tmuxPane ? `tmux select-pane -t ${shellQuote(options.tmuxPane)} 2>/dev/null || true; ` : '';
|
|
182
|
+
const script = `${selectPane}exec tmux attach-session -t ${shellQuote(options.tmuxSession)}`;
|
|
183
|
+
if (platform !== 'win32') {
|
|
184
|
+
const file = resolvePosixShell(runtime.env || process.env, platform, runtime.fileSystem || fs);
|
|
185
|
+
return { file, args: ['-lc', script], cwd: options.cwd || os.homedir(), label: `tmux · ${options.tmuxSession}` };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
file: 'wsl.exe',
|
|
189
|
+
args: ['-d', options.distro, '--', 'sh', '-lc', script],
|
|
190
|
+
cwd: os.homedir(),
|
|
191
|
+
label: `tmux · ${options.tmuxSession}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function publicSession(session, includeReplay = false) {
|
|
196
|
+
const value = {
|
|
197
|
+
id: session.id,
|
|
198
|
+
type: session.options.type,
|
|
199
|
+
title: session.title,
|
|
200
|
+
shell: session.shell,
|
|
201
|
+
cwd: session.options.cwd,
|
|
202
|
+
distro: session.options.distro,
|
|
203
|
+
tmuxSession: session.options.tmuxSession,
|
|
204
|
+
tmuxPane: session.options.tmuxPane,
|
|
205
|
+
provider: session.options.provider,
|
|
206
|
+
bridgeId: session.options.bridgeId,
|
|
207
|
+
background: session.options.type === 'agent',
|
|
208
|
+
pid: session.pid,
|
|
209
|
+
status: session.status,
|
|
210
|
+
createdAt: session.createdAt,
|
|
211
|
+
updatedAt: session.updatedAt,
|
|
212
|
+
exitCode: session.exitCode,
|
|
213
|
+
signal: session.signal,
|
|
214
|
+
cols: session.cols,
|
|
215
|
+
rows: session.rows,
|
|
216
|
+
};
|
|
217
|
+
if (includeReplay) value.replay = session.replay;
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
class TerminalManager extends EventEmitter {
|
|
222
|
+
constructor(options = {}) {
|
|
223
|
+
super();
|
|
224
|
+
this.ptyModule = options.ptyModule || null;
|
|
225
|
+
this.killTree = options.killTree || killPtyTree;
|
|
226
|
+
this.platform = options.platform || process.platform;
|
|
227
|
+
this.agentProviders = options.agentProviders || AGENT_PROVIDERS;
|
|
228
|
+
this.sessions = new Map();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
pty() {
|
|
232
|
+
if (!this.ptyModule) this.ptyModule = require('node-pty');
|
|
233
|
+
return this.ptyModule;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
create(rawOptions = {}) {
|
|
237
|
+
if (this.sessions.size >= MAX_SESSIONS) throw new Error(`동시에 열 수 있는 터미널은 최대 ${MAX_SESSIONS}개입니다.`);
|
|
238
|
+
const options = normalizeLaunchOptions(rawOptions, this.platform);
|
|
239
|
+
const spec = launchSpec(options, this.platform, this.agentProviders);
|
|
240
|
+
const id = `terminal:${Date.now().toString(36)}:${crypto.randomBytes(4).toString('hex')}`;
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
const session = {
|
|
243
|
+
id,
|
|
244
|
+
options,
|
|
245
|
+
spec,
|
|
246
|
+
title: options.title || spec.label,
|
|
247
|
+
shell: spec.file,
|
|
248
|
+
pid: null,
|
|
249
|
+
status: 'starting',
|
|
250
|
+
createdAt: now,
|
|
251
|
+
updatedAt: now,
|
|
252
|
+
exitCode: null,
|
|
253
|
+
signal: null,
|
|
254
|
+
cols: options.cols,
|
|
255
|
+
rows: options.rows,
|
|
256
|
+
replay: '',
|
|
257
|
+
process: null,
|
|
258
|
+
generation: 0,
|
|
259
|
+
};
|
|
260
|
+
this.sessions.set(id, session);
|
|
261
|
+
try {
|
|
262
|
+
this.spawn(session);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.sessions.delete(id);
|
|
265
|
+
this.emit('state', { change: 'removed', session: publicSession(session, false), sessions: this.list() });
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return publicSession(session, true);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
spawn(session) {
|
|
272
|
+
const generation = ++session.generation;
|
|
273
|
+
session.status = 'starting';
|
|
274
|
+
session.exitCode = null;
|
|
275
|
+
session.signal = null;
|
|
276
|
+
session.updatedAt = new Date().toISOString();
|
|
277
|
+
this.emitState('updated', session);
|
|
278
|
+
try {
|
|
279
|
+
const spawnOptions = {
|
|
280
|
+
name: 'xterm-256color',
|
|
281
|
+
cols: session.cols,
|
|
282
|
+
rows: session.rows,
|
|
283
|
+
cwd: session.spec.cwd,
|
|
284
|
+
env: terminalEnvironment(),
|
|
285
|
+
useConpty: this.platform === 'win32',
|
|
286
|
+
};
|
|
287
|
+
if (this.platform !== 'win32') spawnOptions.encoding = 'utf8';
|
|
288
|
+
const processHandle = this.pty().spawn(session.spec.file, session.spec.args, spawnOptions);
|
|
289
|
+
session.process = processHandle;
|
|
290
|
+
session.pid = processHandle.pid;
|
|
291
|
+
session.status = 'running';
|
|
292
|
+
session.updatedAt = new Date().toISOString();
|
|
293
|
+
processHandle.onData(data => {
|
|
294
|
+
if (session.generation !== generation) return;
|
|
295
|
+
const text = String(data || '');
|
|
296
|
+
session.replay = `${session.replay}${text}`.slice(-MAX_REPLAY_CHARS);
|
|
297
|
+
session.updatedAt = new Date().toISOString();
|
|
298
|
+
this.emit('data', { id: session.id, data: text });
|
|
299
|
+
});
|
|
300
|
+
processHandle.onExit(event => {
|
|
301
|
+
processHandle.__loadtoagentExited = true;
|
|
302
|
+
if (session.generation !== generation) return;
|
|
303
|
+
session.process = null;
|
|
304
|
+
session.status = 'exited';
|
|
305
|
+
session.exitCode = Number.isFinite(event.exitCode) ? event.exitCode : null;
|
|
306
|
+
session.signal = Number.isFinite(event.signal) ? event.signal : null;
|
|
307
|
+
session.updatedAt = new Date().toISOString();
|
|
308
|
+
this.emitState('updated', session);
|
|
309
|
+
});
|
|
310
|
+
this.emitState('updated', session);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
session.process = null;
|
|
313
|
+
session.status = 'failed';
|
|
314
|
+
session.updatedAt = new Date().toISOString();
|
|
315
|
+
const failureMessage = `\r\n[LoadToAgent] 터미널을 시작하지 못했습니다: ${error.message}\r\n`;
|
|
316
|
+
session.replay = `${session.replay}${failureMessage}`.slice(-MAX_REPLAY_CHARS);
|
|
317
|
+
this.emit('data', { id: session.id, data: failureMessage });
|
|
318
|
+
this.emitState('updated', session);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
emitState(change, session) {
|
|
324
|
+
this.emit('state', { change, session: session ? publicSession(session, false) : null, sessions: this.list() });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
list() {
|
|
328
|
+
return [...this.sessions.values()].map(session => publicSession(session, false));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
get(id, includeReplay = true) {
|
|
332
|
+
const session = this.sessions.get(String(id || ''));
|
|
333
|
+
return session ? publicSession(session, includeReplay) : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
required(id) {
|
|
337
|
+
const session = this.sessions.get(String(id || ''));
|
|
338
|
+
if (!session) throw new Error('터미널 세션을 찾을 수 없습니다.');
|
|
339
|
+
return session;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
write(id, value) {
|
|
343
|
+
const session = this.required(id);
|
|
344
|
+
if (!session.process || session.status !== 'running') throw new Error('실행 중인 터미널이 아닙니다.');
|
|
345
|
+
const data = String(value == null ? '' : value);
|
|
346
|
+
if (data.length > MAX_INPUT_CHARS) throw new Error('한 번에 보낼 수 있는 입력 크기를 초과했습니다.');
|
|
347
|
+
session.process.write(data);
|
|
348
|
+
return { ok: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
command(id, value) {
|
|
352
|
+
const command = String(value == null ? '' : value).replace(/\r?\n/g, '\r');
|
|
353
|
+
if (!command.trim()) return { ok: false, error: '명령을 입력하세요.' };
|
|
354
|
+
this.write(id, `${command}\r`);
|
|
355
|
+
return { ok: true };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
resize(id, cols, rows) {
|
|
359
|
+
const session = this.required(id);
|
|
360
|
+
session.cols = numericDimension(cols, session.cols, 20, 500);
|
|
361
|
+
session.rows = numericDimension(rows, session.rows, 5, 200);
|
|
362
|
+
if (session.process && session.status === 'running') session.process.resize(session.cols, session.rows);
|
|
363
|
+
return { ok: true, cols: session.cols, rows: session.rows };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
signal(id, signal) {
|
|
367
|
+
const session = this.required(id);
|
|
368
|
+
const key = String(signal || '').toLowerCase();
|
|
369
|
+
if (key === 'interrupt') return this.write(id, '\x03');
|
|
370
|
+
if (key === 'eof') return this.write(id, '\x04');
|
|
371
|
+
if (key === 'clear') {
|
|
372
|
+
if (session.process && typeof session.process.clear === 'function') session.process.clear();
|
|
373
|
+
return this.write(id, '\x0c');
|
|
374
|
+
}
|
|
375
|
+
if (key === 'terminate') return this.kill(id);
|
|
376
|
+
throw new Error('지원하지 않는 터미널 신호입니다.');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
kill(id) {
|
|
380
|
+
const session = this.required(id);
|
|
381
|
+
if (session.process) {
|
|
382
|
+
const handle = session.process;
|
|
383
|
+
session.process = null;
|
|
384
|
+
session.generation += 1;
|
|
385
|
+
this.killTree(handle, session.pid);
|
|
386
|
+
}
|
|
387
|
+
session.status = 'exited';
|
|
388
|
+
session.updatedAt = new Date().toISOString();
|
|
389
|
+
this.emitState('updated', session);
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
restart(id) {
|
|
394
|
+
const session = this.required(id);
|
|
395
|
+
if (session.process) {
|
|
396
|
+
const handle = session.process;
|
|
397
|
+
session.process = null;
|
|
398
|
+
session.generation += 1;
|
|
399
|
+
this.killTree(handle, session.pid);
|
|
400
|
+
}
|
|
401
|
+
session.replay = '';
|
|
402
|
+
this.spawn(session);
|
|
403
|
+
return publicSession(session, true);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
close(id) {
|
|
407
|
+
const session = this.required(id);
|
|
408
|
+
if (session.process) {
|
|
409
|
+
const handle = session.process;
|
|
410
|
+
session.process = null;
|
|
411
|
+
session.generation += 1;
|
|
412
|
+
this.killTree(handle, session.pid);
|
|
413
|
+
}
|
|
414
|
+
session.status = 'exited';
|
|
415
|
+
session.updatedAt = new Date().toISOString();
|
|
416
|
+
this.sessions.delete(session.id);
|
|
417
|
+
this.emit('state', { change: 'removed', session: publicSession(session, false), sessions: this.list() });
|
|
418
|
+
return { ok: true };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
dispose() {
|
|
422
|
+
for (const id of [...this.sessions.keys()]) {
|
|
423
|
+
runBestEffort(`terminal-dispose:${id}`, () => this.close(id));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
module.exports = {
|
|
429
|
+
TerminalManager,
|
|
430
|
+
normalizeLaunchOptions,
|
|
431
|
+
launchSpec,
|
|
432
|
+
shellQuote,
|
|
433
|
+
numericDimension,
|
|
434
|
+
killPtyTree,
|
|
435
|
+
AGENT_PROVIDERS,
|
|
436
|
+
resolveWindowsCommand,
|
|
437
|
+
resolvePosixShell,
|
|
438
|
+
};
|