loadtoagent 1.3.4 → 1.3.5
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/package.json +4 -2
- package/src/nodePtyRuntime.js +58 -0
- package/src/terminalHost.js +104 -5
- package/src/terminalManager.js +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loadtoagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "A local desktop dashboard for Claude, Codex, Gemini, and Grok sessions.",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"author": "wincube AX",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"start": "electron .",
|
|
45
45
|
"test": "node scripts/regression-test.js",
|
|
46
46
|
"test:terminal": "electron scripts/terminal-integration-test.js",
|
|
47
|
+
"test:terminal:mac-stress": "node scripts/mac-terminal-stress-check.js",
|
|
47
48
|
"test:terminal-restart": "node scripts/terminal-restart-persistence-check.js",
|
|
48
49
|
"test:bridge": "electron scripts/bridge-integration-test.js",
|
|
49
50
|
"test:tmux": "node scripts/tmux-integration-test.js",
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"test:control-room": "electron scripts/control-room-visual.js",
|
|
57
58
|
"test:readability": "electron scripts/readability-visual-check.js",
|
|
58
59
|
"test:update:mac": "node scripts/mac-update-integration-test.js",
|
|
60
|
+
"test:artifacts:mac": "node scripts/mac-artifact-check.js",
|
|
59
61
|
"demo:readme": "electron scripts/readme-demo.js",
|
|
60
62
|
"dist": "electron-builder",
|
|
61
63
|
"dist:win": "electron-builder --win --publish never",
|
|
@@ -70,7 +72,7 @@
|
|
|
70
72
|
"dependencies": {
|
|
71
73
|
"@xterm/addon-fit": "^0.11.0",
|
|
72
74
|
"@xterm/xterm": "^6.0.0",
|
|
73
|
-
"node-pty": "
|
|
75
|
+
"node-pty": "1.2.0-beta.14"
|
|
74
76
|
},
|
|
75
77
|
"peerDependencies": {
|
|
76
78
|
"electron": "^31.0.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function unpackedAsarPath(file) {
|
|
7
|
+
return String(file || '')
|
|
8
|
+
.replace(/([\\/])app\.asar(?=[\\/])/g, '$1app.asar.unpacked')
|
|
9
|
+
.replace(/([\\/])node_modules\.asar(?=[\\/])/g, '$1node_modules.asar.unpacked');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function macNodePtyRuntimeFiles(options = {}) {
|
|
13
|
+
const platform = options.platform || process.platform;
|
|
14
|
+
if (platform !== 'darwin') return null;
|
|
15
|
+
const arch = String(options.arch || process.arch);
|
|
16
|
+
if (!/^(?:arm64|x64)$/.test(arch)) throw new Error(`지원하지 않는 macOS 아키텍처입니다: ${arch}`);
|
|
17
|
+
const resolvePackage = options.resolvePackage || (() => require.resolve('node-pty/package.json'));
|
|
18
|
+
const packageRoot = path.dirname(unpackedAsarPath(resolvePackage()));
|
|
19
|
+
const prebuild = path.join(packageRoot, 'prebuilds', `darwin-${arch}`);
|
|
20
|
+
return {
|
|
21
|
+
packageRoot,
|
|
22
|
+
helper: path.join(prebuild, 'spawn-helper'),
|
|
23
|
+
addon: path.join(prebuild, 'pty.node'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureMacNodePtyRuntime(options = {}) {
|
|
28
|
+
const files = macNodePtyRuntimeFiles(options);
|
|
29
|
+
if (!files) return { repaired: false, files: null };
|
|
30
|
+
const fileSystem = options.fileSystem || fs;
|
|
31
|
+
const executableMode = fileSystem.constants?.X_OK ?? fs.constants.X_OK;
|
|
32
|
+
for (const file of [files.addon, files.helper]) {
|
|
33
|
+
let stat;
|
|
34
|
+
try {
|
|
35
|
+
stat = fileSystem.statSync(file);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`macOS 터미널 구성 요소를 찾을 수 없습니다: ${file}`, { cause: error });
|
|
38
|
+
}
|
|
39
|
+
if (!stat.isFile()) throw new Error(`macOS 터미널 구성 요소가 파일이 아닙니다: ${file}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let repaired = false;
|
|
43
|
+
try {
|
|
44
|
+
fileSystem.accessSync(files.helper, executableMode);
|
|
45
|
+
} catch (_notExecutable) {
|
|
46
|
+
const mode = fileSystem.statSync(files.helper).mode;
|
|
47
|
+
try {
|
|
48
|
+
fileSystem.chmodSync(files.helper, mode | 0o111);
|
|
49
|
+
fileSystem.accessSync(files.helper, executableMode);
|
|
50
|
+
repaired = true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`macOS 터미널 실행 권한을 복구할 수 없습니다: ${files.helper}`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { repaired, files };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { ensureMacNodePtyRuntime, macNodePtyRuntimeFiles, unpackedAsarPath };
|
package/src/terminalHost.js
CHANGED
|
@@ -10,7 +10,8 @@ const { spawn } = require('child_process');
|
|
|
10
10
|
const { endpointFor, safeWriteJson } = require('./bridgeServer');
|
|
11
11
|
const { runBestEffort } = require('./diagnostics');
|
|
12
12
|
|
|
13
|
-
const TERMINAL_HOST_PROTOCOL =
|
|
13
|
+
const TERMINAL_HOST_PROTOCOL = 2;
|
|
14
|
+
const TERMINAL_HOST_RUNTIME = `node-pty-${require('node-pty/package.json').version}`;
|
|
14
15
|
const MAX_FRAME_CHARS = 4 * 1024 * 1024;
|
|
15
16
|
const AUTH_TIMEOUT_MS = 5_000;
|
|
16
17
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -21,14 +22,94 @@ function sendFrame(socket, payload) {
|
|
|
21
22
|
socket.write(`${JSON.stringify(payload)}\n`, 'utf8');
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function
|
|
25
|
+
function incompatibleHostError(message, discovery) {
|
|
26
|
+
const error = new Error(message);
|
|
27
|
+
error.code = 'LOADTOAGENT_INCOMPATIBLE_TERMINAL_HOST';
|
|
28
|
+
error.discovery = discovery;
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readHostDiscovery(file, fileSystem = fs, expectedRuntime = TERMINAL_HOST_RUNTIME) {
|
|
25
33
|
const parsed = JSON.parse(fileSystem.readFileSync(file, 'utf8'));
|
|
26
|
-
if (parsed?.
|
|
34
|
+
if (!parsed?.endpoint || !parsed.token || !Number.isSafeInteger(Number(parsed.pid)) || Number(parsed.pid) <= 0) {
|
|
27
35
|
throw new Error('터미널 호스트 연결 정보가 올바르지 않습니다.');
|
|
28
36
|
}
|
|
37
|
+
if (parsed.protocol !== TERMINAL_HOST_PROTOCOL || parsed.runtime !== expectedRuntime) {
|
|
38
|
+
throw incompatibleHostError('현재 앱과 호환되지 않는 터미널 호스트입니다.', parsed);
|
|
39
|
+
}
|
|
29
40
|
return parsed;
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
function verifyHostDiscovery(discovery, timeoutMs = 1_500) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const socket = net.createConnection(discovery.endpoint);
|
|
46
|
+
let buffer = '';
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = error => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
socket.destroy();
|
|
53
|
+
if (error) reject(error);
|
|
54
|
+
else resolve();
|
|
55
|
+
};
|
|
56
|
+
const timer = setTimeout(() => finish(new Error('이전 터미널 호스트 인증 시간이 초과되었습니다.')), timeoutMs);
|
|
57
|
+
socket.setNoDelay(true);
|
|
58
|
+
socket.on('connect', () => sendFrame(socket, { type: 'authenticate', token: discovery.token }));
|
|
59
|
+
socket.on('data', chunk => {
|
|
60
|
+
buffer += chunk.toString('utf8');
|
|
61
|
+
if (buffer.length > MAX_FRAME_CHARS) {
|
|
62
|
+
finish(new Error('이전 터미널 호스트 응답이 너무 큽니다.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let newline;
|
|
66
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
67
|
+
const line = buffer.slice(0, newline).trim();
|
|
68
|
+
buffer = buffer.slice(newline + 1);
|
|
69
|
+
if (!line) continue;
|
|
70
|
+
try {
|
|
71
|
+
if (JSON.parse(line).type === 'ready') {
|
|
72
|
+
finish();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
} catch (_invalidFrame) {}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
socket.on('error', finish);
|
|
79
|
+
socket.on('close', () => {
|
|
80
|
+
if (!settled) finish(new Error('이전 터미널 호스트 인증 연결이 닫혔습니다.'));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function processExists(pid) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code === 'ESRCH') return false;
|
|
91
|
+
if (error?.code === 'EPERM') return true;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function terminateHostProcess(discovery) {
|
|
97
|
+
const pid = Number(discovery?.pid);
|
|
98
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
|
|
99
|
+
throw new Error('교체할 터미널 호스트 프로세스 정보가 올바르지 않습니다.');
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 'SIGTERM');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
105
|
+
}
|
|
106
|
+
const deadline = Date.now() + 3_000;
|
|
107
|
+
while (processExists(pid)) {
|
|
108
|
+
if (Date.now() >= deadline) throw new Error(`이전 터미널 호스트가 종료되지 않았습니다: PID ${pid}`);
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
32
113
|
function activeSessions(manager) {
|
|
33
114
|
return manager.list().filter(session => session.status === 'running' || session.status === 'starting');
|
|
34
115
|
}
|
|
@@ -40,6 +121,7 @@ class TerminalHostServer {
|
|
|
40
121
|
this.discoveryFile = path.resolve(options.discoveryFile || path.join(os.tmpdir(), 'loadtoagent-terminal-host.json'));
|
|
41
122
|
this.endpoint = options.endpoint || endpointFor(this.platform, `${path.dirname(this.discoveryFile)}:terminal-host`);
|
|
42
123
|
this.token = options.token || crypto.randomBytes(32).toString('hex');
|
|
124
|
+
this.runtime = String(options.runtime || TERMINAL_HOST_RUNTIME);
|
|
43
125
|
this.server = null;
|
|
44
126
|
this.clients = new Set();
|
|
45
127
|
this.shutdownTimer = null;
|
|
@@ -57,6 +139,7 @@ class TerminalHostServer {
|
|
|
57
139
|
info() {
|
|
58
140
|
return {
|
|
59
141
|
protocol: TERMINAL_HOST_PROTOCOL,
|
|
142
|
+
runtime: this.runtime,
|
|
60
143
|
endpoint: this.endpoint,
|
|
61
144
|
token: this.token,
|
|
62
145
|
pid: process.pid,
|
|
@@ -207,7 +290,7 @@ class TerminalHostServer {
|
|
|
207
290
|
if (this.server) runBestEffort('terminal-host-server-close', () => this.server.close());
|
|
208
291
|
this.server = null;
|
|
209
292
|
try {
|
|
210
|
-
const current = readHostDiscovery(this.discoveryFile);
|
|
293
|
+
const current = readHostDiscovery(this.discoveryFile, fs, this.runtime);
|
|
211
294
|
if (current.pid === process.pid && current.token === this.token) fs.unlinkSync(this.discoveryFile);
|
|
212
295
|
} catch (_missingOrReplacedDiscovery) {}
|
|
213
296
|
if (this.platform !== 'win32' && fs.existsSync(this.endpoint)) {
|
|
@@ -263,6 +346,9 @@ class TerminalHostClient extends EventEmitter {
|
|
|
263
346
|
this.discoveryFile = path.resolve(options.discoveryFile);
|
|
264
347
|
this.spawnHost = typeof options.spawnHost === 'function' ? options.spawnHost : null;
|
|
265
348
|
this.connectTimeoutMs = Number(options.connectTimeoutMs || 12_000);
|
|
349
|
+
this.expectedRuntime = String(options.expectedRuntime || TERMINAL_HOST_RUNTIME);
|
|
350
|
+
this.verifyHost = typeof options.verifyHost === 'function' ? options.verifyHost : verifyHostDiscovery;
|
|
351
|
+
this.terminateHost = typeof options.terminateHost === 'function' ? options.terminateHost : terminateHostProcess;
|
|
266
352
|
this.socket = null;
|
|
267
353
|
this.buffer = '';
|
|
268
354
|
this.connected = false;
|
|
@@ -300,6 +386,16 @@ class TerminalHostClient extends EventEmitter {
|
|
|
300
386
|
} catch (error) {
|
|
301
387
|
lastError = error;
|
|
302
388
|
this.resetSocket();
|
|
389
|
+
if (error?.code === 'LOADTOAGENT_INCOMPATIBLE_TERMINAL_HOST') {
|
|
390
|
+
let verified = false;
|
|
391
|
+
try {
|
|
392
|
+
await Promise.resolve(this.verifyHost(error.discovery));
|
|
393
|
+
verified = true;
|
|
394
|
+
} catch (verificationError) {
|
|
395
|
+
lastError = verificationError;
|
|
396
|
+
}
|
|
397
|
+
if (verified) await Promise.resolve(this.terminateHost(error.discovery));
|
|
398
|
+
}
|
|
303
399
|
}
|
|
304
400
|
if (!launched) {
|
|
305
401
|
if (!this.spawnHost) throw lastError;
|
|
@@ -313,7 +409,7 @@ class TerminalHostClient extends EventEmitter {
|
|
|
313
409
|
}
|
|
314
410
|
|
|
315
411
|
connectExisting() {
|
|
316
|
-
const discovery = readHostDiscovery(this.discoveryFile);
|
|
412
|
+
const discovery = readHostDiscovery(this.discoveryFile, fs, this.expectedRuntime);
|
|
317
413
|
return new Promise((resolve, reject) => {
|
|
318
414
|
const socket = net.createConnection(discovery.endpoint);
|
|
319
415
|
const timer = setTimeout(() => {
|
|
@@ -449,7 +545,10 @@ module.exports = {
|
|
|
449
545
|
TerminalHostServer,
|
|
450
546
|
TerminalHostClient,
|
|
451
547
|
TERMINAL_HOST_PROTOCOL,
|
|
548
|
+
TERMINAL_HOST_RUNTIME,
|
|
452
549
|
readHostDiscovery,
|
|
550
|
+
verifyHostDiscovery,
|
|
551
|
+
terminateHostProcess,
|
|
453
552
|
launchTerminalHost,
|
|
454
553
|
resolveTerminalHostExecutable,
|
|
455
554
|
};
|
package/src/terminalManager.js
CHANGED
|
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
|
|
7
7
|
const { EventEmitter } = require('events');
|
|
8
8
|
const { spawn: spawnChild } = require('child_process');
|
|
9
9
|
const { runBestEffort } = require('./diagnostics');
|
|
10
|
+
const { ensureMacNodePtyRuntime } = require('./nodePtyRuntime');
|
|
10
11
|
|
|
11
12
|
const MAX_SESSIONS = 24;
|
|
12
13
|
const MAX_INPUT_CHARS = 128 * 1024;
|
|
@@ -370,7 +371,10 @@ class TerminalManager extends EventEmitter {
|
|
|
370
371
|
}
|
|
371
372
|
|
|
372
373
|
pty() {
|
|
373
|
-
if (!this.ptyModule)
|
|
374
|
+
if (!this.ptyModule) {
|
|
375
|
+
ensureMacNodePtyRuntime({ platform: this.platform });
|
|
376
|
+
this.ptyModule = require('node-pty');
|
|
377
|
+
}
|
|
374
378
|
return this.ptyModule;
|
|
375
379
|
}
|
|
376
380
|
|