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.
@@ -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 = 1;
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 !== STORE_VERSION || !Array.isArray(parsed.sessions)) throw new Error('지원하지 않는 터미널 기록 형식입니다.');
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 = restoredOptions(value?.options, this.platform);
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 = value.status === 'failed' ? 'failed' : 'exited';
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.status = 'exited';
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
- if (session.process) {
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
- if (session.process) {
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
- if (session.process) {
630
- const handle = session.process;
631
- const pid = session.pid;
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
- if (session.process) {
651
- const handle = session.process;
652
- session.process = null;
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
  }