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.
Files changed (40) hide show
  1. package/README.ko.md +11 -9
  2. package/docs/PROVIDER-CONTRACTS.md +8 -6
  3. package/main.js +25 -3
  4. package/package.json +1 -1
  5. package/preload.js +7 -2
  6. package/renderer/app-dashboard.js +18 -12
  7. package/renderer/app-graph-view.js +1 -1
  8. package/renderer/app-management.js +91 -45
  9. package/renderer/app-run-modal.js +1 -1
  10. package/renderer/app.js +1 -1
  11. package/renderer/i18n-messages.js +212 -199
  12. package/renderer/index.html +28 -27
  13. package/renderer/styles-agent-map.css +10 -10
  14. package/renderer/styles-cards.css +19 -19
  15. package/renderer/styles-collaboration.css +23 -23
  16. package/renderer/styles-components.css +36 -36
  17. package/renderer/styles-management.css +23 -14
  18. package/renderer/styles-onboarding.css +8 -8
  19. package/renderer/styles-overlays.css +5 -5
  20. package/renderer/styles-product.css +24 -24
  21. package/renderer/styles-quality.css +4 -4
  22. package/renderer/styles-readability.css +179 -1
  23. package/renderer/styles-responsive-runtime.css +5 -5
  24. package/renderer/styles-responsive-workflows.css +2 -2
  25. package/renderer/styles-run-composer.css +23 -23
  26. package/renderer/styles-runtime-overview.css +22 -22
  27. package/renderer/styles-settings.css +21 -21
  28. package/renderer/styles-terminal.css +66 -66
  29. package/renderer/styles-tmux.css +49 -49
  30. package/renderer/styles-workflows.css +48 -48
  31. package/renderer/styles.css +7 -7
  32. package/renderer/terminal-events.js +34 -11
  33. package/renderer/terminal-workbench.js +52 -21
  34. package/renderer/terminal.js +9 -1
  35. package/src/contracts.js +5 -5
  36. package/src/ipc/registerTerminalIpc.js +6 -14
  37. package/src/sessionIntelligence.js +18 -4
  38. package/src/terminalHost.js +98 -26
  39. package/src/terminalHostDaemon.js +1 -0
  40. package/src/terminalManager.js +61 -4
@@ -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 Observed shell and background execution units owned by this AI session.
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 Structured progress reconstructed from lifecycle events.
50
- * @property {{level:string,score:number,signals:Array<Object>,lastActivityAt:string|null,ageSeconds:number|null}} health Stalls, failures, context pressure, and hierarchy anomalies.
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 Observation provenance and confidence.
53
- * @property {{status:string,summary:string,verified:boolean,verification:string,completedAt:string|null,artifacts:Array<Object>,checks:Array<Object>}} outcome Completion summary, artifacts, and verification evidence.
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.on('terminals:write', (event, id, data) => {
23
- if (!trustedSender(event) || !manager()) return;
24
- try {
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.on('terminals:resize', (event, id, cols, rows) => {
35
- if (!trustedSender(event) || !manager()) return;
36
- try {
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 = /(?:test|spec|검증|테스트)/i;
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
- for (const match of body.match(/\b[0-9a-f]{7,40}\b/gi) || []) add('commit', match, true);
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 === 'failed' ? 'failed' : row.status === 'running' ? 'running' : 'passed',
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' : attention.required ? 'attention' : max === 1 ? 'unknown' : 'healthy',
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,
@@ -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 => this.broadcast({ type: 'event', event: 'state', 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
- if (this.shutdownTimer) {
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
- if (activeSessions(this.manager).length === 0 && !this.shutdownTimer) {
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
- async connect() {
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
- if (this.handshake) this.handshake.reject(error);
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
- consume(chunk) {
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) this.emit('disconnect');
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
- if (this.socket) this.socket.destroy();
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
- return Promise.reject(new Error('터미널 호스트에 연결되어 있지 않습니다.'));
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;
@@ -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, session.pid);
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, session.pid);
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, session.pid);
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 (session.status === 'running' || session.status === 'starting') session.status = 'exited';
626
+ if (shouldRecover) session.status = 'running';
570
627
  session.pid = null;
571
628
  session.updatedAt = now;
572
629
  }