gekto 0.0.7 → 0.0.9

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.
@@ -1,12 +1,29 @@
1
1
  import { spawn } from 'child_process';
2
+ import { readFileSync, existsSync } from 'fs';
2
3
  import { CLAUDE_PATH } from '../claudePath.js';
3
4
  export class HeadlessAgent {
4
5
  sessionId = null;
5
6
  config;
6
7
  currentProc = null;
8
+ pendingFileChanges = new Map();
7
9
  constructor(config = {}) {
8
10
  this.config = config;
9
11
  }
12
+ // Read file content safely, returns null if file doesn't exist
13
+ readFileSafe(filePath) {
14
+ try {
15
+ // Resolve path relative to working directory
16
+ const fullPath = filePath.startsWith('/')
17
+ ? filePath
18
+ : `${this.config.workingDir || process.cwd()}/${filePath}`;
19
+ if (!existsSync(fullPath))
20
+ return null;
21
+ return readFileSync(fullPath, 'utf-8');
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
10
27
  kill() {
11
28
  if (this.currentProc && !this.currentProc.killed) {
12
29
  this.currentProc.kill('SIGTERM');
@@ -18,9 +35,15 @@ export class HeadlessAgent {
18
35
  isRunning() {
19
36
  return this.currentProc !== null && !this.currentProc.killed;
20
37
  }
21
- async send(message, callbacks) {
38
+ async send(message, callbacks, imagePaths) {
39
+ // Append image file paths to the message so Claude can read them
40
+ let finalMessage = message;
41
+ if (imagePaths && imagePaths.length > 0) {
42
+ const imageRefs = imagePaths.map(p => ` - ${p}`).join('\n');
43
+ finalMessage += `\n\n[The user attached ${imagePaths.length} image(s). Use the Read tool to view them:\n${imageRefs}]`;
44
+ }
22
45
  const args = [
23
- '-p', message,
46
+ '-p', finalMessage,
24
47
  '--output-format', 'stream-json',
25
48
  '--verbose',
26
49
  '--model', 'claude-opus-4-5-20251101',
@@ -54,6 +77,8 @@ export class HeadlessAgent {
54
77
  let buffer = '';
55
78
  let lastResult = null;
56
79
  let currentTool = null;
80
+ const toolUseIdToName = new Map();
81
+ const streamState = { receivedDeltas: false };
57
82
  proc.stdout.on('data', (data) => {
58
83
  buffer += data.toString();
59
84
  const lines = buffer.split('\n');
@@ -63,7 +88,7 @@ export class HeadlessAgent {
63
88
  continue;
64
89
  try {
65
90
  const event = JSON.parse(line);
66
- this.processStreamEvent(event, callbacks, (tool) => {
91
+ this.processStreamEvent(event, callbacks, toolUseIdToName, streamState, (tool) => {
67
92
  currentTool = tool;
68
93
  }, () => {
69
94
  currentTool = null;
@@ -108,7 +133,7 @@ export class HeadlessAgent {
108
133
  proc.on('error', reject);
109
134
  });
110
135
  }
111
- processStreamEvent(event, callbacks, setCurrentTool, clearCurrentTool) {
136
+ processStreamEvent(event, callbacks, toolUseIdToName, streamState, setCurrentTool, clearCurrentTool) {
112
137
  if (event.type === 'assistant' && event.message) {
113
138
  const message = event.message;
114
139
  if (message.content) {
@@ -116,30 +141,96 @@ export class HeadlessAgent {
116
141
  if (block.type === 'tool_use' && block.name) {
117
142
  setCurrentTool?.(block.name);
118
143
  callbacks?.onToolStart?.(block.name, block.input);
144
+ // Track tool name by ID for tool_result matching
145
+ if (block.id) {
146
+ toolUseIdToName?.set(block.id, block.name);
147
+ }
148
+ // Track file changes for Write/Edit tools
149
+ if ((block.name === 'Write' || block.name === 'Edit') && block.id && block.input?.file_path) {
150
+ const filePath = String(block.input.file_path);
151
+ const before = this.readFileSafe(filePath);
152
+ this.pendingFileChanges.set(block.id, {
153
+ tool: block.name,
154
+ filePath,
155
+ before,
156
+ });
157
+ }
158
+ }
159
+ // Fallback: emit full text block only if no streaming deltas were received
160
+ if (block.type === 'text' && block.text && !streamState?.receivedDeltas) {
161
+ callbacks?.onText?.(block.text);
162
+ }
163
+ // Thinking block from assistant event (fallback when no thinking_delta)
164
+ if (block.type === 'thinking' && block.thinking && !streamState?.receivedDeltas) {
165
+ callbacks?.onThinking?.(block.thinking);
119
166
  }
120
167
  }
121
168
  }
122
169
  }
170
+ // Streaming deltas — character-by-character text and thinking
171
+ if (event.type === 'content_block_delta') {
172
+ const delta = event.delta;
173
+ if (delta?.type === 'text_delta' && delta.text) {
174
+ if (streamState)
175
+ streamState.receivedDeltas = true;
176
+ callbacks?.onText?.(delta.text);
177
+ }
178
+ else if (delta?.type === 'thinking_delta' && delta.thinking) {
179
+ if (streamState)
180
+ streamState.receivedDeltas = true;
181
+ callbacks?.onThinking?.(delta.thinking);
182
+ }
183
+ }
123
184
  if (event.type === 'user' && event.message) {
124
185
  const message = event.message;
125
186
  if (message.content) {
126
187
  for (const block of message.content) {
127
188
  if (block.type === 'tool_result') {
128
189
  clearCurrentTool?.();
190
+ // Extract tool result content
191
+ if (block.tool_use_id && block.content != null) {
192
+ const toolName = toolUseIdToName?.get(block.tool_use_id) || 'unknown';
193
+ let resultText = '';
194
+ if (typeof block.content === 'string') {
195
+ resultText = block.content;
196
+ }
197
+ else if (Array.isArray(block.content)) {
198
+ // content is array of {type: 'text', text: '...'} blocks
199
+ resultText = block.content
200
+ .filter(c => c.type === 'text' && c.text)
201
+ .map(c => c.text)
202
+ .join('\n');
203
+ }
204
+ if (resultText) {
205
+ callbacks?.onToolResult?.(toolName, resultText, block.tool_use_id);
206
+ }
207
+ }
208
+ // Complete file change tracking
209
+ if (block.tool_use_id && this.pendingFileChanges.has(block.tool_use_id)) {
210
+ const pending = this.pendingFileChanges.get(block.tool_use_id);
211
+ this.pendingFileChanges.delete(block.tool_use_id);
212
+ const after = this.readFileSafe(pending.filePath);
213
+ // Only emit if file actually changed (or was created)
214
+ if (after !== null && after !== pending.before) {
215
+ callbacks?.onFileChange?.({
216
+ tool: pending.tool,
217
+ filePath: pending.filePath,
218
+ before: pending.before,
219
+ after,
220
+ });
221
+ }
222
+ }
129
223
  }
130
224
  }
131
225
  }
132
226
  }
133
- if (event.type === 'content_block_delta') {
134
- const delta = event.delta;
135
- if (delta?.type === 'text_delta' && delta.text) {
136
- callbacks?.onText?.(delta.text);
137
- }
138
- }
139
227
  }
140
228
  getSessionId() {
141
229
  return this.sessionId;
142
230
  }
231
+ setSessionId(id) {
232
+ this.sessionId = id;
233
+ }
143
234
  resetSession() {
144
235
  this.sessionId = null;
145
236
  }
@@ -1,5 +1,10 @@
1
1
  import path from 'path';
2
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
3
+ import { dirname } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import { tmpdir } from 'os';
2
6
  import { HeadlessAgent } from './HeadlessAgent.js';
7
+ import { getState, mutate, broadcastFileChange, broadcastAgent } from '../state.js';
3
8
  // Per-lizard sessions
4
9
  const sessions = new Map();
5
10
  // Summarize tool input for display
@@ -28,7 +33,16 @@ Your job is ONLY to:
28
33
  - Write and edit code using Write and Edit tools
29
34
  - Make the requested code changes
30
35
 
31
- After making changes, simply report what you did. The user will handle building, testing, and running the code themselves.`;
36
+ After making changes, simply report what you did. The user will handle building, testing, and running the code themselves.
37
+
38
+ STATUS MARKER - At the END of EVERY response, you MUST include exactly one of these markers:
39
+ - [STATUS:DONE] - Use when the task is complete and you have no questions for the user
40
+ - [STATUS:PENDING] - Use when you need user input, confirmation, clarification, or approval to proceed
41
+
42
+ Examples:
43
+ - After completing a code change: "I've updated the function. [STATUS:DONE]"
44
+ - When asking a question: "Which approach would you prefer? [STATUS:PENDING]"
45
+ - After answering a simple question: "The file is located at src/utils.ts [STATUS:DONE]"`;
32
46
  // Tools that agents are not allowed to use
33
47
  const DISALLOWED_TOOLS = ['Bash', 'Task'];
34
48
  function getOrCreateSession(lizardId, ws) {
@@ -52,6 +66,31 @@ function getOrCreateSession(lizardId, ws) {
52
66
  }
53
67
  return session;
54
68
  }
69
+ export function resumeSession(lizardId, sessionId, ws) {
70
+ let session = sessions.get(lizardId);
71
+ if (!session) {
72
+ const agent = new HeadlessAgent({
73
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
74
+ workingDir: getWorkingDir(),
75
+ disallowedTools: DISALLOWED_TOOLS,
76
+ });
77
+ // Restore Claude Code session ID so --resume works
78
+ if (sessionId) {
79
+ agent.setSessionId(sessionId);
80
+ }
81
+ session = {
82
+ agent,
83
+ isProcessing: false,
84
+ queue: [],
85
+ currentWs: ws ?? null,
86
+ };
87
+ sessions.set(lizardId, session);
88
+ }
89
+ else if (ws) {
90
+ session.currentWs = ws;
91
+ }
92
+ return session;
93
+ }
55
94
  export function isProcessing(lizardId) {
56
95
  const session = sessions.get(lizardId);
57
96
  return session?.isProcessing ?? false;
@@ -64,14 +103,51 @@ export function getQueueLength(lizardId) {
64
103
  function safeSend(session, data) {
65
104
  const ws = session.currentWs;
66
105
  if (ws && ws.readyState === ws.OPEN) {
106
+ const type = data.type;
107
+ const lizardId = data.lizardId;
108
+ // Log outgoing messages (skip noisy streaming deltas)
109
+ if (type !== 'text' && type !== 'thinking') {
110
+ console.log(`[WS→] ${type}${lizardId ? ` [${lizardId}]` : ''}${data.tool ? ` tool=${data.tool}` : ''}${data.state ? ` state=${data.state}` : ''}`);
111
+ }
67
112
  ws.send(JSON.stringify(data));
68
113
  }
69
114
  }
70
- export async function sendMessage(lizardId, message, ws) {
115
+ // Save base64 data URL images to temp files and return file paths
116
+ export function saveImagesToTempFiles(images) {
117
+ const paths = [];
118
+ const dir = path.join(tmpdir(), 'gekto-images');
119
+ if (!existsSync(dir)) {
120
+ mkdirSync(dir, { recursive: true });
121
+ }
122
+ for (const dataUrl of images) {
123
+ const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
124
+ if (!match)
125
+ continue;
126
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
127
+ const buffer = Buffer.from(match[2], 'base64');
128
+ const filePath = path.join(dir, `gekto-img-${randomUUID()}.${ext}`);
129
+ writeFileSync(filePath, buffer);
130
+ paths.push(filePath);
131
+ }
132
+ return paths;
133
+ }
134
+ export async function sendMessage(lizardId, message, ws, images) {
71
135
  const session = getOrCreateSession(lizardId, ws);
136
+ // Save images to temp files and build the final message
137
+ let finalMessage = message;
138
+ let imagePaths;
139
+ if (images && images.length > 0) {
140
+ imagePaths = saveImagesToTempFiles(images);
141
+ }
142
+ // Accumulators for streaming deltas — reset on each tool start
143
+ let accumulatedText = '';
144
+ let accumulatedThinking = '';
72
145
  // Create streaming callbacks that use session's current WebSocket
73
146
  const callbacks = {
74
147
  onToolStart: (tool, input) => {
148
+ // Reset accumulators when a new tool starts (text block ended)
149
+ accumulatedText = '';
150
+ accumulatedThinking = '';
75
151
  safeSend(session, {
76
152
  type: 'tool',
77
153
  lizardId,
@@ -89,11 +165,76 @@ export async function sendMessage(lizardId, message, ws) {
89
165
  tool,
90
166
  });
91
167
  },
168
+ onText: (text) => {
169
+ accumulatedText += text;
170
+ safeSend(session, {
171
+ type: 'text',
172
+ lizardId,
173
+ text: accumulatedText,
174
+ });
175
+ },
176
+ onThinking: (text) => {
177
+ accumulatedThinking += text;
178
+ safeSend(session, {
179
+ type: 'thinking',
180
+ lizardId,
181
+ text: accumulatedThinking,
182
+ });
183
+ },
184
+ onToolResult: (tool, content, toolUseId) => {
185
+ safeSend(session, {
186
+ type: 'tool_result',
187
+ lizardId,
188
+ tool,
189
+ content: content.length > 2000 ? content.substring(0, 2000) + '…' : content,
190
+ toolUseId,
191
+ });
192
+ },
193
+ onFileChange: (change) => {
194
+ // Encode path for use as key
195
+ const encodedPath = change.filePath.replace(/\//g, '--');
196
+ // Enrich change with metadata
197
+ const enrichedChange = {
198
+ ...change,
199
+ agentId: lizardId,
200
+ taskId: getState().agents[lizardId]?.taskId,
201
+ timestamp: new Date().toISOString(),
202
+ };
203
+ // Write to top-level fileChanges collection
204
+ mutate(`fileChanges.${encodedPath}`, enrichedChange);
205
+ broadcastFileChange(encodedPath);
206
+ // Also update agent's fileChangePaths for reference
207
+ const agent = getState().agents[lizardId];
208
+ if (agent) {
209
+ const paths = agent.fileChangePaths ?? [];
210
+ if (!paths.includes(change.filePath)) {
211
+ mutate(`agents.${lizardId}.fileChangePaths`, [...paths, change.filePath]);
212
+ }
213
+ // Keep backward-compat fileChanges on agent
214
+ const existing = agent.fileChanges ?? [];
215
+ const existingIndex = existing.findIndex(fc => fc.filePath === change.filePath);
216
+ let updated;
217
+ if (existingIndex >= 0) {
218
+ updated = [...existing];
219
+ updated[existingIndex] = { ...updated[existingIndex], after: change.after, tool: change.tool };
220
+ }
221
+ else {
222
+ updated = [...existing, enrichedChange];
223
+ }
224
+ mutate(`agents.${lizardId}.fileChanges`, updated);
225
+ broadcastAgent(lizardId);
226
+ }
227
+ safeSend(session, {
228
+ type: 'file_change',
229
+ lizardId,
230
+ change: enrichedChange,
231
+ });
232
+ },
92
233
  };
93
234
  // If already processing, queue the message
94
235
  if (session.isProcessing) {
95
236
  return new Promise((resolve, reject) => {
96
- session.queue.push({ message, ws, callbacks, resolve, reject });
237
+ session.queue.push({ message: finalMessage, ws, callbacks, resolve, reject, imagePaths });
97
238
  const position = session.queue.length;
98
239
  ws.send(JSON.stringify({
99
240
  type: 'queued',
@@ -103,14 +244,14 @@ export async function sendMessage(lizardId, message, ws) {
103
244
  });
104
245
  }
105
246
  // Process immediately
106
- return processMessage(lizardId, session, message, ws, callbacks);
247
+ return processMessage(lizardId, session, finalMessage, ws, callbacks, imagePaths);
107
248
  }
108
249
  async function processMessage(lizardId, session, message, _ws, // Kept for queue compatibility, but we use session.currentWs
109
- callbacks) {
250
+ callbacks, imagePaths) {
110
251
  session.isProcessing = true;
111
252
  safeSend(session, { type: 'state', lizardId, state: 'working' });
112
253
  try {
113
- const response = await session.agent.send(message, callbacks);
254
+ const response = await session.agent.send(message, callbacks, imagePaths);
114
255
  safeSend(session, {
115
256
  type: 'response',
116
257
  lizardId,
@@ -135,7 +276,7 @@ callbacks) {
135
276
  // Process next queued message if any
136
277
  if (session.queue.length > 0) {
137
278
  const next = session.queue.shift();
138
- processMessage(lizardId, session, next.message, next.ws, next.callbacks)
279
+ processMessage(lizardId, session, next.message, next.ws, next.callbacks, next.imagePaths)
139
280
  .then(next.resolve)
140
281
  .catch(next.reject);
141
282
  }
@@ -194,10 +335,50 @@ export function killSession(lizardId) {
194
335
  const killed = session.agent.kill();
195
336
  session.isProcessing = false;
196
337
  session.queue = [];
338
+ // Remove from map so getActiveSessions() doesn't return dead agents
339
+ sessions.delete(lizardId);
197
340
  return killed;
198
341
  }
199
342
  return false;
200
343
  }
344
+ // Revert files to their pre-agent state using the before content from FileChange objects
345
+ export function revertFiles(filePaths, fileChanges) {
346
+ const reverted = [];
347
+ const failed = [];
348
+ const workingDir = getWorkingDir();
349
+ for (const filePath of filePaths) {
350
+ const change = fileChanges.find(fc => fc.filePath === filePath);
351
+ if (!change) {
352
+ failed.push(filePath);
353
+ continue;
354
+ }
355
+ try {
356
+ const fullPath = filePath.startsWith('/')
357
+ ? filePath
358
+ : path.resolve(workingDir, filePath);
359
+ if (change.before === null) {
360
+ // File was newly created by agent — delete it
361
+ if (existsSync(fullPath)) {
362
+ unlinkSync(fullPath);
363
+ }
364
+ }
365
+ else {
366
+ // File existed before — restore original content
367
+ const dir = dirname(fullPath);
368
+ if (!existsSync(dir)) {
369
+ mkdirSync(dir, { recursive: true });
370
+ }
371
+ writeFileSync(fullPath, change.before, 'utf-8');
372
+ }
373
+ reverted.push(filePath);
374
+ }
375
+ catch (err) {
376
+ console.error(`[AgentPool] Failed to revert ${filePath}:`, err);
377
+ failed.push(filePath);
378
+ }
379
+ }
380
+ return { reverted, failed };
381
+ }
201
382
  export function killAllSessions() {
202
383
  let count = 0;
203
384
  for (const [, session] of sessions) {
@@ -207,5 +388,7 @@ export function killAllSessions() {
207
388
  session.isProcessing = false;
208
389
  session.queue = [];
209
390
  }
391
+ // Clear all sessions so getActiveSessions() returns empty
392
+ sessions.clear();
210
393
  return count;
211
394
  }