@mmmbuto/nexuscli 0.5.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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/nexuscli.js +117 -0
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/frontend/dist/assets/index-Bn_l1e6e.css +1 -0
- package/frontend/dist/assets/index-CikJbUR5.js +8617 -0
- package/frontend/dist/browserconfig.xml +12 -0
- package/frontend/dist/favicon-16x16.png +0 -0
- package/frontend/dist/favicon-32x32.png +0 -0
- package/frontend/dist/favicon-48x48.png +0 -0
- package/frontend/dist/favicon.ico +0 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/icon-maskable-192.png +0 -0
- package/frontend/dist/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +79 -0
- package/frontend/dist/manifest.json +75 -0
- package/frontend/dist/sw.js +122 -0
- package/frontend/package.json +28 -0
- package/lib/cli/api.js +156 -0
- package/lib/cli/boot.js +172 -0
- package/lib/cli/config.js +185 -0
- package/lib/cli/engines.js +257 -0
- package/lib/cli/init.js +660 -0
- package/lib/cli/logs.js +72 -0
- package/lib/cli/start.js +220 -0
- package/lib/cli/status.js +187 -0
- package/lib/cli/stop.js +64 -0
- package/lib/cli/uninstall.js +194 -0
- package/lib/cli/users.js +295 -0
- package/lib/cli/workspaces.js +337 -0
- package/lib/config/manager.js +233 -0
- package/lib/server/.env.example +20 -0
- package/lib/server/db/adapter.js +314 -0
- package/lib/server/db/drivers/better-sqlite3.js +38 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_ultra_light_schema.sql +96 -0
- package/lib/server/db/migrations/002_session_conversation_mapping.sql +19 -0
- package/lib/server/db/migrations/003_message_engine_tracking.sql +18 -0
- package/lib/server/db/migrations/004_performance_indexes.sql +16 -0
- package/lib/server/db.js +2 -0
- package/lib/server/lib/cli-wrapper.js +164 -0
- package/lib/server/lib/output-parser.js +132 -0
- package/lib/server/lib/pty-adapter.js +57 -0
- package/lib/server/middleware/auth.js +103 -0
- package/lib/server/models/Conversation.js +259 -0
- package/lib/server/models/Message.js +228 -0
- package/lib/server/models/User.js +115 -0
- package/lib/server/package-lock.json +5895 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/chat.js +206 -0
- package/lib/server/routes/codex.js +205 -0
- package/lib/server/routes/conversations.js +224 -0
- package/lib/server/routes/gemini.js +228 -0
- package/lib/server/routes/jobs.js +317 -0
- package/lib/server/routes/messages.js +60 -0
- package/lib/server/routes/models.js +198 -0
- package/lib/server/routes/sessions.js +285 -0
- package/lib/server/routes/upload.js +134 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspace.js +80 -0
- package/lib/server/routes/workspaces.js +142 -0
- package/lib/server/scripts/cleanup-ghost-sessions.js +71 -0
- package/lib/server/scripts/seed-users.js +37 -0
- package/lib/server/scripts/test-history-access.js +50 -0
- package/lib/server/server.js +227 -0
- package/lib/server/services/cache.js +85 -0
- package/lib/server/services/claude-wrapper.js +312 -0
- package/lib/server/services/cli-loader.js +384 -0
- package/lib/server/services/codex-output-parser.js +277 -0
- package/lib/server/services/codex-wrapper.js +224 -0
- package/lib/server/services/context-bridge.js +289 -0
- package/lib/server/services/gemini-output-parser.js +398 -0
- package/lib/server/services/gemini-wrapper.js +249 -0
- package/lib/server/services/history-sync.js +407 -0
- package/lib/server/services/output-parser.js +415 -0
- package/lib/server/services/session-manager.js +465 -0
- package/lib/server/services/summary-generator.js +259 -0
- package/lib/server/services/workspace-manager.js +516 -0
- package/lib/server/tests/history-sync.test.js +90 -0
- package/lib/server/tests/integration-session-sync.test.js +151 -0
- package/lib/server/tests/integration.test.js +76 -0
- package/lib/server/tests/performance.test.js +118 -0
- package/lib/server/tests/services.test.js +160 -0
- package/lib/setup/postinstall.js +216 -0
- package/lib/utils/paths.js +107 -0
- package/lib/utils/termux.js +145 -0
- package/package.json +82 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const pty = require('../lib/pty-adapter');
|
|
4
|
+
const OutputParser = require('./output-parser');
|
|
5
|
+
const { getApiKey } = require('../db');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapper for Claude Code CLI (local installation)
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Uses local Claude Code installation (/home/dag/.claude/local/claude)
|
|
12
|
+
* - OAuth authentication (handled by CLI)
|
|
13
|
+
* - Real-time status streaming via onStatus callback
|
|
14
|
+
* - Session management with conversation ID
|
|
15
|
+
*
|
|
16
|
+
* Architecture:
|
|
17
|
+
* - Spawns Claude Code CLI with node-pty
|
|
18
|
+
* - Parses stdout for tool use, thinking, file ops
|
|
19
|
+
* - Emits status events → SSE stream
|
|
20
|
+
* - Returns final response text → saved in DB
|
|
21
|
+
*/
|
|
22
|
+
class ClaudeWrapper {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.claudePath = this.resolveClaudePath(options.claudePath);
|
|
25
|
+
this.workspaceDir = options.workspaceDir || process.env.NEXUSCLI_WORKSPACE || process.cwd();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
isExistingSession(sessionId, workspacePath) {
|
|
29
|
+
// ONLY check filesystem - Claude CLI's .jsonl files are the source of truth
|
|
30
|
+
// NexusCLI's database (sessions/conversations tables) may have IDs that
|
|
31
|
+
// Claude CLI doesn't recognize, causing "No conversation found" errors
|
|
32
|
+
try {
|
|
33
|
+
const projectsDir = path.join(process.env.HOME || '', '.claude', 'projects');
|
|
34
|
+
if (fs.existsSync(projectsDir)) {
|
|
35
|
+
const dirs = fs.readdirSync(projectsDir);
|
|
36
|
+
for (const dir of dirs) {
|
|
37
|
+
const sessionFile = path.join(projectsDir, dir, `${sessionId}.jsonl`);
|
|
38
|
+
if (fs.existsSync(sessionFile)) {
|
|
39
|
+
console.log(`[ClaudeWrapper] Session ${sessionId} found in filesystem: ${sessionFile}`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn('[ClaudeWrapper] Filesystem lookup failed:', err.message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`[ClaudeWrapper] Session ${sessionId} not found in Claude projects - treating as NEW`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resolveClaudePath(overridePath) {
|
|
53
|
+
if (overridePath && fs.existsSync(overridePath)) return overridePath;
|
|
54
|
+
const envPath = process.env.NEXUSCLI_CLAUDE_PATH;
|
|
55
|
+
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
56
|
+
|
|
57
|
+
const candidates = [
|
|
58
|
+
path.join(process.env.HOME || '', '.claude', 'local', 'claude'),
|
|
59
|
+
path.join(process.env.PREFIX || '', 'bin', 'claude'),
|
|
60
|
+
path.join(process.env.HOME || '', 'bin', 'claude'),
|
|
61
|
+
'/usr/local/bin/claude',
|
|
62
|
+
'/usr/bin/claude',
|
|
63
|
+
].filter(Boolean);
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).mode & 0o111) {
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Send message to Claude Code CLI
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} params
|
|
82
|
+
* @param {string} params.prompt - User message
|
|
83
|
+
* @param {string} params.conversationId - Session ID for Claude
|
|
84
|
+
* @param {string} params.model - Model (sonnet, opus, haiku, or full name)
|
|
85
|
+
* @param {string} params.workspacePath - Workspace directory for Claude CLI --cwd
|
|
86
|
+
* @param {Function} params.onStatus - Callback for status events (tool use, thinking)
|
|
87
|
+
* @returns {Promise<{text: string, usage: Object}>}
|
|
88
|
+
*/
|
|
89
|
+
async sendMessage({ prompt, conversationId, model = 'sonnet', workspacePath, onStatus }) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const parser = new OutputParser();
|
|
92
|
+
// Prevent double-settling when PTY fires both error and exit events
|
|
93
|
+
let promiseSettled = false;
|
|
94
|
+
|
|
95
|
+
if (!this.claudePath || !fs.existsSync(this.claudePath)) {
|
|
96
|
+
const msg = `Claude CLI not found (set NEXUSCLI_CLAUDE_PATH)`;
|
|
97
|
+
console.error('[ClaudeWrapper]', msg);
|
|
98
|
+
if (onStatus) {
|
|
99
|
+
onStatus({ type: 'error', category: 'runtime', message: msg });
|
|
100
|
+
}
|
|
101
|
+
return reject(new Error(msg));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if this is an existing session (DB is source of truth)
|
|
105
|
+
const isExistingSession = this.isExistingSession(conversationId);
|
|
106
|
+
|
|
107
|
+
// Build Claude Code CLI args
|
|
108
|
+
const args = [
|
|
109
|
+
'--dangerously-skip-permissions', // Auto-approve all tool use
|
|
110
|
+
'--model', model,
|
|
111
|
+
'--print', // Non-interactive mode
|
|
112
|
+
'--verbose', // Enable detailed output
|
|
113
|
+
'--output-format', 'stream-json', // JSON streaming events
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Session management: -r (resume) or --session-id (new)
|
|
117
|
+
if (isExistingSession) {
|
|
118
|
+
args.push('-r', conversationId); // Resume with full history
|
|
119
|
+
} else {
|
|
120
|
+
args.push('--session-id', conversationId); // Create new session
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
args.push(prompt);
|
|
124
|
+
|
|
125
|
+
// Use provided workspace or fallback to default
|
|
126
|
+
const cwd = workspacePath || this.workspaceDir;
|
|
127
|
+
|
|
128
|
+
// Build environment - detect DeepSeek models and configure API accordingly
|
|
129
|
+
const spawnEnv = { ...process.env };
|
|
130
|
+
const isDeepSeek = model.startsWith('deepseek-');
|
|
131
|
+
|
|
132
|
+
if (isDeepSeek) {
|
|
133
|
+
// Get API key from database (priority) or fallback to env var
|
|
134
|
+
const deepseekKey = getApiKey('deepseek') || process.env.DEEPSEEK_API_KEY;
|
|
135
|
+
|
|
136
|
+
if (!deepseekKey) {
|
|
137
|
+
const errorMsg = `DeepSeek API key not configured!\n\n` +
|
|
138
|
+
`Run this command to add your API key:\n` +
|
|
139
|
+
` nexuscli api set deepseek YOUR_API_KEY\n\n` +
|
|
140
|
+
`Get your key at: https://platform.deepseek.com/api_keys`;
|
|
141
|
+
|
|
142
|
+
console.error(`[ClaudeWrapper] ❌ ${errorMsg}`);
|
|
143
|
+
|
|
144
|
+
if (onStatus) {
|
|
145
|
+
onStatus({
|
|
146
|
+
type: 'error',
|
|
147
|
+
category: 'config',
|
|
148
|
+
message: errorMsg
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return reject(new Error(errorMsg));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// DeepSeek uses Anthropic-compatible API at different endpoint
|
|
156
|
+
spawnEnv.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic';
|
|
157
|
+
spawnEnv.ANTHROPIC_AUTH_TOKEN = deepseekKey;
|
|
158
|
+
console.log(`[ClaudeWrapper] DeepSeek detected - using api.deepseek.com/anthropic`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(`[ClaudeWrapper] Model: ${model}${isDeepSeek ? ' (DeepSeek API)' : ''}`);
|
|
162
|
+
console.log(`[ClaudeWrapper] Session: ${conversationId} (${isExistingSession ? 'RESUME' : 'NEW'})`);
|
|
163
|
+
console.log(`[ClaudeWrapper] Working dir: ${cwd}`);
|
|
164
|
+
|
|
165
|
+
// Spawn Claude Code CLI with PTY
|
|
166
|
+
// On Termux, invoke node directly with the cli.js script for better compatibility
|
|
167
|
+
let command = this.claudePath;
|
|
168
|
+
let spawnArgs = args;
|
|
169
|
+
|
|
170
|
+
if (!pty.isPtyAvailable() && this.claudePath.endsWith('/claude')) {
|
|
171
|
+
// Resolve symlink to actual cli.js and invoke with node
|
|
172
|
+
const fs = require('fs');
|
|
173
|
+
try {
|
|
174
|
+
const realPath = fs.realpathSync(this.claudePath);
|
|
175
|
+
if (realPath.endsWith('.js')) {
|
|
176
|
+
command = process.execPath; // node binary
|
|
177
|
+
spawnArgs = [realPath, ...args];
|
|
178
|
+
console.log('[ClaudeWrapper] Termux mode: invoking node directly with', realPath);
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.warn('[ClaudeWrapper] Failed to resolve symlink, using path as-is:', err.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let ptyProcess;
|
|
186
|
+
try {
|
|
187
|
+
ptyProcess = pty.spawn(command, spawnArgs, {
|
|
188
|
+
name: 'xterm-color',
|
|
189
|
+
cols: 80,
|
|
190
|
+
rows: 30,
|
|
191
|
+
cwd: cwd, // Use session-specific workspace
|
|
192
|
+
env: spawnEnv, // Use configured env (includes DeepSeek API if needed)
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const msg = `Failed to spawn Claude CLI: ${err.message}`;
|
|
196
|
+
console.error('[ClaudeWrapper]', msg);
|
|
197
|
+
if (onStatus) {
|
|
198
|
+
onStatus({ type: 'error', category: 'runtime', message: msg });
|
|
199
|
+
}
|
|
200
|
+
return reject(new Error(msg));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let stdout = '';
|
|
204
|
+
|
|
205
|
+
// Process output chunks
|
|
206
|
+
ptyProcess.onData((data) => {
|
|
207
|
+
stdout += data;
|
|
208
|
+
console.log(`[ClaudeWrapper] PTY data chunk: ${data.substring(0, 200)}`);
|
|
209
|
+
|
|
210
|
+
// Parse and emit status events
|
|
211
|
+
if (onStatus) {
|
|
212
|
+
try {
|
|
213
|
+
const events = parser.parse(data);
|
|
214
|
+
console.log(`[ClaudeWrapper] Parsed ${events.length} events`);
|
|
215
|
+
events.forEach(event => {
|
|
216
|
+
// Only emit status events (not response chunks)
|
|
217
|
+
if (event.type === 'status' || event.type === 'status_update') {
|
|
218
|
+
console.log(`[ClaudeWrapper] Status: ${event.category} - ${event.message}`);
|
|
219
|
+
onStatus(event);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
} catch (parseError) {
|
|
223
|
+
console.error('[ClaudeWrapper] Parser error:', parseError);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Handle exit
|
|
229
|
+
const handleError = (err) => {
|
|
230
|
+
console.error('[ClaudeWrapper] spawn error:', err);
|
|
231
|
+
|
|
232
|
+
// Ignore spurious EIO errors that occur after process exit (PTY race condition)
|
|
233
|
+
if (err.code === 'EIO' && promiseSettled) {
|
|
234
|
+
console.log('[ClaudeWrapper] Ignoring post-exit EIO error (PTY race condition)');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (promiseSettled) {
|
|
239
|
+
console.log('[ClaudeWrapper] PTY cleanup error (ignored):', err.code || err.message);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
promiseSettled = true;
|
|
244
|
+
|
|
245
|
+
if (onStatus) {
|
|
246
|
+
onStatus({ type: 'error', category: 'runtime', message: err.message });
|
|
247
|
+
}
|
|
248
|
+
reject(new Error(`Claude CLI spawn error: ${err.message}`));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (typeof ptyProcess.on === 'function') {
|
|
252
|
+
ptyProcess.on('error', handleError);
|
|
253
|
+
} else if (typeof ptyProcess.onError === 'function') {
|
|
254
|
+
ptyProcess.onError(handleError);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
258
|
+
console.log(`[ClaudeWrapper] Exit code: ${exitCode}`);
|
|
259
|
+
|
|
260
|
+
if (exitCode !== 0) {
|
|
261
|
+
if (promiseSettled) {
|
|
262
|
+
console.log(`[ClaudeWrapper] Exit after settle (exit ${exitCode}) - ignoring`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
promiseSettled = true;
|
|
267
|
+
console.error('[ClaudeWrapper] Error output:', stdout.substring(0, 500));
|
|
268
|
+
reject(new Error(`Claude CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (promiseSettled) {
|
|
273
|
+
console.log('[ClaudeWrapper] Exit already handled - skipping resolve');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
promiseSettled = true;
|
|
278
|
+
|
|
279
|
+
// Extract final response from JSON stream
|
|
280
|
+
const finalResponse = parser.extractFinalResponse();
|
|
281
|
+
|
|
282
|
+
// Get real token usage from JSON metadata
|
|
283
|
+
const usageData = parser.getUsage();
|
|
284
|
+
const usage = {
|
|
285
|
+
prompt_tokens: usageData?.input_tokens || 0,
|
|
286
|
+
completion_tokens: usageData?.output_tokens || 0,
|
|
287
|
+
total_tokens: (usageData?.input_tokens || 0) + (usageData?.output_tokens || 0),
|
|
288
|
+
cache_read_tokens: usageData?.cache_read_input_tokens || 0,
|
|
289
|
+
cache_creation_tokens: usageData?.cache_creation_input_tokens || 0,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
console.log(`[ClaudeWrapper] Response length: ${finalResponse.length} chars`);
|
|
293
|
+
console.log(`[ClaudeWrapper] Token usage:`, usage);
|
|
294
|
+
|
|
295
|
+
resolve({
|
|
296
|
+
text: finalResponse,
|
|
297
|
+
usage,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Clean up session tracking when conversation is deleted
|
|
306
|
+
*/
|
|
307
|
+
deleteSession(conversationId) {
|
|
308
|
+
console.log(`[ClaudeWrapper] Removed session tracking: ${conversationId}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = ClaudeWrapper;
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini)
|
|
3
|
+
*
|
|
4
|
+
* Loads messages on-demand from CLI history files (lazy loading).
|
|
5
|
+
* Filesystem is the source of truth - no DB caching of messages.
|
|
6
|
+
*
|
|
7
|
+
* Session file locations:
|
|
8
|
+
* - Claude: ~/.claude/projects/<workspace-slug>/<sessionId>.jsonl
|
|
9
|
+
* - Codex: ~/.codex/sessions/<sessionId>.jsonl (if available)
|
|
10
|
+
* - Gemini: ~/.gemini/sessions/<sessionId>.jsonl (if available)
|
|
11
|
+
*
|
|
12
|
+
* @version 0.4.0 - TRI CLI Support
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const readline = require('readline');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_LIMIT = 30;
|
|
20
|
+
|
|
21
|
+
// Engine-specific paths
|
|
22
|
+
const ENGINE_PATHS = {
|
|
23
|
+
claude: path.join(process.env.HOME || '', '.claude'),
|
|
24
|
+
codex: path.join(process.env.HOME || '', '.codex'),
|
|
25
|
+
gemini: path.join(process.env.HOME || '', '.gemini'),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class CliLoader {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.claudePath = ENGINE_PATHS.claude;
|
|
31
|
+
this.codexPath = ENGINE_PATHS.codex;
|
|
32
|
+
this.geminiPath = ENGINE_PATHS.gemini;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load messages from CLI history by session.
|
|
37
|
+
* Supports all three engines: Claude, Codex, Gemini.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} params
|
|
40
|
+
* @param {string} params.sessionId - Session UUID
|
|
41
|
+
* @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'
|
|
42
|
+
* @param {string} params.workspacePath - Workspace directory (required for Claude)
|
|
43
|
+
* @param {number} [params.limit=30] - Max messages to return
|
|
44
|
+
* @param {number} [params.before] - Timestamp cursor for pagination (ms)
|
|
45
|
+
* @param {string} [params.mode='asc'] - Return order ('asc'|'desc')
|
|
46
|
+
* @returns {Promise<{messages: Array, pagination: Object}>}
|
|
47
|
+
*/
|
|
48
|
+
async loadMessagesFromCLI({
|
|
49
|
+
sessionId,
|
|
50
|
+
engine = 'claude',
|
|
51
|
+
workspacePath,
|
|
52
|
+
limit = DEFAULT_LIMIT,
|
|
53
|
+
before,
|
|
54
|
+
mode = 'asc'
|
|
55
|
+
}) {
|
|
56
|
+
if (!sessionId) {
|
|
57
|
+
throw new Error('sessionId is required');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
const normalizedEngine = this._normalizeEngine(engine);
|
|
62
|
+
|
|
63
|
+
let result;
|
|
64
|
+
switch (normalizedEngine) {
|
|
65
|
+
case 'claude':
|
|
66
|
+
result = await this.loadClaudeMessages({ sessionId, workspacePath, limit, before, mode });
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case 'codex':
|
|
70
|
+
result = await this.loadCodexMessages({ sessionId, limit, before, mode });
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case 'gemini':
|
|
74
|
+
result = await this.loadGeminiMessages({ sessionId, limit, before, mode });
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
default:
|
|
78
|
+
throw new Error(`Unsupported engine: ${engine}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(`[CliLoader] ${normalizedEngine} messages loaded in ${Date.now() - startedAt}ms (session ${sessionId}, ${result.messages.length} msgs)`);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize engine name variants
|
|
87
|
+
*/
|
|
88
|
+
_normalizeEngine(engine) {
|
|
89
|
+
if (!engine) return 'claude';
|
|
90
|
+
const lower = engine.toLowerCase();
|
|
91
|
+
if (lower.includes('claude')) return 'claude';
|
|
92
|
+
if (lower.includes('codex') || lower.includes('openai')) return 'codex';
|
|
93
|
+
if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
|
|
94
|
+
return lower;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert workspace path to slug (for .claude/projects/ directory)
|
|
99
|
+
* Same as Claude Code behavior: /path/to/dir → -path-to-dir
|
|
100
|
+
* Also converts dots to dashes (e.g., com.termux → com-termux)
|
|
101
|
+
*/
|
|
102
|
+
pathToSlug(workspacePath) {
|
|
103
|
+
if (!workspacePath) return '-default';
|
|
104
|
+
// Replace slashes AND dots with dashes (matches Claude Code behavior)
|
|
105
|
+
return workspacePath.replace(/[\/\.]/g, '-');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================
|
|
109
|
+
// CLAUDE - Load from ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
110
|
+
// ============================================================
|
|
111
|
+
|
|
112
|
+
async loadClaudeMessages({ sessionId, workspacePath, limit, before, mode }) {
|
|
113
|
+
if (!workspacePath) {
|
|
114
|
+
console.warn('[CliLoader] No workspacePath for Claude, using cwd');
|
|
115
|
+
workspacePath = process.cwd();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const slug = this.pathToSlug(workspacePath);
|
|
119
|
+
const sessionFile = path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
|
|
120
|
+
|
|
121
|
+
if (!fs.existsSync(sessionFile)) {
|
|
122
|
+
console.warn(`[CliLoader] Claude session file not found: ${sessionFile}`);
|
|
123
|
+
return this._emptyResult();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
127
|
+
|
|
128
|
+
// Filter and normalize
|
|
129
|
+
const messages = rawMessages
|
|
130
|
+
.filter(entry => entry.type === 'user' || entry.type === 'assistant')
|
|
131
|
+
.map(entry => this._normalizeClaudeEntry(entry));
|
|
132
|
+
|
|
133
|
+
return this._paginateMessages(messages, limit, before, mode);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize Claude Code session entry to message shape
|
|
138
|
+
*/
|
|
139
|
+
_normalizeClaudeEntry(entry) {
|
|
140
|
+
// Extract content - handle both string and array of content blocks
|
|
141
|
+
let content = '';
|
|
142
|
+
const rawContent = entry.message?.content;
|
|
143
|
+
|
|
144
|
+
if (typeof rawContent === 'string') {
|
|
145
|
+
content = rawContent;
|
|
146
|
+
} else if (Array.isArray(rawContent)) {
|
|
147
|
+
// Claude Code uses array of content blocks: [{type: 'text', text: '...'}, ...]
|
|
148
|
+
content = rawContent
|
|
149
|
+
.filter(block => block.type === 'text' && block.text)
|
|
150
|
+
.map(block => block.text)
|
|
151
|
+
.join('\n');
|
|
152
|
+
} else if (entry.display || entry.text) {
|
|
153
|
+
// Fallback for older formats
|
|
154
|
+
content = entry.display || entry.text || '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const role = entry.message?.role || entry.type || 'assistant';
|
|
158
|
+
const created_at = new Date(entry.timestamp).getTime() || Date.now();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
id: entry.message?.id || `claude-${created_at}`,
|
|
162
|
+
role,
|
|
163
|
+
content,
|
|
164
|
+
engine: 'claude',
|
|
165
|
+
created_at,
|
|
166
|
+
metadata: {
|
|
167
|
+
model: entry.message?.model,
|
|
168
|
+
stop_reason: entry.message?.stop_reason
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================
|
|
174
|
+
// CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
|
|
175
|
+
// ============================================================
|
|
176
|
+
|
|
177
|
+
async loadCodexMessages({ sessionId, limit, before, mode }) {
|
|
178
|
+
const sessionFile = path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
|
|
179
|
+
|
|
180
|
+
// Codex may not persist sessions locally - check if file exists
|
|
181
|
+
if (!fs.existsSync(sessionFile)) {
|
|
182
|
+
// This is expected - Codex exec mode doesn't save history locally
|
|
183
|
+
console.log(`[CliLoader] Codex session file not found (expected): ${sessionFile}`);
|
|
184
|
+
return this._emptyResult();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
188
|
+
|
|
189
|
+
// Filter and normalize
|
|
190
|
+
const messages = rawMessages
|
|
191
|
+
.filter(entry => entry.role === 'user' || entry.role === 'assistant')
|
|
192
|
+
.map(entry => this._normalizeCodexEntry(entry));
|
|
193
|
+
|
|
194
|
+
return this._paginateMessages(messages, limit, before, mode);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalize Codex session entry to message shape
|
|
199
|
+
*/
|
|
200
|
+
_normalizeCodexEntry(entry) {
|
|
201
|
+
const role = entry.role || 'assistant';
|
|
202
|
+
const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
|
|
203
|
+
|
|
204
|
+
// Codex may store content as string or object
|
|
205
|
+
let content = '';
|
|
206
|
+
if (typeof entry.content === 'string') {
|
|
207
|
+
content = entry.content;
|
|
208
|
+
} else if (entry.message) {
|
|
209
|
+
content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id: entry.id || `codex-${created_at}`,
|
|
214
|
+
role,
|
|
215
|
+
content,
|
|
216
|
+
engine: 'codex',
|
|
217
|
+
created_at,
|
|
218
|
+
metadata: {
|
|
219
|
+
model: entry.model,
|
|
220
|
+
reasoning_effort: entry.reasoning_effort
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ============================================================
|
|
226
|
+
// GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
|
|
227
|
+
// ============================================================
|
|
228
|
+
|
|
229
|
+
async loadGeminiMessages({ sessionId, limit, before, mode }) {
|
|
230
|
+
const sessionFile = path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
|
|
231
|
+
|
|
232
|
+
// Gemini CLI may not save sessions - check if file exists
|
|
233
|
+
if (!fs.existsSync(sessionFile)) {
|
|
234
|
+
console.log(`[CliLoader] Gemini session file not found: ${sessionFile}`);
|
|
235
|
+
return this._emptyResult();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
239
|
+
|
|
240
|
+
// Filter and normalize
|
|
241
|
+
const messages = rawMessages
|
|
242
|
+
.filter(entry => entry.role === 'user' || entry.role === 'model' || entry.role === 'assistant')
|
|
243
|
+
.map(entry => this._normalizeGeminiEntry(entry));
|
|
244
|
+
|
|
245
|
+
return this._paginateMessages(messages, limit, before, mode);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Normalize Gemini session entry to message shape
|
|
250
|
+
*/
|
|
251
|
+
_normalizeGeminiEntry(entry) {
|
|
252
|
+
// Gemini uses 'model' instead of 'assistant'
|
|
253
|
+
const role = entry.role === 'model' ? 'assistant' : (entry.role || 'assistant');
|
|
254
|
+
const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
|
|
255
|
+
|
|
256
|
+
// Gemini content format
|
|
257
|
+
let content = '';
|
|
258
|
+
if (typeof entry.content === 'string') {
|
|
259
|
+
content = entry.content;
|
|
260
|
+
} else if (Array.isArray(entry.parts)) {
|
|
261
|
+
// Gemini uses parts array: [{text: '...'}]
|
|
262
|
+
content = entry.parts
|
|
263
|
+
.filter(p => p.text)
|
|
264
|
+
.map(p => p.text)
|
|
265
|
+
.join('\n');
|
|
266
|
+
} else if (entry.text) {
|
|
267
|
+
content = entry.text;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
id: entry.id || `gemini-${created_at}`,
|
|
272
|
+
role,
|
|
273
|
+
content,
|
|
274
|
+
engine: 'gemini',
|
|
275
|
+
created_at,
|
|
276
|
+
metadata: {
|
|
277
|
+
model: entry.model
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ============================================================
|
|
283
|
+
// UTILITY METHODS
|
|
284
|
+
// ============================================================
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Parse JSONL file line by line (memory efficient)
|
|
288
|
+
*/
|
|
289
|
+
async _parseJsonlFile(filePath) {
|
|
290
|
+
const entries = [];
|
|
291
|
+
|
|
292
|
+
const fileStream = fs.createReadStream(filePath);
|
|
293
|
+
const rl = readline.createInterface({
|
|
294
|
+
input: fileStream,
|
|
295
|
+
crlfDelay: Infinity
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
for await (const line of rl) {
|
|
299
|
+
if (!line.trim()) continue;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const entry = JSON.parse(line);
|
|
303
|
+
entries.push(entry);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
// Skip malformed lines
|
|
306
|
+
console.warn(`[CliLoader] Skipping malformed JSON line in ${filePath}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return entries;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Apply pagination to messages array
|
|
315
|
+
*/
|
|
316
|
+
_paginateMessages(messages, limit, before, mode) {
|
|
317
|
+
// Filter by timestamp if 'before' cursor provided
|
|
318
|
+
let filtered = messages;
|
|
319
|
+
if (before) {
|
|
320
|
+
filtered = messages.filter(m => m.created_at < Number(before));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Sort newest first for pagination slicing
|
|
324
|
+
filtered.sort((a, b) => b.created_at - a.created_at);
|
|
325
|
+
|
|
326
|
+
// Apply limit
|
|
327
|
+
const page = filtered.slice(0, limit);
|
|
328
|
+
const hasMore = filtered.length > limit;
|
|
329
|
+
const oldestTimestamp = page.length ? page[page.length - 1].created_at : null;
|
|
330
|
+
|
|
331
|
+
// Return in requested order (default asc for UI rendering)
|
|
332
|
+
const ordered = mode === 'desc'
|
|
333
|
+
? page
|
|
334
|
+
: [...page].sort((a, b) => a.created_at - b.created_at);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
messages: ordered,
|
|
338
|
+
pagination: {
|
|
339
|
+
hasMore,
|
|
340
|
+
oldestTimestamp,
|
|
341
|
+
total: messages.length
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Return empty result structure
|
|
348
|
+
*/
|
|
349
|
+
_emptyResult() {
|
|
350
|
+
return {
|
|
351
|
+
messages: [],
|
|
352
|
+
pagination: {
|
|
353
|
+
hasMore: false,
|
|
354
|
+
oldestTimestamp: null,
|
|
355
|
+
total: 0
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get session file path for an engine
|
|
362
|
+
* Useful for external checks
|
|
363
|
+
*/
|
|
364
|
+
getSessionFilePath(sessionId, engine, workspacePath) {
|
|
365
|
+
const normalizedEngine = this._normalizeEngine(engine);
|
|
366
|
+
|
|
367
|
+
switch (normalizedEngine) {
|
|
368
|
+
case 'claude':
|
|
369
|
+
const slug = this.pathToSlug(workspacePath);
|
|
370
|
+
return path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
|
|
371
|
+
|
|
372
|
+
case 'codex':
|
|
373
|
+
return path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
|
|
374
|
+
|
|
375
|
+
case 'gemini':
|
|
376
|
+
return path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
|
|
377
|
+
|
|
378
|
+
default:
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
module.exports = CliLoader;
|