loadtoagent 1.3.10 → 1.3.11
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 +6 -3
- package/README.md +6 -3
- package/README.zh-CN.md +6 -3
- package/bin/loadtoagent.js +3 -1
- package/docs/ARCHITECTURE.md +7 -0
- package/docs/MANAGED-TERMINAL-SESSIONS.md +64 -0
- package/main.js +5 -1
- package/package.json +2 -1
- package/preload.js +3 -0
- package/renderer/app-agent-actions.js +83 -35
- package/renderer/app-drawer.js +107 -26
- package/renderer/app-events-dialogs.js +66 -8
- package/renderer/app-events-sessions.js +3 -23
- package/renderer/app.js +11 -1
- package/renderer/i18n-messages.js +33 -0
- package/renderer/index.html +2 -0
- package/renderer/styles-control-room.css +399 -0
- package/renderer/styles-terminal.css +6 -0
- package/renderer/terminal-events.js +30 -4
- package/renderer/terminal-workbench.js +15 -3
- package/renderer/terminal.js +2 -0
- package/src/bridgeServer.js +1 -1
- package/src/ipc/registerTerminalIpc.js +1 -1
- package/src/managedTmuxRuntime.js +53 -0
- package/src/terminalHost.js +8 -2
- package/src/terminalManager.js +173 -33
package/src/terminalManager.js
CHANGED
|
@@ -7,15 +7,18 @@ 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 { ManagedTmuxRuntime } = require('./managedTmuxRuntime');
|
|
10
11
|
const { ensureMacNodePtyRuntime } = require('./nodePtyRuntime');
|
|
11
12
|
|
|
12
13
|
const MAX_SESSIONS = 24;
|
|
13
14
|
const MAX_INPUT_CHARS = 128 * 1024;
|
|
14
15
|
const MAX_REPLAY_CHARS = 2 * 1024 * 1024;
|
|
15
16
|
const MAX_STORE_BYTES = 64 * 1024 * 1024;
|
|
16
|
-
const STORE_VERSION =
|
|
17
|
+
const STORE_VERSION = 2;
|
|
17
18
|
const PERSIST_DELAY_MS = 150;
|
|
18
19
|
const TERMINAL_TYPES = new Set(['powershell', 'cmd', 'shell', 'wsl', 'tmux', 'agent']);
|
|
20
|
+
const SESSION_BACKENDS = new Set(['direct', 'managed-tmux']);
|
|
21
|
+
const DEFAULT_TMUX_SOCKET = 'loadtoagent';
|
|
19
22
|
const AGENT_PROVIDERS = Object.freeze({
|
|
20
23
|
claude: { command: 'claude', label: 'Claude' },
|
|
21
24
|
codex: { command: 'codex', label: 'GPT · Codex' },
|
|
@@ -27,6 +30,13 @@ function cleanText(value, max = 200) {
|
|
|
27
30
|
return String(value == null ? '' : value).replace(/[\u0000\r\n]/g, ' ').trim().slice(0, max);
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
function safeTmuxName(value, fallback = '') {
|
|
34
|
+
const text = cleanText(value, 100);
|
|
35
|
+
if (!text) return fallback;
|
|
36
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(text)) throw new Error('tmux 이름에는 영문, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다.');
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
function shellQuote(value) {
|
|
31
41
|
return `'${String(value == null ? '' : value).replace(/'/g, `'"'"'`)}'`;
|
|
32
42
|
}
|
|
@@ -131,6 +141,16 @@ function normalizeLaunchOptions(options = {}, platform = process.platform) {
|
|
|
131
141
|
if (type === 'tmux' && !tmuxSession) throw new Error('연결할 tmux 세션이 필요합니다.');
|
|
132
142
|
const provider = cleanText(options.provider, 30).toLowerCase();
|
|
133
143
|
if (type === 'agent' && !AGENT_PROVIDERS[provider]) throw new Error('지원하지 않는 AI 제공사입니다.');
|
|
144
|
+
const requestedBackend = cleanText(options.sessionBackend || options.backend, 40);
|
|
145
|
+
const managedByDefault = type === 'agent'
|
|
146
|
+
&& !options.transient
|
|
147
|
+
&& (platform !== 'win32' || Boolean(distro));
|
|
148
|
+
const sessionBackend = SESSION_BACKENDS.has(requestedBackend)
|
|
149
|
+
? requestedBackend
|
|
150
|
+
: (managedByDefault ? 'managed-tmux' : 'direct');
|
|
151
|
+
if (sessionBackend === 'managed-tmux' && type !== 'agent') {
|
|
152
|
+
throw new Error('관리형 tmux 백엔드는 AI 터미널에서만 사용할 수 있습니다.');
|
|
153
|
+
}
|
|
134
154
|
const args = Array.isArray(options.args)
|
|
135
155
|
? options.args.slice(0, 80).map(value => cleanText(value, 2_000))
|
|
136
156
|
: [];
|
|
@@ -142,6 +162,13 @@ function normalizeLaunchOptions(options = {}, platform = process.platform) {
|
|
|
142
162
|
tmuxPane,
|
|
143
163
|
provider,
|
|
144
164
|
args,
|
|
165
|
+
sessionBackend,
|
|
166
|
+
tmuxSocket: sessionBackend === 'managed-tmux'
|
|
167
|
+
? safeTmuxName(options.tmuxSocket, DEFAULT_TMUX_SOCKET)
|
|
168
|
+
: '',
|
|
169
|
+
managedTmuxSession: sessionBackend === 'managed-tmux'
|
|
170
|
+
? safeTmuxName(options.managedTmuxSession)
|
|
171
|
+
: '',
|
|
145
172
|
bridgeId: cleanText(options.bridgeId, 100),
|
|
146
173
|
title: cleanText(options.title, 100),
|
|
147
174
|
transient: Boolean(options.transient),
|
|
@@ -162,6 +189,34 @@ function launchSpec(options, platform = process.platform, agentProviders = AGENT
|
|
|
162
189
|
}
|
|
163
190
|
if (options.type === 'agent') {
|
|
164
191
|
const provider = agentProviders[options.provider] || AGENT_PROVIDERS[options.provider];
|
|
192
|
+
if (options.sessionBackend === 'managed-tmux') {
|
|
193
|
+
if (!options.managedTmuxSession) throw new Error('관리형 tmux 세션 이름이 필요합니다.');
|
|
194
|
+
const tmuxArgs = [
|
|
195
|
+
'-L', options.tmuxSocket,
|
|
196
|
+
'new-session', '-A',
|
|
197
|
+
'-s', options.managedTmuxSession,
|
|
198
|
+
'-c', options.cwd,
|
|
199
|
+
provider.command,
|
|
200
|
+
...(provider.args || []),
|
|
201
|
+
...options.args,
|
|
202
|
+
';',
|
|
203
|
+
'set-option', '-g', 'window-size', 'largest',
|
|
204
|
+
];
|
|
205
|
+
if (platform !== 'win32') {
|
|
206
|
+
return {
|
|
207
|
+
file: 'tmux',
|
|
208
|
+
args: tmuxArgs,
|
|
209
|
+
cwd: options.cwd,
|
|
210
|
+
label: provider.label,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
file: 'wsl.exe',
|
|
215
|
+
args: ['-d', options.distro, '--cd', options.cwd, '--', 'tmux', ...tmuxArgs],
|
|
216
|
+
cwd: os.homedir(),
|
|
217
|
+
label: `${provider.label} · ${options.distro}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
165
220
|
if (platform === 'win32') {
|
|
166
221
|
if (options.distro) {
|
|
167
222
|
const args = ['-d', options.distro];
|
|
@@ -220,6 +275,9 @@ function publicSession(session, includeReplay = false) {
|
|
|
220
275
|
tmuxSession: session.options.tmuxSession,
|
|
221
276
|
tmuxPane: session.options.tmuxPane,
|
|
222
277
|
provider: session.options.provider,
|
|
278
|
+
backend: session.options.sessionBackend,
|
|
279
|
+
tmuxSocket: session.options.tmuxSocket,
|
|
280
|
+
managedTmuxSession: session.options.managedTmuxSession,
|
|
223
281
|
bridgeId: session.options.bridgeId,
|
|
224
282
|
transient: Boolean(session.options.transient),
|
|
225
283
|
background: session.options.type === 'agent',
|
|
@@ -243,7 +301,7 @@ function validTimestamp(value, fallback) {
|
|
|
243
301
|
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
|
|
244
302
|
}
|
|
245
303
|
|
|
246
|
-
function restoredOptions(value = {}, platform = process.platform) {
|
|
304
|
+
function restoredOptions(value = {}, platform = process.platform, storeVersion = STORE_VERSION) {
|
|
247
305
|
const fallbackType = platform === 'win32' ? 'powershell' : 'shell';
|
|
248
306
|
const type = TERMINAL_TYPES.has(value.type) ? value.type : fallbackType;
|
|
249
307
|
const provider = cleanText(value.provider, 30).toLowerCase();
|
|
@@ -256,6 +314,11 @@ function restoredOptions(value = {}, platform = process.platform) {
|
|
|
256
314
|
tmuxPane: cleanText(value.tmuxPane, 100),
|
|
257
315
|
provider,
|
|
258
316
|
args: Array.isArray(value.args) ? value.args.slice(0, 80).map(item => cleanText(item, 2_000)) : [],
|
|
317
|
+
sessionBackend: SESSION_BACKENDS.has(value.sessionBackend)
|
|
318
|
+
? value.sessionBackend
|
|
319
|
+
: (storeVersion < STORE_VERSION ? 'direct' : undefined),
|
|
320
|
+
tmuxSocket: cleanText(value.tmuxSocket, 100),
|
|
321
|
+
managedTmuxSession: cleanText(value.managedTmuxSession, 100),
|
|
259
322
|
bridgeId: cleanText(value.bridgeId, 100),
|
|
260
323
|
title: cleanText(value.title, 100),
|
|
261
324
|
transient: Boolean(value.transient),
|
|
@@ -294,6 +357,7 @@ class TerminalManager extends EventEmitter {
|
|
|
294
357
|
this.killTree = options.killTree || killPtyTree;
|
|
295
358
|
this.platform = options.platform || process.platform;
|
|
296
359
|
this.agentProviders = options.agentProviders || AGENT_PROVIDERS;
|
|
360
|
+
this.managedTmuxRuntime = options.managedTmuxRuntime || new ManagedTmuxRuntime({ platform: this.platform });
|
|
297
361
|
this.fileSystem = options.fileSystem || fs;
|
|
298
362
|
this.storeFile = typeof options.storeFile === 'string' && options.storeFile.trim()
|
|
299
363
|
? path.resolve(options.storeFile)
|
|
@@ -316,15 +380,20 @@ class TerminalManager extends EventEmitter {
|
|
|
316
380
|
const stat = this.fileSystem.statSync(this.storeFile);
|
|
317
381
|
if (!stat.isFile() || stat.size > MAX_STORE_BYTES) throw new Error('터미널 기록 파일의 크기가 허용 범위를 초과했습니다.');
|
|
318
382
|
const parsed = JSON.parse(this.fileSystem.readFileSync(this.storeFile, 'utf8'));
|
|
319
|
-
if (parsed?.version
|
|
383
|
+
if (![1, STORE_VERSION].includes(parsed?.version) || !Array.isArray(parsed.sessions)) throw new Error('지원하지 않는 터미널 기록 형식입니다.');
|
|
320
384
|
for (const value of parsed.sessions.slice(0, MAX_SESSIONS)) {
|
|
321
385
|
const id = cleanText(value?.id, 200);
|
|
322
|
-
const options =
|
|
386
|
+
const options = normalizeLaunchOptions(
|
|
387
|
+
restoredOptions(value?.options, this.platform, parsed.version),
|
|
388
|
+
this.platform,
|
|
389
|
+
);
|
|
323
390
|
if (!id || !options || this.sessions.has(id)) continue;
|
|
324
391
|
const now = new Date().toISOString();
|
|
325
392
|
const createdAt = validTimestamp(value.createdAt, now);
|
|
326
393
|
const updatedAt = validTimestamp(value.updatedAt, createdAt);
|
|
327
|
-
const status =
|
|
394
|
+
const status = options.sessionBackend === 'managed-tmux' && ['detached', 'stopped'].includes(value.status)
|
|
395
|
+
? value.status
|
|
396
|
+
: (value.status === 'failed' ? 'failed' : 'exited');
|
|
328
397
|
this.sessions.set(id, {
|
|
329
398
|
id,
|
|
330
399
|
options,
|
|
@@ -395,6 +464,28 @@ class TerminalManager extends EventEmitter {
|
|
|
395
464
|
for (const session of this.sessions.values()) {
|
|
396
465
|
if (!session.recoveryPending) continue;
|
|
397
466
|
session.recoveryPending = false;
|
|
467
|
+
if (session.options.sessionBackend === 'managed-tmux') {
|
|
468
|
+
if (!this.managedTmuxRuntime.exists(session.options)) {
|
|
469
|
+
session.status = 'stopped';
|
|
470
|
+
session.pid = null;
|
|
471
|
+
session.recoveredAfterHostRestart = false;
|
|
472
|
+
session.recoverySkippedReason = 'managed-tmux-missing';
|
|
473
|
+
const missingMessage = '\r\n[LoadToAgent] 저장된 tmux 세션이 없어 자동으로 새 AI 대화를 시작하지 않았습니다.\r\n';
|
|
474
|
+
session.replay = `${session.replay}${missingMessage}`.slice(-MAX_REPLAY_CHARS);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
session.recoveredAfterHostRestart = true;
|
|
478
|
+
session.recoverySkippedReason = '';
|
|
479
|
+
const reattachMessage = '\r\n[LoadToAgent] 터미널 호스트 중단 뒤 살아 있는 tmux 세션에 다시 연결했습니다.\r\n';
|
|
480
|
+
session.replay = `${session.replay}${reattachMessage}`.slice(-MAX_REPLAY_CHARS);
|
|
481
|
+
try {
|
|
482
|
+
this.spawn(session);
|
|
483
|
+
recovered.push(publicSession(session, true));
|
|
484
|
+
} catch (_recoveryFailed) {
|
|
485
|
+
session.recoveredAfterHostRestart = false;
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
398
489
|
if (session.options.type === 'agent'
|
|
399
490
|
&& /TERM is set to ["']?dumb["']?[\s\S]{0,500}Continue anyway\?/i.test(session.replay)) {
|
|
400
491
|
this.sessions.delete(session.id);
|
|
@@ -427,8 +518,11 @@ class TerminalManager extends EventEmitter {
|
|
|
427
518
|
create(rawOptions = {}) {
|
|
428
519
|
if (this.sessions.size >= MAX_SESSIONS) throw new Error(`동시에 열 수 있는 터미널은 최대 ${MAX_SESSIONS}개입니다.`);
|
|
429
520
|
const options = normalizeLaunchOptions(rawOptions, this.platform);
|
|
430
|
-
const spec = launchSpec(options, this.platform, this.agentProviders);
|
|
431
521
|
const id = `terminal:${Date.now().toString(36)}:${crypto.randomBytes(4).toString('hex')}`;
|
|
522
|
+
if (options.sessionBackend === 'managed-tmux' && !options.managedTmuxSession) {
|
|
523
|
+
options.managedTmuxSession = safeTmuxName(`lta-${options.provider}-${id.split(':').slice(1).join('-')}`);
|
|
524
|
+
}
|
|
525
|
+
const spec = launchSpec(options, this.platform, this.agentProviders);
|
|
432
526
|
const now = new Date().toISOString();
|
|
433
527
|
const session = {
|
|
434
528
|
id,
|
|
@@ -505,7 +599,11 @@ class TerminalManager extends EventEmitter {
|
|
|
505
599
|
if (session.generation !== generation) return;
|
|
506
600
|
session.process = null;
|
|
507
601
|
session.pid = null;
|
|
508
|
-
session.
|
|
602
|
+
if (session.options.sessionBackend === 'managed-tmux') {
|
|
603
|
+
session.status = this.managedTmuxRuntime.exists(session.options) ? 'detached' : 'stopped';
|
|
604
|
+
} else {
|
|
605
|
+
session.status = 'exited';
|
|
606
|
+
}
|
|
509
607
|
session.exitCode = Number.isFinite(event.exitCode) ? event.exitCode : null;
|
|
510
608
|
session.signal = Number.isFinite(event.signal) ? event.signal : null;
|
|
511
609
|
session.updatedAt = new Date().toISOString();
|
|
@@ -590,15 +688,19 @@ class TerminalManager extends EventEmitter {
|
|
|
590
688
|
throw new Error('지원하지 않는 터미널 신호입니다.');
|
|
591
689
|
}
|
|
592
690
|
|
|
691
|
+
releaseProcess(session) {
|
|
692
|
+
if (!session.process) return false;
|
|
693
|
+
const handle = session.process;
|
|
694
|
+
const pid = session.pid;
|
|
695
|
+
session.process = null;
|
|
696
|
+
session.generation += 1;
|
|
697
|
+
this.killTree(handle, pid);
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
593
701
|
kill(id) {
|
|
594
702
|
const session = this.required(id);
|
|
595
|
-
|
|
596
|
-
const handle = session.process;
|
|
597
|
-
const pid = session.pid;
|
|
598
|
-
session.process = null;
|
|
599
|
-
session.generation += 1;
|
|
600
|
-
this.killTree(handle, pid);
|
|
601
|
-
}
|
|
703
|
+
this.releaseProcess(session);
|
|
602
704
|
session.pid = null;
|
|
603
705
|
session.status = 'exited';
|
|
604
706
|
session.updatedAt = new Date().toISOString();
|
|
@@ -611,27 +713,68 @@ class TerminalManager extends EventEmitter {
|
|
|
611
713
|
const session = this.required(id);
|
|
612
714
|
session.recoveredAfterHostRestart = false;
|
|
613
715
|
session.recoverySkippedReason = '';
|
|
614
|
-
|
|
615
|
-
const handle = session.process;
|
|
616
|
-
const pid = session.pid;
|
|
617
|
-
session.process = null;
|
|
618
|
-
session.generation += 1;
|
|
619
|
-
this.killTree(handle, pid);
|
|
620
|
-
}
|
|
716
|
+
this.releaseProcess(session);
|
|
621
717
|
session.pid = null;
|
|
622
718
|
session.replay = '';
|
|
623
719
|
this.spawn(session);
|
|
624
720
|
return publicSession(session, true);
|
|
625
721
|
}
|
|
626
722
|
|
|
723
|
+
reconnect(id) {
|
|
724
|
+
const session = this.required(id);
|
|
725
|
+
if (session.options.sessionBackend !== 'managed-tmux') {
|
|
726
|
+
throw new Error('직접 실행 터미널은 기존 백그라운드 세션에 재접속할 수 없습니다.');
|
|
727
|
+
}
|
|
728
|
+
if (session.process && session.status === 'running') return publicSession(session, true);
|
|
729
|
+
if (!this.managedTmuxRuntime.exists(session.options)) {
|
|
730
|
+
session.pid = null;
|
|
731
|
+
session.status = 'stopped';
|
|
732
|
+
session.recoveredAfterHostRestart = false;
|
|
733
|
+
session.recoverySkippedReason = 'managed-tmux-missing';
|
|
734
|
+
session.updatedAt = new Date().toISOString();
|
|
735
|
+
this.emitState('updated', session);
|
|
736
|
+
this.persistNow();
|
|
737
|
+
throw new Error('기존 tmux 세션이 종료되어 다시 연결할 수 없습니다.');
|
|
738
|
+
}
|
|
739
|
+
session.recoveredAfterHostRestart = false;
|
|
740
|
+
session.recoverySkippedReason = '';
|
|
741
|
+
this.spawn(session);
|
|
742
|
+
return publicSession(session, true);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
detach(id) {
|
|
746
|
+
const session = this.required(id);
|
|
747
|
+
if (session.options.sessionBackend !== 'managed-tmux') {
|
|
748
|
+
throw new Error('직접 실행 터미널은 작업을 유지한 채 화면만 분리할 수 없습니다.');
|
|
749
|
+
}
|
|
750
|
+
this.releaseProcess(session);
|
|
751
|
+
session.pid = null;
|
|
752
|
+
session.status = 'detached';
|
|
753
|
+
session.updatedAt = new Date().toISOString();
|
|
754
|
+
this.emitState('updated', session);
|
|
755
|
+
this.persistNow();
|
|
756
|
+
return publicSession(session, true);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
stop(id) {
|
|
760
|
+
const session = this.required(id);
|
|
761
|
+
this.releaseProcess(session);
|
|
762
|
+
if (session.options.sessionBackend === 'managed-tmux') {
|
|
763
|
+
this.managedTmuxRuntime.stop(session.options);
|
|
764
|
+
}
|
|
765
|
+
session.pid = null;
|
|
766
|
+
session.status = 'stopped';
|
|
767
|
+
session.updatedAt = new Date().toISOString();
|
|
768
|
+
this.emitState('updated', session);
|
|
769
|
+
this.persistNow();
|
|
770
|
+
return publicSession(session, true);
|
|
771
|
+
}
|
|
772
|
+
|
|
627
773
|
close(id) {
|
|
628
774
|
const session = this.required(id);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
session.process = null;
|
|
633
|
-
session.generation += 1;
|
|
634
|
-
this.killTree(handle, pid);
|
|
775
|
+
this.releaseProcess(session);
|
|
776
|
+
if (session.options.sessionBackend === 'managed-tmux') {
|
|
777
|
+
this.managedTmuxRuntime.stop(session.options);
|
|
635
778
|
}
|
|
636
779
|
session.pid = null;
|
|
637
780
|
session.status = 'exited';
|
|
@@ -647,13 +790,10 @@ class TerminalManager extends EventEmitter {
|
|
|
647
790
|
const now = new Date().toISOString();
|
|
648
791
|
for (const session of this.sessions.values()) {
|
|
649
792
|
const shouldRecover = session.status === 'running' || session.status === 'starting';
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
session.
|
|
653
|
-
session.generation += 1;
|
|
654
|
-
this.killTree(handle, session.pid);
|
|
793
|
+
this.releaseProcess(session);
|
|
794
|
+
if (shouldRecover) {
|
|
795
|
+
session.status = session.options.sessionBackend === 'managed-tmux' ? 'detached' : 'running';
|
|
655
796
|
}
|
|
656
|
-
if (shouldRecover) session.status = 'running';
|
|
657
797
|
session.pid = null;
|
|
658
798
|
session.updatedAt = now;
|
|
659
799
|
}
|