loadtoagent 1.1.0 → 1.3.1

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 (83) hide show
  1. package/README.ko.md +7 -0
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +7 -0
  4. package/docs/PROVIDER-CONTRACTS.md +23 -1
  5. package/docs/UI-AUDIT-100.md +118 -0
  6. package/docs/UI-AUDIT-101-200.md +120 -0
  7. package/docs/UI-AUDIT-201-300.md +120 -0
  8. package/main.js +135 -38
  9. package/package.json +13 -4
  10. package/preload.js +6 -0
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -19
  13. package/renderer/app-dashboard.js +190 -55
  14. package/renderer/app-drawer-content.js +48 -44
  15. package/renderer/app-drawer-data.js +26 -13
  16. package/renderer/app-drawer.js +72 -54
  17. package/renderer/app-events-dialogs.js +146 -37
  18. package/renderer/app-events-filters.js +172 -13
  19. package/renderer/app-events-navigation.js +77 -30
  20. package/renderer/app-events-sessions.js +156 -9
  21. package/renderer/app-events.js +2 -1
  22. package/renderer/app-graph-model.js +20 -38
  23. package/renderer/app-graph-orchestration.js +15 -13
  24. package/renderer/app-graph-view.js +228 -113
  25. package/renderer/app-management.js +211 -0
  26. package/renderer/app-provider-visibility.js +124 -0
  27. package/renderer/app-quality.js +568 -0
  28. package/renderer/app-run-modal.js +103 -33
  29. package/renderer/app-runtime-overview.js +312 -0
  30. package/renderer/app-session-render.js +94 -37
  31. package/renderer/app-tmux-render.js +52 -44
  32. package/renderer/app.js +153 -75
  33. package/renderer/i18n-messages.js +833 -16
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +145 -59
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +6 -0
  38. package/renderer/styles-cards.css +26 -0
  39. package/renderer/styles-components.css +86 -9
  40. package/renderer/styles-management.css +355 -0
  41. package/renderer/styles-overlays.css +8 -2
  42. package/renderer/styles-product.css +24 -16
  43. package/renderer/styles-quality.css +312 -0
  44. package/renderer/styles-readability.css +862 -0
  45. package/renderer/styles-responsive-product.css +84 -12
  46. package/renderer/styles-responsive-runtime.css +22 -14
  47. package/renderer/styles-responsive-shell.css +44 -48
  48. package/renderer/styles-responsive-workflows.css +37 -3
  49. package/renderer/styles-run-composer.css +5 -0
  50. package/renderer/styles-runtime-overview.css +285 -0
  51. package/renderer/styles-settings.css +160 -0
  52. package/renderer/styles-terminal.css +339 -2
  53. package/renderer/styles-workflow-map.css +362 -15
  54. package/renderer/styles-workflows.css +69 -2
  55. package/renderer/styles.css +27 -12
  56. package/renderer/terminal-agent.js +17 -13
  57. package/renderer/terminal-events.js +233 -40
  58. package/renderer/terminal-workbench.js +165 -77
  59. package/renderer/terminal.js +341 -34
  60. package/src/agentMonitor/claudeParser.js +96 -7
  61. package/src/agentMonitor/codexParser.js +59 -4
  62. package/src/agentMonitor/executionActivity.js +282 -0
  63. package/src/agentMonitor/genericParser.js +56 -13
  64. package/src/agentMonitor/hierarchy.js +17 -0
  65. package/src/agentMonitor/responseIntent.js +51 -0
  66. package/src/agentMonitor.js +21 -3
  67. package/src/agentRunner.js +66 -1
  68. package/src/attentionNotifier.js +14 -10
  69. package/src/automationMonitor.js +258 -0
  70. package/src/contracts.js +10 -1
  71. package/src/ipc/registerAgentIpc.js +9 -3
  72. package/src/ipc/registerAppIpc.js +4 -1
  73. package/src/ipc/registerTerminalIpc.js +12 -6
  74. package/src/macUpdateHelper.js +210 -0
  75. package/src/monitorWorker.js +65 -6
  76. package/src/platformPath.js +80 -0
  77. package/src/processMonitor.js +80 -22
  78. package/src/providerVisibilityStore.js +45 -0
  79. package/src/sessionIntelligence.js +247 -0
  80. package/src/terminalHost.js +381 -0
  81. package/src/terminalHostDaemon.js +60 -0
  82. package/src/terminalManager.js +158 -3
  83. package/src/updateInstaller.js +175 -0
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ const LIVE_STATUSES = new Set(['starting', 'running']);
4
+ const COMPLETE_STATUSES = new Set(['completed', 'cancelled', 'failed']);
5
+ const FAILURE_PATTERN = /(?:error|failed|failure|fatal|exception|오류|실패)/i;
6
+ const PERMISSION_PATTERN = /(?:permission|approve|approval|권한|승인)/i;
7
+ const DECISION_PATTERN = /(?:choose|select|decision|선택|결정|골라)/i;
8
+ const INPUT_PATTERN = /(?:input|reply|answer|confirm|provide|입력|답변|확인|알려|제공)/i;
9
+ const TEST_PATTERN = /(?:test|spec|검증|테스트)/i;
10
+
11
+ function text(value, limit = 1200) {
12
+ const output = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
13
+ return output.length > limit ? `${output.slice(0, limit).trimEnd()}…` : output;
14
+ }
15
+
16
+ function timestamp(value) {
17
+ const parsed = Date.parse(value || 0);
18
+ return Number.isFinite(parsed) ? parsed : 0;
19
+ }
20
+
21
+ function latestActivity(session) {
22
+ const values = [session.updatedAt, session.startedAt];
23
+ for (const row of session.messages || []) values.push(row && row.timestamp);
24
+ for (const row of session.lifecycle || []) values.push(row && (row.completedAt || row.timestamp));
25
+ const millis = Math.max(0, ...values.map(timestamp));
26
+ return millis ? new Date(millis).toISOString() : null;
27
+ }
28
+
29
+ function latestMeaningfulText(session) {
30
+ const messages = [...(session.messages || [])].reverse();
31
+ const preferred = messages.find(row => row && row.role === 'assistant' && text(row.text));
32
+ const fallback = messages.find(row => row && text(row.text));
33
+ return text((preferred || fallback || {}).text || session.statusDetail || session.result || '', 360);
34
+ }
35
+
36
+ function controlCapabilities(session) {
37
+ const live = LIVE_STATUSES.has(session.status);
38
+ const managed = Boolean(session.runId);
39
+ const resumable = Boolean(session.externalId && ['claude', 'codex', 'gemini'].includes(session.provider));
40
+ const directlyControllable = (session.runtimePresence || []).some(item => item && (item.terminalId || item.paneId || item.nativeId));
41
+ const canSend = directlyControllable || resumable;
42
+ return {
43
+ managed,
44
+ respond: (session.status === 'waiting' || live) && canSend,
45
+ approve: session.status === 'waiting' && canSend,
46
+ deny: session.status === 'waiting' && canSend,
47
+ sendInstruction: canSend,
48
+ stop: managed && (live || session.status === 'paused'),
49
+ pause: managed && session.status === 'running',
50
+ resume: (managed && session.status === 'paused') || (!live && resumable),
51
+ retry: managed && ['failed', 'cancelled'].includes(session.status),
52
+ reassign: Boolean(session.cwd && (session.title || session.sharedGoal || latestMeaningfulText(session))),
53
+ openOrigin: ['claude-desktop', 'codex-desktop'].includes(session.clientKind),
54
+ };
55
+ }
56
+
57
+ function evidenceFor(session) {
58
+ const statusObserved = Boolean(session.statusObserved || session.runId || session.runtimePresence?.length);
59
+ const delegation = session.delegation || {};
60
+ const hierarchyObserved = !session.parentId
61
+ || Boolean(delegation.assignmentObserved && delegation.assignmentSource !== 'unavailable')
62
+ || session.source === 'collaboration-history';
63
+ const completionObserved = Boolean(session.completionObserved || (session.runId && COMPLETE_STATUSES.has(session.status)));
64
+ const sources = [session.sourceLabel, statusObserved ? 'runtime-event' : 'activity-inference'];
65
+ if (session.parentId) sources.push(hierarchyObserved ? 'delegation-event' : 'hierarchy-inference');
66
+ if (completionObserved) sources.push('completion-event');
67
+ return {
68
+ confidence: statusObserved && hierarchyObserved ? 'high' : statusObserved || hierarchyObserved ? 'medium' : 'low',
69
+ status: statusObserved ? 'observed' : 'inferred',
70
+ hierarchy: hierarchyObserved ? 'observed' : 'inferred',
71
+ completion: completionObserved ? 'observed' : 'unverified',
72
+ sources: [...new Set(sources.filter(Boolean).map(value => text(value, 80)))],
73
+ };
74
+ }
75
+
76
+ function progressFor(session, attention) {
77
+ const lifecycle = (session.lifecycle || []).filter(Boolean);
78
+ const checkpoints = lifecycle.slice(-12).map((row, index) => ({
79
+ id: String(row.id || `${session.id}:checkpoint:${index}`),
80
+ label: text(row.label || row.type || 'Activity', 140),
81
+ detail: text(row.detail || '', 240),
82
+ status: ['failed', 'error'].includes(row.status) ? 'failed'
83
+ : ['running', 'pending', 'started'].includes(row.status) ? 'running'
84
+ : 'completed',
85
+ timestamp: row.completedAt || row.timestamp || session.updatedAt || null,
86
+ }));
87
+ const completedSteps = checkpoints.filter(row => row.status === 'completed').length;
88
+ const failedSteps = checkpoints.filter(row => row.status === 'failed').length;
89
+ const running = [...checkpoints].reverse().find(row => row.status === 'running');
90
+ const last = checkpoints.at(-1);
91
+ const totalSteps = checkpoints.length;
92
+ let percent = totalSteps ? Math.round(completedSteps / totalSteps * 100) : 0;
93
+ if (session.status === 'completed') percent = 100;
94
+ else if (LIVE_STATUSES.has(session.status) && percent >= 100) percent = 95;
95
+ const stage = session.status === 'completed' ? 'completed'
96
+ : session.status === 'failed' ? 'failed'
97
+ : session.status === 'waiting' ? 'waiting'
98
+ : session.status === 'paused' ? 'paused'
99
+ : LIVE_STATUSES.has(session.status) ? 'executing' : 'idle';
100
+ return {
101
+ stage,
102
+ percent,
103
+ completedSteps,
104
+ failedSteps,
105
+ totalSteps,
106
+ currentStep: text((running || last || {}).label || session.statusDetail || '', 180),
107
+ blocker: attention.required ? attention.summary : '',
108
+ lastActivityAt: latestActivity(session),
109
+ source: totalSteps ? 'lifecycle-events' : 'session-status',
110
+ checkpoints,
111
+ };
112
+ }
113
+
114
+ function extractArtifacts(session) {
115
+ const body = [
116
+ session.result,
117
+ ...(session.messages || []).map(row => row && row.text),
118
+ ...(session.lifecycle || []).map(row => row && `${row.label || ''} ${row.detail || ''}`),
119
+ ].filter(Boolean).join('\n');
120
+ const artifacts = [];
121
+ const seen = new Set();
122
+ const add = (kind, value, verified = false) => {
123
+ const clean = text(value, 260).replace(/[),.;:]+$/, '');
124
+ const key = `${kind}:${clean.toLowerCase()}`;
125
+ if (!clean || seen.has(key) || artifacts.length >= 24) return;
126
+ seen.add(key);
127
+ artifacts.push({ kind, value: clean, verified });
128
+ };
129
+ const filePattern = /(?:[A-Za-z]:\\|\/)?(?:[\w.@-]+[\\/])+[\w.@()+-]+\.[A-Za-z0-9]{1,12}/g;
130
+ for (const match of body.match(filePattern) || []) add(TEST_PATTERN.test(match) ? 'test' : 'file', match, false);
131
+ if (/(?:commit|커밋)/i.test(body)) {
132
+ for (const match of body.match(/\b[0-9a-f]{7,40}\b/gi) || []) add('commit', match, true);
133
+ }
134
+ return artifacts;
135
+ }
136
+
137
+ function outcomeFor(session, evidence) {
138
+ const artifacts = extractArtifacts(session);
139
+ const checks = (session.lifecycle || [])
140
+ .filter(row => row && TEST_PATTERN.test(`${row.label || ''} ${row.detail || ''}`))
141
+ .slice(-12)
142
+ .map(row => ({
143
+ label: text(row.label || row.detail || 'Test', 180),
144
+ status: row.status === 'failed' ? 'failed' : row.status === 'running' ? 'running' : 'passed',
145
+ timestamp: row.completedAt || row.timestamp || null,
146
+ }));
147
+ const latestAssistant = [...(session.messages || [])].reverse().find(row => row && row.role === 'assistant' && text(row.text));
148
+ return {
149
+ status: session.status === 'completed' ? 'completed'
150
+ : session.status === 'failed' ? 'failed'
151
+ : session.status === 'cancelled' ? 'cancelled' : 'in-progress',
152
+ summary: text(session.result || (COMPLETE_STATUSES.has(session.status) && latestAssistant && latestAssistant.text) || session.statusDetail || '', 800),
153
+ verified: evidence.completion === 'observed',
154
+ verification: evidence.completion,
155
+ completedAt: session.completedAt || session.endedAt || null,
156
+ artifacts,
157
+ checks,
158
+ };
159
+ }
160
+
161
+ function attentionFor(session) {
162
+ const latest = latestMeaningfulText(session);
163
+ const combined = `${session.statusDetail || ''} ${latest}`;
164
+ let kind = 'none';
165
+ if (session.status === 'failed' || (session.status === 'waiting' && FAILURE_PATTERN.test(session.statusDetail || ''))) kind = 'error';
166
+ else if (session.status === 'paused') kind = 'paused';
167
+ else if (session.status === 'waiting' && PERMISSION_PATTERN.test(combined)) kind = 'approval';
168
+ else if (session.status === 'waiting' && DECISION_PATTERN.test(combined)) kind = 'decision';
169
+ else if (session.status === 'waiting' && INPUT_PATTERN.test(combined)) kind = 'input';
170
+ else if (session.status === 'waiting') kind = 'response';
171
+ const required = kind !== 'none';
172
+ const summaries = {
173
+ error: session.statusDetail || latest || 'The run failed and needs review.',
174
+ paused: session.statusDetail || 'The run is paused.',
175
+ approval: latest || session.statusDetail || 'Approval is required.',
176
+ decision: latest || session.statusDetail || 'A decision is required.',
177
+ input: latest || session.statusDetail || 'Input is required.',
178
+ response: latest || session.statusDetail || 'A response is required.',
179
+ };
180
+ return {
181
+ required,
182
+ kind,
183
+ summary: required ? text(summaries[kind], 360) : '',
184
+ requestedAt: required ? latestActivity(session) : null,
185
+ source: session.statusObserved || session.runId ? 'observed-status' : 'message-inference',
186
+ confidence: session.statusObserved || session.runId ? 'high' : required ? 'medium' : 'low',
187
+ };
188
+ }
189
+
190
+ function healthFor(session, sessions, attention, progress, evidence, nowValue) {
191
+ const now = Number(nowValue || Date.now());
192
+ const byId = new Map((sessions || []).map(row => [row.id, row]));
193
+ const signals = [];
194
+ const add = (code, severity, detail = '') => signals.push({ code, severity, detail: text(detail, 240) });
195
+ const activity = timestamp(progress.lastActivityAt);
196
+ const ageMs = activity ? Math.max(0, now - activity) : 0;
197
+ if (session.status === 'failed') add('run-failed', 'critical', session.statusDetail);
198
+ if (session.status === 'paused') add('run-paused', 'warning', session.statusDetail);
199
+ if (LIVE_STATUSES.has(session.status) && ageMs >= 10 * 60_000) add('stalled', 'critical', progress.currentStep);
200
+ else if (LIVE_STATUSES.has(session.status) && ageMs >= 2 * 60_000) add('stale', 'warning', progress.currentStep);
201
+ if (session.status === 'waiting' && ageMs >= 60 * 60_000) add('waiting-too-long', 'critical', attention.summary);
202
+ else if (session.status === 'waiting' && ageMs >= 10 * 60_000) add('waiting-too-long', 'warning', attention.summary);
203
+ const contextPercent = Number(session.context && session.context.percent || 0);
204
+ if (contextPercent >= 90) add('context-critical', 'critical', `${contextPercent.toFixed(1)}%`);
205
+ else if (contextPercent >= 75) add('context-warning', 'warning', `${contextPercent.toFixed(1)}%`);
206
+ if (progress.failedSteps >= 2) add('repeated-failures', 'critical', String(progress.failedSteps));
207
+ if (session.parentId && !byId.has(session.parentId)) add('orphan-agent', 'warning', session.parentId);
208
+ if (evidence.confidence === 'low') add('low-confidence', 'info', evidence.sources.join(', '));
209
+ const rank = { info: 1, warning: 2, critical: 3 };
210
+ const max = signals.reduce((value, signal) => Math.max(value, rank[signal.severity] || 0), 0);
211
+ return {
212
+ level: max >= 3 ? 'critical' : max === 2 ? 'warning' : attention.required ? 'attention' : max === 1 ? 'unknown' : 'healthy',
213
+ score: Math.max(0, 100 - signals.reduce((sum, signal) => sum + (signal.severity === 'critical' ? 35 : signal.severity === 'warning' ? 18 : 5), 0)),
214
+ signals,
215
+ lastActivityAt: progress.lastActivityAt,
216
+ ageSeconds: activity ? Math.round(ageMs / 1000) : null,
217
+ };
218
+ }
219
+
220
+ function enrichSession(session, sessions = [], nowValue = Date.now()) {
221
+ if (!session) return session;
222
+ const attention = attentionFor(session);
223
+ const progress = progressFor(session, attention);
224
+ const evidence = evidenceFor(session);
225
+ const controls = controlCapabilities(session);
226
+ const health = healthFor(session, sessions, attention, progress, evidence, nowValue);
227
+ return {
228
+ ...session,
229
+ attention,
230
+ progress,
231
+ health,
232
+ controlCapabilities: controls,
233
+ evidence,
234
+ outcome: outcomeFor(session, evidence),
235
+ };
236
+ }
237
+
238
+ module.exports = {
239
+ attentionFor,
240
+ controlCapabilities,
241
+ enrichSession,
242
+ evidenceFor,
243
+ extractArtifacts,
244
+ healthFor,
245
+ outcomeFor,
246
+ progressFor,
247
+ };
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const net = require('net');
7
+ const crypto = require('crypto');
8
+ const { EventEmitter } = require('events');
9
+ const { spawn } = require('child_process');
10
+ const { endpointFor, safeWriteJson } = require('./bridgeServer');
11
+ const { runBestEffort } = require('./diagnostics');
12
+
13
+ const TERMINAL_HOST_PROTOCOL = 1;
14
+ const MAX_FRAME_CHARS = 4 * 1024 * 1024;
15
+ const AUTH_TIMEOUT_MS = 5_000;
16
+ const REQUEST_TIMEOUT_MS = 15_000;
17
+ const HOST_OPERATIONS = new Set(['list', 'get', 'create', 'write', 'command', 'resize', 'signal', 'restart', 'close']);
18
+
19
+ function sendFrame(socket, payload) {
20
+ if (!socket || socket.destroyed) return;
21
+ socket.write(`${JSON.stringify(payload)}\n`, 'utf8');
22
+ }
23
+
24
+ function readHostDiscovery(file, fileSystem = fs) {
25
+ const parsed = JSON.parse(fileSystem.readFileSync(file, 'utf8'));
26
+ if (parsed?.protocol !== TERMINAL_HOST_PROTOCOL || !parsed.endpoint || !parsed.token) {
27
+ throw new Error('터미널 호스트 연결 정보가 올바르지 않습니다.');
28
+ }
29
+ return parsed;
30
+ }
31
+
32
+ function activeSessions(manager) {
33
+ return manager.list().filter(session => session.status === 'running' || session.status === 'starting');
34
+ }
35
+
36
+ class TerminalHostServer {
37
+ constructor(options = {}) {
38
+ this.manager = options.manager;
39
+ this.platform = options.platform || process.platform;
40
+ this.discoveryFile = path.resolve(options.discoveryFile || path.join(os.tmpdir(), 'loadtoagent-terminal-host.json'));
41
+ this.endpoint = options.endpoint || endpointFor(this.platform, `${path.dirname(this.discoveryFile)}:terminal-host`);
42
+ this.token = options.token || crypto.randomBytes(32).toString('hex');
43
+ this.server = null;
44
+ this.clients = new Set();
45
+ this.shutdownTimer = null;
46
+ this.onShutdown = typeof options.onShutdown === 'function' ? options.onShutdown : () => {};
47
+ this.onManagerData = payload => this.broadcast({ type: 'event', event: 'data', payload });
48
+ this.onManagerState = payload => this.broadcast({ type: 'event', event: 'state', payload });
49
+ }
50
+
51
+ info() {
52
+ return {
53
+ protocol: TERMINAL_HOST_PROTOCOL,
54
+ endpoint: this.endpoint,
55
+ token: this.token,
56
+ pid: process.pid,
57
+ platform: this.platform,
58
+ updatedAt: new Date().toISOString(),
59
+ };
60
+ }
61
+
62
+ start() {
63
+ if (!this.manager) return Promise.reject(new Error('터미널 관리자가 준비되지 않았습니다.'));
64
+ if (this.server) return Promise.resolve(this.info());
65
+ if (this.platform !== 'win32' && fs.existsSync(this.endpoint)) {
66
+ runBestEffort('terminal-host-stale-endpoint', () => fs.unlinkSync(this.endpoint));
67
+ }
68
+ this.server = net.createServer(socket => this.accept(socket));
69
+ return new Promise((resolve, reject) => {
70
+ const fail = error => {
71
+ if (this.server) runBestEffort('terminal-host-start-close', () => this.server.close());
72
+ this.server = null;
73
+ reject(error);
74
+ };
75
+ this.server.once('error', fail);
76
+ this.server.listen(this.endpoint, () => {
77
+ this.server.removeListener('error', fail);
78
+ try {
79
+ safeWriteJson(this.discoveryFile, this.info());
80
+ this.manager.on('data', this.onManagerData);
81
+ this.manager.on('state', this.onManagerState);
82
+ resolve(this.info());
83
+ } catch (error) {
84
+ fail(error);
85
+ }
86
+ });
87
+ });
88
+ }
89
+
90
+ accept(socket) {
91
+ socket.setNoDelay(true);
92
+ const client = {
93
+ socket,
94
+ buffer: '',
95
+ authenticated: false,
96
+ queue: Promise.resolve(),
97
+ authTimer: setTimeout(() => socket.destroy(new Error('터미널 호스트 인증 시간이 초과되었습니다.')), AUTH_TIMEOUT_MS),
98
+ };
99
+ this.clients.add(client);
100
+ socket.on('data', chunk => this.consume(client, chunk));
101
+ socket.on('error', () => this.detach(client));
102
+ socket.on('close', () => this.detach(client));
103
+ }
104
+
105
+ consume(client, chunk) {
106
+ client.buffer += chunk.toString('utf8');
107
+ if (client.buffer.length > MAX_FRAME_CHARS) {
108
+ client.socket.destroy(new Error('터미널 호스트 요청이 너무 큽니다.'));
109
+ return;
110
+ }
111
+ let newline;
112
+ while ((newline = client.buffer.indexOf('\n')) >= 0) {
113
+ const line = client.buffer.slice(0, newline).trim();
114
+ client.buffer = client.buffer.slice(newline + 1);
115
+ if (!line) continue;
116
+ let message;
117
+ try {
118
+ message = JSON.parse(line);
119
+ } catch (_invalidFrame) {
120
+ client.socket.destroy(new Error('터미널 호스트 요청 형식이 올바르지 않습니다.'));
121
+ return;
122
+ }
123
+ client.queue = client.queue
124
+ .then(() => this.handle(client, message || {}))
125
+ .catch(error => sendFrame(client.socket, {
126
+ type: 'response',
127
+ requestId: String(message?.requestId || ''),
128
+ ok: false,
129
+ error: String(error?.message || error),
130
+ }));
131
+ }
132
+ }
133
+
134
+ async handle(client, message) {
135
+ if (!client.authenticated) {
136
+ if (message.type !== 'authenticate' || message.token !== this.token) {
137
+ throw new Error('터미널 호스트 인증에 실패했습니다.');
138
+ }
139
+ client.authenticated = true;
140
+ clearTimeout(client.authTimer);
141
+ client.authTimer = null;
142
+ if (this.shutdownTimer) {
143
+ clearTimeout(this.shutdownTimer);
144
+ this.shutdownTimer = null;
145
+ }
146
+ sendFrame(client.socket, { type: 'ready', sessions: this.manager.list() });
147
+ return;
148
+ }
149
+ if (message.type === 'control' && message.operation === 'shutdown-if-idle') {
150
+ if (activeSessions(this.manager).length === 0 && !this.shutdownTimer) {
151
+ this.shutdownTimer = setTimeout(() => {
152
+ this.shutdownTimer = null;
153
+ const connectedClients = [...this.clients].filter(entry => entry.authenticated && !entry.socket.destroyed);
154
+ if (activeSessions(this.manager).length === 0 && connectedClients.length === 0) this.onShutdown();
155
+ }, 1_500);
156
+ if (typeof this.shutdownTimer.unref === 'function') this.shutdownTimer.unref();
157
+ }
158
+ return;
159
+ }
160
+ if (message.type !== 'request' || !HOST_OPERATIONS.has(message.operation)) {
161
+ throw new Error('지원하지 않는 터미널 호스트 작업입니다.');
162
+ }
163
+ const operation = message.operation;
164
+ const args = Array.isArray(message.args) ? message.args : [];
165
+ const result = await Promise.resolve(this.manager[operation](...args));
166
+ sendFrame(client.socket, { type: 'response', requestId: String(message.requestId || ''), ok: true, result });
167
+ }
168
+
169
+ broadcast(payload) {
170
+ for (const client of this.clients) {
171
+ if (client.authenticated) sendFrame(client.socket, payload);
172
+ }
173
+ }
174
+
175
+ detach(client) {
176
+ if (client.authTimer) clearTimeout(client.authTimer);
177
+ this.clients.delete(client);
178
+ }
179
+
180
+ dispose() {
181
+ if (this.shutdownTimer) clearTimeout(this.shutdownTimer);
182
+ this.shutdownTimer = null;
183
+ this.manager.removeListener('data', this.onManagerData);
184
+ this.manager.removeListener('state', this.onManagerState);
185
+ for (const client of this.clients) runBestEffort('terminal-host-client-close', () => client.socket.destroy());
186
+ this.clients.clear();
187
+ if (this.server) runBestEffort('terminal-host-server-close', () => this.server.close());
188
+ this.server = null;
189
+ try {
190
+ const current = readHostDiscovery(this.discoveryFile);
191
+ if (current.pid === process.pid && current.token === this.token) fs.unlinkSync(this.discoveryFile);
192
+ } catch (_missingOrReplacedDiscovery) {}
193
+ if (this.platform !== 'win32' && fs.existsSync(this.endpoint)) {
194
+ runBestEffort('terminal-host-endpoint-cleanup', () => fs.unlinkSync(this.endpoint));
195
+ }
196
+ }
197
+ }
198
+
199
+ function launchTerminalHost(options = {}) {
200
+ const executable = options.executable || process.execPath;
201
+ const script = options.script;
202
+ if (!script) throw new Error('터미널 호스트 스크립트 경로가 없습니다.');
203
+ const config = Buffer.from(JSON.stringify({
204
+ storeFile: options.storeFile,
205
+ discoveryFile: options.discoveryFile,
206
+ bridgeHome: options.bridgeHome,
207
+ }), 'utf8').toString('base64');
208
+ fs.mkdirSync(path.dirname(options.discoveryFile), { recursive: true });
209
+ const child = (options.spawnProcess || spawn)(executable, [script, '--config', config], {
210
+ cwd: path.dirname(options.discoveryFile),
211
+ detached: true,
212
+ windowsHide: true,
213
+ stdio: 'ignore',
214
+ env: { ...process.env, ...options.env, ELECTRON_RUN_AS_NODE: '1' },
215
+ });
216
+ child.unref();
217
+ return child.pid;
218
+ }
219
+
220
+ class TerminalHostClient extends EventEmitter {
221
+ constructor(options = {}) {
222
+ super();
223
+ this.discoveryFile = path.resolve(options.discoveryFile);
224
+ this.spawnHost = typeof options.spawnHost === 'function' ? options.spawnHost : null;
225
+ this.connectTimeoutMs = Number(options.connectTimeoutMs || 12_000);
226
+ this.socket = null;
227
+ this.buffer = '';
228
+ this.connected = false;
229
+ this.disposed = false;
230
+ this.sessions = [];
231
+ this.sequence = 0;
232
+ this.pending = new Map();
233
+ this.handshake = null;
234
+ }
235
+
236
+ async connect() {
237
+ this.disposed = false;
238
+ const deadline = Date.now() + this.connectTimeoutMs;
239
+ let launched = false;
240
+ let lastError = null;
241
+ while (Date.now() < deadline) {
242
+ try {
243
+ await this.connectExisting();
244
+ return this;
245
+ } catch (error) {
246
+ lastError = error;
247
+ this.resetSocket();
248
+ }
249
+ if (!launched) {
250
+ if (!this.spawnHost) throw lastError;
251
+ await Promise.resolve(this.spawnHost());
252
+ launched = true;
253
+ }
254
+ await new Promise(resolve => setTimeout(resolve, 120));
255
+ }
256
+ throw new Error(`터미널 호스트에 연결하지 못했습니다: ${lastError?.message || '시간 초과'}`);
257
+ }
258
+
259
+ connectExisting() {
260
+ const discovery = readHostDiscovery(this.discoveryFile);
261
+ return new Promise((resolve, reject) => {
262
+ const socket = net.createConnection(discovery.endpoint);
263
+ const timer = setTimeout(() => {
264
+ socket.destroy();
265
+ reject(new Error('터미널 호스트 연결 시간이 초과되었습니다.'));
266
+ }, 1_500);
267
+ this.socket = socket;
268
+ this.buffer = '';
269
+ this.handshake = {
270
+ resolve: () => { clearTimeout(timer); this.handshake = null; resolve(); },
271
+ reject: error => { clearTimeout(timer); this.handshake = null; reject(error); },
272
+ };
273
+ socket.setNoDelay(true);
274
+ socket.on('connect', () => sendFrame(socket, { type: 'authenticate', token: discovery.token }));
275
+ socket.on('data', chunk => this.consume(chunk));
276
+ socket.on('error', error => {
277
+ if (this.handshake) this.handshake.reject(error);
278
+ });
279
+ socket.on('close', () => this.handleDisconnect());
280
+ });
281
+ }
282
+
283
+ consume(chunk) {
284
+ this.buffer += chunk.toString('utf8');
285
+ if (this.buffer.length > MAX_FRAME_CHARS) {
286
+ this.socket?.destroy(new Error('터미널 호스트 응답이 너무 큽니다.'));
287
+ return;
288
+ }
289
+ let newline;
290
+ while ((newline = this.buffer.indexOf('\n')) >= 0) {
291
+ const line = this.buffer.slice(0, newline).trim();
292
+ this.buffer = this.buffer.slice(newline + 1);
293
+ if (!line) continue;
294
+ let message;
295
+ try { message = JSON.parse(line); } catch { continue; }
296
+ if (message.type === 'ready') {
297
+ this.sessions = Array.isArray(message.sessions) ? message.sessions : [];
298
+ this.connected = true;
299
+ if (this.handshake) this.handshake.resolve();
300
+ } else if (message.type === 'response') {
301
+ const pending = this.pending.get(String(message.requestId || ''));
302
+ if (!pending) continue;
303
+ this.pending.delete(String(message.requestId || ''));
304
+ clearTimeout(pending.timer);
305
+ if (message.ok) pending.resolve(message.result);
306
+ else pending.reject(new Error(String(message.error || '터미널 호스트 작업 실패')));
307
+ } else if (message.type === 'event' && message.event === 'data') {
308
+ this.emit('data', message.payload);
309
+ } else if (message.type === 'event' && message.event === 'state') {
310
+ if (Array.isArray(message.payload?.sessions)) this.sessions = message.payload.sessions;
311
+ this.emit('state', message.payload);
312
+ }
313
+ }
314
+ }
315
+
316
+ handleDisconnect() {
317
+ const wasConnected = this.connected;
318
+ this.connected = false;
319
+ if (this.handshake) this.handshake.reject(new Error('터미널 호스트 연결이 닫혔습니다.'));
320
+ for (const pending of this.pending.values()) {
321
+ clearTimeout(pending.timer);
322
+ pending.reject(new Error('터미널 호스트 연결이 닫혔습니다.'));
323
+ }
324
+ this.pending.clear();
325
+ this.socket = null;
326
+ if (wasConnected && !this.disposed) this.emit('disconnect');
327
+ }
328
+
329
+ resetSocket() {
330
+ if (this.socket) this.socket.destroy();
331
+ this.socket = null;
332
+ this.connected = false;
333
+ this.buffer = '';
334
+ }
335
+
336
+ request(operation, ...args) {
337
+ if (!this.connected || !this.socket || this.socket.destroyed) {
338
+ return Promise.reject(new Error('터미널 호스트에 연결되어 있지 않습니다.'));
339
+ }
340
+ const requestId = String(++this.sequence);
341
+ return new Promise((resolve, reject) => {
342
+ const timer = setTimeout(() => {
343
+ this.pending.delete(requestId);
344
+ reject(new Error(`터미널 호스트 작업 시간이 초과되었습니다: ${operation}`));
345
+ }, REQUEST_TIMEOUT_MS);
346
+ this.pending.set(requestId, { resolve, reject, timer });
347
+ sendFrame(this.socket, { type: 'request', requestId, operation, args });
348
+ });
349
+ }
350
+
351
+ list() { return this.sessions.map(session => ({ ...session })); }
352
+ get(id, includeReplay = true) { return this.request('get', id, includeReplay); }
353
+ create(options) { return this.request('create', options); }
354
+ write(id, data) { return this.request('write', id, data); }
355
+ command(id, command) { return this.request('command', id, command); }
356
+ resize(id, cols, rows) { return this.request('resize', id, cols, rows); }
357
+ signal(id, signal) { return this.request('signal', id, signal); }
358
+ restart(id) { return this.request('restart', id); }
359
+ close(id) { return this.request('close', id); }
360
+
361
+ dispose({ shutdownIfIdle = false } = {}) {
362
+ this.disposed = true;
363
+ if (this.socket && !this.socket.destroyed) {
364
+ if (shutdownIfIdle) sendFrame(this.socket, { type: 'control', operation: 'shutdown-if-idle' });
365
+ this.socket.end();
366
+ }
367
+ for (const pending of this.pending.values()) {
368
+ clearTimeout(pending.timer);
369
+ pending.reject(new Error('터미널 호스트 클라이언트가 종료되었습니다.'));
370
+ }
371
+ this.pending.clear();
372
+ }
373
+ }
374
+
375
+ module.exports = {
376
+ TerminalHostServer,
377
+ TerminalHostClient,
378
+ TERMINAL_HOST_PROTOCOL,
379
+ readHostDiscovery,
380
+ launchTerminalHost,
381
+ };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { TerminalManager } = require('./terminalManager');
6
+ const { BridgeServer } = require('./bridgeServer');
7
+ const { TerminalHostServer } = require('./terminalHost');
8
+
9
+ function parseConfig(argv = process.argv.slice(2)) {
10
+ const index = argv.indexOf('--config');
11
+ if (index < 0 || !argv[index + 1]) throw new Error('터미널 호스트 설정이 없습니다.');
12
+ const parsed = JSON.parse(Buffer.from(argv[index + 1], 'base64').toString('utf8'));
13
+ for (const key of ['storeFile', 'discoveryFile', 'bridgeHome']) {
14
+ if (!parsed[key] || typeof parsed[key] !== 'string') throw new Error(`터미널 호스트 설정이 올바르지 않습니다: ${key}`);
15
+ }
16
+ return {
17
+ storeFile: path.resolve(parsed.storeFile),
18
+ discoveryFile: path.resolve(parsed.discoveryFile),
19
+ bridgeHome: path.resolve(parsed.bridgeHome),
20
+ };
21
+ }
22
+
23
+ async function run(config = parseConfig()) {
24
+ process.title = 'LoadToAgent Terminal Host';
25
+ const manager = new TerminalManager({ storeFile: config.storeFile });
26
+ let stopping = false;
27
+ let host = null;
28
+ let bridge = null;
29
+ const stop = () => {
30
+ if (stopping) return;
31
+ stopping = true;
32
+ if (bridge) bridge.dispose();
33
+ if (host) host.dispose();
34
+ manager.dispose({ preserveSessions: true });
35
+ setImmediate(() => process.exit(0));
36
+ };
37
+ host = new TerminalHostServer({ manager, discoveryFile: config.discoveryFile, onShutdown: stop });
38
+ bridge = new BridgeServer({ terminalManager: manager, home: config.bridgeHome, platform: process.platform });
39
+ await host.start();
40
+ try { await bridge.start(); } catch (error) {
41
+ const logFile = path.join(path.dirname(config.discoveryFile), 'terminal-host.log');
42
+ fs.appendFileSync(logFile, `${new Date().toISOString()} bridge: ${error.stack || error.message}\n`, 'utf8');
43
+ }
44
+ process.on('SIGINT', stop);
45
+ process.on('SIGTERM', stop);
46
+ return { manager, host, bridge, stop };
47
+ }
48
+
49
+ if (require.main === module) {
50
+ run().catch(error => {
51
+ try {
52
+ const config = parseConfig();
53
+ fs.mkdirSync(path.dirname(config.discoveryFile), { recursive: true });
54
+ fs.appendFileSync(path.join(path.dirname(config.discoveryFile), 'terminal-host.log'), `${new Date().toISOString()} fatal: ${error.stack || error.message}\n`, 'utf8');
55
+ } catch {}
56
+ process.exitCode = 1;
57
+ });
58
+ }
59
+
60
+ module.exports = { parseConfig, run };