gekto 0.0.6 → 0.0.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.
@@ -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');
@@ -116,6 +133,20 @@ export class HeadlessAgent {
116
133
  if (block.type === 'tool_use' && block.name) {
117
134
  setCurrentTool?.(block.name);
118
135
  callbacks?.onToolStart?.(block.name, block.input);
136
+ // Track file changes for Write/Edit tools
137
+ if ((block.name === 'Write' || block.name === 'Edit') && block.id && block.input?.file_path) {
138
+ const filePath = String(block.input.file_path);
139
+ const before = this.readFileSafe(filePath);
140
+ this.pendingFileChanges.set(block.id, {
141
+ tool: block.name,
142
+ filePath,
143
+ before,
144
+ });
145
+ }
146
+ }
147
+ // Extract text content from assistant messages
148
+ if (block.type === 'text' && block.text) {
149
+ callbacks?.onText?.(block.text);
119
150
  }
120
151
  }
121
152
  }
@@ -126,20 +157,32 @@ export class HeadlessAgent {
126
157
  for (const block of message.content) {
127
158
  if (block.type === 'tool_result') {
128
159
  clearCurrentTool?.();
160
+ // Complete file change tracking
161
+ if (block.tool_use_id && this.pendingFileChanges.has(block.tool_use_id)) {
162
+ const pending = this.pendingFileChanges.get(block.tool_use_id);
163
+ this.pendingFileChanges.delete(block.tool_use_id);
164
+ const after = this.readFileSafe(pending.filePath);
165
+ // Only emit if file actually changed (or was created)
166
+ if (after !== null && after !== pending.before) {
167
+ callbacks?.onFileChange?.({
168
+ tool: pending.tool,
169
+ filePath: pending.filePath,
170
+ before: pending.before,
171
+ after,
172
+ });
173
+ }
174
+ }
129
175
  }
130
176
  }
131
177
  }
132
178
  }
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
179
  }
140
180
  getSessionId() {
141
181
  return this.sessionId;
142
182
  }
183
+ setSessionId(id) {
184
+ this.sessionId = id;
185
+ }
143
186
  resetSession() {
144
187
  this.sessionId = null;
145
188
  }
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
3
+ import { dirname } from 'path';
2
4
  import { HeadlessAgent } from './HeadlessAgent.js';
3
5
  // Per-lizard sessions
4
6
  const sessions = new Map();
@@ -28,7 +30,16 @@ Your job is ONLY to:
28
30
  - Write and edit code using Write and Edit tools
29
31
  - Make the requested code changes
30
32
 
31
- After making changes, simply report what you did. The user will handle building, testing, and running the code themselves.`;
33
+ After making changes, simply report what you did. The user will handle building, testing, and running the code themselves.
34
+
35
+ STATUS MARKER - At the END of EVERY response, you MUST include exactly one of these markers:
36
+ - [STATUS:DONE] - Use when the task is complete and you have no questions for the user
37
+ - [STATUS:PENDING] - Use when you need user input, confirmation, clarification, or approval to proceed
38
+
39
+ Examples:
40
+ - After completing a code change: "I've updated the function. [STATUS:DONE]"
41
+ - When asking a question: "Which approach would you prefer? [STATUS:PENDING]"
42
+ - After answering a simple question: "The file is located at src/utils.ts [STATUS:DONE]"`;
32
43
  // Tools that agents are not allowed to use
33
44
  const DISALLOWED_TOOLS = ['Bash', 'Task'];
34
45
  function getOrCreateSession(lizardId, ws) {
@@ -52,6 +63,31 @@ function getOrCreateSession(lizardId, ws) {
52
63
  }
53
64
  return session;
54
65
  }
66
+ export function resumeSession(lizardId, sessionId, ws) {
67
+ let session = sessions.get(lizardId);
68
+ if (!session) {
69
+ const agent = new HeadlessAgent({
70
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
71
+ workingDir: getWorkingDir(),
72
+ disallowedTools: DISALLOWED_TOOLS,
73
+ });
74
+ // Restore Claude Code session ID so --resume works
75
+ if (sessionId) {
76
+ agent.setSessionId(sessionId);
77
+ }
78
+ session = {
79
+ agent,
80
+ isProcessing: false,
81
+ queue: [],
82
+ currentWs: ws ?? null,
83
+ };
84
+ sessions.set(lizardId, session);
85
+ }
86
+ else if (ws) {
87
+ session.currentWs = ws;
88
+ }
89
+ return session;
90
+ }
55
91
  export function isProcessing(lizardId) {
56
92
  const session = sessions.get(lizardId);
57
93
  return session?.isProcessing ?? false;
@@ -89,6 +125,20 @@ export async function sendMessage(lizardId, message, ws) {
89
125
  tool,
90
126
  });
91
127
  },
128
+ onText: (text) => {
129
+ safeSend(session, {
130
+ type: 'text',
131
+ lizardId,
132
+ text,
133
+ });
134
+ },
135
+ onFileChange: (change) => {
136
+ safeSend(session, {
137
+ type: 'file_change',
138
+ lizardId,
139
+ change,
140
+ });
141
+ },
92
142
  };
93
143
  // If already processing, queue the message
94
144
  if (session.isProcessing) {
@@ -194,10 +244,50 @@ export function killSession(lizardId) {
194
244
  const killed = session.agent.kill();
195
245
  session.isProcessing = false;
196
246
  session.queue = [];
247
+ // Remove from map so getActiveSessions() doesn't return dead agents
248
+ sessions.delete(lizardId);
197
249
  return killed;
198
250
  }
199
251
  return false;
200
252
  }
253
+ // Revert files to their pre-agent state using the before content from FileChange objects
254
+ export function revertFiles(filePaths, fileChanges) {
255
+ const reverted = [];
256
+ const failed = [];
257
+ const workingDir = getWorkingDir();
258
+ for (const filePath of filePaths) {
259
+ const change = fileChanges.find(fc => fc.filePath === filePath);
260
+ if (!change) {
261
+ failed.push(filePath);
262
+ continue;
263
+ }
264
+ try {
265
+ const fullPath = filePath.startsWith('/')
266
+ ? filePath
267
+ : path.resolve(workingDir, filePath);
268
+ if (change.before === null) {
269
+ // File was newly created by agent — delete it
270
+ if (existsSync(fullPath)) {
271
+ unlinkSync(fullPath);
272
+ }
273
+ }
274
+ else {
275
+ // File existed before — restore original content
276
+ const dir = dirname(fullPath);
277
+ if (!existsSync(dir)) {
278
+ mkdirSync(dir, { recursive: true });
279
+ }
280
+ writeFileSync(fullPath, change.before, 'utf-8');
281
+ }
282
+ reverted.push(filePath);
283
+ }
284
+ catch (err) {
285
+ console.error(`[AgentPool] Failed to revert ${filePath}:`, err);
286
+ failed.push(filePath);
287
+ }
288
+ }
289
+ return { reverted, failed };
290
+ }
201
291
  export function killAllSessions() {
202
292
  let count = 0;
203
293
  for (const [, session] of sessions) {
@@ -207,5 +297,7 @@ export function killAllSessions() {
207
297
  session.isProcessing = false;
208
298
  session.queue = [];
209
299
  }
300
+ // Clear all sessions so getActiveSessions() returns empty
301
+ sessions.clear();
210
302
  return count;
211
303
  }
@@ -1,6 +1,6 @@
1
1
  import { WebSocketServer } from 'ws';
2
- import { sendMessage, resetSession, getWorkingDir, getActiveSessions, killSession, killAllSessions, attachWebSocket } from './agentPool.js';
3
- import { processWithTools } from './gektoTools.js';
2
+ import { sendMessage, resumeSession, resetSession, getWorkingDir, getActiveSessions, killSession, killAllSessions, attachWebSocket, revertFiles } from './agentPool.js';
3
+ import { processWithTools, generateTaskPrompts } from './gektoTools.js';
4
4
  import { initGekto, sendToGekto, getGektoState, abortGekto, setStateCallback } from './gektoPersistent.js';
5
5
  // Track connected clients to broadcast Gekto state
6
6
  const connectedClients = new Set();
@@ -151,8 +151,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
151
151
  tool,
152
152
  }));
153
153
  },
154
+ onText: (text) => {
155
+ ws.send(JSON.stringify({
156
+ type: 'gekto_text',
157
+ planId: msg.planId,
158
+ text,
159
+ }));
160
+ },
154
161
  };
155
- const planResult = await processWithTools(msg.prompt, msg.planId, getWorkingDir(), getActiveSessions(), planCallbacks);
162
+ const planResult = await processWithTools(msg.prompt, msg.planId, getWorkingDir(), getActiveSessions(), planCallbacks, msg.existingPlan);
156
163
  if (planResult.type === 'build' && planResult.plan) {
157
164
  activePlans.set(msg.planId, planResult.plan);
158
165
  ws.send(JSON.stringify({
@@ -199,6 +206,59 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
199
206
  ws.send(JSON.stringify({ type: 'state', lizardId: 'master', state: 'ready' }));
200
207
  }
201
208
  return;
209
+ case 'generate_prompts': {
210
+ const genPlan = activePlans.get(msg.planId);
211
+ if (!genPlan) {
212
+ ws.send(JSON.stringify({ type: 'error', message: 'Plan not found' }));
213
+ return;
214
+ }
215
+ // Set master to working while generating
216
+ ws.send(JSON.stringify({ type: 'state', lizardId: 'master', state: 'working' }));
217
+ try {
218
+ const genCallbacks = {
219
+ onTaskPromptGenerated: (taskId, prompt) => {
220
+ // Update server-side plan
221
+ const task = genPlan.tasks.find(t => t.id === taskId);
222
+ if (task)
223
+ task.prompt = prompt;
224
+ // Notify client
225
+ ws.send(JSON.stringify({
226
+ type: 'prompt_generated',
227
+ planId: msg.planId,
228
+ taskId,
229
+ prompt,
230
+ }));
231
+ },
232
+ onAllPromptsReady: () => {
233
+ genPlan.status = 'prompts_ready';
234
+ ws.send(JSON.stringify({
235
+ type: 'prompts_ready',
236
+ planId: msg.planId,
237
+ }));
238
+ },
239
+ onError: (taskId, error) => {
240
+ ws.send(JSON.stringify({
241
+ type: 'prompt_generated',
242
+ planId: msg.planId,
243
+ taskId,
244
+ prompt: genPlan.tasks.find(t => t.id === taskId)?.description || 'Execute task',
245
+ error,
246
+ }));
247
+ },
248
+ };
249
+ await generateTaskPrompts(genPlan, getWorkingDir(), genCallbacks);
250
+ }
251
+ catch (err) {
252
+ console.error('[Agent] Prompt generation failed:', err);
253
+ ws.send(JSON.stringify({
254
+ type: 'gekto_chat',
255
+ planId: msg.planId,
256
+ message: `Error generating prompts: ${err instanceof Error ? err.message : 'Failed'}`,
257
+ }));
258
+ }
259
+ ws.send(JSON.stringify({ type: 'state', lizardId: 'master', state: 'ready' }));
260
+ return;
261
+ }
202
262
  case 'execute_plan':
203
263
  // Execute an existing plan
204
264
  const plan = activePlans.get(msg.planId);
@@ -217,6 +277,23 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
217
277
  activePlans.delete(msg.planId);
218
278
  }
219
279
  return;
280
+ case 'resume_agent': {
281
+ const { lizardId: resumeId, sessionId: resumeSessionId, prompt: resumePrompt } = msg;
282
+ if (!resumeId) {
283
+ ws.send(JSON.stringify({ type: 'error', message: 'Missing lizardId for resume' }));
284
+ return;
285
+ }
286
+ // Create session with restored sessionId
287
+ resumeSession(resumeId, resumeSessionId, ws);
288
+ // Send the original prompt to resume work
289
+ try {
290
+ await sendMessage(resumeId, resumePrompt || 'Continue where you left off.', ws);
291
+ }
292
+ catch (err) {
293
+ console.error(`[Agent] Resume failed for ${resumeId}:`, err);
294
+ }
295
+ return;
296
+ }
220
297
  }
221
298
  // Commands that require lizardId
222
299
  const lizardId = msg.lizardId;
@@ -237,6 +314,16 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
237
314
  resetSession(lizardId);
238
315
  ws.send(JSON.stringify({ type: 'state', lizardId, state: 'ready' }));
239
316
  break;
317
+ case 'revert_files': {
318
+ const revertResult = revertFiles(msg.filePaths || [], msg.fileChanges || []);
319
+ ws.send(JSON.stringify({
320
+ type: 'files_reverted',
321
+ lizardId,
322
+ reverted: revertResult.reverted,
323
+ failed: revertResult.failed,
324
+ }));
325
+ break;
326
+ }
240
327
  case 'kill':
241
328
  // For master, abort the persistent Gekto process instead of killing session
242
329
  const killed = lizardId === 'master' ? abortGekto() : killSession(lizardId);
@@ -0,0 +1,30 @@
1
+ // Shared Bash safety rules for all agents (Gekto + workers)
2
+ // Mirrors the restrictions in CLAUDE.md and .claude/settings.json
3
+ export const BASH_SAFETY_RULES = `
4
+ BASH SAFETY RULES — You MUST follow these when using Bash:
5
+
6
+ ALLOWED commands:
7
+ - git (status, add, commit, diff, log, branch, checkout, stash)
8
+ - npm/yarn/pnpm/bun (run, test, install, build)
9
+ - npx, node, tsc, python, pip install
10
+ - cat, ls, find, grep, echo, mkdir, cp, mv, head, tail, wc, sort, diff, pwd, which
11
+
12
+ STRICTLY FORBIDDEN — NEVER run these:
13
+ - rm -rf (any path outside the current project)
14
+ - sudo (any command)
15
+ - chmod 777, chown, mkfs, dd
16
+ - curl|bash, curl|sh, wget|bash, wget|sh (piped execution)
17
+ - eval with untrusted input
18
+ - shutdown, reboot, kill -9 1, killall, systemctl, service
19
+ - Any command that modifies system files (/etc, /usr, /var, /bin, /sbin, /boot)
20
+ - Any command that reads /etc/shadow, /etc/passwd
21
+ - Any command that writes to /root, ~/.ssh, ~/.bashrc, ~/.zshrc
22
+ - Fork bombs or disk-wiping commands
23
+ - Reading or writing .env files (they contain secrets)
24
+
25
+ GENERAL RULES:
26
+ 1. Only run commands within the current project directory
27
+ 2. Never run destructive commands without confirming the paths are project-local
28
+ 3. Prefer dedicated tools (Read, Write, Edit, Glob, Grep) over bash equivalents when available
29
+ 4. If a task requires operations outside the project, STOP and report it
30
+ `;
@@ -46,7 +46,7 @@ function spawnOpus() {
46
46
  '--input-format', 'stream-json',
47
47
  '--output-format', 'stream-json',
48
48
  '--verbose',
49
- '--model', 'claude-opus-4-5-20251101',
49
+ '--model', 'claude-sonnet-4-6',
50
50
  '--system-prompt', OPUS_SYSTEM_PROMPT,
51
51
  '--dangerously-skip-permissions',
52
52
  '--disallowed-tools', 'Bash', 'Task',
@@ -58,6 +58,8 @@ function spawnOpus() {
58
58
  env: process.env,
59
59
  stdio: ['pipe', 'pipe', 'pipe'],
60
60
  });
61
+ // Prevent EPIPE crash when writing to stdin of a dead process
62
+ opusProcess.stdin.on('error', () => { });
61
63
  opusProcess.on('error', (err) => {
62
64
  console.error(`[GektoPersistent] Spawn error:`, err);
63
65
  });
@@ -100,7 +102,7 @@ function spawnOpus() {
100
102
  });
101
103
  // Warm up: send a quick message to trigger ready state
102
104
  setTimeout(() => {
103
- if (opusProcess && !opusReady) {
105
+ if (opusProcess && !opusProcess.killed && opusProcess.stdin.writable) {
104
106
  const warmup = { type: 'user', message: { role: 'user', content: 'hi' } };
105
107
  opusProcess.stdin.write(JSON.stringify(warmup) + '\n');
106
108
  }
@@ -139,12 +141,15 @@ function handleOpusEvent(event) {
139
141
  }
140
142
  }
141
143
  }
142
- // Text streaming
144
+ // Text streaming (text_delta = response text, thinking_delta = extended thinking)
143
145
  if (event.type === 'content_block_delta') {
144
146
  const delta = event.delta;
145
147
  if (delta?.type === 'text_delta' && delta.text) {
146
148
  opusCallbacks?.onText?.(delta.text);
147
149
  }
150
+ else if (delta?.type === 'thinking_delta' && delta.thinking) {
151
+ opusCallbacks?.onText?.(delta.thinking);
152
+ }
148
153
  }
149
154
  // Final result
150
155
  if (event.type === 'result' && event.result) {
@@ -173,7 +178,13 @@ async function sendToOpus(prompt, callbacks) {
173
178
  type: 'user',
174
179
  message: { role: 'user', content: prompt },
175
180
  };
176
- opusProcess.stdin.write(JSON.stringify(inputMessage) + '\n');
181
+ if (opusProcess && !opusProcess.killed && opusProcess.stdin.writable) {
182
+ opusProcess.stdin.write(JSON.stringify(inputMessage) + '\n');
183
+ }
184
+ else {
185
+ opusPendingResolve?.('Process is not available, please try again.');
186
+ opusPendingResolve = null;
187
+ }
177
188
  // Timeout after 5 min for complex tasks
178
189
  setTimeout(() => {
179
190
  if (opusPendingResolve) {
@@ -183,6 +194,10 @@ async function sendToOpus(prompt, callbacks) {
183
194
  }, 300000);
184
195
  });
185
196
  }
197
+ // === Planning API (reuses warm persistent process) ===
198
+ export async function sendPlanningPrompt(prompt, callbacks) {
199
+ return sendToOpus(prompt, callbacks || {});
200
+ }
186
201
  // Mode is now passed as parameter - default is 'plan', UI can toggle to 'direct'
187
202
  export async function sendToGekto(prompt, mode = 'plan', callbacks) {
188
203
  const startTime = Date.now();
@@ -93,8 +93,13 @@ function runHaiku(prompt, systemPrompt, workingDir) {
93
93
  }
94
94
  }
95
95
  }
96
- else if (event.type === 'content_block_delta' && event.delta?.text) {
97
- resultText += event.delta.text;
96
+ else if (event.type === 'content_block_delta') {
97
+ if (event.delta?.text) {
98
+ resultText += event.delta.text;
99
+ }
100
+ else if (event.delta?.thinking) {
101
+ resultText += event.delta.thinking;
102
+ }
98
103
  }
99
104
  }
100
105
  catch {