loadtoagent 1.3.1 → 1.3.3
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 +11 -9
- package/docs/PROVIDER-CONTRACTS.md +8 -6
- package/main.js +25 -3
- package/package.json +1 -1
- package/preload.js +7 -2
- package/renderer/app-dashboard.js +18 -12
- package/renderer/app-graph-view.js +1 -1
- package/renderer/app-management.js +91 -45
- package/renderer/app-run-modal.js +1 -1
- package/renderer/app.js +1 -1
- package/renderer/i18n-messages.js +212 -199
- package/renderer/index.html +28 -27
- package/renderer/styles-agent-map.css +10 -10
- package/renderer/styles-cards.css +19 -19
- package/renderer/styles-collaboration.css +23 -23
- package/renderer/styles-components.css +36 -36
- package/renderer/styles-management.css +23 -14
- package/renderer/styles-onboarding.css +8 -8
- package/renderer/styles-overlays.css +5 -5
- package/renderer/styles-product.css +24 -24
- package/renderer/styles-quality.css +4 -4
- package/renderer/styles-readability.css +179 -1
- package/renderer/styles-responsive-runtime.css +5 -5
- package/renderer/styles-responsive-workflows.css +2 -2
- package/renderer/styles-run-composer.css +23 -23
- package/renderer/styles-runtime-overview.css +22 -22
- package/renderer/styles-settings.css +21 -21
- package/renderer/styles-terminal.css +66 -66
- package/renderer/styles-tmux.css +49 -49
- package/renderer/styles-workflows.css +48 -48
- package/renderer/styles.css +7 -7
- package/renderer/terminal-events.js +34 -11
- package/renderer/terminal-workbench.js +52 -21
- package/renderer/terminal.js +9 -1
- package/src/contracts.js +5 -5
- package/src/ipc/registerTerminalIpc.js +6 -14
- package/src/sessionIntelligence.js +18 -4
- package/src/terminalHost.js +100 -26
- package/src/terminalHostDaemon.js +1 -0
- package/src/terminalManager.js +61 -4
package/renderer/terminal.js
CHANGED
|
@@ -633,7 +633,7 @@
|
|
|
633
633
|
}
|
|
634
634
|
|
|
635
635
|
const {
|
|
636
|
-
createXtermHost, fitEntry, ensureSessionTerminal, ensureRemoteTerminal, hideScreens, renderSessions, renderTmuxResources, renderTarget, showSelection, selectSession, selectTmux, selectTmuxById, renderAll, refreshSessions, createTerminal, captureRemote, startCapture, stopCapture, sendCommand, sendSignal, openTmuxModal, closeTmuxModal, refreshSnapshot, attachTmux, manageTmux,
|
|
636
|
+
createXtermHost, fitEntry, ensureSessionTerminal, ensureRemoteTerminal, hideScreens, linkedAgentSession, isAiTerminalSession, renderSessions, renderTmuxResources, renderTarget, showSelection, selectSession, selectTmux, selectTmuxById, renderAll, refreshSessions, createTerminal, captureRemote, startCapture, stopCapture, sendCommand, sendSignal, openTmuxModal, closeTmuxModal, refreshSnapshot, attachTmux, manageTmux,
|
|
637
637
|
} = window.LoadToAgentTerminalWorkbench({
|
|
638
638
|
$, state, notice, setConnectionState, currentSession, currentTmux, saveCurrentDraft, restoreCurrentDraft,
|
|
639
639
|
renderHistoryPanel, terminalTypeMark, terminalTypeLabel, xtermOptions, preferredWorkspace, firstDistro, guarded,
|
|
@@ -678,6 +678,7 @@
|
|
|
678
678
|
moveSessionByOffset,
|
|
679
679
|
setTerminalFontSize,
|
|
680
680
|
toggleTerminalFocusMode,
|
|
681
|
+
isAiTerminalSession,
|
|
681
682
|
});
|
|
682
683
|
}
|
|
683
684
|
|
|
@@ -773,6 +774,13 @@
|
|
|
773
774
|
state.wslDistros = Array.isArray(environments) ? environments : [];
|
|
774
775
|
state.initialized = true;
|
|
775
776
|
configurePlatform();
|
|
777
|
+
if (!state.resizeObserver && 'ResizeObserver' in window) {
|
|
778
|
+
state.resizeObserver = new ResizeObserver(() => {
|
|
779
|
+
const entry = currentSession() ? state.terminals.get(state.selectedId) : state.remoteTerminal;
|
|
780
|
+
fitEntry(entry, state.selectedId || '');
|
|
781
|
+
});
|
|
782
|
+
state.resizeObserver.observe($('#terminalViewport'));
|
|
783
|
+
}
|
|
776
784
|
syncTerminalViewControls();
|
|
777
785
|
renderAll();
|
|
778
786
|
})().catch(error => {
|
package/src/contracts.js
CHANGED
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
* @property {TokenUsage} usage
|
|
43
43
|
* @property {Array<Object>} messages
|
|
44
44
|
* @property {Array<Object>} lifecycle
|
|
45
|
-
* @property {Array<{id:string,kind:'shell'|'background',mode:'foreground'|'background',tool:string,runtime:string,label:string,command:string,cwd:string,status:'running'|'completed'|'failed',statusDetail:string,output:string,backgroundId:string,exitCode:number|null,startedAt:string|null,updatedAt:string|null,completedAt:string|null}>} executions
|
|
45
|
+
* @property {Array<{id:string,kind:'shell'|'background',mode:'foreground'|'background',tool:string,runtime:string,label:string,command:string,cwd:string,status:'running'|'completed'|'failed',statusDetail:string,output:string,backgroundId:string,exitCode:number|null,startedAt:string|null,updatedAt:string|null,completedAt:string|null}>} executions Logged regular and background command runs owned by this AI session.
|
|
46
46
|
* @property {CollaborationSummary|null} collaboration
|
|
47
47
|
* @property {{kind:string,iteration:number,phase?:string}|boolean|null} loop Safe execution-loop metadata; internal goal text is never included.
|
|
48
48
|
* @property {{required:boolean,kind:string,summary:string,requestedAt:string|null,source:string,confidence:string}} attention Actionable reason why the session needs the user.
|
|
49
|
-
* @property {{stage:string,percent:number,completedSteps:number,failedSteps:number,totalSteps:number,currentStep:string,blocker:string,lastActivityAt:string|null,source:string,checkpoints:Array<Object>}} progress
|
|
50
|
-
* @property {{level:string,score:number,signals:Array<Object>,lastActivityAt:string|null,ageSeconds:number|null}} health
|
|
49
|
+
* @property {{stage:string,percent:number,completedSteps:number,failedSteps:number,totalSteps:number,currentStep:string,blocker:string,lastActivityAt:string|null,source:string,checkpoints:Array<Object>}} progress Recent lifecycle-event completion ratio; it is not whole-plan progress.
|
|
50
|
+
* @property {{level:string,score:number,signals:Array<Object>,lastActivityAt:string|null,ageSeconds:number|null}} health Detected status signals. The legacy score is internal and is not presented as a validated health metric.
|
|
51
51
|
* @property {{managed:boolean,respond:boolean,approve:boolean,deny:boolean,sendInstruction:boolean,stop:boolean,pause:boolean,resume:boolean,retry:boolean,reassign:boolean,openOrigin:boolean}} controlCapabilities Safe provider-aware actions available for this session.
|
|
52
|
-
* @property {{confidence:string,status:string,hierarchy:string,completion:string,sources:Array<string>}} evidence
|
|
53
|
-
* @property {{status:string,summary:string,verified:boolean,verification:string,completedAt:string|null,artifacts:Array<Object>,checks:Array<Object>}} outcome Completion summary,
|
|
52
|
+
* @property {{confidence:string,status:string,hierarchy:string,completion:string,sources:Array<string>}} evidence Confirmation method for status, hierarchy, and completion signals; not result-quality confidence.
|
|
53
|
+
* @property {{status:string,summary:string,verified:boolean,verification:string,completedAt:string|null,artifacts:Array<Object>,checks:Array<Object>}} outcome Completion summary, log-derived artifact candidates and test records, plus whether a completion signal was received.
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -19,25 +19,17 @@ function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, man
|
|
|
19
19
|
if (options && options.type === 'agent' && !isProviderVisible(options.provider)) throw new Error('설정에서 숨긴 AI는 실행할 수 없습니다.');
|
|
20
20
|
return requireManager(manager).create(options || {});
|
|
21
21
|
});
|
|
22
|
-
ipcMain.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Promise.resolve(manager().write(id, data)).catch(error => sendError({ id: String(id || ''), message: error.message }));
|
|
26
|
-
} catch (error) {
|
|
27
|
-
sendError({ id: String(id || ''), message: error.message });
|
|
28
|
-
}
|
|
22
|
+
ipcMain.handle('terminals:write', (event, id, data) => {
|
|
23
|
+
requireTrustedSender(event);
|
|
24
|
+
return requireManager(manager).write(id, data);
|
|
29
25
|
});
|
|
30
26
|
ipcMain.handle('terminals:command', (event, id, command) => {
|
|
31
27
|
requireTrustedSender(event);
|
|
32
28
|
return requireManager(manager).command(id, command);
|
|
33
29
|
});
|
|
34
|
-
ipcMain.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Promise.resolve(manager().resize(id, cols, rows)).catch(error => sendError({ id: String(id || ''), message: error.message }));
|
|
38
|
-
} catch (error) {
|
|
39
|
-
sendError({ id: String(id || ''), message: error.message });
|
|
40
|
-
}
|
|
30
|
+
ipcMain.handle('terminals:resize', (event, id, cols, rows) => {
|
|
31
|
+
requireTrustedSender(event);
|
|
32
|
+
return requireManager(manager).resize(id, cols, rows);
|
|
41
33
|
});
|
|
42
34
|
for (const operation of ['signal', 'restart', 'close']) {
|
|
43
35
|
ipcMain.handle(`terminals:${operation}`, (event, ...args) => {
|
|
@@ -6,7 +6,10 @@ const FAILURE_PATTERN = /(?:error|failed|failure|fatal|exception|오류|실패)/
|
|
|
6
6
|
const PERMISSION_PATTERN = /(?:permission|approve|approval|권한|승인)/i;
|
|
7
7
|
const DECISION_PATTERN = /(?:choose|select|decision|선택|결정|골라)/i;
|
|
8
8
|
const INPUT_PATTERN = /(?:input|reply|answer|confirm|provide|입력|답변|확인|알려|제공)/i;
|
|
9
|
-
const TEST_PATTERN = /(?:
|
|
9
|
+
const TEST_PATTERN = /(?:^|[^A-Za-z0-9])(?:tests?|testing|specs?|pytest|vitest|jest|mocha)(?=$|[^A-Za-z0-9])|검증|테스트/i;
|
|
10
|
+
const FAILED_CHECK_STATUSES = new Set(['failed', 'failure', 'error', 'errored']);
|
|
11
|
+
const RUNNING_CHECK_STATUSES = new Set(['running', 'pending', 'started', 'starting', 'in-progress', 'in_progress']);
|
|
12
|
+
const PASSED_CHECK_STATUSES = new Set(['passed', 'completed', 'complete', 'done', 'success', 'succeeded']);
|
|
10
13
|
|
|
11
14
|
function text(value, limit = 1200) {
|
|
12
15
|
const output = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
@@ -33,6 +36,14 @@ function latestMeaningfulText(session) {
|
|
|
33
36
|
return text((preferred || fallback || {}).text || session.statusDetail || session.result || '', 360);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function checkStatus(value) {
|
|
40
|
+
const status = String(value || '').trim().toLowerCase();
|
|
41
|
+
if (FAILED_CHECK_STATUSES.has(status)) return 'failed';
|
|
42
|
+
if (RUNNING_CHECK_STATUSES.has(status)) return 'running';
|
|
43
|
+
if (PASSED_CHECK_STATUSES.has(status)) return 'passed';
|
|
44
|
+
return 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
function controlCapabilities(session) {
|
|
37
48
|
const live = LIVE_STATUSES.has(session.status);
|
|
38
49
|
const managed = Boolean(session.runId);
|
|
@@ -129,7 +140,10 @@ function extractArtifacts(session) {
|
|
|
129
140
|
const filePattern = /(?:[A-Za-z]:\\|\/)?(?:[\w.@-]+[\\/])+[\w.@()+-]+\.[A-Za-z0-9]{1,12}/g;
|
|
130
141
|
for (const match of body.match(filePattern) || []) add(TEST_PATTERN.test(match) ? 'test' : 'file', match, false);
|
|
131
142
|
if (/(?:commit|커밋)/i.test(body)) {
|
|
132
|
-
|
|
143
|
+
// A hash mentioned in a log is only a candidate reference. Confirming that
|
|
144
|
+
// the commit exists belongs to repository verification, which this view
|
|
145
|
+
// intentionally does not perform.
|
|
146
|
+
for (const match of body.match(/\b[0-9a-f]{7,40}\b/gi) || []) add('commit', match, false);
|
|
133
147
|
}
|
|
134
148
|
return artifacts;
|
|
135
149
|
}
|
|
@@ -141,7 +155,7 @@ function outcomeFor(session, evidence) {
|
|
|
141
155
|
.slice(-12)
|
|
142
156
|
.map(row => ({
|
|
143
157
|
label: text(row.label || row.detail || 'Test', 180),
|
|
144
|
-
status: row.status
|
|
158
|
+
status: checkStatus(row.status),
|
|
145
159
|
timestamp: row.completedAt || row.timestamp || null,
|
|
146
160
|
}));
|
|
147
161
|
const latestAssistant = [...(session.messages || [])].reverse().find(row => row && row.role === 'assistant' && text(row.text));
|
|
@@ -209,7 +223,7 @@ function healthFor(session, sessions, attention, progress, evidence, nowValue) {
|
|
|
209
223
|
const rank = { info: 1, warning: 2, critical: 3 };
|
|
210
224
|
const max = signals.reduce((value, signal) => Math.max(value, rank[signal.severity] || 0), 0);
|
|
211
225
|
return {
|
|
212
|
-
level: max >= 3 ? 'critical' : max === 2 ? 'warning' :
|
|
226
|
+
level: max >= 3 ? 'critical' : max === 2 ? 'warning' : max === 1 ? 'unknown' : 'healthy',
|
|
213
227
|
score: Math.max(0, 100 - signals.reduce((sum, signal) => sum + (signal.severity === 'critical' ? 35 : signal.severity === 'warning' ? 18 : 5), 0)),
|
|
214
228
|
signals,
|
|
215
229
|
lastActivityAt: progress.lastActivityAt,
|
package/src/terminalHost.js
CHANGED
|
@@ -43,9 +43,15 @@ class TerminalHostServer {
|
|
|
43
43
|
this.server = null;
|
|
44
44
|
this.clients = new Set();
|
|
45
45
|
this.shutdownTimer = null;
|
|
46
|
+
this.idleShutdownMs = Number.isFinite(Number(options.idleShutdownMs))
|
|
47
|
+
? Math.max(0, Number(options.idleShutdownMs))
|
|
48
|
+
: 1_500;
|
|
46
49
|
this.onShutdown = typeof options.onShutdown === 'function' ? options.onShutdown : () => {};
|
|
47
50
|
this.onManagerData = payload => this.broadcast({ type: 'event', event: 'data', payload });
|
|
48
|
-
this.onManagerState = payload =>
|
|
51
|
+
this.onManagerState = payload => {
|
|
52
|
+
this.broadcast({ type: 'event', event: 'state', payload });
|
|
53
|
+
this.scheduleShutdownIfIdle();
|
|
54
|
+
};
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
info() {
|
|
@@ -79,6 +85,7 @@ class TerminalHostServer {
|
|
|
79
85
|
safeWriteJson(this.discoveryFile, this.info());
|
|
80
86
|
this.manager.on('data', this.onManagerData);
|
|
81
87
|
this.manager.on('state', this.onManagerState);
|
|
88
|
+
this.scheduleShutdownIfIdle();
|
|
82
89
|
resolve(this.info());
|
|
83
90
|
} catch (error) {
|
|
84
91
|
fail(error);
|
|
@@ -88,6 +95,7 @@ class TerminalHostServer {
|
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
accept(socket) {
|
|
98
|
+
this.cancelIdleShutdown();
|
|
91
99
|
socket.setNoDelay(true);
|
|
92
100
|
const client = {
|
|
93
101
|
socket,
|
|
@@ -139,22 +147,12 @@ class TerminalHostServer {
|
|
|
139
147
|
client.authenticated = true;
|
|
140
148
|
clearTimeout(client.authTimer);
|
|
141
149
|
client.authTimer = null;
|
|
142
|
-
|
|
143
|
-
clearTimeout(this.shutdownTimer);
|
|
144
|
-
this.shutdownTimer = null;
|
|
145
|
-
}
|
|
150
|
+
this.cancelIdleShutdown();
|
|
146
151
|
sendFrame(client.socket, { type: 'ready', sessions: this.manager.list() });
|
|
147
152
|
return;
|
|
148
153
|
}
|
|
149
154
|
if (message.type === 'control' && message.operation === 'shutdown-if-idle') {
|
|
150
|
-
|
|
151
|
-
this.shutdownTimer = setTimeout(() => {
|
|
152
|
-
this.shutdownTimer = null;
|
|
153
|
-
const connectedClients = [...this.clients].filter(entry => entry.authenticated && !entry.socket.destroyed);
|
|
154
|
-
if (activeSessions(this.manager).length === 0 && connectedClients.length === 0) this.onShutdown();
|
|
155
|
-
}, 1_500);
|
|
156
|
-
if (typeof this.shutdownTimer.unref === 'function') this.shutdownTimer.unref();
|
|
157
|
-
}
|
|
155
|
+
this.scheduleShutdownIfIdle();
|
|
158
156
|
return;
|
|
159
157
|
}
|
|
160
158
|
if (message.type !== 'request' || !HOST_OPERATIONS.has(message.operation)) {
|
|
@@ -175,6 +173,28 @@ class TerminalHostServer {
|
|
|
175
173
|
detach(client) {
|
|
176
174
|
if (client.authTimer) clearTimeout(client.authTimer);
|
|
177
175
|
this.clients.delete(client);
|
|
176
|
+
this.scheduleShutdownIfIdle();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cancelIdleShutdown() {
|
|
180
|
+
if (!this.shutdownTimer) return;
|
|
181
|
+
clearTimeout(this.shutdownTimer);
|
|
182
|
+
this.shutdownTimer = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
scheduleShutdownIfIdle() {
|
|
186
|
+
const connectedClients = [...this.clients].filter(entry => entry.authenticated && !entry.socket.destroyed);
|
|
187
|
+
if (activeSessions(this.manager).length > 0 || connectedClients.length > 0) {
|
|
188
|
+
this.cancelIdleShutdown();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (this.shutdownTimer) return;
|
|
192
|
+
this.shutdownTimer = setTimeout(() => {
|
|
193
|
+
this.shutdownTimer = null;
|
|
194
|
+
const activeClients = [...this.clients].filter(entry => entry.authenticated && !entry.socket.destroyed);
|
|
195
|
+
if (activeSessions(this.manager).length === 0 && activeClients.length === 0) this.onShutdown();
|
|
196
|
+
}, this.idleShutdownMs);
|
|
197
|
+
if (typeof this.shutdownTimer.unref === 'function') this.shutdownTimer.unref();
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
dispose() {
|
|
@@ -217,6 +237,26 @@ function launchTerminalHost(options = {}) {
|
|
|
217
237
|
return child.pid;
|
|
218
238
|
}
|
|
219
239
|
|
|
240
|
+
function resolveTerminalHostExecutable(options = {}) {
|
|
241
|
+
const platform = options.platform || process.platform;
|
|
242
|
+
const executable = String(options.executable || process.execPath);
|
|
243
|
+
if (platform !== 'darwin' || !options.isPackaged) return executable;
|
|
244
|
+
const targetPath = path.posix;
|
|
245
|
+
const resolvedExecutable = targetPath.resolve(executable);
|
|
246
|
+
const productName = targetPath.basename(resolvedExecutable);
|
|
247
|
+
const helper = targetPath.resolve(
|
|
248
|
+
targetPath.dirname(resolvedExecutable),
|
|
249
|
+
'..',
|
|
250
|
+
'Frameworks',
|
|
251
|
+
`${productName} Helper.app`,
|
|
252
|
+
'Contents',
|
|
253
|
+
'MacOS',
|
|
254
|
+
`${productName} Helper`,
|
|
255
|
+
);
|
|
256
|
+
const fileSystem = options.fileSystem || fs;
|
|
257
|
+
return fileSystem.existsSync(helper) ? helper : executable;
|
|
258
|
+
}
|
|
259
|
+
|
|
220
260
|
class TerminalHostClient extends EventEmitter {
|
|
221
261
|
constructor(options = {}) {
|
|
222
262
|
super();
|
|
@@ -231,16 +271,31 @@ class TerminalHostClient extends EventEmitter {
|
|
|
231
271
|
this.sequence = 0;
|
|
232
272
|
this.pending = new Map();
|
|
233
273
|
this.handshake = null;
|
|
274
|
+
this.connectPromise = null;
|
|
275
|
+
this.connectGeneration = 0;
|
|
234
276
|
}
|
|
235
277
|
|
|
236
|
-
|
|
278
|
+
connect() {
|
|
279
|
+
if (this.connected && this.socket && !this.socket.destroyed) return Promise.resolve(this);
|
|
280
|
+
if (this.connectPromise) return this.connectPromise;
|
|
237
281
|
this.disposed = false;
|
|
282
|
+
const generation = ++this.connectGeneration;
|
|
283
|
+
const connecting = this.connectLoop(generation);
|
|
284
|
+
const tracked = connecting.finally(() => {
|
|
285
|
+
if (this.connectPromise === tracked) this.connectPromise = null;
|
|
286
|
+
});
|
|
287
|
+
this.connectPromise = tracked;
|
|
288
|
+
return this.connectPromise;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async connectLoop(generation) {
|
|
238
292
|
const deadline = Date.now() + this.connectTimeoutMs;
|
|
239
293
|
let launched = false;
|
|
240
294
|
let lastError = null;
|
|
241
|
-
while (Date.now() < deadline) {
|
|
295
|
+
while (!this.disposed && generation === this.connectGeneration && Date.now() < deadline) {
|
|
242
296
|
try {
|
|
243
297
|
await this.connectExisting();
|
|
298
|
+
if (this.disposed || generation !== this.connectGeneration) throw new Error('터미널 호스트 재연결이 취소되었습니다.');
|
|
244
299
|
return this;
|
|
245
300
|
} catch (error) {
|
|
246
301
|
lastError = error;
|
|
@@ -253,6 +308,7 @@ class TerminalHostClient extends EventEmitter {
|
|
|
253
308
|
}
|
|
254
309
|
await new Promise(resolve => setTimeout(resolve, 120));
|
|
255
310
|
}
|
|
311
|
+
if (this.disposed || generation !== this.connectGeneration) throw new Error('터미널 호스트 재연결이 취소되었습니다.');
|
|
256
312
|
throw new Error(`터미널 호스트에 연결하지 못했습니다: ${lastError?.message || '시간 초과'}`);
|
|
257
313
|
}
|
|
258
314
|
|
|
@@ -272,15 +328,19 @@ class TerminalHostClient extends EventEmitter {
|
|
|
272
328
|
};
|
|
273
329
|
socket.setNoDelay(true);
|
|
274
330
|
socket.on('connect', () => sendFrame(socket, { type: 'authenticate', token: discovery.token }));
|
|
275
|
-
socket.on('data', chunk => this.consume(chunk));
|
|
276
|
-
socket.on('error', error =>
|
|
277
|
-
|
|
278
|
-
});
|
|
279
|
-
socket.on('close', () => this.handleDisconnect());
|
|
331
|
+
socket.on('data', chunk => this.consume(chunk, socket));
|
|
332
|
+
socket.on('error', error => this.handleSocketError(socket, error));
|
|
333
|
+
socket.on('close', () => this.handleDisconnect(socket));
|
|
280
334
|
});
|
|
281
335
|
}
|
|
282
336
|
|
|
283
|
-
|
|
337
|
+
handleSocketError(socket, error) {
|
|
338
|
+
if (socket && socket !== this.socket) return;
|
|
339
|
+
if (this.handshake) this.handshake.reject(error);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
consume(chunk, socket = this.socket) {
|
|
343
|
+
if (socket && socket !== this.socket) return;
|
|
284
344
|
this.buffer += chunk.toString('utf8');
|
|
285
345
|
if (this.buffer.length > MAX_FRAME_CHARS) {
|
|
286
346
|
this.socket?.destroy(new Error('터미널 호스트 응답이 너무 큽니다.'));
|
|
@@ -313,7 +373,8 @@ class TerminalHostClient extends EventEmitter {
|
|
|
313
373
|
}
|
|
314
374
|
}
|
|
315
375
|
|
|
316
|
-
handleDisconnect() {
|
|
376
|
+
handleDisconnect(socket = this.socket) {
|
|
377
|
+
if (socket && socket !== this.socket) return;
|
|
317
378
|
const wasConnected = this.connected;
|
|
318
379
|
this.connected = false;
|
|
319
380
|
if (this.handshake) this.handshake.reject(new Error('터미널 호스트 연결이 닫혔습니다.'));
|
|
@@ -323,19 +384,30 @@ class TerminalHostClient extends EventEmitter {
|
|
|
323
384
|
}
|
|
324
385
|
this.pending.clear();
|
|
325
386
|
this.socket = null;
|
|
326
|
-
if (wasConnected && !this.disposed)
|
|
387
|
+
if (wasConnected && !this.disposed) {
|
|
388
|
+
this.emit('disconnect');
|
|
389
|
+
this.connect()
|
|
390
|
+
.then(() => this.emit('reconnect', { sessions: this.list() }))
|
|
391
|
+
.catch(error => {
|
|
392
|
+
if (!this.disposed) this.emit('reconnect-error', error);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
327
395
|
}
|
|
328
396
|
|
|
329
397
|
resetSocket() {
|
|
330
|
-
|
|
398
|
+
const socket = this.socket;
|
|
331
399
|
this.socket = null;
|
|
332
400
|
this.connected = false;
|
|
333
401
|
this.buffer = '';
|
|
402
|
+
if (socket) socket.destroy();
|
|
334
403
|
}
|
|
335
404
|
|
|
336
|
-
request(operation, ...args) {
|
|
405
|
+
async request(operation, ...args) {
|
|
406
|
+
if (!this.connected || !this.socket || this.socket.destroyed) {
|
|
407
|
+
await this.connect();
|
|
408
|
+
}
|
|
337
409
|
if (!this.connected || !this.socket || this.socket.destroyed) {
|
|
338
|
-
|
|
410
|
+
throw new Error('터미널 호스트에 연결되어 있지 않습니다.');
|
|
339
411
|
}
|
|
340
412
|
const requestId = String(++this.sequence);
|
|
341
413
|
return new Promise((resolve, reject) => {
|
|
@@ -360,6 +432,7 @@ class TerminalHostClient extends EventEmitter {
|
|
|
360
432
|
|
|
361
433
|
dispose({ shutdownIfIdle = false } = {}) {
|
|
362
434
|
this.disposed = true;
|
|
435
|
+
this.connectGeneration += 1;
|
|
363
436
|
if (this.socket && !this.socket.destroyed) {
|
|
364
437
|
if (shutdownIfIdle) sendFrame(this.socket, { type: 'control', operation: 'shutdown-if-idle' });
|
|
365
438
|
this.socket.end();
|
|
@@ -378,4 +451,5 @@ module.exports = {
|
|
|
378
451
|
TERMINAL_HOST_PROTOCOL,
|
|
379
452
|
readHostDiscovery,
|
|
380
453
|
launchTerminalHost,
|
|
454
|
+
resolveTerminalHostExecutable,
|
|
381
455
|
};
|
|
@@ -23,6 +23,7 @@ function parseConfig(argv = process.argv.slice(2)) {
|
|
|
23
23
|
async function run(config = parseConfig()) {
|
|
24
24
|
process.title = 'LoadToAgent Terminal Host';
|
|
25
25
|
const manager = new TerminalManager({ storeFile: config.storeFile });
|
|
26
|
+
manager.recoverPersistedSessions();
|
|
26
27
|
let stopping = false;
|
|
27
28
|
let host = null;
|
|
28
29
|
let bridge = null;
|
package/src/terminalManager.js
CHANGED
|
@@ -208,6 +208,8 @@ function publicSession(session, includeReplay = false) {
|
|
|
208
208
|
provider: session.options.provider,
|
|
209
209
|
bridgeId: session.options.bridgeId,
|
|
210
210
|
background: session.options.type === 'agent',
|
|
211
|
+
recoveredAfterHostRestart: Boolean(session.recoveredAfterHostRestart),
|
|
212
|
+
recoverySkippedReason: session.recoverySkippedReason || '',
|
|
211
213
|
pid: session.pid,
|
|
212
214
|
status: session.status,
|
|
213
215
|
createdAt: session.createdAt,
|
|
@@ -261,6 +263,14 @@ function persistedSession(session) {
|
|
|
261
263
|
};
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
function hasSafeAgentResume(options = {}) {
|
|
267
|
+
if (options.type !== 'agent') return true;
|
|
268
|
+
const args = Array.isArray(options.args) ? options.args.map(value => String(value || '')) : [];
|
|
269
|
+
if (options.provider === 'codex') return args[0] === 'resume' && Boolean(args[1]);
|
|
270
|
+
const resumeIndex = args.indexOf('--resume');
|
|
271
|
+
return resumeIndex >= 0 && Boolean(args[resumeIndex + 1]);
|
|
272
|
+
}
|
|
273
|
+
|
|
264
274
|
class TerminalManager extends EventEmitter {
|
|
265
275
|
constructor(options = {}) {
|
|
266
276
|
super();
|
|
@@ -316,6 +326,9 @@ class TerminalManager extends EventEmitter {
|
|
|
316
326
|
replay: String(value.replay || '').slice(-MAX_REPLAY_CHARS),
|
|
317
327
|
process: null,
|
|
318
328
|
generation: 0,
|
|
329
|
+
recoveryPending: value.status === 'running' || value.status === 'starting',
|
|
330
|
+
recoveredAfterHostRestart: false,
|
|
331
|
+
recoverySkippedReason: '',
|
|
319
332
|
});
|
|
320
333
|
}
|
|
321
334
|
} catch (error) {
|
|
@@ -358,6 +371,35 @@ class TerminalManager extends EventEmitter {
|
|
|
358
371
|
return this.ptyModule;
|
|
359
372
|
}
|
|
360
373
|
|
|
374
|
+
recoverPersistedSessions() {
|
|
375
|
+
const recovered = [];
|
|
376
|
+
for (const session of this.sessions.values()) {
|
|
377
|
+
if (!session.recoveryPending) continue;
|
|
378
|
+
session.recoveryPending = false;
|
|
379
|
+
if (!hasSafeAgentResume(session.options)) {
|
|
380
|
+
session.status = 'exited';
|
|
381
|
+
session.pid = null;
|
|
382
|
+
session.recoveredAfterHostRestart = false;
|
|
383
|
+
session.recoverySkippedReason = 'unsafe-agent-restart';
|
|
384
|
+
const skippedMessage = '\r\n[LoadToAgent] 이 터미널은 재개할 기존 AI 세션 ID가 없어, 새 AI 대화를 만들 수 있어 자동 재개하지 않았습니다.\r\n';
|
|
385
|
+
session.replay = `${session.replay}${skippedMessage}`.slice(-MAX_REPLAY_CHARS);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
session.recoveredAfterHostRestart = true;
|
|
389
|
+
session.recoverySkippedReason = '';
|
|
390
|
+
const message = '\r\n[LoadToAgent] 터미널 호스트 중단 뒤 새 프로세스로 복구했습니다. 이전 셸의 메모리 상태는 이어지지 않습니다.\r\n';
|
|
391
|
+
session.replay = `${session.replay}${message}`.slice(-MAX_REPLAY_CHARS);
|
|
392
|
+
try {
|
|
393
|
+
this.spawn(session);
|
|
394
|
+
} catch (_recoveryFailed) {
|
|
395
|
+
session.recoveredAfterHostRestart = false;
|
|
396
|
+
}
|
|
397
|
+
recovered.push(publicSession(session, true));
|
|
398
|
+
}
|
|
399
|
+
this.persistNow();
|
|
400
|
+
return recovered;
|
|
401
|
+
}
|
|
402
|
+
|
|
361
403
|
create(rawOptions = {}) {
|
|
362
404
|
if (this.sessions.size >= MAX_SESSIONS) throw new Error(`동시에 열 수 있는 터미널은 최대 ${MAX_SESSIONS}개입니다.`);
|
|
363
405
|
const options = normalizeLaunchOptions(rawOptions, this.platform);
|
|
@@ -381,6 +423,9 @@ class TerminalManager extends EventEmitter {
|
|
|
381
423
|
replay: '',
|
|
382
424
|
process: null,
|
|
383
425
|
generation: 0,
|
|
426
|
+
recoveryPending: false,
|
|
427
|
+
recoveredAfterHostRestart: false,
|
|
428
|
+
recoverySkippedReason: '',
|
|
384
429
|
};
|
|
385
430
|
this.sessions.set(id, session);
|
|
386
431
|
try {
|
|
@@ -435,15 +480,18 @@ class TerminalManager extends EventEmitter {
|
|
|
435
480
|
processHandle.__loadtoagentExited = true;
|
|
436
481
|
if (session.generation !== generation) return;
|
|
437
482
|
session.process = null;
|
|
483
|
+
session.pid = null;
|
|
438
484
|
session.status = 'exited';
|
|
439
485
|
session.exitCode = Number.isFinite(event.exitCode) ? event.exitCode : null;
|
|
440
486
|
session.signal = Number.isFinite(event.signal) ? event.signal : null;
|
|
441
487
|
session.updatedAt = new Date().toISOString();
|
|
488
|
+
this.persistNow();
|
|
442
489
|
this.emitState('updated', session);
|
|
443
490
|
});
|
|
444
491
|
this.emitState('updated', session);
|
|
445
492
|
} catch (error) {
|
|
446
493
|
session.process = null;
|
|
494
|
+
session.pid = null;
|
|
447
495
|
session.status = 'failed';
|
|
448
496
|
session.updatedAt = new Date().toISOString();
|
|
449
497
|
const failureMessage = `\r\n[LoadToAgent] 터미널을 시작하지 못했습니다: ${error.message}\r\n`;
|
|
@@ -516,10 +564,12 @@ class TerminalManager extends EventEmitter {
|
|
|
516
564
|
const session = this.required(id);
|
|
517
565
|
if (session.process) {
|
|
518
566
|
const handle = session.process;
|
|
567
|
+
const pid = session.pid;
|
|
519
568
|
session.process = null;
|
|
520
569
|
session.generation += 1;
|
|
521
|
-
this.killTree(handle,
|
|
570
|
+
this.killTree(handle, pid);
|
|
522
571
|
}
|
|
572
|
+
session.pid = null;
|
|
523
573
|
session.status = 'exited';
|
|
524
574
|
session.updatedAt = new Date().toISOString();
|
|
525
575
|
this.emitState('updated', session);
|
|
@@ -529,12 +579,16 @@ class TerminalManager extends EventEmitter {
|
|
|
529
579
|
|
|
530
580
|
restart(id) {
|
|
531
581
|
const session = this.required(id);
|
|
582
|
+
session.recoveredAfterHostRestart = false;
|
|
583
|
+
session.recoverySkippedReason = '';
|
|
532
584
|
if (session.process) {
|
|
533
585
|
const handle = session.process;
|
|
586
|
+
const pid = session.pid;
|
|
534
587
|
session.process = null;
|
|
535
588
|
session.generation += 1;
|
|
536
|
-
this.killTree(handle,
|
|
589
|
+
this.killTree(handle, pid);
|
|
537
590
|
}
|
|
591
|
+
session.pid = null;
|
|
538
592
|
session.replay = '';
|
|
539
593
|
this.spawn(session);
|
|
540
594
|
return publicSession(session, true);
|
|
@@ -544,10 +598,12 @@ class TerminalManager extends EventEmitter {
|
|
|
544
598
|
const session = this.required(id);
|
|
545
599
|
if (session.process) {
|
|
546
600
|
const handle = session.process;
|
|
601
|
+
const pid = session.pid;
|
|
547
602
|
session.process = null;
|
|
548
603
|
session.generation += 1;
|
|
549
|
-
this.killTree(handle,
|
|
604
|
+
this.killTree(handle, pid);
|
|
550
605
|
}
|
|
606
|
+
session.pid = null;
|
|
551
607
|
session.status = 'exited';
|
|
552
608
|
session.updatedAt = new Date().toISOString();
|
|
553
609
|
this.sessions.delete(session.id);
|
|
@@ -560,13 +616,14 @@ class TerminalManager extends EventEmitter {
|
|
|
560
616
|
if (preserveSessions) {
|
|
561
617
|
const now = new Date().toISOString();
|
|
562
618
|
for (const session of this.sessions.values()) {
|
|
619
|
+
const shouldRecover = session.status === 'running' || session.status === 'starting';
|
|
563
620
|
if (session.process) {
|
|
564
621
|
const handle = session.process;
|
|
565
622
|
session.process = null;
|
|
566
623
|
session.generation += 1;
|
|
567
624
|
this.killTree(handle, session.pid);
|
|
568
625
|
}
|
|
569
|
-
if (
|
|
626
|
+
if (shouldRecover) session.status = 'running';
|
|
570
627
|
session.pid = null;
|
|
571
628
|
session.updatedAt = now;
|
|
572
629
|
}
|