@axhub/genie 0.1.6 → 0.1.8

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 (37) hide show
  1. package/dist/api-docs.html +351 -909
  2. package/dist/assets/index-CVjMty4a.js +902 -0
  3. package/dist/assets/index-eo5scY_Z.css +32 -0
  4. package/dist/index.html +5 -5
  5. package/dist/manifest.json +2 -2
  6. package/package.json +8 -2
  7. package/server/channels/core/ChannelManager.js +399 -0
  8. package/server/channels/core/PluginManager.js +59 -0
  9. package/server/channels/index.js +3 -0
  10. package/server/channels/plugins/BasePlugin.js +46 -0
  11. package/server/channels/plugins/dingtalk/DingTalkAdapter.js +156 -0
  12. package/server/channels/plugins/dingtalk/DingTalkPlugin.js +592 -0
  13. package/server/channels/plugins/dingtalk/index.js +2 -0
  14. package/server/channels/plugins/lark/LarkAdapter.js +100 -0
  15. package/server/channels/plugins/lark/LarkCards.js +43 -0
  16. package/server/channels/plugins/lark/LarkPlugin.js +260 -0
  17. package/server/channels/runtime/AgentRuntimeAdapter.js +179 -0
  18. package/server/channels/runtime/DingTalkStreamWriter.js +105 -0
  19. package/server/channels/runtime/LarkStreamWriter.js +99 -0
  20. package/server/channels/store/ChannelStore.js +236 -0
  21. package/server/database/db.js +109 -1
  22. package/server/database/init.sql +47 -1
  23. package/server/gemini-cli.js +280 -0
  24. package/server/index.js +230 -11
  25. package/server/openai-codex.js +104 -8
  26. package/server/opencode-cli.js +673 -0
  27. package/server/projects.js +645 -5
  28. package/server/routes/agent.js +40 -12
  29. package/server/routes/channels.js +221 -0
  30. package/server/routes/cli-auth.js +317 -0
  31. package/server/routes/commands.js +29 -3
  32. package/server/routes/git.js +15 -5
  33. package/server/routes/opencode.js +72 -0
  34. package/shared/modelConstants.js +62 -17
  35. package/dist/assets/index-CtRxrKDm.css +0 -32
  36. package/dist/assets/index-OENtErNy.js +0 -1249
  37. package/server/database/auth.db +0 -0
package/server/index.js CHANGED
@@ -73,10 +73,12 @@ import pty from 'node-pty';
73
73
  import fetch from 'node-fetch';
74
74
  import mime from 'mime-types';
75
75
 
76
- import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
76
+ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, getGeminiSessionMessages } from './projects.js';
77
77
  import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
78
78
  import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
79
79
  import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
80
+ import { queryGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
81
+ import { queryOpencode, abortOpencodeSession, isOpencodeSessionActive, getActiveOpencodeSessions } from './opencode-cli.js';
80
82
  import gitRoutes from './routes/git.js';
81
83
  import authRoutes from './routes/auth.js';
82
84
  import mcpRoutes from './routes/mcp.js';
@@ -85,20 +87,27 @@ import taskmasterRoutes from './routes/taskmaster.js';
85
87
  import mcpUtilsRoutes from './routes/mcp-utils.js';
86
88
  import commandsRoutes from './routes/commands.js';
87
89
  import settingsRoutes from './routes/settings.js';
90
+ import channelsRoutes from './routes/channels.js';
88
91
  import agentRoutes from './routes/agent.js';
89
92
  import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
90
93
  import cliAuthRoutes from './routes/cli-auth.js';
91
94
  import userRoutes from './routes/user.js';
92
95
  import codexRoutes from './routes/codex.js';
96
+ import opencodeRoutes from './routes/opencode.js';
93
97
  import { parseCodexTokenCountInfo } from './utils/codexTokenUsage.js';
94
98
  import { initializeDatabase } from './database/db.js';
95
99
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
96
100
  import { IS_PLATFORM } from './constants/config.js';
101
+ import { getChannelManager } from './channels/index.js';
97
102
 
98
103
  // File system watcher for projects folder
99
104
  let projectsWatcher = null;
100
105
  const connectedClients = new Set();
101
106
  let isGetProjectsRunning = false; // Flag to prevent reentrant calls
107
+ const UPDATE_PACKAGE_NAME = process.env.UPDATE_PACKAGE_NAME || packageInfo.id || '@axhub/genie';
108
+ const VERSION_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
109
+ let cachedSystemVersion = null;
110
+ let cachedSystemVersionExpiresAt = 0;
102
111
  const RUNTIME_STATUS_PATH = (() => {
103
112
  if (process.env.AXHUB_GENIE_STATUS_PATH) {
104
113
  return process.env.AXHUB_GENIE_STATUS_PATH;
@@ -107,6 +116,120 @@ const RUNTIME_STATUS_PATH = (() => {
107
116
  return path.join(os.homedir(), '.axhub-genie', 'runtime-status.json');
108
117
  })();
109
118
 
119
+ function getNpmCommand() {
120
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
121
+ }
122
+
123
+ function compareSemver(v1, v2) {
124
+ const normalizedV1 = String(v1 || '').replace(/^v/i, '').split('-')[0];
125
+ const normalizedV2 = String(v2 || '').replace(/^v/i, '').split('-')[0];
126
+ const parts1 = normalizedV1.split('.').map((part) => Number(part));
127
+ const parts2 = normalizedV2.split('.').map((part) => Number(part));
128
+
129
+ for (let i = 0; i < 3; i += 1) {
130
+ const a = Number.isFinite(parts1[i]) ? parts1[i] : 0;
131
+ const b = Number.isFinite(parts2[i]) ? parts2[i] : 0;
132
+ if (a > b) return 1;
133
+ if (a < b) return -1;
134
+ }
135
+
136
+ return 0;
137
+ }
138
+
139
+ function readLatestVersionFromNpmRegistry() {
140
+ return new Promise((resolve, reject) => {
141
+ const npmCommand = getNpmCommand();
142
+ const child = spawn(npmCommand, ['view', UPDATE_PACKAGE_NAME, 'version', 'time', '--json'], {
143
+ cwd: path.join(__dirname, '..'),
144
+ env: process.env
145
+ });
146
+
147
+ let output = '';
148
+ let errorOutput = '';
149
+
150
+ child.stdout.on('data', (data) => {
151
+ output += data.toString();
152
+ });
153
+
154
+ child.stderr.on('data', (data) => {
155
+ errorOutput += data.toString();
156
+ });
157
+
158
+ child.on('close', (code) => {
159
+ if (code !== 0) {
160
+ reject(new Error(errorOutput || `npm view exited with code ${code}`));
161
+ return;
162
+ }
163
+
164
+ try {
165
+ const parsed = JSON.parse(output.trim());
166
+ const latestVersion = parsed?.version;
167
+ if (!latestVersion) {
168
+ reject(new Error('npm view returned no version'));
169
+ return;
170
+ }
171
+
172
+ const publishedAt = parsed?.time?.[latestVersion] || parsed?.time?.modified || null;
173
+ resolve({
174
+ latestVersion,
175
+ publishedAt
176
+ });
177
+ } catch (error) {
178
+ reject(new Error(`failed to parse npm view output: ${error.message}`));
179
+ }
180
+ });
181
+
182
+ child.on('error', (error) => {
183
+ reject(error);
184
+ });
185
+ });
186
+ }
187
+
188
+ async function getSystemVersionInfo(forceRefresh = false) {
189
+ const now = Date.now();
190
+ if (!forceRefresh && cachedSystemVersion && now < cachedSystemVersionExpiresAt) {
191
+ return { ...cachedSystemVersion, cacheHit: true, stale: false };
192
+ }
193
+
194
+ try {
195
+ const npmInfo = await readLatestVersionFromNpmRegistry();
196
+ const currentVersion = packageInfo.version;
197
+ const latestVersion = npmInfo.latestVersion;
198
+ const updateAvailable = compareSemver(latestVersion, currentVersion) > 0;
199
+ const fetchedAt = new Date().toISOString();
200
+ const expiresAt = now + VERSION_CACHE_TTL_MS;
201
+
202
+ cachedSystemVersion = {
203
+ packageName: UPDATE_PACKAGE_NAME,
204
+ currentVersion,
205
+ latestVersion,
206
+ updateAvailable,
207
+ releaseInfo: {
208
+ title: `v${latestVersion}`,
209
+ body: '',
210
+ htmlUrl: `https://www.npmjs.com/package/${UPDATE_PACKAGE_NAME}`,
211
+ publishedAt: npmInfo.publishedAt
212
+ },
213
+ fetchedAt,
214
+ expiresAt: new Date(expiresAt).toISOString()
215
+ };
216
+ cachedSystemVersionExpiresAt = expiresAt;
217
+
218
+ return { ...cachedSystemVersion, cacheHit: false, stale: false };
219
+ } catch (error) {
220
+ // Return stale cache when npm registry is temporarily unavailable.
221
+ if (cachedSystemVersion) {
222
+ return {
223
+ ...cachedSystemVersion,
224
+ cacheHit: true,
225
+ stale: true,
226
+ warning: `Using stale cached version data: ${error.message}`
227
+ };
228
+ }
229
+ throw error;
230
+ }
231
+ }
232
+
110
233
  // Broadcast progress to all connected WebSocket clients
111
234
  function broadcastProgress(progress) {
112
235
  const message = JSON.stringify({
@@ -325,6 +448,9 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
325
448
  // Settings API Routes (protected)
326
449
  app.use('/api/settings', authenticateToken, settingsRoutes);
327
450
 
451
+ // Channels API Routes (protected)
452
+ app.use('/api/channels', authenticateToken, channelsRoutes);
453
+
328
454
  // CLI Authentication API Routes (protected)
329
455
  app.use('/api/cli', authenticateToken, cliAuthRoutes);
330
456
 
@@ -333,6 +459,7 @@ app.use('/api/user', authenticateToken, userRoutes);
333
459
 
334
460
  // Codex API Routes (protected)
335
461
  app.use('/api/codex', authenticateToken, codexRoutes);
462
+ app.use('/api/opencode', authenticateToken, opencodeRoutes);
336
463
 
337
464
  // Agent API Routes (uses API key authentication)
338
465
  app.use('/api/agent', agentRoutes);
@@ -360,6 +487,27 @@ app.use(express.static(path.join(__dirname, '../dist'), {
360
487
  // /api/config endpoint removed - no longer needed
361
488
  // Frontend now uses window.location for WebSocket URLs
362
489
 
490
+ // System version check endpoint (npm registry with server-side cache)
491
+ app.get('/api/system/version', authenticateToken, async (req, res) => {
492
+ try {
493
+ const forceRefresh = String(req.query.force || '').toLowerCase() === 'true';
494
+ const versionInfo = await getSystemVersionInfo(forceRefresh);
495
+
496
+ res.setHeader('Cache-Control', 'private, max-age=3600, stale-while-revalidate=300');
497
+ res.json({
498
+ success: true,
499
+ ...versionInfo,
500
+ cacheTtlMs: VERSION_CACHE_TTL_MS
501
+ });
502
+ } catch (error) {
503
+ console.error('System version check error:', error);
504
+ res.status(500).json({
505
+ success: false,
506
+ error: error.message
507
+ });
508
+ }
509
+ });
510
+
363
511
  // System update endpoint
364
512
  app.post('/api/system/update', authenticateToken, async (req, res) => {
365
513
  try {
@@ -368,10 +516,11 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
368
516
 
369
517
  console.log('Starting system update from directory:', projectRoot);
370
518
 
371
- // Run the update command
372
- const updateCommand = 'git checkout main && git pull && npm install';
519
+ const npmCommand = getNpmCommand();
520
+ const updateArgs = ['update', '-g', UPDATE_PACKAGE_NAME];
373
521
 
374
- const child = spawn('sh', ['-c', updateCommand], {
522
+ // Run npm global update command
523
+ const child = spawn(npmCommand, updateArgs, {
375
524
  cwd: projectRoot,
376
525
  env: process.env
377
526
  });
@@ -393,17 +542,21 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
393
542
 
394
543
  child.on('close', (code) => {
395
544
  if (code === 0) {
545
+ cachedSystemVersion = null;
546
+ cachedSystemVersionExpiresAt = 0;
396
547
  res.json({
397
548
  success: true,
398
549
  output: output || 'Update completed successfully',
399
- message: 'Update completed. Please restart the server to apply changes.'
550
+ message: 'Update completed. Please restart the server to apply changes.',
551
+ command: `${npmCommand} ${updateArgs.join(' ')}`
400
552
  });
401
553
  } else {
402
554
  res.status(500).json({
403
555
  success: false,
404
556
  error: 'Update command failed',
405
557
  output: output,
406
- errorOutput: errorOutput
558
+ errorOutput: errorOutput,
559
+ command: `${npmCommand} ${updateArgs.join(' ')}`
407
560
  });
408
561
  }
409
562
  });
@@ -469,6 +622,20 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
469
622
  }
470
623
  });
471
624
 
625
+ app.get('/api/gemini/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
626
+ try {
627
+ const { sessionId } = req.params;
628
+ const { limit, offset } = req.query;
629
+
630
+ const parsedLimit = limit ? parseInt(limit, 10) : null;
631
+ const parsedOffset = offset ? parseInt(offset, 10) : 0;
632
+ const result = await getGeminiSessionMessages(sessionId, parsedLimit, parsedOffset);
633
+ res.json(result);
634
+ } catch (error) {
635
+ res.status(500).json({ error: error.message });
636
+ }
637
+ });
638
+
472
639
  // Rename project endpoint
473
640
  app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
474
641
  try {
@@ -910,6 +1077,18 @@ function handleChatConnection(ws) {
910
1077
  console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
911
1078
  console.log('🤖 Model:', data.options?.model || 'default');
912
1079
  await queryCodex(data.command, data.options, writer);
1080
+ } else if (data.type === 'gemini-command') {
1081
+ console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
1082
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1083
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1084
+ console.log('🤖 Model:', data.options?.model || 'default');
1085
+ await queryGemini(data.command, data.options, writer);
1086
+ } else if (data.type === 'opencode-command') {
1087
+ console.log('[DEBUG] OpenCode message:', data.command || '[Continue/Resume]');
1088
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1089
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1090
+ console.log('🤖 Model:', data.options?.model || 'default');
1091
+ await queryOpencode(data.command, data.options, writer);
913
1092
  } else if (data.type === 'cursor-resume') {
914
1093
  // Backward compatibility: treat as cursor-command with resume and no prompt
915
1094
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -927,6 +1106,10 @@ function handleChatConnection(ws) {
927
1106
  success = abortCursorSession(data.sessionId);
928
1107
  } else if (provider === 'codex') {
929
1108
  success = abortCodexSession(data.sessionId);
1109
+ } else if (provider === 'gemini') {
1110
+ success = abortGeminiSession(data.sessionId);
1111
+ } else if (provider === 'opencode') {
1112
+ success = abortOpencodeSession(data.sessionId);
930
1113
  } else {
931
1114
  // Use Claude Agents SDK
932
1115
  success = await abortClaudeSDKSession(data.sessionId);
@@ -969,6 +1152,10 @@ function handleChatConnection(ws) {
969
1152
  isActive = isCursorSessionActive(sessionId);
970
1153
  } else if (provider === 'codex') {
971
1154
  isActive = isCodexSessionActive(sessionId);
1155
+ } else if (provider === 'gemini') {
1156
+ isActive = isGeminiSessionActive(sessionId);
1157
+ } else if (provider === 'opencode') {
1158
+ isActive = isOpencodeSessionActive(sessionId);
972
1159
  } else {
973
1160
  // Use Claude Agents SDK
974
1161
  isActive = isClaudeSDKSessionActive(sessionId);
@@ -985,7 +1172,9 @@ function handleChatConnection(ws) {
985
1172
  const activeSessions = {
986
1173
  claude: getActiveClaudeSDKSessions(),
987
1174
  cursor: getActiveCursorSessions(),
988
- codex: getActiveCodexSessions()
1175
+ codex: getActiveCodexSessions(),
1176
+ gemini: getActiveGeminiSessions(),
1177
+ opencode: getActiveOpencodeSessions()
989
1178
  };
990
1179
  writer.send({
991
1180
  type: 'active-sessions',
@@ -1091,7 +1280,7 @@ function handleShellConnection(ws) {
1091
1280
  if (isPlainShell) {
1092
1281
  welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
1093
1282
  } else {
1094
- const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
1283
+ const providerName = provider === 'cursor' ? 'Cursor' : provider === 'gemini' ? 'Gemini' : 'Claude';
1095
1284
  welcomeMsg = hasSession ?
1096
1285
  `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
1097
1286
  `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
@@ -1127,6 +1316,20 @@ function handleShellConnection(ws) {
1127
1316
  shellCommand = `cd "${projectPath}" && cursor-agent`;
1128
1317
  }
1129
1318
  }
1319
+ } else if (provider === 'gemini') {
1320
+ if (os.platform() === 'win32') {
1321
+ if (hasSession && sessionId) {
1322
+ shellCommand = `Set-Location -Path "${projectPath}"; npx -y @google/gemini-cli --resume ${sessionId}`;
1323
+ } else {
1324
+ shellCommand = `Set-Location -Path "${projectPath}"; npx -y @google/gemini-cli`;
1325
+ }
1326
+ } else {
1327
+ if (hasSession && sessionId) {
1328
+ shellCommand = `cd "${projectPath}" && npx -y @google/gemini-cli --resume ${sessionId}`;
1329
+ } else {
1330
+ shellCommand = `cd "${projectPath}" && npx -y @google/gemini-cli`;
1331
+ }
1332
+ }
1130
1333
  } else {
1131
1334
  // Use claude command (default) or initialCommand if provided
1132
1335
  const command = initialCommand || 'claude';
@@ -1888,16 +2091,31 @@ function clearRuntimeStatusFile() {
1888
2091
  }
1889
2092
 
1890
2093
  function registerShutdownHandlers() {
1891
- process.on('exit', () => {
2094
+ process.on('exit', async () => {
2095
+ try {
2096
+ await getChannelManager().shutdown();
2097
+ } catch (error) {
2098
+ console.warn('[WARN] Failed to shutdown channel manager on exit:', error.message);
2099
+ }
1892
2100
  clearRuntimeStatusFile();
1893
2101
  });
1894
2102
 
1895
- process.on('SIGINT', () => {
2103
+ process.on('SIGINT', async () => {
2104
+ try {
2105
+ await getChannelManager().shutdown();
2106
+ } catch (error) {
2107
+ console.warn('[WARN] Failed to shutdown channel manager on SIGINT:', error.message);
2108
+ }
1896
2109
  clearRuntimeStatusFile();
1897
2110
  process.exit(0);
1898
2111
  });
1899
2112
 
1900
- process.on('SIGTERM', () => {
2113
+ process.on('SIGTERM', async () => {
2114
+ try {
2115
+ await getChannelManager().shutdown();
2116
+ } catch (error) {
2117
+ console.warn('[WARN] Failed to shutdown channel manager on SIGTERM:', error.message);
2118
+ }
1901
2119
  clearRuntimeStatusFile();
1902
2120
  process.exit(0);
1903
2121
  });
@@ -1908,6 +2126,7 @@ async function startServer() {
1908
2126
  try {
1909
2127
  // Initialize authentication database
1910
2128
  await initializeDatabase();
2129
+ await getChannelManager().initialize();
1911
2130
 
1912
2131
  // Check if running in production mode (dist folder exists)
1913
2132
  const distIndexPath = path.join(__dirname, '../dist/index.html');
@@ -140,7 +140,7 @@ function transformCodexEvent(event) {
140
140
  case 'thread.started':
141
141
  return {
142
142
  type: 'thread_started',
143
- threadId: event.id
143
+ threadId: event.thread_id || event.id
144
144
  };
145
145
 
146
146
  case 'error':
@@ -157,6 +157,42 @@ function transformCodexEvent(event) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Extract thread id from thread.started events (SDK uses thread_id)
162
+ * @param {object} event
163
+ * @returns {string|null}
164
+ */
165
+ function getThreadIdFromEvent(event) {
166
+ const threadId = event?.thread_id || event?.id;
167
+ return typeof threadId === 'string' && threadId.trim() ? threadId.trim() : null;
168
+ }
169
+
170
+ /**
171
+ * Extract text-bearing item info from Codex item events.
172
+ * Only agent_message and reasoning currently stream textual deltas.
173
+ * @param {object} item
174
+ * @returns {{itemId: string, itemType: string, text: string, isReasoning: boolean}|null}
175
+ */
176
+ function getTextItemInfo(item) {
177
+ if (!item || (item.type !== 'agent_message' && item.type !== 'reasoning')) {
178
+ return null;
179
+ }
180
+
181
+ const itemId = typeof item.id === 'string' && item.id.trim()
182
+ ? item.id.trim()
183
+ : null;
184
+ if (!itemId) {
185
+ return null;
186
+ }
187
+
188
+ return {
189
+ itemId,
190
+ itemType: item.type,
191
+ text: typeof item.text === 'string' ? item.text : '',
192
+ isReasoning: item.type === 'reasoning'
193
+ };
194
+ }
195
+
160
196
  /**
161
197
  * Map permission mode to Codex SDK options
162
198
  * @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'
@@ -178,7 +214,8 @@ function mapPermissionModeToCodexOptions(permissionMode) {
178
214
  default:
179
215
  return {
180
216
  sandboxMode: 'workspace-write',
181
- approvalPolicy: 'untrusted'
217
+ // Keep default mode writable and non-blocking for coding workflows.
218
+ approvalPolicy: 'never'
182
219
  };
183
220
  }
184
221
  }
@@ -195,7 +232,8 @@ export async function queryCodex(command, options = {}, ws) {
195
232
  cwd,
196
233
  projectPath,
197
234
  model,
198
- permissionMode = 'default'
235
+ permissionMode = 'default',
236
+ modelReasoningEffort
199
237
  } = options;
200
238
 
201
239
  const workingDirectory = cwd || projectPath || process.cwd();
@@ -205,6 +243,7 @@ export async function queryCodex(command, options = {}, ws) {
205
243
  let thread;
206
244
  let currentSessionId = sessionId;
207
245
  let resolvedSessionIdFromEvent = null;
246
+ const streamedTextByItemId = new Map();
208
247
 
209
248
  const syncResolvedSessionId = (broadcastUpdate = false) => {
210
249
  const resolvedThreadId = typeof thread?.id === 'string' && thread.id.trim()
@@ -252,7 +291,8 @@ export async function queryCodex(command, options = {}, ws) {
252
291
  skipGitRepoCheck: true,
253
292
  sandboxMode,
254
293
  approvalPolicy,
255
- model
294
+ model,
295
+ ...(modelReasoningEffort ? { modelReasoningEffort } : {})
256
296
  };
257
297
 
258
298
  // Start or resume thread
@@ -289,8 +329,11 @@ export async function queryCodex(command, options = {}, ws) {
289
329
  syncResolvedSessionId(true);
290
330
 
291
331
  for await (const event of streamedTurn.events) {
292
- if (event?.type === 'thread.started' && typeof event.id === 'string' && event.id.trim()) {
293
- resolvedSessionIdFromEvent = event.id.trim();
332
+ if (event?.type === 'thread.started') {
333
+ const eventThreadId = getThreadIdFromEvent(event);
334
+ if (eventThreadId) {
335
+ resolvedSessionIdFromEvent = eventThreadId;
336
+ }
294
337
  }
295
338
 
296
339
  syncResolvedSessionId(true);
@@ -301,8 +344,61 @@ export async function queryCodex(command, options = {}, ws) {
301
344
  break;
302
345
  }
303
346
 
304
- if (event.type === 'item.started' || event.type === 'item.updated') {
305
- continue;
347
+ const isItemEvent = event.type === 'item.started' || event.type === 'item.updated' || event.type === 'item.completed';
348
+ if (isItemEvent) {
349
+ const textItemInfo = getTextItemInfo(event.item);
350
+
351
+ if (textItemInfo) {
352
+ const previousText = streamedTextByItemId.get(textItemInfo.itemId) || '';
353
+ const nextText = textItemInfo.text || '';
354
+
355
+ let delta = '';
356
+ if (nextText.startsWith(previousText)) {
357
+ delta = nextText.slice(previousText.length);
358
+ } else if (nextText !== previousText) {
359
+ // Fallback when text is rewritten rather than appended
360
+ delta = nextText;
361
+ }
362
+
363
+ streamedTextByItemId.set(textItemInfo.itemId, nextText);
364
+
365
+ if (delta) {
366
+ sendMessage(ws, {
367
+ type: 'codex-response',
368
+ data: {
369
+ type: 'item_delta',
370
+ itemType: textItemInfo.itemType,
371
+ itemId: textItemInfo.itemId,
372
+ isReasoning: textItemInfo.isReasoning,
373
+ delta
374
+ },
375
+ sessionId: currentSessionId
376
+ });
377
+ }
378
+
379
+ if (event.type === 'item.completed') {
380
+ sendMessage(ws, {
381
+ type: 'codex-response',
382
+ data: {
383
+ type: 'item_done',
384
+ itemType: textItemInfo.itemType,
385
+ itemId: textItemInfo.itemId,
386
+ isReasoning: textItemInfo.isReasoning,
387
+ content: nextText
388
+ },
389
+ sessionId: currentSessionId
390
+ });
391
+ streamedTextByItemId.delete(textItemInfo.itemId);
392
+ }
393
+
394
+ continue;
395
+ }
396
+
397
+ // Non-text items can be noisy during started/updated phases.
398
+ // Keep existing behavior: emit them when completed.
399
+ if (event.type !== 'item.completed') {
400
+ continue;
401
+ }
306
402
  }
307
403
 
308
404
  const transformed = transformCodexEvent(event);