@axhub/genie 0.1.2 → 0.1.3

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.
@@ -0,0 +1,474 @@
1
+ import { getOpencodeClient, resolveOpencodeModel } from './opencode-manager.js';
2
+
3
+ const activeOpencodeSessions = new Map();
4
+
5
+ function getEventSessionId(event) {
6
+ if (!event || typeof event !== 'object') return null;
7
+
8
+ const direct = event.sessionID || event.sessionId;
9
+ if (typeof direct === 'string' && direct.trim()) {
10
+ return direct.trim();
11
+ }
12
+
13
+ const props = event.properties || {};
14
+ const propId = props.sessionID || props.sessionId;
15
+ if (typeof propId === 'string' && propId.trim()) {
16
+ return propId.trim();
17
+ }
18
+
19
+ if (props.info && typeof props.info === 'object') {
20
+ const infoId = props.info.sessionID || props.info.sessionId;
21
+ if (typeof infoId === 'string' && infoId.trim()) {
22
+ return infoId.trim();
23
+ }
24
+ }
25
+
26
+ if (props.part && typeof props.part === 'object') {
27
+ const partId = props.part.sessionID || props.part.sessionId;
28
+ if (typeof partId === 'string' && partId.trim()) {
29
+ return partId.trim();
30
+ }
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ function toIso(value, fallback = null) {
37
+ if (typeof value === 'number' && Number.isFinite(value)) {
38
+ return new Date(value).toISOString();
39
+ }
40
+
41
+ if (typeof value === 'string' && value.trim()) {
42
+ const parsed = Date.parse(value);
43
+ if (!Number.isNaN(parsed)) {
44
+ return new Date(parsed).toISOString();
45
+ }
46
+ }
47
+
48
+ return fallback;
49
+ }
50
+
51
+ function parsePermissionMode(permissionMode) {
52
+ switch (permissionMode) {
53
+ case 'acceptEdits':
54
+ return {
55
+ edit: 'allow',
56
+ bash: 'allow',
57
+ webfetch: 'allow'
58
+ };
59
+ case 'bypassPermissions':
60
+ return {
61
+ edit: 'allow',
62
+ bash: 'allow',
63
+ webfetch: 'allow',
64
+ external_directory: 'allow'
65
+ };
66
+ case 'default':
67
+ default:
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function normalizeToolName(toolName = 'tool') {
73
+ if (toolName === 'bash') return 'Bash';
74
+ return toolName;
75
+ }
76
+
77
+ function mapPartToDelta(part, context) {
78
+ if (!part || typeof part !== 'object') return null;
79
+
80
+ const partId = part.id;
81
+ const messageId = part.messageID;
82
+
83
+ if (part.type === 'text') {
84
+ const currentText = typeof part.text === 'string' ? part.text : '';
85
+ const previousText = context.textPartCache.get(partId) || '';
86
+ let delta = currentText;
87
+
88
+ if (currentText.startsWith(previousText)) {
89
+ delta = currentText.slice(previousText.length);
90
+ }
91
+
92
+ context.textPartCache.set(partId, currentText);
93
+
94
+ if (!delta) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ type: 'text_delta',
100
+ messageId,
101
+ partId,
102
+ delta,
103
+ timestamp: toIso(part.time?.start, new Date().toISOString())
104
+ };
105
+ }
106
+
107
+ if (part.type === 'reasoning') {
108
+ const currentText = typeof part.text === 'string' ? part.text : '';
109
+ const previousText = context.reasoningPartCache.get(partId) || '';
110
+ let delta = currentText;
111
+
112
+ if (currentText.startsWith(previousText)) {
113
+ delta = currentText.slice(previousText.length);
114
+ }
115
+
116
+ context.reasoningPartCache.set(partId, currentText);
117
+
118
+ if (!delta) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ type: 'reasoning_delta',
124
+ messageId,
125
+ partId,
126
+ delta,
127
+ timestamp: toIso(part.time?.start, new Date().toISOString())
128
+ };
129
+ }
130
+
131
+ if (part.type === 'tool') {
132
+ const callId = part.callID || part.id;
133
+ const toolName = normalizeToolName(part.tool);
134
+ const state = part.state || {};
135
+
136
+ let toolInput = '';
137
+ if (state.input && typeof state.input === 'object') {
138
+ if (typeof state.input.command === 'string') {
139
+ toolInput = state.input.command;
140
+ } else {
141
+ try {
142
+ toolInput = JSON.stringify(state.input);
143
+ } catch {
144
+ toolInput = String(state.input);
145
+ }
146
+ }
147
+ }
148
+
149
+ let toolOutput = null;
150
+ if (state.output !== undefined && state.output !== null) {
151
+ toolOutput = typeof state.output === 'string' ? state.output : JSON.stringify(state.output);
152
+ } else if (state.metadata?.output !== undefined && state.metadata?.output !== null) {
153
+ toolOutput = typeof state.metadata.output === 'string'
154
+ ? state.metadata.output
155
+ : JSON.stringify(state.metadata.output);
156
+ }
157
+
158
+ return {
159
+ type: 'tool_update',
160
+ messageId,
161
+ partId,
162
+ toolCallId: callId,
163
+ toolName,
164
+ status: state.status || 'pending',
165
+ toolInput,
166
+ toolOutput,
167
+ exitCode: typeof state.metadata?.exit === 'number' ? state.metadata.exit : undefined,
168
+ timestamp: toIso(state.time?.start || part.time?.start, new Date().toISOString())
169
+ };
170
+ }
171
+
172
+ if (part.type === 'step-finish' && part.tokens) {
173
+ const input = Number(part.tokens.input || 0);
174
+ const output = Number(part.tokens.output || 0);
175
+ const reasoning = Number(part.tokens.reasoning || 0);
176
+ const cacheRead = Number(part.tokens.cache?.read || 0);
177
+ const cacheWrite = Number(part.tokens.cache?.write || 0);
178
+
179
+ return {
180
+ type: 'token_usage',
181
+ usage: {
182
+ used: input + output + reasoning + cacheRead + cacheWrite,
183
+ total: 0,
184
+ percentage: null,
185
+ unsupported: true,
186
+ message: 'OpenCode token total is unavailable from current event payload',
187
+ breakdown: {
188
+ input,
189
+ output,
190
+ reasoning,
191
+ cacheRead,
192
+ cacheCreation: cacheWrite
193
+ }
194
+ }
195
+ };
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ function sendMessage(ws, data) {
202
+ try {
203
+ if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
204
+ ws.send(data);
205
+ } else if (typeof ws.send === 'function') {
206
+ ws.send(JSON.stringify(data));
207
+ }
208
+ } catch (error) {
209
+ console.error('[OpenCode] Error sending message:', error);
210
+ }
211
+ }
212
+
213
+ function updateSessionId(session, ws, nextSessionId, broadcastUpdate = false) {
214
+ if (!nextSessionId || nextSessionId === session.sessionId) return;
215
+
216
+ const previousSessionId = session.sessionId;
217
+ session.sessionId = nextSessionId;
218
+
219
+ if (previousSessionId && activeOpencodeSessions.has(previousSessionId)) {
220
+ activeOpencodeSessions.delete(previousSessionId);
221
+ }
222
+
223
+ activeOpencodeSessions.set(nextSessionId, session);
224
+
225
+ if (ws?.setSessionId && typeof ws.setSessionId === 'function') {
226
+ ws.setSessionId(nextSessionId);
227
+ }
228
+
229
+ if (broadcastUpdate) {
230
+ sendMessage(ws, {
231
+ type: 'session-created',
232
+ sessionId: nextSessionId,
233
+ provider: 'opencode'
234
+ });
235
+ }
236
+ }
237
+
238
+ export async function queryOpencode(command, options = {}, ws) {
239
+ const {
240
+ sessionId,
241
+ cwd,
242
+ projectPath,
243
+ model,
244
+ permissionMode = 'default'
245
+ } = options;
246
+
247
+ const workingDirectory = cwd || projectPath || process.cwd();
248
+
249
+ let client;
250
+ let currentSessionId = sessionId;
251
+
252
+ const sessionState = {
253
+ sessionId: currentSessionId || null,
254
+ status: 'running',
255
+ startedAt: new Date().toISOString(),
256
+ abortController: new AbortController(),
257
+ textPartCache: new Map(),
258
+ reasoningPartCache: new Map()
259
+ };
260
+
261
+ try {
262
+ client = await getOpencodeClient({ directory: workingDirectory });
263
+
264
+ if (!currentSessionId) {
265
+ const created = await client.session.create({
266
+ query: { directory: workingDirectory },
267
+ body: {
268
+ title: 'OpenCode Session'
269
+ }
270
+ });
271
+
272
+ currentSessionId = created?.data?.id;
273
+ if (!currentSessionId) {
274
+ throw new Error('Failed to create OpenCode session');
275
+ }
276
+ }
277
+
278
+ sessionState.sessionId = currentSessionId;
279
+ activeOpencodeSessions.set(currentSessionId, sessionState);
280
+
281
+ if (ws?.setSessionId && typeof ws.setSessionId === 'function') {
282
+ ws.setSessionId(currentSessionId);
283
+ }
284
+
285
+ sendMessage(ws, {
286
+ type: 'session-created',
287
+ sessionId: currentSessionId,
288
+ provider: 'opencode'
289
+ });
290
+
291
+ const modelSelection = resolveOpencodeModel(model);
292
+ const permission = parsePermissionMode(permissionMode);
293
+
294
+ const events = await client.event.subscribe({
295
+ query: { directory: workingDirectory },
296
+ signal: sessionState.abortController.signal
297
+ });
298
+
299
+ await client.session.promptAsync({
300
+ path: { id: currentSessionId },
301
+ query: { directory: workingDirectory },
302
+ body: {
303
+ model: modelSelection,
304
+ permission: permission || undefined,
305
+ parts: [{
306
+ type: 'text',
307
+ text: command
308
+ }]
309
+ }
310
+ });
311
+
312
+ for await (const event of events.stream) {
313
+ if (!event || sessionState.status !== 'running') {
314
+ break;
315
+ }
316
+
317
+ const eventSessionId = getEventSessionId(event);
318
+ if (eventSessionId) {
319
+ updateSessionId(sessionState, ws, eventSessionId, true);
320
+ currentSessionId = sessionState.sessionId;
321
+ }
322
+
323
+ if (eventSessionId && currentSessionId && eventSessionId !== currentSessionId) {
324
+ continue;
325
+ }
326
+
327
+ if (event.type === 'server.connected') {
328
+ continue;
329
+ }
330
+
331
+ if (event.type === 'message.part.updated') {
332
+ const part = event.properties?.part;
333
+ const deltaEvent = mapPartToDelta(part, sessionState);
334
+ if (deltaEvent) {
335
+ sendMessage(ws, {
336
+ type: 'opencode-response',
337
+ data: deltaEvent,
338
+ sessionId: currentSessionId
339
+ });
340
+
341
+ if (deltaEvent.type === 'token_usage') {
342
+ sendMessage(ws, {
343
+ type: 'token-budget',
344
+ data: deltaEvent.usage,
345
+ sessionId: currentSessionId
346
+ });
347
+ }
348
+ }
349
+
350
+ continue;
351
+ }
352
+
353
+ if (event.type === 'message.updated') {
354
+ const info = event.properties?.info;
355
+ if (info?.error?.data?.message) {
356
+ sendMessage(ws, {
357
+ type: 'opencode-response',
358
+ data: {
359
+ type: 'error',
360
+ message: info.error.data.message
361
+ },
362
+ sessionId: currentSessionId
363
+ });
364
+ }
365
+ continue;
366
+ }
367
+
368
+ if (event.type === 'session.status') {
369
+ sendMessage(ws, {
370
+ type: 'opencode-response',
371
+ data: {
372
+ type: 'session_status',
373
+ status: event.properties?.status?.type || 'unknown'
374
+ },
375
+ sessionId: currentSessionId
376
+ });
377
+ continue;
378
+ }
379
+
380
+ if (event.type === 'session.error') {
381
+ sendMessage(ws, {
382
+ type: 'opencode-response',
383
+ data: {
384
+ type: 'error',
385
+ message: event.properties?.error?.data?.message || 'OpenCode session failed'
386
+ },
387
+ sessionId: currentSessionId
388
+ });
389
+ continue;
390
+ }
391
+
392
+ if (event.type === 'session.idle') {
393
+ break;
394
+ }
395
+ }
396
+
397
+ if (sessionState.status === 'running') {
398
+ sendMessage(ws, {
399
+ type: 'opencode-complete',
400
+ sessionId: currentSessionId,
401
+ actualSessionId: currentSessionId
402
+ });
403
+ sessionState.status = 'completed';
404
+ }
405
+ } catch (error) {
406
+ if (sessionState.status === 'aborted') {
407
+ sendMessage(ws, {
408
+ type: 'opencode-complete',
409
+ sessionId: currentSessionId,
410
+ actualSessionId: currentSessionId,
411
+ aborted: true
412
+ });
413
+ } else {
414
+ sendMessage(ws, {
415
+ type: 'opencode-error',
416
+ error: error.message,
417
+ sessionId: currentSessionId
418
+ });
419
+ sessionState.status = 'failed';
420
+ }
421
+ }
422
+ }
423
+
424
+ export function abortOpencodeSession(sessionId) {
425
+ const session = activeOpencodeSessions.get(sessionId);
426
+ if (!session) {
427
+ return false;
428
+ }
429
+
430
+ session.status = 'aborted';
431
+ try {
432
+ session.abortController.abort();
433
+ } catch {
434
+ // no-op
435
+ }
436
+
437
+ return true;
438
+ }
439
+
440
+ export function isOpencodeSessionActive(sessionId) {
441
+ const session = activeOpencodeSessions.get(sessionId);
442
+ return session?.status === 'running';
443
+ }
444
+
445
+ export function getActiveOpencodeSessions() {
446
+ const sessions = [];
447
+
448
+ for (const [id, session] of activeOpencodeSessions.entries()) {
449
+ if (session.status === 'running') {
450
+ sessions.push({
451
+ id,
452
+ status: session.status,
453
+ startedAt: session.startedAt
454
+ });
455
+ }
456
+ }
457
+
458
+ return sessions;
459
+ }
460
+
461
+ setInterval(() => {
462
+ const now = Date.now();
463
+ const maxAge = 30 * 60 * 1000;
464
+
465
+ for (const [id, session] of activeOpencodeSessions.entries()) {
466
+ if (session.status === 'running') continue;
467
+
468
+ const startedAt = new Date(session.startedAt).getTime();
469
+ if (now - startedAt > maxAge) {
470
+ activeOpencodeSessions.delete(id);
471
+ }
472
+ }
473
+ }, 5 * 60 * 1000);
474
+
@@ -66,6 +66,7 @@ import sqlite3 from 'sqlite3';
66
66
  import { open } from 'sqlite';
67
67
  import os from 'os';
68
68
  import { parseCodexTokenCountInfo } from './utils/codexTokenUsage.js';
69
+ import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession } from './opencode-manager.js';
69
70
 
70
71
  // Import TaskMaster detection functions
71
72
  async function detectTaskMasterFolder(projectPath) {
@@ -467,6 +468,14 @@ async function getProjects(progressCallback = null) {
467
468
  project.codexSessions = [];
468
469
  }
469
470
 
471
+ // Also fetch OpenCode sessions for this project
472
+ try {
473
+ project.opencodeSessions = await getOpencodeSessions(actualProjectDir);
474
+ } catch (e) {
475
+ console.warn(`Could not load OpenCode sessions for project ${entry.name}:`, e.message);
476
+ project.opencodeSessions = [];
477
+ }
478
+
470
479
  // Add TaskMaster detection
471
480
  try {
472
481
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -535,7 +544,8 @@ async function getProjects(progressCallback = null) {
535
544
  isManuallyAdded: true,
536
545
  sessions: [],
537
546
  cursorSessions: [],
538
- codexSessions: []
547
+ codexSessions: [],
548
+ opencodeSessions: []
539
549
  };
540
550
 
541
551
  // Try to fetch Cursor sessions for manual projects too
@@ -552,6 +562,13 @@ async function getProjects(progressCallback = null) {
552
562
  console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
553
563
  }
554
564
 
565
+ // Try to fetch OpenCode sessions for manual projects too
566
+ try {
567
+ project.opencodeSessions = await getOpencodeSessions(actualProjectDir);
568
+ } catch (e) {
569
+ console.warn(`Could not load OpenCode sessions for manual project ${projectName}:`, e.message);
570
+ }
571
+
555
572
  // Add TaskMaster detection for manual projects
556
573
  try {
557
574
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
@@ -1063,6 +1080,20 @@ async function deleteProject(projectName, force = false) {
1063
1080
  console.warn('Failed to delete Codex sessions:', err.message);
1064
1081
  }
1065
1082
 
1083
+ // Delete all OpenCode sessions associated with this project
1084
+ try {
1085
+ const opencodeSessions = await getOpencodeSessions(projectPath, { limit: 0 });
1086
+ for (const session of opencodeSessions) {
1087
+ try {
1088
+ await deleteOpencodeSession(session.id, { directory: projectPath });
1089
+ } catch (err) {
1090
+ console.warn(`Failed to delete OpenCode session ${session.id}:`, err.message);
1091
+ }
1092
+ }
1093
+ } catch (err) {
1094
+ console.warn('Failed to delete OpenCode sessions:', err.message);
1095
+ }
1096
+
1066
1097
  // Delete Cursor sessions directory if it exists
1067
1098
  try {
1068
1099
  const hash = crypto.createHash('md5').update(projectPath).digest('hex');
@@ -1129,7 +1160,9 @@ async function addProjectManually(projectPath, displayName = null) {
1129
1160
  displayName: displayName || await generateDisplayName(projectName, absolutePath),
1130
1161
  isManuallyAdded: true,
1131
1162
  sessions: [],
1132
- cursorSessions: []
1163
+ cursorSessions: [],
1164
+ codexSessions: [],
1165
+ opencodeSessions: []
1133
1166
  };
1134
1167
  }
1135
1168
 
@@ -1321,6 +1354,28 @@ async function getCodexSessions(projectPath, options = {}) {
1321
1354
  }
1322
1355
  }
1323
1356
 
1357
+
1358
+
1359
+ let opencodeDiscoveryUnavailable = false;
1360
+
1361
+ async function getOpencodeSessions(projectPath, options = {}) {
1362
+ const { limit = 5 } = options;
1363
+
1364
+ if (opencodeDiscoveryUnavailable) {
1365
+ return [];
1366
+ }
1367
+
1368
+ try {
1369
+ const sessions = await listOpencodeSessions(projectPath, { limit });
1370
+ opencodeDiscoveryUnavailable = false;
1371
+ return sessions;
1372
+ } catch (error) {
1373
+ opencodeDiscoveryUnavailable = true;
1374
+ console.error('Error fetching OpenCode sessions:', error.message);
1375
+ return [];
1376
+ }
1377
+ }
1378
+
1324
1379
  // Parse a Codex session JSONL file to extract metadata
1325
1380
  async function parseCodexSessionFile(filePath) {
1326
1381
  try {
@@ -1622,6 +1677,17 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1622
1677
  }
1623
1678
  }
1624
1679
 
1680
+
1681
+
1682
+ async function getOpencodeSessionMessagesFromManager(sessionId, options = {}) {
1683
+ try {
1684
+ return await getOpencodeSessionMessages(sessionId, options);
1685
+ } catch (error) {
1686
+ console.error(`Error reading OpenCode session messages for ${sessionId}:`, error);
1687
+ return { messages: [], total: 0, hasMore: false };
1688
+ }
1689
+ }
1690
+
1625
1691
  async function deleteCodexSession(sessionId) {
1626
1692
  try {
1627
1693
  const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
@@ -1675,5 +1741,8 @@ export {
1675
1741
  clearProjectDirectoryCache,
1676
1742
  getCodexSessions,
1677
1743
  getCodexSessionMessages,
1678
- deleteCodexSession
1744
+ deleteCodexSession,
1745
+ getOpencodeSessions,
1746
+ getOpencodeSessionMessagesFromManager,
1747
+ deleteOpencodeSession
1679
1748
  };