claude-simple 0.2.8 → 0.2.10

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,189 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import crypto from 'crypto';
6
+
7
+ // Cursor Agent owns its native transcript format. Keep a small UI journal so
8
+ // sessions started here remain searchable/readable without relying on that
9
+ // private format.
10
+ function journalPath(cursorHome, sessionId) {
11
+ return path.join(cursorHome, 'claude-simple-ui', 'sessions', `${sessionId}.jsonl`);
12
+ }
13
+
14
+ function journal(cursorHome, sessionId, value) {
15
+ if (!sessionId) return;
16
+ const file = journalPath(cursorHome, sessionId);
17
+ fs.mkdir(path.dirname(file), { recursive: true })
18
+ .then(() => fs.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8'))
19
+ .catch(() => {});
20
+ }
21
+
22
+ function buildArgs(command, options) {
23
+ const args = ['--print', '--output-format', 'stream-json', '--stream-partial-output'];
24
+ if (options.sessionId) args.push('--resume', options.sessionId);
25
+ if (options.model) args.push('--model', options.model);
26
+ if (options.cwd) args.push('--workspace', options.cwd);
27
+ if (options.permissionMode === 'plan') args.push('--mode', 'plan');
28
+ else if (options.permissionMode === 'ask') args.push('--mode', 'ask');
29
+ else if (options.allowDangerouslySkipPermissions || ['bypassPermissions', 'acceptEdits', 'auto'].includes(options.permissionMode)) args.push('--force');
30
+ // The UI has already selected the project directory; do not trigger Cursor's
31
+ // interactive trust prompt in a non-interactive child process.
32
+ args.push('--trust', command || '');
33
+ return args;
34
+ }
35
+
36
+ function textBlocks(message) {
37
+ const content = message?.content;
38
+ if (typeof content === 'string') return content;
39
+ if (!Array.isArray(content)) return '';
40
+ return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
41
+ }
42
+
43
+ function toolBlocks(message) {
44
+ return Array.isArray(message?.content)
45
+ ? message.content.filter(b => b?.type === 'tool_use' || b?.type === 'tool-call')
46
+ : [];
47
+ }
48
+
49
+ /** Run one Cursor Agent turn in headless stream-json mode. */
50
+ export function runCursor(command, options, send, { cliPath, cursorHome, activeSessions }) {
51
+ return new Promise(resolve => {
52
+ const sessionKey = options.sessionId || crypto.randomUUID();
53
+ const home = cursorHome || path.join(process.env.HOME || '', '.cursor');
54
+ const args = buildArgs(command, options);
55
+ let child;
56
+ try {
57
+ child = spawn(cliPath || 'cursor-agent', args, {
58
+ cwd: options.cwd || process.cwd(), env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
59
+ });
60
+ } catch (error) {
61
+ send({ type: 'error', message: `Failed to start cursor-agent: ${error.message}`, agent: 'cursor' });
62
+ resolve();
63
+ return;
64
+ }
65
+
66
+ const entry = { kind: 'cursor', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
67
+ activeSessions.set(sessionKey, entry);
68
+ let sessionId = options.sessionId || null;
69
+ let stderr = '';
70
+ let textOpen = false;
71
+ let resultSeen = false;
72
+ let aborted = false;
73
+ let finalText = '';
74
+ let thoughtOpen = false;
75
+ const emittedTexts = new Set();
76
+
77
+ const closeText = () => {
78
+ if (!textOpen) return;
79
+ textOpen = false;
80
+ send({ type: 'assistant_stream_end', agent: 'cursor' });
81
+ };
82
+ const attachSession = id => {
83
+ if (!id || sessionId === id) return;
84
+ sessionId = id;
85
+ entry.sessionId = id;
86
+ activeSessions.set(id, entry);
87
+ if (!options.sessionId) send({ type: 'session-created', sessionId: id, agent: 'cursor' });
88
+ journal(home, id, { type: 'session_meta', sessionId: id, cwd: options.cwd || null, model: options.model || null, ts: Date.now() });
89
+ journal(home, id, { type: 'user', content: command || '', ts: Date.now() });
90
+ };
91
+ if (options.sessionId) {
92
+ journal(home, options.sessionId, { type: 'session_meta', sessionId: options.sessionId, cwd: options.cwd || null, model: options.model || null, ts: Date.now() });
93
+ journal(home, options.sessionId, { type: 'user', content: command || '', ts: Date.now() });
94
+ }
95
+ const emitDelta = text => {
96
+ if (!text) return;
97
+ if (!textOpen) {
98
+ textOpen = true;
99
+ send({ type: 'assistant_stream_start', agent: 'cursor', ts: Date.now() });
100
+ }
101
+ send({ type: 'assistant_delta', data: text, agent: 'cursor' });
102
+ journal(home, sessionId, { type: 'assistant_delta', data: text, ts: Date.now() });
103
+ };
104
+
105
+ child.stderr.on('data', d => { stderr += String(d); });
106
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
107
+ rl.on('line', line => {
108
+ if (!line.trim()) return;
109
+ let raw;
110
+ try { raw = JSON.parse(line); } catch { return; }
111
+ attachSession(raw.session_id || raw.sessionId);
112
+ if (raw.type === 'thinking' && raw.subtype === 'delta') {
113
+ if (!thoughtOpen) {
114
+ thoughtOpen = true;
115
+ send({ type: 'thought_stream_start', agent: 'cursor', ts: Date.now() });
116
+ }
117
+ send({ type: 'thought_delta', data: raw.text || '', agent: 'cursor' });
118
+ return;
119
+ }
120
+ if (raw.type === 'thinking' && raw.subtype === 'completed' && thoughtOpen) {
121
+ thoughtOpen = false;
122
+ send({ type: 'thought_stream_end', agent: 'cursor' });
123
+ return;
124
+ }
125
+ if (raw.type === 'assistant') {
126
+ const text = textBlocks(raw.message);
127
+ for (const tool of toolBlocks(raw.message)) {
128
+ closeText();
129
+ const input = tool.input ?? tool.args ?? {};
130
+ send({ type: 'assistant', message: { content: [{ type: 'tool_use', name: tool.name || tool.toolName || 'tool', input }] }, agent: 'cursor' });
131
+ journal(home, sessionId, { type: 'tool', name: tool.name || tool.toolName || 'tool', input, ts: Date.now() });
132
+ }
133
+ // Cursor emits a final assistant message twice in stream-json mode.
134
+ // Deltas are authoritative; only emit a complete message if no delta
135
+ // was received for this exact response.
136
+ if (text && !emittedTexts.has(text)) {
137
+ emittedTexts.add(text);
138
+ if (!textOpen) emitDelta(text);
139
+ finalText = text;
140
+ }
141
+ return;
142
+ }
143
+ if (raw.type === 'result') {
144
+ closeText();
145
+ resultSeen = true;
146
+ if (raw.is_error || raw.subtype === 'error') {
147
+ send({ type: 'error', message: raw.result || raw.error || 'Cursor Agent turn failed', agent: 'cursor' });
148
+ } else {
149
+ const u = raw.usage || {};
150
+ const usage = {
151
+ input_tokens: u.inputTokens ?? u.input_tokens ?? 0,
152
+ output_tokens: u.outputTokens ?? u.output_tokens ?? 0,
153
+ cache_read_input_tokens: u.cacheReadTokens ?? u.cache_read_tokens ?? 0,
154
+ cache_write_input_tokens: u.cacheWriteTokens ?? u.cache_write_tokens ?? 0,
155
+ };
156
+ if (raw.result && !finalText) emitDelta(String(raw.result));
157
+ journal(home, sessionId, { type: 'result', usage, ts: Date.now() });
158
+ send({ type: 'result', is_error: false, usage, sessionId: sessionId || sessionKey, agent: 'cursor' });
159
+ }
160
+ } else if (raw.type === 'error') {
161
+ send({ type: 'error', message: raw.error?.message || raw.message || 'Cursor Agent error', agent: 'cursor' });
162
+ }
163
+ });
164
+
165
+ child.on('close', code => {
166
+ rl.close(); closeText();
167
+ if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'cursor' });
168
+ activeSessions.delete(sessionKey);
169
+ if (sessionId) activeSessions.delete(sessionId);
170
+ if (aborted) send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'cursor' });
171
+ else {
172
+ if (code && !resultSeen) send({ type: 'error', message: stderr.trim() || `cursor-agent exited with code ${code}`, agent: 'cursor' });
173
+ send({ type: 'complete', exitCode: code || 0, sessionId: sessionId || sessionKey, agent: 'cursor' });
174
+ }
175
+ resolve();
176
+ });
177
+ child.on('error', error => {
178
+ activeSessions.delete(sessionKey);
179
+ if (sessionId) activeSessions.delete(sessionId);
180
+ send({ type: 'error', message: error.message, agent: 'cursor' });
181
+ resolve();
182
+ });
183
+ entry.abort = () => {
184
+ aborted = true;
185
+ try { child.kill('SIGTERM'); } catch {}
186
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
187
+ };
188
+ });
189
+ }
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import crypto from 'crypto';
6
+
7
+ // agy (the Antigravity/Gemini CLI) exposes its print mode as NDJSON. Keeping a
8
+ // small, UI-owned journal alongside its native SQLite conversation store gives
9
+ // the web UI reliable history/search support without depending on the CLI's
10
+ // private protobuf schema.
11
+ function journalPath(geminiHome, sessionId) {
12
+ return path.join(geminiHome, 'claude-simple-ui', 'sessions', `${sessionId}.jsonl`);
13
+ }
14
+
15
+ function journal(geminiHome, sessionId, value) {
16
+ if (!sessionId) return;
17
+ const file = journalPath(geminiHome, sessionId);
18
+ fs.mkdir(path.dirname(file), { recursive: true })
19
+ .then(() => fs.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8'))
20
+ .catch(() => {});
21
+ }
22
+
23
+ function isAutoApprove(options) {
24
+ return options.allowDangerouslySkipPermissions
25
+ || ['bypassPermissions', 'acceptEdits', 'auto'].includes(options.permissionMode);
26
+ }
27
+
28
+ function buildArgs(command, options) {
29
+ const args = ['--output-format', 'stream-json'];
30
+ if (options.sessionId) args.push('--conversation', options.sessionId);
31
+ if (options.model) args.push('--model', options.model);
32
+ if (options.effort && ['low', 'medium', 'high'].includes(options.effort)) {
33
+ args.push('--effort', options.effort);
34
+ }
35
+ // In print mode agy otherwise starts command tools in its scratch directory.
36
+ // Adding the requested project preserves normal agent access to the workspace.
37
+ if (options.cwd) args.push('--add-dir', options.cwd);
38
+ if (isAutoApprove(options)) args.push('--dangerously-skip-permissions');
39
+ else if (options.permissionMode === 'plan') args.push('--mode', 'plan');
40
+ else args.push('--mode', 'accept-edits');
41
+ args.push(`--prompt=${command || ''}`);
42
+ return args;
43
+ }
44
+
45
+ function toolInput(update) {
46
+ const info = update.tool_info || update.toolInfo || {};
47
+ return info.parameters || info.input || info.args || {};
48
+ }
49
+
50
+ /** Run a Gemini/Antigravity turn through `agy` print mode. */
51
+ export function runGemini(command, options, send, { cliPath, geminiHome, activeSessions }) {
52
+ return new Promise(resolve => {
53
+ const sessionKey = options.sessionId || crypto.randomUUID();
54
+ const home = geminiHome || path.join(process.env.HOME || '', '.gemini');
55
+ const args = buildArgs(command, options);
56
+ // A resumed native conversation may predate the UI journal. Start recording
57
+ // from this turn so its subsequent history/search/usage is still complete.
58
+ if (options.sessionId) {
59
+ journal(home, options.sessionId, {
60
+ type: 'session_meta', sessionId: options.sessionId, cwd: options.cwd || null,
61
+ model: options.model || null, ts: Date.now(),
62
+ });
63
+ journal(home, options.sessionId, { type: 'user', content: command || '', ts: Date.now() });
64
+ }
65
+ let child;
66
+ try {
67
+ child = spawn(cliPath || 'agy', args, {
68
+ cwd: options.cwd || process.cwd(),
69
+ env: { ...process.env },
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ } catch (error) {
73
+ send({ type: 'error', message: `Failed to start agy: ${error.message}`, agent: 'gemini' });
74
+ resolve();
75
+ return;
76
+ }
77
+
78
+ const entry = {
79
+ kind: 'gemini', child, startTime: Date.now(), busy: true,
80
+ key: sessionKey, sessionId: options.sessionId || null,
81
+ };
82
+ activeSessions.set(sessionKey, entry);
83
+ let sessionId = options.sessionId || null;
84
+ let stderr = '';
85
+ let aborted = false;
86
+ let textOpen = false;
87
+ let resultSeen = false;
88
+
89
+ const closeText = () => {
90
+ if (!textOpen) return;
91
+ textOpen = false;
92
+ send({ type: 'assistant_stream_end', agent: 'gemini' });
93
+ };
94
+ const attachSession = id => {
95
+ if (!id || sessionId === id) return;
96
+ sessionId = id;
97
+ entry.sessionId = id;
98
+ activeSessions.set(id, entry);
99
+ if (!options.sessionId) send({ type: 'session-created', sessionId: id, agent: 'gemini' });
100
+ journal(home, id, {
101
+ type: 'session_meta', sessionId: id, cwd: options.cwd || null,
102
+ model: options.model || null, ts: Date.now(),
103
+ });
104
+ journal(home, id, { type: 'user', content: command || '', ts: Date.now() });
105
+ };
106
+ const usage = raw => ({
107
+ input_tokens: raw?.input_tokens ?? 0,
108
+ output_tokens: raw?.output_tokens ?? 0,
109
+ reasoning_tokens: raw?.thinking_tokens ?? 0,
110
+ cache_read_input_tokens: raw?.cache_read_tokens ?? 0,
111
+ total_tokens: raw?.total_tokens ?? 0,
112
+ });
113
+
114
+ child.stderr.on('data', d => { stderr += String(d); });
115
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
116
+ rl.on('line', line => {
117
+ if (!line.trim()) return;
118
+ let raw;
119
+ try { raw = JSON.parse(line); } catch { return; }
120
+ const id = raw.conversation_id || raw.result?.conversation_id || raw.step_update?.conversation_id;
121
+ attachSession(id);
122
+
123
+ if (raw.event === 'step_update') {
124
+ const update = raw.step_update || {};
125
+ if (update.step_type === 'agent_response' && update.text_delta) {
126
+ if (!textOpen) {
127
+ textOpen = true;
128
+ send({ type: 'assistant_stream_start', agent: 'gemini', ts: Date.now() });
129
+ }
130
+ send({ type: 'assistant_delta', data: update.text_delta, agent: 'gemini' });
131
+ journal(home, sessionId, { type: 'assistant_delta', data: update.text_delta, ts: Date.now() });
132
+ }
133
+ if (update.step_type === 'tool' && update.state === 'ACTIVE') {
134
+ closeText();
135
+ const name = update.tool_name || update.toolName || update.tool_info?.name || 'tool';
136
+ const input = toolInput(update);
137
+ send({ type: 'assistant', message: { content: [{ type: 'tool_use', name, input }] }, agent: 'gemini' });
138
+ journal(home, sessionId, { type: 'tool', name, input, ts: Date.now() });
139
+ }
140
+ return;
141
+ }
142
+ if (raw.event === 'result') {
143
+ closeText();
144
+ resultSeen = true;
145
+ const result = raw.result || {};
146
+ const u = usage(result.usage);
147
+ journal(home, sessionId, { type: 'result', usage: u, ts: Date.now() });
148
+ if (String(result.status || '').toUpperCase() !== 'SUCCESS') {
149
+ send({ type: 'error', message: result.error || result.response || `Gemini turn ${result.status || 'failed'}`, agent: 'gemini' });
150
+ } else {
151
+ send({ type: 'result', is_error: false, usage: u, sessionId: sessionId || sessionKey, agent: 'gemini' });
152
+ }
153
+ } else if (raw.event === 'error') {
154
+ send({ type: 'error', message: raw.error?.message || raw.message || 'Gemini CLI error', agent: 'gemini' });
155
+ }
156
+ });
157
+
158
+ child.on('close', code => {
159
+ rl.close();
160
+ closeText();
161
+ activeSessions.delete(sessionKey);
162
+ if (sessionId) activeSessions.delete(sessionId);
163
+ if (aborted) send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'gemini' });
164
+ else {
165
+ if (code && !resultSeen) send({ type: 'error', message: stderr.trim() || `agy exited with code ${code}`, agent: 'gemini' });
166
+ send({ type: 'complete', exitCode: code || 0, sessionId: sessionId || sessionKey, agent: 'gemini' });
167
+ }
168
+ resolve();
169
+ });
170
+ child.on('error', error => {
171
+ activeSessions.delete(sessionKey);
172
+ if (sessionId) activeSessions.delete(sessionId);
173
+ send({ type: 'error', message: error.message, agent: 'gemini' });
174
+ resolve();
175
+ });
176
+ entry.abort = () => {
177
+ aborted = true;
178
+ try { child.kill('SIGTERM'); } catch {}
179
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
180
+ };
181
+ });
182
+ }