loadtoagent 1.3.1 → 1.3.2
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 +98 -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,24 @@ 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 = path.resolve(options.executable || process.execPath);
|
|
243
|
+
if (platform !== 'darwin' || !options.isPackaged) return executable;
|
|
244
|
+
const productName = path.basename(executable);
|
|
245
|
+
const helper = path.resolve(
|
|
246
|
+
path.dirname(executable),
|
|
247
|
+
'..',
|
|
248
|
+
'Frameworks',
|
|
249
|
+
`${productName} Helper.app`,
|
|
250
|
+
'Contents',
|
|
251
|
+
'MacOS',
|
|
252
|
+
`${productName} Helper`,
|
|
253
|
+
);
|
|
254
|
+
const fileSystem = options.fileSystem || fs;
|
|
255
|
+
return fileSystem.existsSync(helper) ? helper : executable;
|
|
256
|
+
}
|
|
257
|
+
|
|
220
258
|
class TerminalHostClient extends EventEmitter {
|
|
221
259
|
constructor(options = {}) {
|
|
222
260
|
super();
|
|
@@ -231,16 +269,31 @@ class TerminalHostClient extends EventEmitter {
|
|
|
231
269
|
this.sequence = 0;
|
|
232
270
|
this.pending = new Map();
|
|
233
271
|
this.handshake = null;
|
|
272
|
+
this.connectPromise = null;
|
|
273
|
+
this.connectGeneration = 0;
|
|
234
274
|
}
|
|
235
275
|
|
|
236
|
-
|
|
276
|
+
connect() {
|
|
277
|
+
if (this.connected && this.socket && !this.socket.destroyed) return Promise.resolve(this);
|
|
278
|
+
if (this.connectPromise) return this.connectPromise;
|
|
237
279
|
this.disposed = false;
|
|
280
|
+
const generation = ++this.connectGeneration;
|
|
281
|
+
const connecting = this.connectLoop(generation);
|
|
282
|
+
const tracked = connecting.finally(() => {
|
|
283
|
+
if (this.connectPromise === tracked) this.connectPromise = null;
|
|
284
|
+
});
|
|
285
|
+
this.connectPromise = tracked;
|
|
286
|
+
return this.connectPromise;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async connectLoop(generation) {
|
|
238
290
|
const deadline = Date.now() + this.connectTimeoutMs;
|
|
239
291
|
let launched = false;
|
|
240
292
|
let lastError = null;
|
|
241
|
-
while (Date.now() < deadline) {
|
|
293
|
+
while (!this.disposed && generation === this.connectGeneration && Date.now() < deadline) {
|
|
242
294
|
try {
|
|
243
295
|
await this.connectExisting();
|
|
296
|
+
if (this.disposed || generation !== this.connectGeneration) throw new Error('터미널 호스트 재연결이 취소되었습니다.');
|
|
244
297
|
return this;
|
|
245
298
|
} catch (error) {
|
|
246
299
|
lastError = error;
|
|
@@ -253,6 +306,7 @@ class TerminalHostClient extends EventEmitter {
|
|
|
253
306
|
}
|
|
254
307
|
await new Promise(resolve => setTimeout(resolve, 120));
|
|
255
308
|
}
|
|
309
|
+
if (this.disposed || generation !== this.connectGeneration) throw new Error('터미널 호스트 재연결이 취소되었습니다.');
|
|
256
310
|
throw new Error(`터미널 호스트에 연결하지 못했습니다: ${lastError?.message || '시간 초과'}`);
|
|
257
311
|
}
|
|
258
312
|
|
|
@@ -272,15 +326,19 @@ class TerminalHostClient extends EventEmitter {
|
|
|
272
326
|
};
|
|
273
327
|
socket.setNoDelay(true);
|
|
274
328
|
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());
|
|
329
|
+
socket.on('data', chunk => this.consume(chunk, socket));
|
|
330
|
+
socket.on('error', error => this.handleSocketError(socket, error));
|
|
331
|
+
socket.on('close', () => this.handleDisconnect(socket));
|
|
280
332
|
});
|
|
281
333
|
}
|
|
282
334
|
|
|
283
|
-
|
|
335
|
+
handleSocketError(socket, error) {
|
|
336
|
+
if (socket && socket !== this.socket) return;
|
|
337
|
+
if (this.handshake) this.handshake.reject(error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
consume(chunk, socket = this.socket) {
|
|
341
|
+
if (socket && socket !== this.socket) return;
|
|
284
342
|
this.buffer += chunk.toString('utf8');
|
|
285
343
|
if (this.buffer.length > MAX_FRAME_CHARS) {
|
|
286
344
|
this.socket?.destroy(new Error('터미널 호스트 응답이 너무 큽니다.'));
|
|
@@ -313,7 +371,8 @@ class TerminalHostClient extends EventEmitter {
|
|
|
313
371
|
}
|
|
314
372
|
}
|
|
315
373
|
|
|
316
|
-
handleDisconnect() {
|
|
374
|
+
handleDisconnect(socket = this.socket) {
|
|
375
|
+
if (socket && socket !== this.socket) return;
|
|
317
376
|
const wasConnected = this.connected;
|
|
318
377
|
this.connected = false;
|
|
319
378
|
if (this.handshake) this.handshake.reject(new Error('터미널 호스트 연결이 닫혔습니다.'));
|
|
@@ -323,19 +382,30 @@ class TerminalHostClient extends EventEmitter {
|
|
|
323
382
|
}
|
|
324
383
|
this.pending.clear();
|
|
325
384
|
this.socket = null;
|
|
326
|
-
if (wasConnected && !this.disposed)
|
|
385
|
+
if (wasConnected && !this.disposed) {
|
|
386
|
+
this.emit('disconnect');
|
|
387
|
+
this.connect()
|
|
388
|
+
.then(() => this.emit('reconnect', { sessions: this.list() }))
|
|
389
|
+
.catch(error => {
|
|
390
|
+
if (!this.disposed) this.emit('reconnect-error', error);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
327
393
|
}
|
|
328
394
|
|
|
329
395
|
resetSocket() {
|
|
330
|
-
|
|
396
|
+
const socket = this.socket;
|
|
331
397
|
this.socket = null;
|
|
332
398
|
this.connected = false;
|
|
333
399
|
this.buffer = '';
|
|
400
|
+
if (socket) socket.destroy();
|
|
334
401
|
}
|
|
335
402
|
|
|
336
|
-
request(operation, ...args) {
|
|
403
|
+
async request(operation, ...args) {
|
|
404
|
+
if (!this.connected || !this.socket || this.socket.destroyed) {
|
|
405
|
+
await this.connect();
|
|
406
|
+
}
|
|
337
407
|
if (!this.connected || !this.socket || this.socket.destroyed) {
|
|
338
|
-
|
|
408
|
+
throw new Error('터미널 호스트에 연결되어 있지 않습니다.');
|
|
339
409
|
}
|
|
340
410
|
const requestId = String(++this.sequence);
|
|
341
411
|
return new Promise((resolve, reject) => {
|
|
@@ -360,6 +430,7 @@ class TerminalHostClient extends EventEmitter {
|
|
|
360
430
|
|
|
361
431
|
dispose({ shutdownIfIdle = false } = {}) {
|
|
362
432
|
this.disposed = true;
|
|
433
|
+
this.connectGeneration += 1;
|
|
363
434
|
if (this.socket && !this.socket.destroyed) {
|
|
364
435
|
if (shutdownIfIdle) sendFrame(this.socket, { type: 'control', operation: 'shutdown-if-idle' });
|
|
365
436
|
this.socket.end();
|
|
@@ -378,4 +449,5 @@ module.exports = {
|
|
|
378
449
|
TERMINAL_HOST_PROTOCOL,
|
|
379
450
|
readHostDiscovery,
|
|
380
451
|
launchTerminalHost,
|
|
452
|
+
resolveTerminalHostExecutable,
|
|
381
453
|
};
|
|
@@ -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
|
}
|