loadtoagent 1.3.3 → 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.
Files changed (38) hide show
  1. package/main.js +53 -8
  2. package/package.json +5 -2
  3. package/renderer/app-agent-actions.js +172 -66
  4. package/renderer/app-bootstrap.js +3 -1
  5. package/renderer/app-dashboard.js +36 -7
  6. package/renderer/app-drawer-content.js +177 -59
  7. package/renderer/app-drawer-data.js +7 -3
  8. package/renderer/app-drawer.js +72 -33
  9. package/renderer/app-events-dialogs.js +8 -1
  10. package/renderer/app-events-navigation.js +7 -0
  11. package/renderer/app-events-sessions.js +136 -13
  12. package/renderer/app-graph-model.js +2 -5
  13. package/renderer/app-graph-orchestration.js +7 -18
  14. package/renderer/app-graph-view.js +194 -48
  15. package/renderer/app-management.js +330 -35
  16. package/renderer/app-quality.js +5 -0
  17. package/renderer/app-session-render.js +20 -83
  18. package/renderer/app.js +5 -1
  19. package/renderer/i18n-messages.js +232 -21
  20. package/renderer/index.html +9 -6
  21. package/renderer/styles-components.css +140 -0
  22. package/renderer/styles-control-room.css +1439 -0
  23. package/renderer/styles-management.css +390 -3
  24. package/renderer/styles-responsive-shell.css +5 -0
  25. package/renderer/styles-runtime-overview.css +20 -0
  26. package/renderer/terminal-agent.js +31 -7
  27. package/renderer/terminal.js +4 -1
  28. package/src/agentMonitor/claudeParser.js +8 -4
  29. package/src/agentMonitor/codexParser.js +5 -4
  30. package/src/agentMonitor/genericParser.js +7 -6
  31. package/src/agentMonitor.js +44 -4
  32. package/src/attentionNotifier.js +3 -0
  33. package/src/ipc/registerTerminalIpc.js +2 -2
  34. package/src/monitorWorker.js +1 -1
  35. package/src/nodePtyRuntime.js +58 -0
  36. package/src/processMonitor.js +68 -7
  37. package/src/terminalHost.js +104 -5
  38. package/src/terminalManager.js +21 -3
@@ -60,19 +60,20 @@ function createGenericParser(dependencies) {
60
60
  return out;
61
61
  }
62
62
 
63
- function readSessionFile(fileInfo) {
63
+ function readSessionFile(fileInfo, options = {}) {
64
64
  const isJsonl = /\.jsonl$/i.test(fileInfo.file);
65
65
  return isJsonl
66
- ? readJsonLines(fileInfo.file)
66
+ ? readJsonLines(fileInfo.file, options.fullHistory ? Math.max(1, Number(fileInfo.size || 0) + 1) : undefined)
67
67
  : { rows: [readJson(fileInfo.file, {})], truncated: false };
68
68
  }
69
69
 
70
- function initializeSession(fileInfo, provider, parsed) {
70
+ function initializeSession(fileInfo, provider, parsed, options = {}) {
71
71
  const rows = parsed.rows.filter(Boolean);
72
72
  const root = rows.length === 1 ? rows[0] : { events: rows };
73
73
  const externalId = root.session_id || root.sessionId || root.id
74
74
  || path.basename(fileInfo.file).replace(/\.(jsonl?|ndjson)$/i, '');
75
75
  const session = baseSession(provider, externalId, fileInfo.file, fileInfo);
76
+ session.fullHistory = Boolean(options.fullHistory);
76
77
  session.truncated = parsed.truncated;
77
78
  session.cwd = root.cwd || root.projectPath || root.project_path || '';
78
79
  session.originCwd = session.cwd;
@@ -264,9 +265,9 @@ function createGenericParser(dependencies) {
264
265
  return session;
265
266
  }
266
267
 
267
- return function parseGeneric(fileInfo, provider) {
268
- const parsed = readSessionFile(fileInfo);
269
- const initialized = initializeSession(fileInfo, provider, parsed);
268
+ return function parseGeneric(fileInfo, provider, options = {}) {
269
+ const parsed = readSessionFile(fileInfo, options);
270
+ const initialized = initializeSession(fileInfo, provider, parsed, options);
270
271
  if (!initialized.rows.length) return null;
271
272
  const events = initialized.rows.length === 1 && Array.isArray(initialized.root.events)
272
273
  ? initialized.root.events
@@ -56,7 +56,7 @@ function timestamp(value, fallback = null) {
56
56
  }
57
57
 
58
58
  function addMessage(session, message) {
59
- const text = compactText(message.text, message.type === 'tool' ? 1600 : 6000);
59
+ const text = compactText(message.text, session.fullHistory ? Number.MAX_SAFE_INTEGER : (message.type === 'tool' ? 1600 : 6000));
60
60
  if (!text && message.type !== 'tool') return;
61
61
  const row = {
62
62
  id: String(message.id || `${session.id}:m:${session.messages.length}`),
@@ -353,6 +353,13 @@ function contextInfo(used, windowInfo) {
353
353
  }
354
354
 
355
355
  function trimSession(session) {
356
+ if (session.fullHistory) {
357
+ session.omittedMessages = 0;
358
+ session.omittedLifecycle = 0;
359
+ session.truncated = false;
360
+ if (!session.messages.length) addMessage(session, { id: `${session.id}:empty`, role: 'system', text: '표시할 대화 메시지가 아직 없습니다.', timestamp: session.updatedAt });
361
+ return;
362
+ }
356
363
  session.omittedMessages = Math.max(0, session.messages.length - MAX_MESSAGES);
357
364
  session.omittedLifecycle = Math.max(0, session.lifecycle.length - MAX_LIFECYCLE);
358
365
  session.messages = session.messages.slice(-MAX_MESSAGES);
@@ -360,7 +367,7 @@ function trimSession(session) {
360
367
  if (!session.messages.length) addMessage(session, { id: `${session.id}:empty`, role: 'system', text: '표시할 대화 메시지가 아직 없습니다.', timestamp: session.updatedAt });
361
368
  }
362
369
 
363
- function parseManagedSession(runDir) {
370
+ function parseManagedSession(runDir, options = {}) {
364
371
  const meta = readJson(path.join(runDir, 'meta.json'));
365
372
  const live = readJson(path.join(runDir, 'session.json'));
366
373
  if (!meta || !live) return null;
@@ -373,6 +380,7 @@ function parseManagedSession(runDir) {
373
380
  source: 'loadtoagent',
374
381
  sourceLabel: 'LoadToAgent 실행',
375
382
  statusObserved: true,
383
+ fullHistory: Boolean(options.fullHistory),
376
384
  };
377
385
  session.originCwd = session.originCwd || meta.cwd || session.cwd || '';
378
386
  session.usage = finalizeUsage(session.usage);
@@ -482,8 +490,8 @@ class AgentMonitor extends EventEmitter {
482
490
  }).filter(Boolean).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, max);
483
491
  }
484
492
 
485
- parseFile(info, parser) {
486
- const key = `${info.file}|${info.mtimeMs}|${info.size}`;
493
+ parseFile(info, parser, variant = '') {
494
+ const key = `${info.file}|${info.mtimeMs}|${info.size}|${variant}`;
487
495
  const cached = this.parseCache.get(key);
488
496
  if (cached) return cached;
489
497
  const value = parser(info);
@@ -495,6 +503,38 @@ class AgentMonitor extends EventEmitter {
495
503
  return value;
496
504
  }
497
505
 
506
+ detailSession(sessionId) {
507
+ const stored = (this.lastSnapshot.sessions || []).find(session => session.id === String(sessionId || '')) || null;
508
+ if (!stored) return null;
509
+ let detailed = null;
510
+ if (stored.source === 'loadtoagent' && stored.runId && this.runsDir) {
511
+ detailed = parseManagedSession(path.join(this.runsDir, stored.runId), { fullHistory: true });
512
+ } else if (stored.file) {
513
+ const stat = safeStat(stored.file);
514
+ if (stat && stat.isFile()) {
515
+ const info = { file: stored.file, mtimeMs: stat.mtimeMs, size: stat.size };
516
+ const parser = stored.provider === 'claude'
517
+ ? item => parseClaude(item, { fullHistory: true })
518
+ : stored.provider === 'codex'
519
+ ? item => parseCodex(item, { fullHistory: true })
520
+ : item => parseGeneric(item, stored.provider, { fullHistory: true });
521
+ detailed = this.parseFile(info, parser, 'full-history');
522
+ }
523
+ }
524
+ if (!detailed) return stored;
525
+ return {
526
+ ...stored,
527
+ ...detailed,
528
+ environment: stored.environment,
529
+ delegation: stored.delegation || detailed.delegation,
530
+ childIds: stored.childIds || detailed.childIds || [],
531
+ fullHistory: true,
532
+ truncated: false,
533
+ omittedMessages: 0,
534
+ omittedLifecycle: 0,
535
+ };
536
+ }
537
+
498
538
  hintedFiles(paths, max) {
499
539
  return (paths || []).map(value => {
500
540
  if (value && typeof value === 'object' && value.file && Number.isFinite(value.mtimeMs) && Number.isFinite(value.size)) {
@@ -2,6 +2,7 @@
2
2
 
3
3
  class AttentionNotifier {
4
4
  constructor(options = {}) {
5
+ this.enabled = options.enabled !== false;
5
6
  this.Notification = options.Notification;
6
7
  this.isSupported = options.isSupported || (() => Boolean(this.Notification));
7
8
  this.copy = options.copy || (() => ({ title: '내 확인이 필요합니다', body: '응답이나 선택을 기다리는 AI 세션이 있습니다.' }));
@@ -12,6 +13,7 @@ class AttentionNotifier {
12
13
  }
13
14
 
14
15
  sync(snapshot) {
16
+ if (!this.enabled) return [];
15
17
  const needsAttention = (snapshot && Array.isArray(snapshot.sessions) ? snapshot.sessions : [])
16
18
  .filter(session => session && session.id && (
17
19
  session.attention?.required
@@ -32,6 +34,7 @@ class AttentionNotifier {
32
34
  }
33
35
 
34
36
  notify(session) {
37
+ if (!this.enabled) return null;
35
38
  let supported = false;
36
39
  try {
37
40
  supported = Boolean(this.Notification && this.isSupported());
@@ -3,7 +3,7 @@
3
3
  function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, manager, isProviderVisible = () => true, listWslDistros, sendError }) {
4
4
  ipcMain.handle('terminals:list', event => {
5
5
  requireTrustedSender(event);
6
- return manager() ? manager().list().filter(session => session.type !== 'agent' || isProviderVisible(session.provider)) : [];
6
+ return manager() ? manager().list().filter(session => !session.transient && (session.type !== 'agent' || isProviderVisible(session.provider))) : [];
7
7
  });
8
8
  ipcMain.handle('wsl:list-distros', event => {
9
9
  requireTrustedSender(event);
@@ -12,7 +12,7 @@ function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, man
12
12
  ipcMain.handle('terminals:get', async (event, id) => {
13
13
  requireTrustedSender(event);
14
14
  const session = manager() ? await manager().get(id, true) : null;
15
- return session && session.type === 'agent' && !isProviderVisible(session.provider) ? null : session;
15
+ return session && (session.transient || (session.type === 'agent' && !isProviderVisible(session.provider))) ? null : session;
16
16
  });
17
17
  ipcMain.handle('terminals:create', (event, options) => {
18
18
  requireTrustedSender(event);
@@ -317,7 +317,7 @@ parentPort.on('message', message => {
317
317
  }
318
318
  if (message.type === 'detail') {
319
319
  const runtime = lastPublishedSessions.find(item => item.id === message.sessionId) || null;
320
- const stored = (monitor.lastSnapshot.sessions || []).find(item => item.id === message.sessionId) || null;
320
+ const stored = monitor.detailSession(message.sessionId);
321
321
  const merged = stored && runtime
322
322
  ? { ...stored, status: runtime.status, statusDetail: runtime.statusDetail, statusObserved: runtime.statusObserved, runtimePresence: runtime.runtimePresence || [] }
323
323
  : (stored || runtime);
@@ -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 };
@@ -5,6 +5,18 @@ const { blankUsage } = require('./providerRegistry');
5
5
 
6
6
  const DEFAULT_SCAN_TTL_MS = 12_000;
7
7
  const WMIC_QUERY = "Name='claude.exe' or Name='codex.exe' or Name='node.exe' or Name='gemini.exe' or Name='grok.exe'";
8
+ const WINDOWS_PROCESS_SCRIPT = [
9
+ "$ProgressPreference = 'SilentlyContinue'",
10
+ "$ErrorActionPreference = 'Stop'",
11
+ '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)',
12
+ "$names = @('claude.exe','codex.exe','node.exe','gemini.exe','grok.exe')",
13
+ '$rows = Get-CimInstance Win32_Process | Where-Object { $names -contains $_.Name } | ForEach-Object {',
14
+ " $started = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null }",
15
+ ' [pscustomobject]@{ pid = [int]$_.ProcessId; parentPid = [int]$_.ParentProcessId; name = [string]$_.Name; commandLine = [string]$_.CommandLine; startedAt = $started }',
16
+ '}',
17
+ '@($rows) | ConvertTo-Json -Compress',
18
+ ].join('; ');
19
+ const WINDOWS_PROCESS_SCRIPT_BASE64 = Buffer.from(WINDOWS_PROCESS_SCRIPT, 'utf16le').toString('base64');
8
20
 
9
21
  function parseCsvRows(value) {
10
22
  const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value || '');
@@ -131,6 +143,42 @@ function processRows(value) {
131
143
  })).filter(item => item.pid > 0);
132
144
  }
133
145
 
146
+ function powershellProcessRows(value) {
147
+ const text = (Buffer.isBuffer(value) ? value.toString('utf8') : String(value || '')).replace(/^\uFEFF/, '').trim();
148
+ if (!text) return [];
149
+ const parsed = JSON.parse(text);
150
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(row => {
151
+ const timestamp = Date.parse(row.startedAt || '');
152
+ return {
153
+ pid: Number(row.pid || 0),
154
+ parentPid: Number(row.parentPid || 0),
155
+ name: String(row.name || ''),
156
+ commandLine: String(row.commandLine || ''),
157
+ startedAt: Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null,
158
+ };
159
+ }).filter(item => item.pid > 0);
160
+ }
161
+
162
+ function windowsProcessRows(run = execFileSync) {
163
+ try {
164
+ const output = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_PROCESS_SCRIPT_BASE64], {
165
+ encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 4 * 1024 * 1024,
166
+ });
167
+ return powershellProcessRows(output);
168
+ } catch (powershellError) {
169
+ try {
170
+ const output = run('wmic.exe', ['process', 'where', WMIC_QUERY, 'get', 'ProcessId,ParentProcessId,CreationDate,Name,CommandLine', '/format:csv'], {
171
+ encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 2 * 1024 * 1024,
172
+ });
173
+ return processRows(output);
174
+ } catch (wmicError) {
175
+ const error = new Error(`Windows 프로세스 조회 실패: ${powershellError.message || powershellError}; ${wmicError.message || wmicError}`);
176
+ error.cause = powershellError;
177
+ throw error;
178
+ }
179
+ }
180
+ }
181
+
134
182
  function commandArgument(commandLine, name) {
135
183
  const escaped = String(name || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
184
  const match = String(commandLine || '').match(new RegExp(`(?:^|\\s)${escaped}\\s+(?:"([^"]+)"|'([^']+)'|(\\S+))`, 'i'));
@@ -151,6 +199,14 @@ function processSessionExternalId(processInfo = {}, provider = '') {
151
199
  return '';
152
200
  }
153
201
 
202
+ function processInteractionMode(processInfo = {}, provider = '') {
203
+ const commandLine = String(processInfo.commandLine || processInfo.CommandLine || '');
204
+ if (provider === 'claude' && /(?:^|\s)(?:-p|--print)(?:\s|$)/i.test(commandLine)) return 'batch';
205
+ if (provider === 'codex' && /(?:^|\s)exec(?:\s|$)/i.test(commandLine)) return 'batch';
206
+ if (provider === 'gemini' && /(?:^|\s)(?:-p|--prompt)(?:\s|$)/i.test(commandLine)) return 'batch';
207
+ return 'interactive';
208
+ }
209
+
154
210
  function pathlessSessionId(value) {
155
211
  const text = String(value || '').trim().replace(/^"|"$/g, '');
156
212
  if (!text) return '';
@@ -192,14 +248,13 @@ function selectAgentProcesses(rows, options = {}) {
192
248
  command: String(item.name || '').replace(/\.exe$/i, '').split(/[\\/]/).pop(),
193
249
  startedAt: item.startedAt,
194
250
  externalId: processSessionExternalId(item, item.provider),
251
+ interactionMode: processInteractionMode(item, item.provider),
195
252
  })).sort((a, b) => a.provider.localeCompare(b.provider) || a.pid - b.pid);
196
253
  }
197
254
 
198
255
  function utilityProcess(processInfo = {}) {
199
256
  const commandLine = String(processInfo.commandLine || processInfo.args || '');
200
- return /Extract durable memory candidates from this Claude Code transcript tail/i.test(commandLine)
201
- || /Reply with exactly OK\. Do not use tools\.?/i.test(commandLine)
202
- || /You are a memory extraction/i.test(commandLine);
257
+ return /(?:^|\s)(?:-p|--print)\s+["']?(?:Extract durable memory candidates from this Claude Code transcript tail|Reply with exactly OK\. Do not use tools\.?|You are a memory extraction)/i.test(commandLine);
203
258
  }
204
259
 
205
260
  function utilitySession(session) {
@@ -230,6 +285,12 @@ function markRuntime(session, presence) {
230
285
  const existing = Array.isArray(session.runtimePresence) ? session.runtimePresence : [];
231
286
  if (!existing.some(item => item.id === presence.id)) existing.push(presence);
232
287
  session.runtimePresence = existing;
288
+ if (presence.interactionMode === 'batch') {
289
+ session.conversationStatus = session.status;
290
+ session.status = 'running';
291
+ session.statusDetail = `백그라운드 자율 실행 중 · PID ${presence.pid || '--'}`;
292
+ session.statusObserved = true;
293
+ }
233
294
  return session;
234
295
  }
235
296
 
@@ -417,10 +478,7 @@ class ProcessMonitor {
417
478
  try {
418
479
  let processes;
419
480
  if (this.platform === 'win32') {
420
- const output = this.execFileSync('wmic.exe', ['process', 'where', WMIC_QUERY, 'get', 'ProcessId,ParentProcessId,CreationDate,Name,CommandLine', '/format:csv'], {
421
- encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 2 * 1024 * 1024,
422
- });
423
- processes = selectAgentProcesses(processRows(output));
481
+ processes = selectAgentProcesses(windowsProcessRows(this.execFileSync));
424
482
  } else {
425
483
  const output = this.execFileSync('ps', ['-axo', 'pid=,ppid=,etime=,comm=,args='], { encoding: 'utf8', timeout: 8_000, maxBuffer: 2 * 1024 * 1024 });
426
484
  const environment = this.platform === 'darwin' ? 'macos' : 'linux';
@@ -438,6 +496,8 @@ module.exports = {
438
496
  ProcessMonitor,
439
497
  parseCsvRows,
440
498
  processRows,
499
+ powershellProcessRows,
500
+ windowsProcessRows,
441
501
  wmiDateToIso,
442
502
  providerFromWindowsProcess,
443
503
  providerFromPosixProcess,
@@ -445,6 +505,7 @@ module.exports = {
445
505
  elapsedSeconds,
446
506
  selectAgentProcesses,
447
507
  processSessionExternalId,
508
+ processInteractionMode,
448
509
  utilityProcess,
449
510
  runtimeLinkScore,
450
511
  bridgeLinkScore,
@@ -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 = 1;
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 readHostDiscovery(file, fileSystem = fs) {
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?.protocol !== TERMINAL_HOST_PROTOCOL || !parsed.endpoint || !parsed.token) {
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
  };
@@ -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;
@@ -40,7 +41,7 @@ function terminalEnvironment(extra = {}) {
40
41
  for (const [key, value] of Object.entries({ ...process.env, ...extra })) {
41
42
  if (value != null) env[key] = String(value);
42
43
  }
43
- env.TERM = env.TERM || 'xterm-256color';
44
+ env.TERM = !env.TERM || String(env.TERM).toLowerCase() === 'dumb' ? 'xterm-256color' : env.TERM;
44
45
  env.COLORTERM = env.COLORTERM || 'truecolor';
45
46
  return env;
46
47
  }
@@ -142,6 +143,7 @@ function normalizeLaunchOptions(options = {}, platform = process.platform) {
142
143
  args,
143
144
  bridgeId: cleanText(options.bridgeId, 100),
144
145
  title: cleanText(options.title, 100),
146
+ transient: Boolean(options.transient),
145
147
  cols: numericDimension(options.cols, 120, 20, 500),
146
148
  rows: numericDimension(options.rows, 32, 5, 200),
147
149
  };
@@ -207,6 +209,7 @@ function publicSession(session, includeReplay = false) {
207
209
  tmuxPane: session.options.tmuxPane,
208
210
  provider: session.options.provider,
209
211
  bridgeId: session.options.bridgeId,
212
+ transient: Boolean(session.options.transient),
210
213
  background: session.options.type === 'agent',
211
214
  recoveredAfterHostRestart: Boolean(session.recoveredAfterHostRestart),
212
215
  recoverySkippedReason: session.recoverySkippedReason || '',
@@ -243,6 +246,7 @@ function restoredOptions(value = {}, platform = process.platform) {
243
246
  args: Array.isArray(value.args) ? value.args.slice(0, 80).map(item => cleanText(item, 2_000)) : [],
244
247
  bridgeId: cleanText(value.bridgeId, 100),
245
248
  title: cleanText(value.title, 100),
249
+ transient: Boolean(value.transient),
246
250
  cols: numericDimension(value.cols, 120, 20, 500),
247
251
  rows: numericDimension(value.rows, 32, 5, 200),
248
252
  };
@@ -356,7 +360,7 @@ class TerminalManager extends EventEmitter {
356
360
  this.fileSystem.mkdirSync(path.dirname(this.storeFile), { recursive: true });
357
361
  const payload = {
358
362
  version: STORE_VERSION,
359
- sessions: [...this.sessions.values()].map(persistedSession),
363
+ sessions: [...this.sessions.values()].filter(session => !session.options.transient).map(persistedSession),
360
364
  };
361
365
  this.fileSystem.writeFileSync(temporary, JSON.stringify(payload), 'utf8');
362
366
  this.fileSystem.renameSync(temporary, this.storeFile);
@@ -367,7 +371,10 @@ class TerminalManager extends EventEmitter {
367
371
  }
368
372
 
369
373
  pty() {
370
- if (!this.ptyModule) this.ptyModule = require('node-pty');
374
+ if (!this.ptyModule) {
375
+ ensureMacNodePtyRuntime({ platform: this.platform });
376
+ this.ptyModule = require('node-pty');
377
+ }
371
378
  return this.ptyModule;
372
379
  }
373
380
 
@@ -376,6 +383,11 @@ class TerminalManager extends EventEmitter {
376
383
  for (const session of this.sessions.values()) {
377
384
  if (!session.recoveryPending) continue;
378
385
  session.recoveryPending = false;
386
+ if (session.options.type === 'agent'
387
+ && /TERM is set to ["']?dumb["']?[\s\S]{0,500}Continue anyway\?/i.test(session.replay)) {
388
+ this.sessions.delete(session.id);
389
+ continue;
390
+ }
379
391
  if (!hasSafeAgentResume(session.options)) {
380
392
  session.status = 'exited';
381
393
  session.pid = null;
@@ -485,6 +497,12 @@ class TerminalManager extends EventEmitter {
485
497
  session.exitCode = Number.isFinite(event.exitCode) ? event.exitCode : null;
486
498
  session.signal = Number.isFinite(event.signal) ? event.signal : null;
487
499
  session.updatedAt = new Date().toISOString();
500
+ if (session.options.transient) {
501
+ this.sessions.delete(session.id);
502
+ this.emit('state', { change: 'removed', session: publicSession(session, false), sessions: this.list() });
503
+ this.persistNow();
504
+ return;
505
+ }
488
506
  this.persistNow();
489
507
  this.emitState('updated', session);
490
508
  });