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.
- package/main.js +53 -8
- package/package.json +5 -2
- package/renderer/app-agent-actions.js +172 -66
- package/renderer/app-bootstrap.js +3 -1
- package/renderer/app-dashboard.js +36 -7
- package/renderer/app-drawer-content.js +177 -59
- package/renderer/app-drawer-data.js +7 -3
- package/renderer/app-drawer.js +72 -33
- package/renderer/app-events-dialogs.js +8 -1
- package/renderer/app-events-navigation.js +7 -0
- package/renderer/app-events-sessions.js +136 -13
- package/renderer/app-graph-model.js +2 -5
- package/renderer/app-graph-orchestration.js +7 -18
- package/renderer/app-graph-view.js +194 -48
- package/renderer/app-management.js +330 -35
- package/renderer/app-quality.js +5 -0
- package/renderer/app-session-render.js +20 -83
- package/renderer/app.js +5 -1
- package/renderer/i18n-messages.js +232 -21
- package/renderer/index.html +9 -6
- package/renderer/styles-components.css +140 -0
- package/renderer/styles-control-room.css +1439 -0
- package/renderer/styles-management.css +390 -3
- package/renderer/styles-responsive-shell.css +5 -0
- package/renderer/styles-runtime-overview.css +20 -0
- package/renderer/terminal-agent.js +31 -7
- package/renderer/terminal.js +4 -1
- package/src/agentMonitor/claudeParser.js +8 -4
- package/src/agentMonitor/codexParser.js +5 -4
- package/src/agentMonitor/genericParser.js +7 -6
- package/src/agentMonitor.js +44 -4
- package/src/attentionNotifier.js +3 -0
- package/src/ipc/registerTerminalIpc.js +2 -2
- package/src/monitorWorker.js +1 -1
- package/src/nodePtyRuntime.js +58 -0
- package/src/processMonitor.js +68 -7
- package/src/terminalHost.js +104 -5
- 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
|
package/src/agentMonitor.js
CHANGED
|
@@ -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)) {
|
package/src/attentionNotifier.js
CHANGED
|
@@ -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);
|
package/src/monitorWorker.js
CHANGED
|
@@ -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 =
|
|
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 };
|
package/src/processMonitor.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
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;
|
|
@@ -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)
|
|
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
|
});
|