agentlytics 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Your Cursor, Windsurf, Claude Code sessions — analyzed, unified, tracked.</strong><br>
9
- <sub>One command to turn scattered AI conversations from <b>15 editors</b> into a unified analytics dashboard.<br>Sessions, costs, models, tools — finally in one place. 100% local.</sub>
9
+ <sub>One command to turn scattered AI conversations from <b>16 editors</b> into a unified analytics dashboard.<br>Sessions, costs, models, tools — finally in one place. 100% local.</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
14
- <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-15-818cf8" alt="editors"></a>
14
+ <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-16-818cf8" alt="editors"></a>
15
15
  <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
16
16
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
17
17
  </p>
@@ -98,6 +98,7 @@ npx agentlytics --collect
98
98
  | **Cursor Agent** | ✅ | ❌ | ❌ | ❌ |
99
99
  | **Command Code** | ✅ | ✅ | ❌ | ❌ |
100
100
  | **Goose** | ✅ | ✅ | ✅ | ❌ |
101
+ | **Kiro** | ✅ | ✅ | ✅ | ❌ |
101
102
 
102
103
  > Windsurf, Windsurf Next, and Antigravity must be running during scan.
103
104
 
package/editors/index.js CHANGED
@@ -10,8 +10,9 @@ const copilot = require('./copilot');
10
10
  const cursorAgent = require('./cursor-agent');
11
11
  const commandcode = require('./commandcode');
12
12
  const goose = require('./goose');
13
+ const kiro = require('./kiro');
13
14
 
14
- const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose];
15
+ const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose, kiro];
15
16
 
16
17
  // Build a unified source → display-label map from all editor modules
17
18
  const editorLabels = {};
@@ -0,0 +1,296 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const { getAppDataPath } = require('./base');
5
+
6
+ // ============================================================
7
+ // Kiro editor adapter
8
+ // ============================================================
9
+
10
+ const name = 'kiro';
11
+
12
+ const KIRO_AGENT_DIR = path.join(
13
+ getAppDataPath('Kiro'), 'User', 'globalStorage', 'kiro.kiroagent'
14
+ );
15
+ const WORKSPACE_SESSIONS_DIR = path.join(KIRO_AGENT_DIR, 'workspace-sessions');
16
+
17
+ function getChats() {
18
+ const chats = [];
19
+ if (!fs.existsSync(KIRO_AGENT_DIR)) return chats;
20
+
21
+ // Strategy 1: workspace-sessions (structured, has workspace info)
22
+ if (fs.existsSync(WORKSPACE_SESSIONS_DIR)) {
23
+ try {
24
+ for (const folder of fs.readdirSync(WORKSPACE_SESSIONS_DIR)) {
25
+ const wsDir = path.join(WORKSPACE_SESSIONS_DIR, folder);
26
+ if (!fs.statSync(wsDir).isDirectory()) continue;
27
+
28
+ // Decode base64 folder name to get workspace path
29
+ let workspacePath = null;
30
+ try {
31
+ workspacePath = Buffer.from(folder, 'base64').toString('utf-8');
32
+ } catch {}
33
+
34
+ const indexPath = path.join(wsDir, 'sessions.json');
35
+ let sessions = [];
36
+ try {
37
+ sessions = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
38
+ } catch { continue; }
39
+
40
+ for (const session of sessions) {
41
+ const sessionFile = path.join(wsDir, `${session.sessionId}.json`);
42
+ const exists = fs.existsSync(sessionFile);
43
+
44
+ chats.push({
45
+ source: 'kiro',
46
+ composerId: session.sessionId,
47
+ name: cleanTitle(session.title),
48
+ createdAt: parseInt(session.dateCreated) || null,
49
+ lastUpdatedAt: exists ? getFileMtime(sessionFile) : parseInt(session.dateCreated) || null,
50
+ mode: 'kiro',
51
+ folder: session.workspaceDirectory || workspacePath || null,
52
+ encrypted: false,
53
+ bubbleCount: 0,
54
+ _fullPath: exists ? sessionFile : null,
55
+ _type: 'workspace-session',
56
+ });
57
+ }
58
+ }
59
+ } catch {}
60
+ }
61
+
62
+ // Strategy 2: .chat files in hash directories (individual agent executions)
63
+ // Kiro saves a snapshot of the conversation after each API call, so multiple
64
+ // .chat files can share the same executionId. We group by executionId and
65
+ // keep only the latest snapshot (highest message count) per conversation.
66
+ const seenIds = new Set(chats.map(c => c.composerId));
67
+ const executionMap = new Map(); // executionId -> best candidate
68
+ try {
69
+ for (const dir of fs.readdirSync(KIRO_AGENT_DIR)) {
70
+ // Skip known non-workspace directories
71
+ if (['default', 'dev_data', 'index', 'sessions', 'workspace-sessions'].includes(dir)) continue;
72
+ const fullDir = path.join(KIRO_AGENT_DIR, dir);
73
+ if (!fs.statSync(fullDir).isDirectory()) continue;
74
+
75
+ let files;
76
+ try { files = fs.readdirSync(fullDir).filter(f => f.endsWith('.chat')); } catch { continue; }
77
+
78
+ for (const file of files) {
79
+ const fullPath = path.join(fullDir, file);
80
+ try {
81
+ const stat = fs.statSync(fullPath);
82
+ const meta = peekChatMeta(fullPath);
83
+ const chatId = meta.executionId || `${dir}/${file.replace('.chat', '')}`;
84
+ if (seenIds.has(chatId)) continue;
85
+
86
+ const candidate = {
87
+ source: 'kiro',
88
+ composerId: chatId,
89
+ name: meta.title || null,
90
+ createdAt: meta.startTime || stat.birthtime.getTime(),
91
+ lastUpdatedAt: meta.endTime || stat.mtime.getTime(),
92
+ mode: meta.workflow || 'kiro',
93
+ folder: meta.folder || null,
94
+ encrypted: false,
95
+ bubbleCount: meta.messageCount || 0,
96
+ _fullPath: fullPath,
97
+ _type: 'chat-file',
98
+ };
99
+
100
+ // Keep the snapshot with the most messages per executionId
101
+ if (meta.executionId) {
102
+ const existing = executionMap.get(meta.executionId);
103
+ if (!existing || meta.messageCount > existing.bubbleCount) {
104
+ // Update createdAt to the earliest startTime seen
105
+ if (existing && existing.createdAt < candidate.createdAt) {
106
+ candidate.createdAt = existing.createdAt;
107
+ }
108
+ executionMap.set(meta.executionId, candidate);
109
+ } else if (existing && meta.startTime && meta.startTime < existing.createdAt) {
110
+ existing.createdAt = meta.startTime;
111
+ }
112
+ } else {
113
+ chats.push(candidate);
114
+ }
115
+ } catch {}
116
+ }
117
+ }
118
+ } catch {}
119
+
120
+ // Add the deduplicated execution sessions
121
+ for (const chat of executionMap.values()) {
122
+ chats.push(chat);
123
+ }
124
+
125
+ return chats;
126
+ }
127
+
128
+ function peekChatMeta(filePath) {
129
+ const meta = { title: null, folder: null, startTime: null, endTime: null, workflow: null, messageCount: 0, executionId: null };
130
+ try {
131
+ const raw = fs.readFileSync(filePath, 'utf-8');
132
+ const data = JSON.parse(raw);
133
+
134
+ meta.executionId = data.executionId || null;
135
+
136
+ if (data.metadata) {
137
+ meta.startTime = data.metadata.startTime || null;
138
+ meta.endTime = data.metadata.endTime || null;
139
+ meta.workflow = data.metadata.workflow || null;
140
+ }
141
+
142
+ const chat = data.chat || [];
143
+ for (const msg of chat) {
144
+ if (msg.role === 'human') {
145
+ // Try to extract user request from rules block
146
+ const userReq = extractUserRequest(msg.content);
147
+ if (userReq && !meta.title) {
148
+ meta.title = cleanTitle(userReq);
149
+ }
150
+ }
151
+ if (msg.role === 'bot' || msg.role === 'human') meta.messageCount++;
152
+ }
153
+
154
+ // Try to extract folder from context
155
+ for (const ctx of data.context || []) {
156
+ if (ctx.type === 'steering' && ctx.id) {
157
+ // Extract workspace from steering file path
158
+ const match = ctx.id.match(/file:\/\/(.*?)\/.kiro\//);
159
+ if (match) meta.folder = match[1];
160
+ }
161
+ }
162
+ } catch {}
163
+ return meta;
164
+ }
165
+
166
+ function isSystemPrompt(content) {
167
+ if (typeof content !== 'string') return false;
168
+ return content.startsWith('<identity>') || content.startsWith('# ');
169
+ }
170
+
171
+ function extractUserRequest(content) {
172
+ if (typeof content !== 'string') return null;
173
+ // "## Included Rules" messages contain the actual user request after </user-rule>
174
+ const ruleEnd = content.lastIndexOf('</user-rule>');
175
+ if (ruleEnd >= 0) {
176
+ let userPart = content.substring(ruleEnd + '</user-rule>'.length).trim();
177
+ // Strip trailing EnvironmentContext block
178
+ const envIdx = userPart.indexOf('<EnvironmentContext>');
179
+ if (envIdx >= 0) userPart = userPart.substring(0, envIdx).trim();
180
+ // Strip steering-reminder blocks
181
+ const steerIdx = userPart.indexOf('<steering-reminder>');
182
+ if (steerIdx >= 0) userPart = userPart.substring(0, steerIdx).trim();
183
+ if (userPart) return userPart;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ function getMessages(chat) {
189
+ if (!chat._fullPath || !fs.existsSync(chat._fullPath)) return [];
190
+
191
+ if (chat._type === 'workspace-session') {
192
+ return getWorkspaceSessionMessages(chat._fullPath);
193
+ }
194
+ return getChatFileMessages(chat._fullPath);
195
+ }
196
+
197
+ function getWorkspaceSessionMessages(filePath) {
198
+ const messages = [];
199
+ try {
200
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
201
+ const history = data.history || [];
202
+
203
+ for (const entry of history) {
204
+ const msg = entry.message;
205
+ if (!msg) continue;
206
+
207
+ const role = msg.role === 'user' ? 'user' : msg.role === 'assistant' ? 'assistant' : null;
208
+ if (!role) continue;
209
+
210
+ const content = extractContentFromMessage(msg.content);
211
+ if (!content) continue;
212
+
213
+ const result = { role, content };
214
+
215
+ // Extract model info from promptLogs
216
+ if (role === 'assistant' && entry.promptLogs && entry.promptLogs.length > 0) {
217
+ const log = entry.promptLogs[0];
218
+ result._model = log.modelTitle || null;
219
+ }
220
+
221
+ messages.push(result);
222
+ }
223
+ } catch {}
224
+ return messages;
225
+ }
226
+
227
+ function getChatFileMessages(filePath) {
228
+ const messages = [];
229
+ try {
230
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
231
+ const chat = data.chat || [];
232
+ const model = data.metadata?.modelId || null;
233
+
234
+ for (const msg of chat) {
235
+ if (msg.role === 'human') {
236
+ if (isSystemPrompt(msg.content)) continue;
237
+ // Try extracting user request from rules block first
238
+ const userReq = extractUserRequest(msg.content);
239
+ const content = userReq || extractUserText(msg.content);
240
+ if (content) messages.push({ role: 'user', content });
241
+ } else if (msg.role === 'bot') {
242
+ const content = typeof msg.content === 'string' ? msg.content : '';
243
+ if (content) messages.push({ role: 'assistant', content, _model: model });
244
+ } else if (msg.role === 'tool') {
245
+ const content = typeof msg.content === 'string' ? msg.content : '';
246
+ if (content) messages.push({ role: 'tool', content: content.substring(0, 2000) });
247
+ }
248
+ }
249
+ } catch {}
250
+ return messages;
251
+ }
252
+
253
+ function extractContentFromMessage(content) {
254
+ if (typeof content === 'string') return content;
255
+ if (!Array.isArray(content)) return '';
256
+ return content
257
+ .filter(c => c.type === 'text' || c.type === 'mention')
258
+ .map(c => c.text)
259
+ .join('\n') || '';
260
+ }
261
+
262
+ function extractUserText(content) {
263
+ if (typeof content === 'string') {
264
+ // Skip system prompt content
265
+ if (isSystemPrompt(content)) return null;
266
+ // Strip XML tags and rules blocks
267
+ return cleanTitle(content);
268
+ }
269
+ if (Array.isArray(content)) {
270
+ return content
271
+ .filter(c => c.type === 'text' || c.type === 'mention')
272
+ .map(c => c.text)
273
+ .join('\n') || '';
274
+ }
275
+ return '';
276
+ }
277
+
278
+ function cleanTitle(title) {
279
+ if (!title) return null;
280
+ let clean = title
281
+ .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '')
282
+ .replace(/<[^>]+>/g, '')
283
+ .replace(/## Included Rules[\s\S]*$/m, '')
284
+ .replace(/\s+/g, ' ')
285
+ .trim()
286
+ .substring(0, 120);
287
+ return clean || null;
288
+ }
289
+
290
+ function getFileMtime(filePath) {
291
+ try { return fs.statSync(filePath).mtime.getTime(); } catch { return null; }
292
+ }
293
+
294
+ const labels = { 'kiro': 'Kiro' };
295
+
296
+ module.exports = { name, labels, getChats, getMessages };
@@ -1,103 +1,182 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
- const { execSync } = require('child_process');
5
4
 
6
- const DB_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
5
+ // OpenCode stores data in different locations depending on the platform
6
+ // - Windows: %LOCALAPPDATA%\opencode\storage (not Roaming)
7
+ // - macOS/Linux: ~/.local/share/opencode/storage (XDG path)
8
+ function getOpenCodeStoragePath() {
9
+ const home = os.homedir();
10
+ switch (process.platform) {
11
+ case 'win32':
12
+ return path.join(home, 'AppData', 'Local', 'opencode', 'storage');
13
+ case 'darwin':
14
+ case 'linux':
15
+ default:
16
+ return path.join(home, '.local', 'share', 'opencode', 'storage');
17
+ }
18
+ }
19
+
20
+ const STORAGE_DIR = getOpenCodeStoragePath();
21
+ const SESSION_DIR = path.join(STORAGE_DIR, 'session');
22
+ const MESSAGE_DIR = path.join(STORAGE_DIR, 'message');
23
+ const PART_DIR = path.join(STORAGE_DIR, 'part');
7
24
 
8
25
  // ============================================================
9
- // Query SQLite via CLI
26
+ // Scan JSON files from OpenCode storage
10
27
  // ============================================================
11
28
 
12
- function queryDb(sql) {
13
- if (!fs.existsSync(DB_PATH)) return [];
29
+ function readJson(filePath) {
14
30
  try {
15
- const raw = execSync(
16
- `sqlite3 -json ${JSON.stringify(DB_PATH)} ${JSON.stringify(sql)}`,
17
- { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
18
- );
19
- return JSON.parse(raw);
20
- } catch { return []; }
31
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
32
+ } catch { return null; }
21
33
  }
22
34
 
23
- // ============================================================
24
- // Adapter interface
25
- // ============================================================
35
+ function getAllSessions() {
36
+ const sessions = [];
37
+ if (!fs.existsSync(SESSION_DIR)) return sessions;
26
38
 
27
- const name = 'opencode';
39
+ for (const projectHash of fs.readdirSync(SESSION_DIR)) {
40
+ const projectDir = path.join(SESSION_DIR, projectHash);
41
+ if (!fs.statSync(projectDir).isDirectory()) continue;
28
42
 
29
- function getChats() {
30
- const rows = queryDb('SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, p.worktree, p.name as project_name, (SELECT count(*) FROM message m WHERE m.session_id = s.id) as msg_count FROM session s LEFT JOIN project p ON s.project_id = p.id ORDER BY s.time_updated DESC');
43
+ let files;
44
+ try { files = fs.readdirSync(projectDir).filter(f => f.startsWith('ses_') && f.endsWith('.json')); } catch { continue; }
31
45
 
32
- return rows.map(row => ({
33
- source: 'opencode',
34
- composerId: row.id,
35
- name: cleanTitle(row.title),
36
- createdAt: row.time_created,
37
- lastUpdatedAt: row.time_updated,
38
- mode: 'opencode',
39
- folder: row.worktree || row.directory || null,
40
- encrypted: false,
41
- bubbleCount: row.msg_count || 0,
42
- }));
46
+ for (const file of files) {
47
+ const filePath = path.join(projectDir, file);
48
+ const data = readJson(filePath);
49
+ if (data && data.id) {
50
+ sessions.push({ ...data, _filePath: filePath });
51
+ }
52
+ }
53
+ }
54
+ return sessions;
43
55
  }
44
56
 
45
- function cleanTitle(title) {
46
- if (!title) return null;
47
- // Strip default "New session - <date>" titles
48
- if (title.startsWith('New session - ')) return null;
49
- return title.substring(0, 120) || null;
57
+ function getMessageCount(sessionId) {
58
+ const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
59
+ if (!fs.existsSync(sessionMsgDir)) return 0;
60
+
61
+ try {
62
+ return fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')).length;
63
+ } catch { return 0; }
50
64
  }
51
65
 
52
- function getMessages(chat) {
53
- // Get messages with their parts joined
54
- const messages = queryDb(`SELECT m.id as msg_id, m.data as msg_data, m.time_created FROM message m WHERE m.session_id = '${chat.composerId}' ORDER BY m.time_created ASC`);
66
+ function getMessagesForSession(sessionId) {
67
+ const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
68
+ if (!fs.existsSync(sessionMsgDir)) return [];
55
69
 
56
- const result = [];
57
- for (const msg of messages) {
58
- let msgData;
59
- try { msgData = JSON.parse(msg.msg_data); } catch { continue; }
70
+ let files;
71
+ try { files = fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')); } catch { return []; }
60
72
 
61
- const role = msgData.role;
62
- if (!role) continue;
73
+ const messages = [];
74
+ for (const file of files) {
75
+ const msgPath = path.join(sessionMsgDir, file);
76
+ const msg = readJson(msgPath);
77
+ if (!msg || !msg.id) continue;
63
78
 
64
79
  // Get parts for this message
65
- const parts = queryDb(`SELECT data FROM part WHERE message_id = '${msg.msg_id}' ORDER BY time_created ASC`);
80
+ const msgPartDir = path.join(PART_DIR, msg.id);
81
+ const parts = [];
82
+ if (fs.existsSync(msgPartDir)) {
83
+ try {
84
+ const partFiles = fs.readdirSync(msgPartDir).filter(f => f.startsWith('prt_') && f.endsWith('.json'));
85
+ for (const partFile of partFiles) {
86
+ const part = readJson(path.join(msgPartDir, partFile));
87
+ if (part) parts.push(part);
88
+ }
89
+ } catch { /* skip */ }
90
+ }
66
91
 
92
+ // Build content from parts
67
93
  const contentParts = [];
68
94
  for (const part of parts) {
69
- let partData;
70
- try { partData = JSON.parse(part.data); } catch { continue; }
71
-
72
- if (partData.type === 'text' && partData.text) {
73
- contentParts.push(partData.text);
74
- } else if (partData.type === 'tool-use' || partData.type === 'tool_use') {
75
- const toolName = partData.name || partData.toolName || 'tool';
76
- let argKeys = '';
77
- try {
78
- const input = typeof partData.input === 'string' ? JSON.parse(partData.input) : (partData.input || {});
79
- argKeys = Object.keys(input).join(', ');
80
- } catch {}
95
+ const type = part.type;
96
+
97
+ if (type === 'text' && part.text) {
98
+ contentParts.push(part.text);
99
+ } else if (type === 'thinking' || type === 'reasoning') {
100
+ if (part.text) contentParts.push(`[thinking] ${part.text}`);
101
+ } else if (type === 'tool-call' || type === 'tool_use' || type === 'tool') {
102
+ const toolName = part.name || part.toolName || part.tool || 'tool';
103
+ const args = part.args || part.arguments || part.state?.input || {};
104
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
81
105
  contentParts.push(`[tool-call: ${toolName}(${argKeys})]`);
82
- } else if (partData.type === 'tool-result' || partData.type === 'tool_result') {
83
- const preview = (partData.text || partData.output || '').substring(0, 500);
106
+ } else if (type === 'tool-result' || type === 'tool_result') {
107
+ const preview = (part.text || part.output || part.state?.output || '').substring(0, 500);
84
108
  contentParts.push(`[tool-result] ${preview}`);
109
+ } else if (type === 'step-start' || type === 'step-finish') {
110
+ // Skip metadata parts
85
111
  }
86
- // Skip step-start, step-finish (metadata only)
112
+ }
113
+
114
+ // If no parts with content, check if message itself has content
115
+ if (contentParts.length === 0 && msg.role) {
116
+ contentParts.push(`[${msg.role}]`);
87
117
  }
88
118
 
89
119
  const content = contentParts.join('\n');
90
- if (!content) continue;
91
-
92
- const mappedRole = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role;
93
- result.push({
94
- role: mappedRole,
95
- content,
96
- _model: msgData.model?.modelID,
97
- });
120
+ if (content) {
121
+ // Extract model value - handle both string and object formats
122
+ let modelValue = null;
123
+ if (typeof msg.modelID === 'string') {
124
+ modelValue = msg.modelID;
125
+ } else if (msg.model && typeof msg.model === 'object' && msg.model.modelID) {
126
+ modelValue = msg.model.modelID;
127
+ } else if (typeof msg.model === 'string') {
128
+ modelValue = msg.model;
129
+ }
130
+
131
+ messages.push({
132
+ role: msg.role || 'assistant',
133
+ content,
134
+ _model: modelValue,
135
+ _inputTokens: msg.tokens?.input,
136
+ _outputTokens: msg.tokens?.output,
137
+ _cacheRead: msg.tokens?.cache?.read,
138
+ _cacheWrite: msg.tokens?.cache?.write,
139
+ _finish: msg.finish,
140
+ });
141
+ }
98
142
  }
99
143
 
100
- return result;
144
+ // Sort by creation time
145
+ return messages.sort((a, b) => {
146
+ const aTime = a.time?.created || 0;
147
+ const bTime = b.time?.created || 0;
148
+ return aTime - bTime;
149
+ });
150
+ }
151
+
152
+ // ============================================================
153
+ // Adapter interface
154
+ // ============================================================
155
+
156
+ const name = 'opencode';
157
+
158
+ function getChats() {
159
+ const sessions = getAllSessions();
160
+
161
+ return sessions.map(s => ({
162
+ source: 'opencode',
163
+ composerId: s.id,
164
+ name: s.title || null,
165
+ createdAt: s.time?.created || null,
166
+ lastUpdatedAt: s.time?.updated || null,
167
+ mode: s.mode || 'opencode',
168
+ folder: s.directory || null,
169
+ encrypted: false,
170
+ bubbleCount: getMessageCount(s.id),
171
+ _agent: s.agent,
172
+ _model: s.modelID,
173
+ _provider: s.providerID,
174
+ _sessionData: s,
175
+ })).sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
176
+ }
177
+
178
+ function getMessages(chat) {
179
+ return getMessagesForSession(chat.composerId);
101
180
  }
102
181
 
103
182
  const labels = { 'opencode': 'OpenCode' };
@@ -1,20 +1,108 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const os = require('os');
4
1
  const { execSync } = require('child_process');
5
- const http = require('http');
6
2
 
7
- const HOME = os.homedir();
8
-
9
- // Windsurf-family variants: Windsurf, Windsurf Next, Antigravity
3
+ // Windsurf-family variants: Windsurf, Antigravity
10
4
  const VARIANTS = [
11
5
  { id: 'windsurf', matchKey: 'ide', matchVal: 'windsurf', https: false },
12
6
  { id: 'windsurf-next', matchKey: 'ide', matchVal: 'windsurf-next', https: false },
13
7
  { id: 'antigravity', matchKey: 'appDataDir', matchVal: 'antigravity', https: true },
14
8
  ];
15
9
 
10
+ // Antigravity model ID to friendly name mapping
11
+ const ANTIGRAVITY_MODEL_MAP = {
12
+ 'MODEL_PLACEHOLDER_M1': 'claude-3-5-sonnet-20241022',
13
+ 'MODEL_PLACEHOLDER_M2': 'claude-3-5-sonnet-20241022',
14
+ 'MODEL_PLACEHOLDER_M3': 'claude-3-5-sonnet-20241022',
15
+ 'MODEL_PLACEHOLDER_M4': 'claude-3-5-haiku-20241022',
16
+ 'MODEL_PLACEHOLDER_M5': 'claude-3-5-haiku-20241022',
17
+ 'MODEL_PLACEHOLDER_M6': 'claude-3-5-haiku-20241022',
18
+ 'MODEL_PLACEHOLDER_M7': 'claude-3-5-sonnet-20241022',
19
+ 'MODEL_PLACEHOLDER_M8': 'claude-3.5-sonnet',
20
+ 'MODEL_PLACEHOLDER_M9': 'claude-3.5-sonnet',
21
+ 'MODEL_PLACEHOLDER_M10': 'claude-3.5-sonnet',
22
+ 'MODEL_CLAUDE_4_5_SONNET': 'claude-4.5-sonnet',
23
+ };
24
+
25
+ function normalizeAntigravityModel(modelId) {
26
+ if (!modelId) return null;
27
+ return ANTIGRAVITY_MODEL_MAP[modelId] || modelId;
28
+ }
29
+
16
30
  // ============================================================
17
- // Find running Windsurf language server (port + CSRF token)
31
+ // Cross-platform process utilities
32
+ // ============================================================
33
+
34
+ const IS_WINDOWS = process.platform === 'win32';
35
+
36
+ function getProcessList() {
37
+ try {
38
+ if (IS_WINDOWS) {
39
+ // wmic provides CSV-formatted process data
40
+ const output = execSync('wmic process get CommandLine,ProcessId /format:csv', {
41
+ encoding: 'utf-8',
42
+ maxBuffer: 10 * 1024 * 1024,
43
+ });
44
+ // Parse CSV: skip header, split by comma
45
+ const lines = output.split('\n').slice(1);
46
+ return lines.map(line => {
47
+ const parts = line.split(',');
48
+ if (parts.length < 2) return null;
49
+ const commandLine = parts.slice(0, -1).join(',').trim().replace(/^"|"$/g, '');
50
+ const pid = parts[parts.length - 1].trim();
51
+ return { commandLine, pid };
52
+ }).filter(Boolean);
53
+ } else {
54
+ // ps aux on Unix-like systems
55
+ const output = execSync('ps aux', { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
56
+ return output.split('\n').slice(1).map(line => {
57
+ const parts = line.trim().split(/\s+/);
58
+ if (parts.length < 11) return null;
59
+ const pid = parts[1];
60
+ const commandLine = parts.slice(10).join(' ');
61
+ return { commandLine, pid };
62
+ }).filter(Boolean);
63
+ }
64
+ } catch { return []; }
65
+ }
66
+
67
+ function getListeningPorts(pid) {
68
+ try {
69
+ if (IS_WINDOWS) {
70
+ // netstat -ano shows PID in the last column
71
+ const output = execSync(`netstat -ano | findstr ${pid}`, {
72
+ encoding: 'utf-8',
73
+ maxBuffer: 10 * 1024 * 1024,
74
+ });
75
+ const ports = [];
76
+ for (const line of output.split('\n')) {
77
+ // Match: 127.0.0.1:PORT ... LISTENING PID
78
+ // Check if line ends with the PID we're looking for
79
+ if (!line.trim().endsWith(pid)) continue;
80
+ const match = line.match(/127\.0\.0\.1:(\d+).*LISTENING/);
81
+ if (match) {
82
+ ports.push(parseInt(match[1]));
83
+ }
84
+ }
85
+ return ports;
86
+ } else {
87
+ // lsof on Unix-like systems
88
+ const output = execSync(`lsof -i TCP -P -n -a -p ${pid} 2>/dev/null`, {
89
+ encoding: 'utf-8',
90
+ maxBuffer: 10 * 1024 * 1024,
91
+ });
92
+ const ports = [];
93
+ for (const line of output.split('\n')) {
94
+ const match = line.match(/TCP\s+127\.0\.0\.1:(\d+)\s+\(LISTEN\)/);
95
+ if (match) {
96
+ ports.push(parseInt(match[1]));
97
+ }
98
+ }
99
+ return ports;
100
+ }
101
+ } catch { return []; }
102
+ }
103
+
104
+ // ============================================================
105
+ // Find running Windsurf/Antigravity language server (port + CSRF token)
18
106
  // ============================================================
19
107
 
20
108
  let _lsCache = null;
@@ -22,43 +110,71 @@ let _lsCache = null;
22
110
  function findLanguageServers() {
23
111
  if (_lsCache) return _lsCache;
24
112
  _lsCache = [];
25
- try {
26
- const ps = execSync('ps aux', { encoding: 'utf-8', maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
27
- // Also grab env vars for processes that use WINDSURF_CSRF_TOKEN instead of --csrf_token
28
- const psEnv = execSync('ps eww -A', { encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).split('\n');
29
- const envCsrfByPid = {};
30
- for (const envLine of psEnv) {
31
- const envCsrf = envLine.match(/WINDSURF_CSRF_TOKEN=(\S+)/);
32
- if (envCsrf) {
33
- const envPid = envLine.match(/^\s*(\d+)/);
34
- if (envPid) envCsrfByPid[envPid[1]] = envCsrf[1];
113
+
114
+ // Language server executable name varies by platform
115
+ const serverProcessName = IS_WINDOWS
116
+ ? 'language_server_windows'
117
+ : process.platform === 'darwin'
118
+ ? 'language_server_macos'
119
+ : 'language_server_linux';
120
+
121
+ // On macOS/Linux, also check env vars for WINDSURF_CSRF_TOKEN (newer Windsurf Next passes CSRF via env, not CLI arg)
122
+ const envCsrfByPid = {};
123
+ if (!IS_WINDOWS) {
124
+ try {
125
+ const psEnv = execSync('ps eww -A', { encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
126
+ for (const envLine of psEnv.split('\n')) {
127
+ const envCsrf = envLine.match(/WINDSURF_CSRF_TOKEN=(\S+)/);
128
+ if (envCsrf) {
129
+ const envPid = envLine.match(/^\s*(\d+)/);
130
+ if (envPid) envCsrfByPid[envPid[1]] = envCsrf[1];
131
+ }
132
+ }
133
+ } catch {}
134
+ }
135
+
136
+ for (const proc of getProcessList()) {
137
+ const { commandLine, pid } = proc;
138
+ if (!commandLine.includes(serverProcessName)) continue;
139
+
140
+ const csrfMatch = commandLine.match(/--csrf_token\s+(\S+)/);
141
+ const ideMatch = commandLine.match(/--ide_name\s+(\S+)/);
142
+ const appDirMatch = commandLine.match(/--app_data_dir\s+(\S+)/);
143
+
144
+ // Try CLI arg first, then env var fallback
145
+ const csrf = csrfMatch ? csrfMatch[1] : envCsrfByPid[pid] || null;
146
+ if (!csrf) continue;
147
+
148
+ const ide = ideMatch ? ideMatch[1] : null;
149
+ const appDataDir = appDirMatch ? appDirMatch[1] : null;
150
+
151
+ // Antigravity has a separate extension server CSRF token
152
+ const extCsrfMatch = commandLine.match(/--extension_server_csrf_token\s+(\S+)/);
153
+
154
+ // Check for explicit server port (Antigravity uses --server_port)
155
+ const serverPortMatch = commandLine.match(/--server_port\s+(\d+)/);
156
+
157
+ // Find actual listening ports for this process
158
+ const ports = getListeningPorts(pid);
159
+ if (ports.length === 0) continue;
160
+
161
+ // Use explicit server_port if available, otherwise use lowest port
162
+ let port;
163
+ if (serverPortMatch) {
164
+ port = parseInt(serverPortMatch[1], 10);
165
+ if (!ports.includes(port)) {
166
+ port = Math.min(...ports);
35
167
  }
168
+ } else {
169
+ port = Math.min(...ports);
36
170
  }
37
171
 
38
- for (const line of ps.split('\n')) {
39
- if (!line.includes('language_server_macos')) continue;
40
- const csrfMatch = line.match(/--csrf_token\s+(\S+)/);
41
- const ideMatch = line.match(/--ide_name\s+(\S+)/);
42
- const appDirMatch = line.match(/--app_data_dir\s+(\S+)/);
43
- const pidMatch = line.match(/^\S+\s+(\d+)/);
44
- if (!pidMatch) continue;
45
- const pid = pidMatch[1];
46
- const csrf = csrfMatch ? csrfMatch[1] : envCsrfByPid[pid] || null;
47
- if (!csrf) continue;
48
- const ide = ideMatch ? ideMatch[1] : 'windsurf';
49
- const appDataDir = appDirMatch ? appDirMatch[1] : null;
50
- // Find port by checking listening sockets for this process
51
- try {
52
- const lsof = execSync(`lsof -i TCP -P -n -a -p ${pid} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
53
- for (const l of lsof.split('\n')) {
54
- const portMatch = l.match(/TCP\s+127\.0\.0\.1:(\d+)\s+\(LISTEN\)/);
55
- if (portMatch) {
56
- _lsCache.push({ ide, appDataDir, port: parseInt(portMatch[1]), csrf, pid });
57
- }
58
- }
59
- } catch { /* skip */ }
172
+ if (ide || appDataDir) {
173
+ const isHttps = appDataDir?.includes('antigravity');
174
+ _lsCache.push({ ide, appDataDir, port, csrf, pid, extCsrf: extCsrfMatch ? extCsrfMatch[1] : null, isHttps });
60
175
  }
61
- } catch { /* ps failed */ }
176
+ }
177
+
62
178
  return _lsCache;
63
179
  }
64
180
 
@@ -66,10 +182,9 @@ function getLsForVariant(variant) {
66
182
  const servers = findLanguageServers();
67
183
  let matches;
68
184
  if (variant.matchKey === 'appDataDir') {
69
- matches = servers.filter(s => s.appDataDir === variant.matchVal);
185
+ matches = servers.filter(s => s.appDataDir?.includes(variant.matchVal));
70
186
  } else {
71
- // Exclude servers that have appDataDir set (they belong to a different variant)
72
- matches = servers.filter(s => s.ide === variant.matchVal && !s.appDataDir);
187
+ matches = servers.filter(s => s.ide === variant.matchVal);
73
188
  }
74
189
  return matches.length > 0 ? matches[0] : null;
75
190
  }
@@ -78,16 +193,20 @@ function getLsForVariant(variant) {
78
193
  // Connect protocol HTTP client for language server RPC
79
194
  // ============================================================
80
195
 
81
- function callRpc(port, csrf, method, body, useHttps) {
196
+ function callRpc(port, csrf, method, body, isHttps = false, extCsrf = null, useMainCsrf = false) {
82
197
  const data = JSON.stringify(body || {});
83
- const scheme = useHttps ? 'https' : 'http';
198
+ const scheme = isHttps ? 'https' : 'http';
84
199
  const url = `${scheme}://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/${method}`;
85
- const insecure = useHttps ? '-k ' : '';
200
+ const insecure = isHttps ? '-k ' : '';
201
+
202
+ // For Antigravity, use main CSRF. For Windsurf, use extension CSRF if available.
203
+ const actualCsrf = useMainCsrf ? csrf : (extCsrf || csrf);
204
+
86
205
  try {
87
206
  const result = execSync(
88
207
  `curl -s ${insecure}-X POST ${JSON.stringify(url)} ` +
89
208
  `-H "Content-Type: application/json" ` +
90
- `-H "x-codeium-csrf-token: ${csrf}" ` +
209
+ `-H "x-codeium-csrf-token: ${actualCsrf}" ` +
91
210
  `-d ${JSON.stringify(data)} ` +
92
211
  `--max-time 10`,
93
212
  { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
@@ -110,12 +229,17 @@ function getChats() {
110
229
  const ls = getLsForVariant(variant);
111
230
  if (!ls) continue;
112
231
 
113
- const resp = callRpc(ls.port, ls.csrf, 'GetAllCascadeTrajectories', {}, variant.https);
232
+ // Antigravity uses main CSRF, Windsurf uses extension CSRF
233
+ const useMainCsrf = variant.id === 'antigravity';
234
+ const resp = callRpc(ls.port, ls.csrf, 'GetAllCascadeTrajectories', {}, ls.isHttps, ls.extCsrf, useMainCsrf);
114
235
  if (!resp || !resp.trajectorySummaries) continue;
115
236
 
116
237
  for (const [cascadeId, summary] of Object.entries(resp.trajectorySummaries)) {
117
238
  const ws = (summary.workspaces || [])[0];
118
239
  const folder = ws?.workspaceFolderAbsoluteUri?.replace('file://', '') || null;
240
+ const rawModel = summary.lastGeneratorModelUid;
241
+ // Normalize Antigravity models so they show correctly in dashboard
242
+ const normalizedModel = variant.id === 'antigravity' && rawModel ? normalizeAntigravityModel(rawModel) : rawModel;
119
243
  chats.push({
120
244
  source: variant.id,
121
245
  composerId: cascadeId,
@@ -128,9 +252,11 @@ function getChats() {
128
252
  bubbleCount: summary.stepCount || 0,
129
253
  _port: ls.port,
130
254
  _csrf: ls.csrf,
131
- _https: variant.https,
255
+ _extCsrf: ls.extCsrf,
256
+ _isHttps: ls.isHttps,
132
257
  _stepCount: summary.stepCount,
133
- _model: summary.lastGeneratorModelUid,
258
+ _model: normalizedModel,
259
+ _rawModel: rawModel,
134
260
  });
135
261
  }
136
262
  }
@@ -141,16 +267,19 @@ function getChats() {
141
267
  function getSteps(chat) {
142
268
  if (!chat._port || !chat._csrf) return [];
143
269
 
270
+ // Determine if this is Antigravity based on source
271
+ const isAntigravity = chat.source === 'antigravity';
272
+
144
273
  // Prefer GetCascadeTrajectorySteps (returns more steps than GetCascadeTrajectory)
145
274
  const resp = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectorySteps', {
146
275
  cascadeId: chat.composerId,
147
- }, chat._https);
276
+ }, chat._isHttps, chat._extCsrf, isAntigravity);
148
277
  if (resp && resp.steps && resp.steps.length > 0) return resp.steps;
149
278
 
150
279
  // Fallback to old method
151
280
  const resp2 = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectory', {
152
281
  cascadeId: chat.composerId,
153
- }, chat._https);
282
+ }, chat._isHttps, chat._extCsrf, isAntigravity);
154
283
  if (resp2 && resp2.trajectory && resp2.trajectory.steps) return resp2.trajectory.steps;
155
284
 
156
285
  return [];
@@ -162,9 +291,10 @@ function getSteps(chat) {
162
291
  * We find the overlap with step-based messages by matching the last user message content.
163
292
  */
164
293
  function getTailMessages(chat, stepMessages) {
294
+ const isAntigravity = chat.source === 'antigravity';
165
295
  const resp = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectory', {
166
296
  cascadeId: chat.composerId,
167
- }, chat._https);
297
+ }, chat._isHttps, chat._extCsrf, isAntigravity);
168
298
  if (!resp || !resp.trajectory) return [];
169
299
 
170
300
  const gm = resp.trajectory.generatorMetadata || [];
@@ -220,7 +350,7 @@ function getTailMessages(chat, stepMessages) {
220
350
  return tail;
221
351
  }
222
352
 
223
- function parseStep(step) {
353
+ function parseStep(step, isAntigravity = false) {
224
354
  const type = step.type || '';
225
355
  const meta = step.metadata || {};
226
356
 
@@ -256,10 +386,12 @@ function parseStep(step) {
256
386
  }
257
387
  }
258
388
  if (parts.length > 0) {
389
+ // Try both generatorModel (Antigravity) and generatorModelUid (Windsurf)
390
+ const model = meta.generatorModel || meta.generatorModelUid;
259
391
  return {
260
392
  role: 'assistant',
261
393
  content: parts.join('\n'),
262
- _model: meta.generatorModelUid,
394
+ _model: isAntigravity && model ? normalizeAntigravityModel(model) : model,
263
395
  _toolCalls,
264
396
  };
265
397
  }
@@ -330,9 +462,10 @@ function parseStep(step) {
330
462
 
331
463
  function getMessages(chat) {
332
464
  const steps = getSteps(chat);
465
+ const isAntigravity = chat.source === 'antigravity';
333
466
  const messages = [];
334
467
  for (const step of steps) {
335
- const msg = parseStep(step);
468
+ const msg = parseStep(step, isAntigravity);
336
469
  if (msg) messages.push(msg);
337
470
  }
338
471
 
package/editors/zed.js CHANGED
@@ -2,9 +2,26 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
  const { execSync } = require('child_process');
5
- const { getAppDataPath } = require('./base');
6
5
 
7
- const THREADS_DB = path.join(getAppDataPath('Zed'), 'threads', 'threads.db');
6
+ const Database = require('better-sqlite3');
7
+
8
+ // Zed stores data in different locations depending on the platform
9
+ // - Windows: %LOCALAPPDATA%\Zed (not Roaming)
10
+ // - macOS: ~/Library/Application Support/Zed
11
+ // - Linux: ~/.config/Zed
12
+ function getZedDataPath() {
13
+ const home = os.homedir();
14
+ switch (process.platform) {
15
+ case 'win32':
16
+ return path.join(home, 'AppData', 'Local', 'Zed');
17
+ case 'darwin':
18
+ return path.join(home, 'Library', 'Application Support', 'Zed');
19
+ default: // linux, etc.
20
+ return path.join(home, '.config', 'Zed');
21
+ }
22
+ }
23
+
24
+ const THREADS_DB = path.join(getZedDataPath(), 'threads', 'threads.db');
8
25
 
9
26
  // ============================================================
10
27
  // Decompress zstd blob via CLI
@@ -25,30 +42,32 @@ function decompressZstd(buf) {
25
42
  }
26
43
 
27
44
  // ============================================================
28
- // Query SQLite via CLI (avoids native module dependency)
45
+ // Query SQLite using better-sqlite3 (cross-platform)
29
46
  // ============================================================
30
47
 
31
48
  function queryDb(sql) {
32
49
  if (!fs.existsSync(THREADS_DB)) return [];
33
50
  try {
34
- const raw = execSync(
35
- `sqlite3 -json ${JSON.stringify(THREADS_DB)} ${JSON.stringify(sql)}`,
36
- { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
37
- );
38
- return JSON.parse(raw);
39
- } catch { return []; }
51
+ const db = new Database(THREADS_DB, { readonly: true });
52
+ const rows = db.prepare(sql).all();
53
+ db.close();
54
+ return rows;
55
+ } catch (e) {
56
+ // Silently fail if database is locked or inaccessible
57
+ return [];
58
+ }
40
59
  }
41
60
 
42
- function queryBlobHex(id) {
61
+ function queryBlob(id) {
43
62
  if (!fs.existsSync(THREADS_DB)) return null;
44
63
  try {
45
- const hex = execSync(
46
- `sqlite3 ${JSON.stringify(THREADS_DB)} "SELECT hex(data) FROM threads WHERE id = '${id}'"`,
47
- { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
48
- ).trim();
49
- if (!hex) return null;
50
- return Buffer.from(hex, 'hex');
51
- } catch { return null; }
64
+ const db = new Database(THREADS_DB, { readonly: true });
65
+ const row = db.prepare('SELECT data FROM threads WHERE id = ?').get(id);
66
+ db.close();
67
+ return row ? row.data : null;
68
+ } catch {
69
+ return null;
70
+ }
52
71
  }
53
72
 
54
73
  // ============================================================
@@ -71,13 +90,14 @@ function getChats() {
71
90
  mode: 'thread',
72
91
  folder: null,
73
92
  encrypted: false,
93
+ bubbleCount: 0,
74
94
  _dataType: row.data_type,
75
95
  _gitBranch: row.worktree_branch,
76
96
  }));
77
97
  }
78
98
 
79
99
  function getMessages(chat) {
80
- const blob = queryBlobHex(chat.composerId);
100
+ const blob = queryBlob(chat.composerId);
81
101
  if (!blob) return [];
82
102
 
83
103
  let json;
@@ -88,7 +108,10 @@ function getMessages(chat) {
88
108
  } else {
89
109
  json = blob.toString('utf-8');
90
110
  }
91
- } catch { return []; }
111
+ } catch (e) {
112
+ // Decompression failed - zstd CLI not available
113
+ return [];
114
+ }
92
115
 
93
116
  let data;
94
117
  try { data = JSON.parse(json); } catch { return []; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@ const PATHS = {
12
12
  antigravity: 'M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z',
13
13
  command: 'M6,2A4,4 0 0,1 10,6V8H14V6A4,4 0 0,1 18,2A4,4 0 0,1 22,6A4,4 0 0,1 18,10H16V14H18A4,4 0 0,1 22,18A4,4 0 0,1 18,22A4,4 0 0,1 14,18V16H10V18A4,4 0 0,1 6,22A4,4 0 0,1 2,18A4,4 0 0,1 6,14H8V10H6A4,4 0 0,1 2,6A4,4 0 0,1 6,2M16,18A2,2 0 0,0 18,20A2,2 0 0,0 20,18A2,2 0 0,0 18,16H16V18M14,10H10V14H14V10M6,16A2,2 0 0,0 4,18A2,2 0 0,0 6,20A2,2 0 0,0 8,18V16H6M8,6A2,2 0 0,0 6,4A2,2 0 0,0 4,6A2,2 0 0,0 6,8H8V6M18,8A2,2 0 0,0 20,6A2,2 0 0,0 18,4A2,2 0 0,0 16,6V8H18Z',
14
14
  goose: 'M22.112 23.596C23.018 23.399 23.979 22.864 23.979 22.864L22.297 21.479C21.466 20.795 20.759 19.973 20.206 19.05C19.441 17.774 18.385 16.697 17.125 15.908L16.509 15.55C16.298 15.403 16.151 15.175 16.13 14.917C16.117 14.751 16.157 14.602 16.25 14.471C16.57 14.019 18.227 12.053 18.531 11.802C18.922 11.479 19.357 11.21 19.762 10.902L19.934 10.77C19.936 10.768 19.938 10.767 19.94 10.765C20.07 10.663 20.192 10.554 20.29 10.425C20.641 10.018 20.726 9.658 20.747 9.499C20.7 9.346 20.558 9.003 20.163 8.608C20.411 8.623 20.71 8.819 20.982 9.05C21.165 8.758 21.356 8.45 21.547 8.141C21.674 7.934 21.485 7.78 21.48 7.775L21.479 7.775C21.479 7.775 21.479 7.774 21.479 7.774C21.474 7.769 21.319 7.579 21.114 7.707C20.673 7.979 20.234 8.252 19.842 8.5C19.842 8.5 19.379 8.49 18.829 8.964C18.7 9.062 18.591 9.184 18.489 9.314L18.484 9.32C18.439 9.377 18.396 9.434 18.352 9.492C18.044 9.897 17.775 10.332 17.452 10.723C17.201 11.027 15.235 12.684 14.783 13.004C14.652 13.097 14.504 13.137 14.337 13.124C14.08 13.103 13.851 12.956 13.704 12.745L13.346 12.129C12.557 10.868 11.48 9.813 10.204 9.048C9.281 8.495 8.459 7.787 7.775 6.957L6.39 5.275C6.39 5.275 5.854 6.236 5.658 7.141C5.931 7.474 6.644 8.298 7.473 8.928C6.581 8.509 5.922 8.184 5.402 7.913C5.322 8.506 5.353 9.403 5.436 10.098C5.999 10.344 6.957 10.724 7.933 10.926C7.152 11.108 6.296 11.141 5.635 11.128C5.751 11.558 5.914 11.997 6.132 12.438C6.224 12.642 6.326 12.842 6.435 13.037C6.785 13.133 8.159 13.333 8.89 13.169C8.163 13.428 6.942 13.865 6.942 13.865C7.88 15.031 8.916 15.98 8.916 15.98C10.492 15.133 10.852 15.017 12.034 14.244C10.119 15.802 9.622 16.438 9.085 17.091L8.71 17.617C8.516 17.89 8.347 18.18 8.206 18.484C7.734 19.5 7.065 21.666 7.065 21.666C6.946 22.043 7.222 22.32 7.589 22.189C7.589 22.189 9.754 21.521 10.77 21.048C11.074 20.907 11.364 20.738 11.637 20.544L12.163 20.169C12.339 20.024 12.514 19.882 12.707 19.714C12.707 19.714 14.03 21.282 15.39 22.313C15.39 22.313 15.826 21.092 16.086 20.364C15.921 21.096 16.121 22.469 16.218 22.819C16.412 22.929 16.612 23.03 16.816 23.123C17.258 23.341 17.696 23.503 18.126 23.619C18.113 22.958 18.146 22.102 18.329 21.321C18.531 22.297 18.91 23.256 19.157 23.819C19.851 23.902 20.748 23.933 21.341 23.853C21.07 23.333 20.746 22.673 20.326 21.781C20.956 22.611 21.781 23.324 22.113 23.597L22.112 23.596Z',
15
+ kiro: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m-1.5 14.5v-9l7 4.5-7 4.5',
15
16
  // Terminal icon for generic editors
16
17
  terminal: 'M4 17.27V19h16v-1.73ZM4 5v1.73l7.07 4.55L4 15.82v1.73l10-6.46Z',
17
18
  }
@@ -34,6 +35,7 @@ const EDITOR_ICONS = {
34
35
  'codex': 'terminal',
35
36
  'commandcode': 'command',
36
37
  'goose': 'goose',
38
+ 'kiro': 'kiro',
37
39
  }
38
40
 
39
41
  export default function EditorIcon({ source, size = 16, className = '' }) {
@@ -15,6 +15,7 @@ export const EDITOR_COLORS = {
15
15
  'cursor-agent': '#f59e0b',
16
16
  'commandcode': '#e11d48',
17
17
  'goose': '#333333',
18
+ 'kiro': '#ff9900',
18
19
  };
19
20
 
20
21
  export const EDITOR_LABELS = {
@@ -34,6 +35,7 @@ export const EDITOR_LABELS = {
34
35
  'cursor-agent': 'Cursor Agent',
35
36
  'commandcode': 'Command Code',
36
37
  'goose': 'Goose',
38
+ 'kiro': 'Kiro',
37
39
  };
38
40
 
39
41
  export function editorColor(src) {