agentgui 1.0.532 → 1.0.534

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/database.js CHANGED
@@ -1434,6 +1434,62 @@ export const queries = {
1434
1434
  };
1435
1435
  },
1436
1436
 
1437
+ getMessagesBefore(conversationId, beforeId, limit = 50) {
1438
+ const countStmt = prep('SELECT COUNT(*) as count FROM messages WHERE conversationId = ?');
1439
+ const total = countStmt.get(conversationId).count;
1440
+
1441
+ const stmt = prep(`
1442
+ SELECT * FROM messages
1443
+ WHERE conversationId = ? AND id < (SELECT id FROM messages WHERE id = ?)
1444
+ ORDER BY created_at DESC LIMIT ?
1445
+ `);
1446
+ const messages = stmt.all(conversationId, beforeId, limit).reverse();
1447
+
1448
+ return {
1449
+ messages: messages.map(msg => {
1450
+ if (typeof msg.content === 'string') {
1451
+ try {
1452
+ msg.content = JSON.parse(msg.content);
1453
+ } catch (_) {}
1454
+ }
1455
+ return msg;
1456
+ }),
1457
+ total,
1458
+ limit,
1459
+ hasMore: total > (limit + 1)
1460
+ };
1461
+ },
1462
+
1463
+ getChunksBefore(conversationId, beforeTimestamp, limit = 500) {
1464
+ const countStmt = prep('SELECT COUNT(*) as count FROM chunks WHERE conversationId = ?');
1465
+ const total = countStmt.get(conversationId).count;
1466
+
1467
+ const stmt = prep(`
1468
+ SELECT id, sessionId, conversationId, sequence, type, data, created_at
1469
+ FROM chunks
1470
+ WHERE conversationId = ? AND created_at < ?
1471
+ ORDER BY created_at DESC LIMIT ?
1472
+ `);
1473
+ const rows = stmt.all(conversationId, beforeTimestamp, limit);
1474
+ rows.reverse();
1475
+
1476
+ return {
1477
+ chunks: rows.map(row => {
1478
+ try {
1479
+ return {
1480
+ ...row,
1481
+ data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data
1482
+ };
1483
+ } catch (e) {
1484
+ return row;
1485
+ }
1486
+ }),
1487
+ total,
1488
+ limit,
1489
+ hasMore: total > (limit + 1)
1490
+ };
1491
+ },
1492
+
1437
1493
  getChunksSince(sessionId, timestamp) {
1438
1494
  const stmt = prep(
1439
1495
  `SELECT id, sessionId, conversationId, sequence, type, data, created_at
@@ -0,0 +1,170 @@
1
+ import pm2 from 'pm2';
2
+
3
+ const ACTIVE_STATES = new Set(['online', 'launching', 'stopping', 'waiting restart']);
4
+
5
+ class PM2Manager {
6
+ constructor() {
7
+ this.connected = false;
8
+ this.monitoring = false;
9
+ this.monitorInterval = null;
10
+ this.broadcastFn = null;
11
+ this.logStreams = new Map();
12
+ }
13
+
14
+ async connect() {
15
+ if (this.connected) return true;
16
+ return new Promise((resolve, reject) => {
17
+ pm2.connect(false, (err) => {
18
+ if (err) { this.connected = false; reject(err); }
19
+ else { this.connected = true; resolve(true); }
20
+ });
21
+ });
22
+ }
23
+
24
+ async ensureConnected() {
25
+ if (this.connected) return true;
26
+ await this.connect();
27
+ return true;
28
+ }
29
+
30
+ async listProcesses() {
31
+ try {
32
+ await this.ensureConnected();
33
+ return new Promise((resolve) => {
34
+ pm2.list((err, list) => {
35
+ if (err) { this.connected = false; resolve([]); return; }
36
+ if (!Array.isArray(list)) { resolve([]); return; }
37
+ resolve(list.map(proc => ({
38
+ name: proc.name,
39
+ pm_id: proc.pm_id,
40
+ status: proc.status,
41
+ pid: proc.pid,
42
+ cpu: proc.monit ? (proc.monit.cpu || 0) : 0,
43
+ memory: proc.monit ? (typeof proc.monit.memory === 'number' ? proc.monit.memory : 0) : 0,
44
+ uptime: proc.pm2_env ? proc.pm2_env.pm_uptime : null,
45
+ restarts: proc.pm2_env ? (proc.pm2_env.restart_time || 0) : 0,
46
+ watching: proc.pm2_env ? (proc.pm2_env.watch || false) : false,
47
+ isActive: ACTIVE_STATES.has(proc.status)
48
+ })));
49
+ });
50
+ });
51
+ } catch (_) {
52
+ this.connected = false;
53
+ return [];
54
+ }
55
+ }
56
+
57
+ async startMonitoring(broadcastFn) {
58
+ if (this.monitoring) return;
59
+ this.monitoring = true;
60
+ this.broadcastFn = broadcastFn;
61
+ const tick = async () => {
62
+ if (!this.monitoring) return;
63
+ try {
64
+ const processes = await this.listProcesses();
65
+ const hasActive = processes.some(p => p.isActive);
66
+ broadcastFn({ type: 'pm2_monit_update', processes, hasActive, available: true, timestamp: Date.now() });
67
+ } catch (_) {}
68
+ };
69
+ this.monitorInterval = setInterval(tick, 2000);
70
+ await tick();
71
+ }
72
+
73
+ stopMonitoring() {
74
+ this.monitoring = false;
75
+ if (this.monitorInterval) { clearInterval(this.monitorInterval); this.monitorInterval = null; }
76
+ this.broadcastFn = null;
77
+ }
78
+
79
+ async startProcess(name) {
80
+ try {
81
+ await this.ensureConnected();
82
+ return new Promise((resolve) => {
83
+ pm2.start(name, (err) => resolve(err ? { success: false, error: err.message } : { success: true }));
84
+ });
85
+ } catch (err) { return { success: false, error: err.message }; }
86
+ }
87
+
88
+ async stopProcess(name) {
89
+ try {
90
+ await this.ensureConnected();
91
+ return new Promise((resolve) => {
92
+ pm2.stop(name, (err) => resolve(err ? { success: false, error: err.message } : { success: true }));
93
+ });
94
+ } catch (err) { return { success: false, error: err.message }; }
95
+ }
96
+
97
+ async restartProcess(name) {
98
+ try {
99
+ await this.ensureConnected();
100
+ return new Promise((resolve) => {
101
+ pm2.restart(name, (err) => resolve(err ? { success: false, error: err.message } : { success: true }));
102
+ });
103
+ } catch (err) { return { success: false, error: err.message }; }
104
+ }
105
+
106
+ async deleteProcess(name) {
107
+ try {
108
+ await this.ensureConnected();
109
+ return new Promise((resolve) => {
110
+ pm2.delete(name, (err) => resolve(err ? { success: false, error: err.message } : { success: true }));
111
+ });
112
+ } catch (err) { return { success: false, error: err.message }; }
113
+ }
114
+
115
+ async getLogs(name, options = {}) {
116
+ try {
117
+ await this.ensureConnected();
118
+ const existing = this.logStreams.get(name);
119
+ if (existing) { try { existing.destroy(); } catch (_) {} this.logStreams.delete(name); }
120
+ return new Promise((resolve) => {
121
+ const lines = [];
122
+ let settled = false;
123
+ const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
124
+ const timer = setTimeout(() => finish({ success: true, logs: lines.join('\n') }), 4000);
125
+ try {
126
+ const stream = pm2.logs(name, { raw: true, lines: options.lines || 100, follow: false });
127
+ this.logStreams.set(name, stream);
128
+ stream.on('data', (c) => lines.push(c.toString()));
129
+ stream.on('end', () => { clearTimeout(timer); this.logStreams.delete(name); finish({ success: true, logs: lines.join('\n') }); });
130
+ stream.on('error', (e) => { clearTimeout(timer); this.logStreams.delete(name); finish({ success: false, error: e.message }); });
131
+ } catch (err) { clearTimeout(timer); finish({ success: false, error: err.message }); }
132
+ });
133
+ } catch (err) { return { success: false, error: err.message }; }
134
+ }
135
+
136
+ async flushLogs(name) {
137
+ try {
138
+ await this.ensureConnected();
139
+ return new Promise((resolve) => {
140
+ pm2.flush(name, (err) => resolve(err ? { success: false, error: err.message } : { success: true }));
141
+ });
142
+ } catch (err) { return { success: false, error: err.message }; }
143
+ }
144
+
145
+ async ping() {
146
+ try {
147
+ await this.ensureConnected();
148
+ return { success: true, status: 'connected' };
149
+ } catch (err) { return { success: false, error: err.message }; }
150
+ }
151
+
152
+ async heal() {
153
+ this.connected = false;
154
+ try {
155
+ await this.connect();
156
+ return { success: true };
157
+ } catch (err) { return { success: false, error: err.message }; }
158
+ }
159
+
160
+ disconnect() {
161
+ this.stopMonitoring();
162
+ for (const [, s] of this.logStreams) { try { s.destroy(); } catch (_) {} }
163
+ this.logStreams.clear();
164
+ this.connected = false;
165
+ try { pm2.disconnect(); } catch (_) {}
166
+ }
167
+ }
168
+
169
+ export const pm2Manager = new PM2Manager();
170
+ export default pm2Manager;
@@ -5,10 +5,10 @@ import path from 'path';
5
5
 
6
6
  const isWindows = os.platform() === 'win32';
7
7
  const TOOLS = [
8
- { id: 'gm-cc', name: 'gm-cc', pkg: '@anthropic-ai/claude-code', pluginId: 'gm-cc' },
9
- { id: 'gm-oc', name: 'gm-oc', pkg: 'opencode-ai', pluginId: 'gm-oc' },
10
- { id: 'gm-gc', name: 'gm-gc', pkg: '@google/gemini-cli', pluginId: 'gm' },
11
- { id: 'gm-kilo', name: 'gm-kilo', pkg: '@kilocode/cli', pluginId: 'gm-kilo' },
8
+ { id: 'gm-cc', name: 'gm-cc', pkg: 'gm-cc', pluginId: 'gm-cc' },
9
+ { id: 'gm-oc', name: 'gm-oc', pkg: 'gm-oc', pluginId: 'gm-oc' },
10
+ { id: 'gm-gc', name: 'gm-gc', pkg: 'gm-gc', pluginId: 'gm' },
11
+ { id: 'gm-kilo', name: 'gm-kilo', pkg: 'gm-kilo', pluginId: 'gm-kilo' },
12
12
  ];
13
13
 
14
14
  const statusCache = new Map();
@@ -73,6 +73,14 @@ export function register(router, deps) {
73
73
  return { ok: true, chunks: since > 0 ? allChunks.filter(c => c.created_at > since) : allChunks };
74
74
  });
75
75
 
76
+ router.handle('conv.chunks.earlier', (p) => {
77
+ if (!queries.getConversation(p.id)) notFound('Conversation not found');
78
+ const beforeTimestamp = parseInt(p.before || Date.now());
79
+ const limit = Math.min(p.limit || 500, 5000);
80
+ const result = queries.getChunksBefore(p.id, beforeTimestamp, limit);
81
+ return { ok: true, chunks: result.chunks, total: result.total, hasMore: result.hasMore, limit: result.limit };
82
+ });
83
+
76
84
  router.handle('conv.cancel', (p) => {
77
85
  const entry = activeExecutions.get(p.id);
78
86
  if (!entry) notFound('No active execution to cancel');
@@ -104,6 +112,14 @@ export function register(router, deps) {
104
112
  return queries.getPaginatedMessages(p.id, Math.min(p.limit || 50, 100), Math.max(p.offset || 0, 0));
105
113
  });
106
114
 
115
+ router.handle('msg.ls.earlier', (p) => {
116
+ if (!p.id) fail(400, 'Missing conversation id');
117
+ if (!p.before) fail(400, 'Missing before messageId parameter');
118
+ const limit = Math.min(p.limit || 50, 100);
119
+ const result = queries.getMessagesBefore(p.id, p.before, limit);
120
+ return { ok: true, messages: result.messages, total: result.total, hasMore: result.hasMore, limit: result.limit };
121
+ });
122
+
107
123
  function startExecution(convId, message, agentId, model, content, subAgent) {
108
124
  const session = queries.createSession(convId);
109
125
  queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, convId, session.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.532",
3
+ "version": "1.0.534",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -35,6 +35,7 @@
35
35
  "node-pty": "^1.0.0",
36
36
  "onnxruntime-node": "1.21.0",
37
37
  "opencode-ai": "^1.2.15",
38
+ "pm2": "^5.4.3",
38
39
  "puppeteer-core": "^24.37.5",
39
40
  "webtalk": "^1.0.31",
40
41
  "ws": "^8.14.2"
package/server.js CHANGED
@@ -25,6 +25,7 @@ import { register as registerUtilHandlers } from './lib/ws-handlers-util.js';
25
25
  import { startAll as startACPTools, stopAll as stopACPTools, getStatus as getACPStatus, getPort as getACPPort, queryModels as queryACPModels, touch as touchACP } from './lib/acp-manager.js';
26
26
  import { installGMAgentConfigs } from './lib/gm-agent-configs.js';
27
27
  import * as toolManager from './lib/tool-manager.js';
28
+ import { pm2Manager } from './lib/pm2-manager.js';
28
29
 
29
30
 
30
31
  process.on('uncaughtException', (err, origin) => {
@@ -4045,6 +4046,7 @@ const hotReloadClients = [];
4045
4046
  const syncClients = new Set();
4046
4047
  const subscriptionIndex = new Map();
4047
4048
  const sseStreamHandlers = new Map();
4049
+ const pm2Subscribers = new Set();
4048
4050
 
4049
4051
  wss.on('connection', (ws, req) => {
4050
4052
  // req.url in WebSocket is just the path (e.g., '/gm/sync'), not a full URL
@@ -4072,16 +4074,19 @@ wss.on('connection', (ws, req) => {
4072
4074
  });
4073
4075
 
4074
4076
  ws.on('pong', () => { ws.isAlive = true; });
4075
- ws.on('close', () => {
4076
- if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch(e) {} ws.terminalProc = null; }
4077
- syncClients.delete(ws);
4078
- wsOptimizer.removeClient(ws);
4079
- for (const sub of ws.subscriptions) {
4080
- const idx = subscriptionIndex.get(sub);
4081
- if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
4082
- }
4083
- console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
4084
- });
4077
+ ws.on('close', () => {
4078
+ if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch(e) {} ws.terminalProc = null; }
4079
+ syncClients.delete(ws);
4080
+ wsOptimizer.removeClient(ws);
4081
+ for (const sub of ws.subscriptions) {
4082
+ const idx = subscriptionIndex.get(sub);
4083
+ if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
4084
+ }
4085
+ if (ws.pm2Subscribed) {
4086
+ pm2Subscribers.delete(ws);
4087
+ }
4088
+ console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
4089
+ });
4085
4090
  }
4086
4091
  });
4087
4092
 
@@ -4094,7 +4099,11 @@ const BROADCAST_TYPES = new Set([
4094
4099
  'streaming_start', 'streaming_progress', 'streaming_complete', 'streaming_error',
4095
4100
  'tool_install_started', 'tool_install_progress', 'tool_install_complete', 'tool_install_failed',
4096
4101
  'tool_update_progress', 'tool_update_complete', 'tool_update_failed',
4097
- 'tools_update_started', 'tools_update_complete', 'tools_refresh_complete'
4102
+ 'tools_update_started', 'tools_update_complete', 'tools_refresh_complete',
4103
+ 'pm2_monit_update', 'pm2_monitoring_started', 'pm2_monitoring_stopped',
4104
+ 'pm2_list_response', 'pm2_start_response', 'pm2_stop_response',
4105
+ 'pm2_restart_response', 'pm2_delete_response', 'pm2_logs_response',
4106
+ 'pm2_flush_logs_response', 'pm2_ping_response', 'pm2_unavailable'
4098
4107
  ]);
4099
4108
 
4100
4109
  const wsOptimizer = new WSOptimizer();
@@ -4288,12 +4297,65 @@ wsRouter.onLegacy((data, ws) => {
4288
4297
  }
4289
4298
  } catch (e) {}
4290
4299
  }
4291
- } else if (data.type === 'terminal_stop') {
4292
- if (ws.terminalProc) {
4293
- try { ws.terminalProc.kill(); } catch(e) {}
4294
- ws.terminalProc = null;
4300
+ } else if (data.type === 'terminal_stop') {
4301
+ if (ws.terminalProc) {
4302
+ try { ws.terminalProc.kill(); } catch(e) {}
4303
+ ws.terminalProc = null;
4304
+ }
4305
+ } else if (data.type === 'pm2_list') {
4306
+ if (!pm2Manager.connected) {
4307
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() }));
4308
+ } else {
4309
+ pm2Manager.listProcesses().then(processes => {
4310
+ if (ws.readyState === 1) {
4311
+ const hasActive = processes.some(p => ['online','launching','stopping','waiting restart'].includes(p.status));
4312
+ ws.send(JSON.stringify({ type: 'pm2_list_response', processes, hasActive }));
4313
+ }
4314
+ }).catch(() => {
4315
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'pm2_unavailable', reason: 'list failed', timestamp: Date.now() }));
4316
+ });
4317
+ }
4318
+ } else if (data.type === 'pm2_start_monitoring') {
4319
+ pm2Subscribers.add(ws);
4320
+ ws.pm2Subscribed = true;
4321
+ if (!pm2Manager.connected) {
4322
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() }));
4323
+ } else {
4324
+ ws.send(JSON.stringify({ type: 'pm2_monitoring_started' }));
4325
+ }
4326
+ } else if (data.type === 'pm2_stop_monitoring') {
4327
+ pm2Subscribers.delete(ws);
4328
+ ws.pm2Subscribed = false;
4329
+ ws.send(JSON.stringify({ type: 'pm2_monitoring_stopped' }));
4330
+ } else if (data.type === 'pm2_start') {
4331
+ pm2Manager.startProcess(data.name).then(result => {
4332
+ ws.send(JSON.stringify({ type: 'pm2_start_response', name: data.name, ...result }));
4333
+ });
4334
+ } else if (data.type === 'pm2_stop') {
4335
+ pm2Manager.stopProcess(data.name).then(result => {
4336
+ ws.send(JSON.stringify({ type: 'pm2_stop_response', name: data.name, ...result }));
4337
+ });
4338
+ } else if (data.type === 'pm2_restart') {
4339
+ pm2Manager.restartProcess(data.name).then(result => {
4340
+ ws.send(JSON.stringify({ type: 'pm2_restart_response', name: data.name, ...result }));
4341
+ });
4342
+ } else if (data.type === 'pm2_delete') {
4343
+ pm2Manager.deleteProcess(data.name).then(result => {
4344
+ ws.send(JSON.stringify({ type: 'pm2_delete_response', name: data.name, ...result }));
4345
+ });
4346
+ } else if (data.type === 'pm2_logs') {
4347
+ pm2Manager.getLogs(data.name, { lines: data.lines || 100 }).then(result => {
4348
+ ws.send(JSON.stringify({ type: 'pm2_logs_response', name: data.name, ...result }));
4349
+ });
4350
+ } else if (data.type === 'pm2_flush_logs') {
4351
+ pm2Manager.flushLogs(data.name).then(result => {
4352
+ ws.send(JSON.stringify({ type: 'pm2_flush_logs_response', name: data.name, ...result }));
4353
+ });
4354
+ } else if (data.type === 'pm2_ping') {
4355
+ pm2Manager.ping().then(result => {
4356
+ ws.send(JSON.stringify({ type: 'pm2_ping_response', ...result }));
4357
+ });
4295
4358
  }
4296
- }
4297
4359
 
4298
4360
  } catch (err) { console.error('[WS-LEGACY] Handler error (contained):', err.message); }
4299
4361
  });
@@ -4327,10 +4389,19 @@ if (watch) {
4327
4389
 
4328
4390
  process.on('SIGTERM', () => {
4329
4391
  console.log('[SIGNAL] SIGTERM received - graceful shutdown');
4330
- stopACPTools().then(() => {
4331
- wss.close(() => server.close(() => process.exit(0)));
4392
+ try { pm2Manager.disconnect(); } catch (_) {}
4393
+ Promise.resolve().then(() => {
4394
+ stopACPTools().then(() => {
4395
+ wss.close(() => server.close(() => process.exit(0)));
4396
+ }).catch(() => {
4397
+ wss.close(() => server.close(() => process.exit(0)));
4398
+ });
4332
4399
  }).catch(() => {
4333
- wss.close(() => server.close(() => process.exit(0)));
4400
+ stopACPTools().then(() => {
4401
+ wss.close(() => server.close(() => process.exit(0)));
4402
+ }).catch(() => {
4403
+ wss.close(() => server.close(() => process.exit(0)));
4404
+ });
4334
4405
  });
4335
4406
  });
4336
4407
 
@@ -4602,15 +4673,44 @@ function onServerReady() {
4602
4673
  }
4603
4674
  });
4604
4675
 
4605
- getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
4676
+ getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
4606
4677
 
4607
- performAutoImport();
4678
+ performAutoImport();
4608
4679
 
4609
- setInterval(performAutoImport, 30000);
4680
+ setInterval(performAutoImport, 30000);
4610
4681
 
4611
- setInterval(performAgentHealthCheck, 30000);
4682
+ setInterval(performAgentHealthCheck, 30000);
4612
4683
 
4613
- }
4684
+ // Initialize PM2 monitoring - only when PM2 daemon is available
4685
+ const broadcastPM2 = (update) => {
4686
+ const msg = JSON.stringify(update);
4687
+ for (const client of pm2Subscribers) {
4688
+ if (client.readyState === 1) { try { client.send(msg); } catch (_) {} }
4689
+ }
4690
+ };
4691
+
4692
+ const startPM2Monitoring = async () => {
4693
+ try {
4694
+ await pm2Manager.connect();
4695
+ await pm2Manager.startMonitoring(broadcastPM2);
4696
+ console.log('[PM2] Monitoring started');
4697
+ } catch (err) {
4698
+ console.log('[PM2] Not available:', err.message);
4699
+ broadcastPM2({ type: 'pm2_unavailable', reason: err.message, timestamp: Date.now() });
4700
+ }
4701
+ };
4702
+
4703
+ setTimeout(startPM2Monitoring, 2000);
4704
+
4705
+ setInterval(async () => {
4706
+ if (!pm2Manager.connected && !pm2Manager.monitoring) {
4707
+ try {
4708
+ const healed = await pm2Manager.heal();
4709
+ if (healed.success) await pm2Manager.startMonitoring(broadcastPM2);
4710
+ } catch (_) {}
4711
+ }
4712
+ }, 30000);
4713
+ }
4614
4714
 
4615
4715
  const importMtimeCache = new Map();
4616
4716
 
package/static/index.html CHANGED
@@ -1202,6 +1202,175 @@
1202
1202
  .sidebar-list { contain: strict; content-visibility: auto; }
1203
1203
  .message { contain: layout style; content-visibility: auto; contain-intrinsic-size: auto 80px; }
1204
1204
 
1205
+ /* PM2 Monitor Panel */
1206
+ .pm2-monitor-panel {
1207
+ border-top: 1px solid var(--color-border);
1208
+ background: var(--color-bg-secondary);
1209
+ display: flex;
1210
+ flex-direction: column;
1211
+ max-height: 35vh;
1212
+ }
1213
+ .pm2-monitor-header {
1214
+ padding: 0.5rem 0.75rem;
1215
+ font-size: 0.75rem;
1216
+ font-weight: 600;
1217
+ text-transform: uppercase;
1218
+ letter-spacing: 0.05em;
1219
+ color: var(--color-text-secondary);
1220
+ display: flex;
1221
+ justify-content: space-between;
1222
+ align-items: center;
1223
+ background: var(--color-bg-primary);
1224
+ border-bottom: 1px solid var(--color-border);
1225
+ flex-shrink: 0;
1226
+ }
1227
+ .pm2-monitor-actions { display: flex; gap: 0.25rem; }
1228
+ .pm2-action-btn {
1229
+ background: none;
1230
+ border: none;
1231
+ color: var(--color-text-secondary);
1232
+ cursor: pointer;
1233
+ padding: 0.125rem 0.375rem;
1234
+ font-size: 0.875rem;
1235
+ line-height: 1;
1236
+ border-radius: 0.25rem;
1237
+ }
1238
+ .pm2-action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text-primary); }
1239
+ .pm2-process-list { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 0.25rem 0; }
1240
+ .pm2-process-item {
1241
+ padding: 0.375rem 0.75rem;
1242
+ margin: 0.125rem 0.5rem;
1243
+ border-radius: 0.375rem;
1244
+ background: var(--color-bg-primary);
1245
+ border-left: 3px solid var(--color-border);
1246
+ font-size: 0.75rem;
1247
+ transition: background-color 0.15s;
1248
+ }
1249
+ .pm2-process-item:hover { background: var(--color-bg-secondary); }
1250
+ .pm2-process-item.online { border-left-color: var(--color-success); }
1251
+ .pm2-process-item.launching { border-left-color: var(--color-primary); }
1252
+ .pm2-process-item.stopping { border-left-color: var(--color-warning); }
1253
+ .pm2-process-item.stopped { border-left-color: var(--color-text-muted, #888); opacity: 0.7; }
1254
+ .pm2-process-item.errored { border-left-color: var(--color-error); }
1255
+ .pm2-process-item.waiting\ restart { border-left-color: var(--color-warning); }
1256
+ .pm2-status-dot {
1257
+ display: inline-block;
1258
+ width: 6px; height: 6px;
1259
+ border-radius: 50%;
1260
+ background: var(--color-text-secondary);
1261
+ vertical-align: middle;
1262
+ margin-left: 4px;
1263
+ }
1264
+ .pm2-status-online .pm2-status-dot,
1265
+ .pm2-process-item.online .pm2-status-dot { background: var(--color-success); }
1266
+ .pm2-status-launching .pm2-status-dot,
1267
+ .pm2-process-item.launching .pm2-status-dot { background: var(--color-primary); }
1268
+ .pm2-status-stopping .pm2-status-dot,
1269
+ .pm2-status-errored .pm2-status-dot,
1270
+ .pm2-process-item.errored .pm2-status-dot { background: var(--color-error); }
1271
+ .pm2-process-name {
1272
+ font-weight: 600;
1273
+ margin-bottom: 0.125rem;
1274
+ white-space: nowrap;
1275
+ overflow: hidden;
1276
+ text-overflow: ellipsis;
1277
+ }
1278
+ .pm2-process-meta {
1279
+ display: flex;
1280
+ gap: 0.5rem;
1281
+ font-size: 0.7rem;
1282
+ color: var(--color-text-secondary);
1283
+ flex-wrap: wrap;
1284
+ }
1285
+ .pm2-process-meta span {
1286
+ display: inline-flex;
1287
+ align-items: center;
1288
+ gap: 0.125rem;
1289
+ }
1290
+ .pm2-process-actions {
1291
+ margin-top: 0.25rem;
1292
+ display: flex;
1293
+ gap: 0.25rem;
1294
+ }
1295
+ .pm2-btn {
1296
+ padding: 0.125rem 0.375rem;
1297
+ font-size: 0.7rem;
1298
+ border-radius: 0.25rem;
1299
+ border: 1px solid var(--color-border);
1300
+ background: var(--color-bg-primary);
1301
+ color: var(--color-text-primary);
1302
+ cursor: pointer;
1303
+ transition: all 0.15s;
1304
+ }
1305
+ .pm2-btn:hover {
1306
+ background: var(--color-primary);
1307
+ color: white;
1308
+ border-color: var(--color-primary);
1309
+ }
1310
+ .pm2-btn.danger:hover {
1311
+ background: var(--color-error);
1312
+ border-color: var(--color-error);
1313
+ }
1314
+ .pm2-loading, .pm2-empty, .pm2-error {
1315
+ padding: 0.75rem;
1316
+ text-align: center;
1317
+ color: var(--color-text-secondary);
1318
+ font-size: 0.8rem;
1319
+ }
1320
+ @keyframes slideIn {
1321
+ from { transform: translateX(100%); opacity: 0; }
1322
+ to { transform: translateX(0); opacity: 1; }
1323
+ }
1324
+ @keyframes fadeOut {
1325
+ from { opacity: 1; }
1326
+ to { opacity: 0; }
1327
+ }
1328
+ .pm2-logs-modal {
1329
+ position: fixed;
1330
+ top: 0; left: 0; right: 0; bottom: 0;
1331
+ background: rgba(0,0,0,0.5);
1332
+ display: flex;
1333
+ align-items: center;
1334
+ justify-content: center;
1335
+ z-index: 1000;
1336
+ padding: 1rem;
1337
+ }
1338
+ .pm2-logs-content {
1339
+ background: var(--color-bg-primary);
1340
+ border-radius: 0.5rem;
1341
+ width: 100%;
1342
+ max-width: 800px;
1343
+ max-height: 80vh;
1344
+ display: flex;
1345
+ flex-direction: column;
1346
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1347
+ }
1348
+ .pm2-logs-header {
1349
+ padding: 0.75rem 1rem;
1350
+ border-bottom: 1px solid var(--color-border);
1351
+ display: flex;
1352
+ justify-content: space-between;
1353
+ align-items: center;
1354
+ font-weight: 600;
1355
+ }
1356
+ .pm2-logs-body {
1357
+ flex: 1;
1358
+ overflow-y: auto;
1359
+ padding: 0.75rem 1rem;
1360
+ font-family: 'Monaco','Menlo','Ubuntu Mono', monospace;
1361
+ font-size: 0.75rem;
1362
+ white-space: pre-wrap;
1363
+ background: var(--color-bg-code);
1364
+ color: #e6edf3;
1365
+ }
1366
+ .pm2-logs-footer {
1367
+ padding: 0.5rem 1rem;
1368
+ border-top: 1px solid var(--color-border);
1369
+ display: flex;
1370
+ justify-content: flex-end;
1371
+ gap: 0.5rem;
1372
+ }
1373
+
1205
1374
  /* ===== RESPONSIVE: TABLET ===== */
1206
1375
  @media (min-width: 769px) and (max-width: 1024px) {
1207
1376
  :root { --sidebar-width: 260px; }
@@ -2883,10 +3052,20 @@
2883
3052
  <button class="clone-go-btn" id="cloneGoBtn" title="Clone">Go</button>
2884
3053
  <button class="clone-cancel-btn" id="cloneCancelBtn" title="Cancel">&times;</button>
2885
3054
  </div>
2886
- <ul class="sidebar-list" data-conversation-list>
2887
- <li class="sidebar-empty" data-conversation-empty>No conversations yet</li>
2888
- </ul>
2889
- </aside>
3055
+ <ul class="sidebar-list" data-conversation-list>
3056
+ <li class="sidebar-empty" data-conversation-empty>No conversations yet</li>
3057
+ </ul>
3058
+ <!-- PM2 Monitor Panel: hidden until active processes detected -->
3059
+ <div class="pm2-monitor-panel" id="pm2MonitorPanel" style="display:none">
3060
+ <div class="pm2-monitor-header">
3061
+ <span>PM2 Processes</span>
3062
+ <div class="pm2-monitor-actions">
3063
+ <button class="pm2-action-btn" id="pm2RefreshBtn" title="Refresh">↻</button>
3064
+ </div>
3065
+ </div>
3066
+ <div class="pm2-process-list" id="pm2ProcessList"></div>
3067
+ </div>
3068
+ </aside>
2890
3069
 
2891
3070
  <!-- ===== MAIN PANEL ===== -->
2892
3071
  <main id="app" class="main-panel">
@@ -3075,15 +3254,16 @@
3075
3254
  <script defer src="/gm/js/syntax-highlighter.js"></script>
3076
3255
  <script defer src="/gm/js/dialogs.js"></script>
3077
3256
  <script defer src="/gm/js/ui-components.js"></script>
3078
- <script defer src="/gm/js/conversations.js"></script>
3079
- <script defer src="/gm/js/terminal.js"></script>
3080
- <script defer src="/gm/js/script-runner.js"></script>
3081
- <script defer src="/gm/js/tools-manager.js"></script>
3082
- <script defer src="/gm/js/stt-handler.js"></script>
3083
- <script defer src="/gm/js/voice.js"></script>
3084
- <script defer src="/gm/js/client.js"></script>
3085
- <script defer src="/gm/js/features.js"></script>
3086
- <script defer src="/gm/js/agent-auth.js"></script>
3257
+ <script defer src="/gm/js/conversations.js"></script>
3258
+ <script defer src="/gm/js/terminal.js"></script>
3259
+ <script defer src="/gm/js/script-runner.js"></script>
3260
+ <script defer src="/gm/js/tools-manager.js"></script>
3261
+ <script defer src="/gm/js/stt-handler.js"></script>
3262
+ <script defer src="/gm/js/voice.js"></script>
3263
+ <script defer src="/gm/js/pm2-monitor.js"></script>
3264
+ <script defer src="/gm/js/client.js"></script>
3265
+ <script defer src="/gm/js/features.js"></script>
3266
+ <script defer src="/gm/js/agent-auth.js"></script>
3087
3267
 
3088
3268
  <script>
3089
3269
  const savedTheme = localStorage.getItem('theme') || 'light';
@@ -2362,6 +2362,7 @@ class AgentGUIClient {
2362
2362
 
2363
2363
  this.cacheCurrentConversation();
2364
2364
  this.stopChunkPolling();
2365
+ this.removeScrollUpDetection();
2365
2366
  if (this.renderer.resetScrollState) this.renderer.resetScrollState();
2366
2367
  this._userScrolledUp = false;
2367
2368
  this._removeNewContentPill();
@@ -2614,6 +2615,7 @@ class AgentGUIClient {
2614
2615
  }
2615
2616
 
2616
2617
  this.restoreScrollPosition(conversationId);
2618
+ this.setupScrollUpDetection(conversationId);
2617
2619
  }
2618
2620
  } catch (error) {
2619
2621
  if (error.name === 'AbortError') return;
@@ -2622,6 +2624,117 @@ class AgentGUIClient {
2622
2624
  }
2623
2625
  }
2624
2626
 
2627
+ removeScrollUpDetection() {
2628
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
2629
+ if (scrollContainer && this._scrollUpHandler) {
2630
+ scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
2631
+ this._scrollUpHandler = null;
2632
+ }
2633
+ }
2634
+
2635
+ setupScrollUpDetection(conversationId) {
2636
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
2637
+ if (!scrollContainer) return;
2638
+
2639
+ if (!this._scrollDetectionState) this._scrollDetectionState = {};
2640
+
2641
+ const detectionState = {
2642
+ isLoading: false,
2643
+ oldestTimestamp: Date.now(),
2644
+ oldestMessageId: null,
2645
+ conversation: conversationId
2646
+ };
2647
+
2648
+ const handleScroll = async () => {
2649
+ const scrollTop = scrollContainer.scrollTop;
2650
+ const scrollHeight = scrollContainer.scrollHeight;
2651
+ const clientHeight = scrollContainer.clientHeight;
2652
+ const THRESHOLD = 300;
2653
+
2654
+ if (scrollTop < THRESHOLD && !detectionState.isLoading && scrollHeight > clientHeight) {
2655
+ detectionState.isLoading = true;
2656
+
2657
+ try {
2658
+ const messagesEl = document.querySelector('.conversation-messages');
2659
+ if (!messagesEl) {
2660
+ detectionState.isLoading = false;
2661
+ return;
2662
+ }
2663
+
2664
+ const firstMessageEl = messagesEl.querySelector('.message[data-msg-id]');
2665
+ if (!firstMessageEl) {
2666
+ const firstChunkEl = messagesEl.querySelector('[data-chunk-created]');
2667
+ if (firstChunkEl) {
2668
+ detectionState.oldestTimestamp = parseInt(firstChunkEl.getAttribute('data-chunk-created')) || 0;
2669
+ }
2670
+ } else {
2671
+ detectionState.oldestMessageId = firstMessageEl.getAttribute('data-msg-id');
2672
+ }
2673
+
2674
+ let result;
2675
+ if (detectionState.oldestMessageId) {
2676
+ result = await window.wsClient.rpc('msg.ls.earlier', {
2677
+ id: conversationId,
2678
+ before: detectionState.oldestMessageId,
2679
+ limit: 50
2680
+ });
2681
+ } else if (detectionState.oldestTimestamp > 0) {
2682
+ result = await window.wsClient.rpc('conv.chunks.earlier', {
2683
+ id: conversationId,
2684
+ before: detectionState.oldestTimestamp,
2685
+ limit: 500
2686
+ });
2687
+ }
2688
+
2689
+ if (result && ((result.messages && result.messages.length > 0) || (result.chunks && result.chunks.length > 0))) {
2690
+ const scrollHeightBefore = scrollContainer.scrollHeight;
2691
+ const scrollTopBefore = scrollContainer.scrollTop;
2692
+ const newContent = document.createDocumentFragment();
2693
+
2694
+ if (result.messages && result.messages.length > 0) {
2695
+ result.messages.forEach(msg => {
2696
+ const div = document.createElement('div');
2697
+ div.className = `message message-${msg.role}`;
2698
+ div.setAttribute('data-msg-id', msg.id);
2699
+ div.innerHTML = `<div class="message-role">${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}</div>${this.renderMessageContent(msg.content)}<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>`;
2700
+ newContent.appendChild(div);
2701
+ });
2702
+ }
2703
+
2704
+ if (result.chunks && result.chunks.length > 0) {
2705
+ result.chunks.forEach(chunk => {
2706
+ const blockEl = this.renderer.renderBlock(chunk.data, chunk.type, false);
2707
+ if (blockEl) {
2708
+ const wrapper = document.createElement('div');
2709
+ wrapper.setAttribute('data-chunk-created', chunk.created_at);
2710
+ wrapper.appendChild(blockEl);
2711
+ newContent.appendChild(wrapper);
2712
+ }
2713
+ });
2714
+ }
2715
+
2716
+ if (messagesEl.firstChild) {
2717
+ messagesEl.insertBefore(newContent, messagesEl.firstChild);
2718
+ } else {
2719
+ messagesEl.appendChild(newContent);
2720
+ }
2721
+
2722
+ const scrollHeightAfter = scrollContainer.scrollHeight;
2723
+ scrollContainer.scrollTop = scrollTopBefore + (scrollHeightAfter - scrollHeightBefore);
2724
+ }
2725
+ } catch (error) {
2726
+ console.error('Failed to load earlier messages:', error);
2727
+ } finally {
2728
+ detectionState.isLoading = false;
2729
+ }
2730
+ }
2731
+ };
2732
+
2733
+ scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
2734
+ this._scrollUpHandler = handleScroll;
2735
+ scrollContainer.addEventListener('scroll', this._scrollUpHandler, { passive: true });
2736
+ }
2737
+
2625
2738
  renderMessagesFragment(messages) {
2626
2739
  const frag = document.createDocumentFragment();
2627
2740
  if (messages.length === 0) {
@@ -0,0 +1,151 @@
1
+ (function() {
2
+ const ACTIVE_STATES = new Set(['online', 'launching', 'stopping', 'waiting restart']);
3
+
4
+ window.pm2Monitor = { processes: [], logsModalOpen: null, initialized: false };
5
+
6
+ const panel = document.getElementById('pm2MonitorPanel');
7
+ const list = document.getElementById('pm2ProcessList');
8
+ const refreshBtn = document.getElementById('pm2RefreshBtn');
9
+
10
+ function send(msg) {
11
+ if (window.wsManager && window.wsManager.isConnected) window.wsManager.sendMessage(msg);
12
+ }
13
+
14
+ function setPanelVisible(visible) {
15
+ if (!panel) return;
16
+ panel.style.display = visible ? 'flex' : 'none';
17
+ }
18
+
19
+ function renderProcessList(processes) {
20
+ if (!list) return;
21
+ const active = processes.filter(p => ACTIVE_STATES.has(p.status));
22
+ const inactive = processes.filter(p => !ACTIVE_STATES.has(p.status));
23
+ const ordered = [...active, ...inactive];
24
+ list.innerHTML = ordered.map(proc => {
25
+ const isActive = ACTIVE_STATES.has(proc.status);
26
+ const uptimeSec = proc.uptime ? Math.floor((Date.now() - proc.uptime) / 1000) : null;
27
+ return `<div class="pm2-process-item ${proc.status}" data-pm2-name="${escAttr(proc.name)}">
28
+ <div class="pm2-process-name">${escHtml(proc.name)} <span class="pm2-status-dot pm2-status-${proc.status}"></span></div>
29
+ <div class="pm2-process-meta">
30
+ <span>${proc.status}</span>
31
+ <span>${(proc.cpu || 0).toFixed(1)}% CPU</span>
32
+ <span>${fmtBytes(proc.memory)}</span>
33
+ ${uptimeSec !== null ? `<span>${fmtUptime(uptimeSec)}</span>` : ''}
34
+ <span>#${proc.pid || '-'}</span>
35
+ ${proc.restarts > 0 ? `<span>${proc.restarts}x restarts</span>` : ''}
36
+ </div>
37
+ <div class="pm2-process-actions">
38
+ ${!isActive ? `<button class="pm2-btn" onclick="window.pm2Monitor.startProcess('${escAttr(proc.name)}')">Start</button>` : ''}
39
+ ${proc.status === 'online' ? `<button class="pm2-btn danger" onclick="window.pm2Monitor.stopProcess('${escAttr(proc.name)}')">Stop</button>` : ''}
40
+ <button class="pm2-btn" onclick="window.pm2Monitor.restartProcess('${escAttr(proc.name)}')">Restart</button>
41
+ <button class="pm2-btn" onclick="window.pm2Monitor.showLogs('${escAttr(proc.name)}')">Logs</button>
42
+ <button class="pm2-btn danger" onclick="window.pm2Monitor.deleteProcess('${escAttr(proc.name)}')">Delete</button>
43
+ </div>
44
+ </div>`;
45
+ }).join('');
46
+ }
47
+
48
+ function handleMessage(msg) {
49
+ if (msg.type === 'pm2_monit_update') {
50
+ const procs = msg.processes || [];
51
+ window.pm2Monitor.processes = procs;
52
+ const hasActive = msg.hasActive || procs.some(p => ACTIVE_STATES.has(p.status));
53
+ setPanelVisible(hasActive);
54
+ if (hasActive) renderProcessList(procs);
55
+ } else if (msg.type === 'pm2_list_response') {
56
+ const procs = msg.processes || [];
57
+ window.pm2Monitor.processes = procs;
58
+ const hasActive = procs.some(p => ACTIVE_STATES.has(p.status));
59
+ setPanelVisible(hasActive);
60
+ if (hasActive) renderProcessList(procs);
61
+ } else if (msg.type === 'pm2_unavailable') {
62
+ setPanelVisible(false);
63
+ } else if (msg.type === 'pm2_start_response' || msg.type === 'pm2_stop_response' ||
64
+ msg.type === 'pm2_restart_response' || msg.type === 'pm2_delete_response') {
65
+ const action = msg.type.replace('pm2_', '').replace('_response', '');
66
+ if (msg.success) {
67
+ toast(`PM2 ${action} ${msg.name} succeeded`, 'success');
68
+ setTimeout(() => send({ type: 'pm2_list' }), 500);
69
+ } else {
70
+ toast(`PM2 ${action} ${msg.name} failed: ${msg.error}`, 'error');
71
+ }
72
+ } else if (msg.type === 'pm2_logs_response') {
73
+ if (msg.success) showLogsModal(msg.name, msg.logs);
74
+ else toast(`Logs error: ${msg.error}`, 'error');
75
+ } else if (msg.type === 'pm2_flush_logs_response') {
76
+ toast(msg.success ? 'Logs flushed' : `Flush failed: ${msg.error}`, msg.success ? 'success' : 'error');
77
+ }
78
+ }
79
+
80
+ function init() {
81
+ if (window.pm2Monitor.initialized) return;
82
+ window.pm2Monitor.initialized = true;
83
+ setPanelVisible(false);
84
+ if (refreshBtn) refreshBtn.addEventListener('click', () => send({ type: 'pm2_list' }));
85
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && window.pm2Monitor.logsModalOpen) window.pm2Monitor.closeLogsModal(); });
86
+ if (window.wsManager) {
87
+ window.wsManager.on('message', handleMessage);
88
+ window.wsManager.on('connected', () => {
89
+ send({ type: 'pm2_start_monitoring' });
90
+ send({ type: 'pm2_list' });
91
+ });
92
+ if (window.wsManager.isConnected) {
93
+ send({ type: 'pm2_start_monitoring' });
94
+ send({ type: 'pm2_list' });
95
+ }
96
+ }
97
+ }
98
+
99
+ function showLogsModal(name, logs) {
100
+ if (window.pm2Monitor.logsModalOpen) window.pm2Monitor.closeLogsModal();
101
+ const modal = document.createElement('div');
102
+ modal.className = 'pm2-logs-modal';
103
+ modal.innerHTML = `<div class="pm2-logs-content">
104
+ <div class="pm2-logs-header"><span>Logs: ${escHtml(name)}</span><button class="pm2-btn" onclick="window.pm2Monitor.closeLogsModal()">Close</button></div>
105
+ <div class="pm2-logs-body">${escHtml(logs || 'No logs available')}</div>
106
+ <div class="pm2-logs-footer">
107
+ <button class="pm2-btn" onclick="window.pm2Monitor.flushLogs('${escAttr(name)}')">Flush</button>
108
+ <button class="pm2-btn" onclick="window.pm2Monitor.closeLogsModal()">Close</button>
109
+ </div></div>`;
110
+ document.body.appendChild(modal);
111
+ window.pm2Monitor.logsModalOpen = name;
112
+ modal.addEventListener('click', (e) => { if (e.target === modal) window.pm2Monitor.closeLogsModal(); });
113
+ }
114
+
115
+ function toast(msg, type) {
116
+ const t = document.createElement('div');
117
+ t.className = 'pm2-toast';
118
+ t.textContent = msg;
119
+ const bg = type === 'error' ? 'var(--color-error)' : type === 'success' ? 'var(--color-success)' : 'var(--color-primary)';
120
+ t.style.cssText = `position:fixed;top:1rem;right:1rem;padding:0.5rem 1rem;border-radius:0.375rem;font-size:0.8rem;color:white;z-index:10000;background:${bg}`;
121
+ document.body.appendChild(t);
122
+ setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(() => t.remove(), 300); }, 3000);
123
+ }
124
+
125
+ function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
126
+ function escAttr(s) { return (s || '').replace(/"/g, '&quot;').replace(/'/g, '&#039;'); }
127
+ function fmtBytes(b) {
128
+ if (!b || typeof b !== 'number') return '0 B';
129
+ const u = ['B','KB','MB','GB'], i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 3);
130
+ return (b / Math.pow(1024, i)).toFixed(1) + ' ' + u[i];
131
+ }
132
+ function fmtUptime(s) {
133
+ if (!s || s < 0) return '0s';
134
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
135
+ if (d > 0) return `${d}d ${h}h`;
136
+ if (h > 0) return `${h}h ${m}m`;
137
+ if (m > 0) return `${m}m ${sec}s`;
138
+ return `${sec}s`;
139
+ }
140
+
141
+ window.pm2Monitor.closeLogsModal = () => { const m = document.querySelector('.pm2-logs-modal'); if (m) m.remove(); window.pm2Monitor.logsModalOpen = null; };
142
+ window.pm2Monitor.startProcess = (name) => send({ type: 'pm2_start', name });
143
+ window.pm2Monitor.stopProcess = (name) => send({ type: 'pm2_stop', name });
144
+ window.pm2Monitor.restartProcess = (name) => send({ type: 'pm2_restart', name });
145
+ window.pm2Monitor.deleteProcess = (name) => { if (confirm(`Delete PM2 process "${name}"?`)) send({ type: 'pm2_delete', name }); };
146
+ window.pm2Monitor.showLogs = (name) => send({ type: 'pm2_logs', name, lines: 200 });
147
+ window.pm2Monitor.flushLogs = (name) => send({ type: 'pm2_flush_logs', name });
148
+
149
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(init, 150));
150
+ else setTimeout(init, 150);
151
+ })();