loadtoagent 1.0.0

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 (82) hide show
  1. package/README.ko.md +175 -0
  2. package/README.md +175 -0
  3. package/README.zh-CN.md +175 -0
  4. package/bin/loadtoagent.js +171 -0
  5. package/docs/ARCHITECTURE.md +30 -0
  6. package/docs/PROVIDER-CONTRACTS.md +58 -0
  7. package/docs/assets/loadtoagent-dashboard.png +0 -0
  8. package/docs/assets/loadtoagent-demo.gif +0 -0
  9. package/main.js +493 -0
  10. package/package.json +133 -0
  11. package/preload.js +75 -0
  12. package/renderer/app-agent-actions.js +285 -0
  13. package/renderer/app-bootstrap.js +95 -0
  14. package/renderer/app-dashboard.js +361 -0
  15. package/renderer/app-drawer-content.js +203 -0
  16. package/renderer/app-drawer-data.js +41 -0
  17. package/renderer/app-drawer.js +183 -0
  18. package/renderer/app-events-dialogs.js +169 -0
  19. package/renderer/app-events-filters.js +74 -0
  20. package/renderer/app-events-navigation.js +96 -0
  21. package/renderer/app-events-sessions.js +195 -0
  22. package/renderer/app-events.js +16 -0
  23. package/renderer/app-graph-layout.js +71 -0
  24. package/renderer/app-graph-model.js +107 -0
  25. package/renderer/app-graph-orchestration.js +76 -0
  26. package/renderer/app-graph-view.js +544 -0
  27. package/renderer/app-run-modal.js +221 -0
  28. package/renderer/app-session-render.js +243 -0
  29. package/renderer/app-tmux-render.js +247 -0
  30. package/renderer/app.js +608 -0
  31. package/renderer/fonts/geist-ext.woff2 +0 -0
  32. package/renderer/fonts/geist.woff2 +0 -0
  33. package/renderer/i18n-messages.js +355 -0
  34. package/renderer/i18n.js +122 -0
  35. package/renderer/index.html +386 -0
  36. package/renderer/shared.js +26 -0
  37. package/renderer/styles-agent-map.css +401 -0
  38. package/renderer/styles-cards.css +600 -0
  39. package/renderer/styles-collaboration.css +534 -0
  40. package/renderer/styles-components.css +1293 -0
  41. package/renderer/styles-onboarding.css +344 -0
  42. package/renderer/styles-overlays.css +284 -0
  43. package/renderer/styles-product.css +686 -0
  44. package/renderer/styles-responsive-product.css +689 -0
  45. package/renderer/styles-responsive-runtime.css +718 -0
  46. package/renderer/styles-responsive-shell.css +533 -0
  47. package/renderer/styles-responsive-workflows.css +778 -0
  48. package/renderer/styles-run-composer.css +695 -0
  49. package/renderer/styles-settings.css +606 -0
  50. package/renderer/styles-terminal.css +1241 -0
  51. package/renderer/styles-tmux.css +1162 -0
  52. package/renderer/styles-workflow-map.css +513 -0
  53. package/renderer/styles-workflows.css +1217 -0
  54. package/renderer/styles.css +486 -0
  55. package/renderer/terminal-agent.js +152 -0
  56. package/renderer/terminal-events.js +226 -0
  57. package/renderer/terminal-workbench.js +464 -0
  58. package/renderer/terminal.js +497 -0
  59. package/src/agentMonitor/claudeParser.js +197 -0
  60. package/src/agentMonitor/codexCollaboration.js +95 -0
  61. package/src/agentMonitor/codexParser.js +447 -0
  62. package/src/agentMonitor/genericParser.js +244 -0
  63. package/src/agentMonitor/hierarchy.js +248 -0
  64. package/src/agentMonitor/sessionFiles.js +86 -0
  65. package/src/agentMonitor.js +591 -0
  66. package/src/agentRunner.js +465 -0
  67. package/src/bridgeServer.js +246 -0
  68. package/src/contracts.js +71 -0
  69. package/src/diagnostics.js +23 -0
  70. package/src/ipc/registerAgentIpc.js +12 -0
  71. package/src/ipc/registerAppIpc.js +20 -0
  72. package/src/ipc/registerTerminalIpc.js +50 -0
  73. package/src/ipc/registerTmuxIpc.js +30 -0
  74. package/src/ipc/registerWorkspaceIpc.js +14 -0
  75. package/src/monitorWorker.js +273 -0
  76. package/src/processMonitor.js +394 -0
  77. package/src/providerRegistry.js +120 -0
  78. package/src/terminalManager.js +438 -0
  79. package/src/tmuxController.js +183 -0
  80. package/src/tmuxMonitor.js +432 -0
  81. package/src/updateManager.js +339 -0
  82. package/src/workspaceStore.js +77 -0
@@ -0,0 +1,591 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { EventEmitter } = require('events');
7
+ const {
8
+ providerList,
9
+ modelContextWindow,
10
+ blankUsage,
11
+ finalizeUsage,
12
+ } = require('./providerRegistry');
13
+ const { createCodexParser } = require('./agentMonitor/codexParser');
14
+ const { createClaudeParser } = require('./agentMonitor/claudeParser');
15
+ const { createGenericParser } = require('./agentMonitor/genericParser');
16
+ const { createHierarchyAttacher } = require('./agentMonitor/hierarchy');
17
+ const {
18
+ MAX_FILES_PER_PROVIDER,
19
+ readJson,
20
+ readJsonLines,
21
+ safeStat,
22
+ walkRecent,
23
+ } = require('./agentMonitor/sessionFiles');
24
+
25
+ const MAX_MESSAGES = 180;
26
+ const MAX_LIFECYCLE = 220;
27
+ const ACTIVE_THRESHOLD_MS = 18_000;
28
+ const STALE_TURN_THRESHOLD_MS = 5 * 60_000;
29
+ const LIST_CACHE_MS = 60_000;
30
+
31
+ function asText(value) {
32
+ if (typeof value === 'string') return value;
33
+ if (value == null) return '';
34
+ if (Array.isArray(value)) return value.map(asText).filter(Boolean).join('\n');
35
+ if (typeof value !== 'object') return String(value);
36
+ if (typeof value.text === 'string') return value.text;
37
+ if (typeof value.output_text === 'string') return value.output_text;
38
+ if (typeof value.content === 'string') return value.content;
39
+ if (Array.isArray(value.content)) return asText(value.content);
40
+ if (typeof value.message === 'string') return value.message;
41
+ return '';
42
+ }
43
+
44
+ function compactText(value, limit = 4000) {
45
+ const text = asText(value).replace(/\u0000/g, '').trim();
46
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
47
+ }
48
+
49
+ function timestamp(value, fallback = null) {
50
+ if (typeof value === 'number' && Number.isFinite(value)) {
51
+ return new Date(value > 10_000_000_000 ? value : value * 1000).toISOString();
52
+ }
53
+ const parsed = Date.parse(String(value || ''));
54
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
55
+ }
56
+
57
+ function addMessage(session, message) {
58
+ const text = compactText(message.text, message.type === 'tool' ? 1600 : 6000);
59
+ if (!text && message.type !== 'tool') return;
60
+ const row = {
61
+ id: String(message.id || `${session.id}:m:${session.messages.length}`),
62
+ role: message.role || 'system',
63
+ type: message.type || 'message',
64
+ text,
65
+ title: compactText(message.title, 160),
66
+ status: message.status || '',
67
+ timestamp: timestamp(message.timestamp, session.updatedAt),
68
+ };
69
+ const duplicate = session.messages.some(item => item.id === row.id
70
+ || (item.role === row.role && item.text === row.text && item.timestamp === row.timestamp));
71
+ if (!duplicate) session.messages.push(row);
72
+ }
73
+
74
+ function jsonObject(value) {
75
+ if (value && typeof value === 'object') return value;
76
+ if (typeof value !== 'string' || !value.trim()) return {};
77
+ try {
78
+ const parsed = JSON.parse(value);
79
+ return parsed && typeof parsed === 'object' ? parsed : {};
80
+ } catch (_invalidJson) {
81
+ // Provider metadata may be absent or partially written while a session is live.
82
+ return {};
83
+ }
84
+ }
85
+
86
+ function collaborationTaskName(value) {
87
+ const normalized = String(value || '').replace(/\\/g, '/').replace(/\/$/, '');
88
+ return normalized.split('/').filter(Boolean).pop() || '';
89
+ }
90
+
91
+ function encryptedCollaborationText(value) {
92
+ return /^gAAAA[A-Za-z0-9_-]+={0,2}$/.test(String(value || '').trim());
93
+ }
94
+
95
+ function agentEnvelope(value) {
96
+ const text = compactText(value, 12000);
97
+ const type = text.match(/(?:^|\n)Message Type:\s*([^\n]+)/i);
98
+ const task = text.match(/(?:^|\n)Task name:\s*([^\n]+)/i);
99
+ const sender = text.match(/(?:^|\n)Sender:\s*([^\n]+)/i);
100
+ const payload = text.match(/(?:^|\n)Payload:\s*\n?([\s\S]*)$/i);
101
+ return {
102
+ type: compactText(type && type[1], 40).toUpperCase(),
103
+ task: compactText(task && task[1], 180),
104
+ sender: compactText(sender && sender[1], 180),
105
+ payload: compactText(payload && payload[1], 6000),
106
+ };
107
+ }
108
+
109
+ function collaborationCapacity(value) {
110
+ const text = String(value || '');
111
+ const match = text.match(/There are\s+(\d+)\s+available concurrency slots[\s\S]{0,240}?including you/i)
112
+ || text.match(/main(?:\s+agent)?\s+included[^\d]{0,40}(\d+)\s+(?:slots|agents)/i)
113
+ || text.match(/메인(?:\s*에이전트)?\s*포함[^\d]{0,40}(\d+)개/i);
114
+ const totalThreads = Number(match && match[1] || 0);
115
+ return totalThreads > 0 ? { totalThreads, subagents: Math.max(0, totalThreads - 1), source: 'runtime-instruction' } : null;
116
+ }
117
+
118
+ function retainedAgentsFromValue(value) {
119
+ const parsed = jsonObject(value);
120
+ const agents = Array.isArray(parsed.agents) ? parsed.agents : [];
121
+ return agents.map(agent => {
122
+ const statusValue = agent && agent.agent_status;
123
+ const status = typeof statusValue === 'string' ? statusValue : (statusValue && typeof statusValue === 'object' ? Object.keys(statusValue)[0] : 'unknown');
124
+ const pathValue = compactText(agent && agent.agent_name, 180);
125
+ return { path: pathValue, taskName: collaborationTaskName(pathValue), name: '', status, observedAt: null };
126
+ }).filter(agent => agent.path && agent.path !== '/root');
127
+ }
128
+
129
+ function retainedAgentsFromWorldState(value, observedAt) {
130
+ const rows = String(value || '').split(/\r?\n/).map(line => line.match(/^\s*-\s*([^:]+):\s*(.+?)\s*$/)).filter(Boolean);
131
+ return rows.map(match => ({ path: `/root/${match[1].trim()}`, taskName: match[1].trim(), name: match[2].trim(), status: 'retained', observedAt }));
132
+ }
133
+
134
+ function addLifecycle(session, event) {
135
+ const row = {
136
+ id: String(event.id || `${session.id}:e:${session.lifecycle.length}`),
137
+ type: event.type || 'activity',
138
+ label: compactText(event.label || '활동', 120),
139
+ detail: compactText(event.detail, 600),
140
+ status: event.status || 'done',
141
+ timestamp: timestamp(event.timestamp, session.updatedAt),
142
+ };
143
+ const duplicate = session.lifecycle.some(item => item.id === row.id);
144
+ if (!duplicate) session.lifecycle.push(row);
145
+ }
146
+
147
+ function settleLifecycle(session, id, status = 'done', completedAt = null) {
148
+ const key = String(id || '');
149
+ if (!key) return;
150
+ const row = session.lifecycle.find(item => item.id === key || item.id === `tool:${key}`);
151
+ if (!row) return;
152
+ row.status = status;
153
+ if (completedAt) row.completedAt = timestamp(completedAt, row.timestamp);
154
+ }
155
+
156
+ function settleRunningLifecycle(session, completedAt = null) {
157
+ for (const row of session.lifecycle) {
158
+ if (row.status !== 'running') continue;
159
+ row.status = 'done';
160
+ if (completedAt) row.completedAt = timestamp(completedAt, row.timestamp);
161
+ }
162
+ }
163
+
164
+ function baseSession(provider, externalId, file, stat) {
165
+ const id = `${provider}:${externalId}`;
166
+ const updatedAt = new Date((stat && stat.mtimeMs) || Date.now()).toISOString();
167
+ return {
168
+ id,
169
+ externalId: String(externalId),
170
+ provider,
171
+ parentId: null,
172
+ depth: 0,
173
+ agentName: '',
174
+ agentRole: '',
175
+ agentPath: '',
176
+ taskName: '',
177
+ sharedGoal: '',
178
+ title: '제목을 불러오는 중',
179
+ model: '',
180
+ cwd: '',
181
+ branch: '',
182
+ source: 'local-history',
183
+ sourceLabel: '로컬 세션',
184
+ clientKind: '',
185
+ status: 'idle',
186
+ statusDetail: '',
187
+ statusObserved: false,
188
+ startedAt: updatedAt,
189
+ updatedAt,
190
+ endedAt: null,
191
+ completedAt: null,
192
+ completionObserved: false,
193
+ result: '',
194
+ file,
195
+ truncated: false,
196
+ usage: blankUsage(),
197
+ turnUsage: blankUsage(),
198
+ context: { used: 0, window: 0, percent: 0, source: 'unknown' },
199
+ messages: [],
200
+ lifecycle: [],
201
+ childIds: [],
202
+ collaboration: {
203
+ capacity: { totalThreads: 0, subagents: 0, source: 'unknown' },
204
+ spawns: [],
205
+ communications: [],
206
+ retainedAgents: [],
207
+ retainedObserved: false,
208
+ metrics: null,
209
+ },
210
+ };
211
+ }
212
+
213
+ function sumUsage(values) {
214
+ const total = blankUsage();
215
+ for (const value of values) {
216
+ const usage = finalizeUsage(value);
217
+ for (const key of Object.keys(total)) total[key] += usage[key] || 0;
218
+ }
219
+ return finalizeUsage(total);
220
+ }
221
+
222
+ const parseClaude = createClaudeParser({
223
+ ACTIVE_THRESHOLD_MS,
224
+ STALE_TURN_THRESHOLD_MS,
225
+ addLifecycle,
226
+ addMessage,
227
+ baseSession,
228
+ compactText,
229
+ contextInfo,
230
+ finalizeUsage,
231
+ modelContextWindow,
232
+ readJsonLines,
233
+ settleLifecycle,
234
+ sumUsage,
235
+ timestamp,
236
+ trimSession,
237
+ });
238
+
239
+ function codexUsage(raw = {}) {
240
+ return finalizeUsage({
241
+ input: raw.input_tokens,
242
+ cachedInput: raw.cached_input_tokens,
243
+ output: raw.output_tokens,
244
+ reasoning: raw.reasoning_output_tokens,
245
+ total: raw.total_tokens,
246
+ });
247
+ }
248
+
249
+ function codexContentText(content) {
250
+ if (!Array.isArray(content)) return compactText(content);
251
+ return content.map(part => part && (part.text || part.input_text || part.output_text || asText(part))).filter(Boolean).join('\n').trim();
252
+ }
253
+
254
+ function codexVisibleUserText(value) {
255
+ const raw = compactText(value, 12000);
256
+ if (!raw) return '';
257
+ const objective = raw.match(/<(?:untrusted_)?objective>\s*([\s\S]*?)\s*<\/(?:untrusted_)?objective>/i);
258
+ if (objective) return compactText(objective[1], 6000);
259
+ const desktopRequest = raw.match(/##\s*My request for Codex:\s*([\s\S]*?)(?:\n{2,}<image\b|$)/i);
260
+ if (desktopRequest) return compactText(desktopRequest[1], 6000);
261
+ if (/^<(?:permissions instructions|app-context|environment_context|skills_instructions|plugins_instructions|apps_instructions|multi_agent_mode|collaboration_mode)>/i.test(raw)) return '';
262
+ if (/^<skill(?:\s|>)/i.test(raw)) return '';
263
+ if (/^#\s*Codex desktop context/i.test(raw)) return '';
264
+ if (/^Approved command prefix saved:/i.test(raw)) return '';
265
+ if (/^You are (?:`?\/root|Codex, an agent based on)/i.test(raw)) return '';
266
+ if (/Filesystem sandboxing defines which files can be read or written/i.test(raw)) return '';
267
+ if (raw.length > 800 && /(?:primary agent in a team of agents|All agents share the same directory|collaboration tools cannot be called|valid channels|Target channel)/i.test(raw)) return '';
268
+ if (raw.length > 2500 && /(?:approval policy|sandbox_mode|workspace dependencies|thread coordination)/i.test(raw)) return '';
269
+ return raw;
270
+ }
271
+
272
+ function addCodexMessage(session, observations, message, source) {
273
+ const type = message.type || 'message';
274
+ const text = compactText(message.text, type === 'tool' ? 1600 : 6000);
275
+ if (!text && type !== 'tool') return;
276
+ const normalized = text.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
277
+ const role = message.role || 'system';
278
+ const at = Date.parse(timestamp(message.timestamp, session.updatedAt));
279
+ const key = `${role}\u0000${type}\u0000${normalized}`;
280
+ const candidates = observations.get(key) || [];
281
+ let match = null;
282
+ let distance = Infinity;
283
+ for (const candidate of candidates) {
284
+ const delta = Math.abs(candidate.at - at);
285
+ if (candidate.source === source || candidate.matched || delta > 2_000 || delta >= distance) continue;
286
+ match = candidate;
287
+ distance = delta;
288
+ }
289
+ const observation = { source, at, matched: Boolean(match) };
290
+ if (match) match.matched = true;
291
+ candidates.push(observation);
292
+ observations.set(key, candidates.filter(candidate => Math.abs(candidate.at - at) <= 5_000 || !candidate.matched));
293
+ if (!match) addMessage(session, { ...message, text });
294
+ }
295
+
296
+ const parseCodex = createCodexParser({
297
+ thresholds: { ACTIVE_THRESHOLD_MS, STALE_TURN_THRESHOLD_MS },
298
+ sessionOps: {
299
+ addCodexMessage, addLifecycle, addMessage, baseSession,
300
+ settleLifecycle, settleRunningLifecycle, trimSession,
301
+ },
302
+ textOps: {
303
+ agentEnvelope, codexContentText, codexVisibleUserText,
304
+ compactText, encryptedCollaborationText, jsonObject,
305
+ },
306
+ collaborationOps: {
307
+ collaborationCapacity, collaborationTaskName,
308
+ retainedAgentsFromValue, retainedAgentsFromWorldState,
309
+ },
310
+ usageOps: { codexUsage, contextInfo, modelContextWindow },
311
+ storageOps: { readJsonLines },
312
+ timeOps: { timestamp },
313
+ });
314
+
315
+ const parseGeneric = createGenericParser({
316
+ ACTIVE_THRESHOLD_MS,
317
+ STALE_TURN_THRESHOLD_MS,
318
+ addLifecycle,
319
+ addMessage,
320
+ baseSession,
321
+ compactText,
322
+ contextInfo,
323
+ finalizeUsage,
324
+ modelContextWindow,
325
+ readJson,
326
+ readJsonLines,
327
+ settleLifecycle,
328
+ sumUsage,
329
+ timestamp,
330
+ trimSession,
331
+ });
332
+
333
+ function contextInfo(used, windowInfo) {
334
+ const window = Number(windowInfo && windowInfo.tokens || 0);
335
+ const current = Number(used || 0);
336
+ return {
337
+ used: current,
338
+ window,
339
+ percent: window ? Math.min(100, Math.max(0, current / window * 100)) : 0,
340
+ source: windowInfo && windowInfo.source || 'unknown',
341
+ };
342
+ }
343
+
344
+ function trimSession(session) {
345
+ session.omittedMessages = Math.max(0, session.messages.length - MAX_MESSAGES);
346
+ session.omittedLifecycle = Math.max(0, session.lifecycle.length - MAX_LIFECYCLE);
347
+ session.messages = session.messages.slice(-MAX_MESSAGES);
348
+ session.lifecycle = session.lifecycle.slice(-MAX_LIFECYCLE);
349
+ if (!session.messages.length) addMessage(session, { id: `${session.id}:empty`, role: 'system', text: '표시할 대화 메시지가 아직 없습니다.', timestamp: session.updatedAt });
350
+ }
351
+
352
+ function parseManagedSession(runDir) {
353
+ const meta = readJson(path.join(runDir, 'meta.json'));
354
+ const live = readJson(path.join(runDir, 'session.json'));
355
+ if (!meta || !live) return null;
356
+ const session = {
357
+ ...baseSession(meta.provider, live.externalId || meta.externalId || meta.id, path.join(runDir, 'events.jsonl'), safeStat(path.join(runDir, 'session.json'))),
358
+ ...live,
359
+ id: `${meta.provider}:${live.externalId || meta.externalId || meta.id}`,
360
+ provider: meta.provider,
361
+ runId: meta.id,
362
+ source: 'loadtoagent',
363
+ sourceLabel: 'LoadToAgent 실행',
364
+ statusObserved: true,
365
+ };
366
+ session.usage = finalizeUsage(session.usage);
367
+ session.turnUsage = finalizeUsage(session.turnUsage);
368
+ const window = modelContextWindow(session.provider, session.model, session.context && session.context.window);
369
+ session.context = contextInfo(session.context && session.context.used || session.turnUsage.total, window);
370
+ trimSession(session);
371
+ return session;
372
+ }
373
+
374
+ function workspaceLabel(cwd) {
375
+ if (!cwd) return '작업 폴더 미상';
376
+ const normalized = String(cwd).replace(/\\/g, '/').replace(/\/$/, '');
377
+ return normalized.split('/').filter(Boolean).pop() || cwd;
378
+ }
379
+
380
+ function isProjectlessSession(session) {
381
+ if (!session || !session.cwd) return true;
382
+ const normalized = String(session.cwd).replace(/\\/g, '/').replace(/\/+$/, '');
383
+ return session.provider === 'codex'
384
+ && session.clientKind === 'codex-desktop'
385
+ && /(?:^|\/)Documents\/Codex\/\d{4}-\d{2}-\d{2}\/new-chat$/i.test(normalized);
386
+ }
387
+
388
+ const attachHierarchy = createHierarchyAttacher({
389
+ addMessage,
390
+ baseSession,
391
+ collaborationTaskName,
392
+ compactText,
393
+ timestamp,
394
+ trimSession,
395
+ });
396
+
397
+ function buildSummary(sessions, availability) {
398
+ const providers = providerList().map(provider => {
399
+ const own = sessions.filter(session => session.provider === provider.id);
400
+ const usage = sumUsage(own.map(session => session.usage));
401
+ return {
402
+ ...provider,
403
+ installed: !!availability[provider.id],
404
+ executable: availability[provider.id] || '',
405
+ sessions: own.length,
406
+ active: own.filter(session => session.status === 'running').length,
407
+ waiting: own.filter(session => session.status === 'waiting').length,
408
+ subagents: own.filter(session => session.parentId).length,
409
+ usage,
410
+ };
411
+ });
412
+ return {
413
+ providers,
414
+ totals: {
415
+ sessions: sessions.length,
416
+ active: sessions.filter(session => session.status === 'running').length,
417
+ waiting: sessions.filter(session => session.status === 'waiting').length,
418
+ subagents: sessions.filter(session => session.parentId).length,
419
+ usage: sumUsage(sessions.map(session => session.usage)),
420
+ },
421
+ };
422
+ }
423
+
424
+ class AgentMonitor extends EventEmitter {
425
+ constructor(options = {}) {
426
+ super();
427
+ this.home = options.home || os.homedir();
428
+ this.runsDir = options.runsDir;
429
+ this.intervalMs = options.intervalMs || 1200;
430
+ this.availability = {};
431
+ this.parseCache = new Map();
432
+ this.listCache = new Map();
433
+ this.historyHomes = [];
434
+ this.timer = null;
435
+ this.scanning = false;
436
+ this.lastSnapshot = { generatedAt: new Date().toISOString(), sessions: [], summary: buildSummary([], {}) };
437
+ this.setHistoryHomes(options.historyHomes || []);
438
+ }
439
+
440
+ setAvailability(availability) {
441
+ this.availability = { ...availability };
442
+ }
443
+
444
+ setHistoryHomes(historyHomes = []) {
445
+ const localKind = process.platform === 'win32' ? 'windows' : (process.platform === 'darwin' ? 'macos' : 'linux');
446
+ const localLabel = process.platform === 'win32' ? 'Windows 로컬' : (process.platform === 'darwin' ? 'macOS 로컬' : 'Linux 로컬');
447
+ const next = [{ home: this.home, kind: localKind, distro: '', label: localLabel }, ...historyHomes]
448
+ .filter(item => item && item.home)
449
+ .filter((item, index, list) => list.findIndex(other => String(other.home).toLowerCase() === String(item.home).toLowerCase()) === index)
450
+ .map(item => ({ home: String(item.home), kind: item.kind || 'external', distro: item.distro || '', label: item.label || item.kind || '외부 환경', files: item.files || null }));
451
+ const previousKey = JSON.stringify(this.historyHomes.map(item => [item.home, item.kind, item.distro]));
452
+ const nextKey = JSON.stringify(next.map(item => [item.home, item.kind, item.distro]));
453
+ this.historyHomes = next;
454
+ if (previousKey !== nextKey && this.listCache) this.listCache.clear();
455
+ }
456
+
457
+ files(key, root, predicate, max, depth, cacheMs = LIST_CACHE_MS) {
458
+ const cached = this.listCache.get(key);
459
+ let paths;
460
+ if (cached && Date.now() - cached.at < cacheMs) {
461
+ paths = cached.paths;
462
+ } else {
463
+ paths = walkRecent(root, predicate, max, depth).map(item => item.file);
464
+ this.listCache.set(key, { at: Date.now(), paths });
465
+ }
466
+ return paths.map(file => {
467
+ const stat = safeStat(file);
468
+ return stat && stat.isFile() ? { file, mtimeMs: stat.mtimeMs, size: stat.size } : null;
469
+ }).filter(Boolean).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, max);
470
+ }
471
+
472
+ parseFile(info, parser) {
473
+ const key = `${info.file}|${info.mtimeMs}|${info.size}`;
474
+ const cached = this.parseCache.get(key);
475
+ if (cached) return cached;
476
+ const value = parser(info);
477
+ if (value) this.parseCache.set(key, value);
478
+ if (this.parseCache.size > 500) {
479
+ const keep = [...this.parseCache.entries()].slice(-300);
480
+ this.parseCache = new Map(keep);
481
+ }
482
+ return value;
483
+ }
484
+
485
+ hintedFiles(paths, max) {
486
+ return (paths || []).map(value => {
487
+ if (value && typeof value === 'object' && value.file && Number.isFinite(value.mtimeMs) && Number.isFinite(value.size)) {
488
+ return { file: value.file, mtimeMs: value.mtimeMs, size: value.size };
489
+ }
490
+ const file = typeof value === 'string' ? value : value && value.file;
491
+ const stat = safeStat(file);
492
+ return stat && stat.isFile() ? { file, mtimeMs: stat.mtimeMs, size: stat.size } : null;
493
+ }).filter(Boolean).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, max);
494
+ }
495
+
496
+ managedSessions() {
497
+ if (!this.runsDir || !fs.existsSync(this.runsDir)) return [];
498
+ let dirs = [];
499
+ try {
500
+ dirs = fs.readdirSync(this.runsDir, { withFileTypes: true }).filter(item => item.isDirectory());
501
+ } catch (_unreadableRunsDirectory) {
502
+ // A missing or temporarily locked run directory represents an empty snapshot.
503
+ return [];
504
+ }
505
+ return dirs.map(item => parseManagedSession(path.join(this.runsDir, item.name))).filter(Boolean);
506
+ }
507
+
508
+ scanNow() {
509
+ if (this.scanning) return this.lastSnapshot;
510
+ this.scanning = true;
511
+ try {
512
+ const sessions = [];
513
+
514
+ for (const [homeIndex, history] of this.historyHomes.entries()) {
515
+ const roots = {
516
+ claude: path.join(history.home, '.claude', 'projects'),
517
+ codex: path.join(history.home, '.codex', 'sessions'),
518
+ gemini: path.join(history.home, '.gemini', 'tmp'),
519
+ grok: path.join(history.home, '.grok', 'sessions'),
520
+ };
521
+ const cacheMs = history.kind === 'wsl' ? 5_000 : LIST_CACHE_MS;
522
+ const addSessions = (provider, predicate, max, parser) => {
523
+ const key = `${history.kind}:${history.distro || homeIndex}:${provider}`;
524
+ const infos = history.files && Array.isArray(history.files[provider])
525
+ ? this.hintedFiles(history.files[provider], max)
526
+ : this.files(key, roots[provider], predicate, max, 6, cacheMs);
527
+ for (const info of infos) {
528
+ const value = this.parseFile(info, parser);
529
+ if (!value) continue;
530
+ const copy = structuredClone(value);
531
+ copy.environment = { kind: history.kind, distro: history.distro, label: history.label, home: history.home };
532
+ if (history.kind === 'wsl') copy.sourceLabel = history.label;
533
+ sessions.push(copy);
534
+ }
535
+ };
536
+ addSessions('claude', (_f, name) => name.endsWith('.jsonl'), MAX_FILES_PER_PROVIDER, parseClaude);
537
+ addSessions('codex', (_f, name) => name.endsWith('.jsonl'), MAX_FILES_PER_PROVIDER, parseCodex);
538
+ addSessions('gemini', (_f, name) => /\.(json|jsonl)$/i.test(name), 50, item => parseGeneric(item, 'gemini'));
539
+ addSessions('grok', (_f, name) => /\.(json|jsonl)$/i.test(name), 50, item => parseGeneric(item, 'grok'));
540
+ }
541
+
542
+ const managed = this.managedSessions();
543
+ const byId = new Map();
544
+ for (const session of sessions) {
545
+ const existing = byId.get(session.id);
546
+ if (!existing || Date.parse(session.updatedAt || 0) > Date.parse(existing.updatedAt || 0)) byId.set(session.id, session);
547
+ }
548
+ for (const session of managed) byId.set(session.id, session);
549
+ const merged = [...byId.values()]
550
+ .map(session => {
551
+ const projectless = isProjectlessSession(session);
552
+ return { ...session, projectless, workspace: projectless ? '프로젝트 없음' : workspaceLabel(session.cwd) };
553
+ })
554
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
555
+ attachHierarchy(merged);
556
+ this.lastSnapshot = {
557
+ generatedAt: new Date().toISOString(),
558
+ sessions: merged,
559
+ summary: buildSummary(merged, this.availability),
560
+ };
561
+ this.emit('snapshot', this.lastSnapshot);
562
+ return this.lastSnapshot;
563
+ } finally {
564
+ this.scanning = false;
565
+ }
566
+ }
567
+
568
+ start() {
569
+ if (this.timer) return;
570
+ this.scanNow();
571
+ this.timer = setInterval(() => this.scanNow(), this.intervalMs);
572
+ if (this.timer.unref) this.timer.unref();
573
+ }
574
+
575
+ stop() {
576
+ if (this.timer) clearInterval(this.timer);
577
+ this.timer = null;
578
+ }
579
+ }
580
+
581
+ module.exports = {
582
+ AgentMonitor,
583
+ parseClaude,
584
+ parseCodex,
585
+ parseGeneric,
586
+ isProjectlessSession,
587
+ readJsonLines,
588
+ buildSummary,
589
+ contextInfo,
590
+ attachHierarchy,
591
+ };