zyndo 0.1.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.
Files changed (47) hide show
  1. package/dist/agentLoop.d.ts +14 -0
  2. package/dist/agentLoop.js +76 -0
  3. package/dist/banner.d.ts +1 -0
  4. package/dist/banner.js +25 -0
  5. package/dist/config.d.ts +41 -0
  6. package/dist/config.js +109 -0
  7. package/dist/connection.d.ts +58 -0
  8. package/dist/connection.js +114 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +68 -0
  11. package/dist/init.d.ts +1 -0
  12. package/dist/init.js +178 -0
  13. package/dist/mcp/mcpCore.d.ts +22 -0
  14. package/dist/mcp/mcpCore.js +448 -0
  15. package/dist/mcp/mcpServer.d.ts +1 -0
  16. package/dist/mcp/mcpServer.js +61 -0
  17. package/dist/providers/anthropic.d.ts +2 -0
  18. package/dist/providers/anthropic.js +52 -0
  19. package/dist/providers/claudeCode.d.ts +5 -0
  20. package/dist/providers/claudeCode.js +174 -0
  21. package/dist/providers/ollama.d.ts +2 -0
  22. package/dist/providers/ollama.js +90 -0
  23. package/dist/providers/openai.d.ts +2 -0
  24. package/dist/providers/openai.js +103 -0
  25. package/dist/providers/types.d.ts +33 -0
  26. package/dist/providers/types.js +4 -0
  27. package/dist/sellerDaemon.d.ts +9 -0
  28. package/dist/sellerDaemon.js +336 -0
  29. package/dist/state.d.ts +21 -0
  30. package/dist/state.js +63 -0
  31. package/dist/tools/askBuyer.d.ts +2 -0
  32. package/dist/tools/askBuyer.js +19 -0
  33. package/dist/tools/bash.d.ts +2 -0
  34. package/dist/tools/bash.js +35 -0
  35. package/dist/tools/glob.d.ts +2 -0
  36. package/dist/tools/glob.js +28 -0
  37. package/dist/tools/grep.d.ts +2 -0
  38. package/dist/tools/grep.js +36 -0
  39. package/dist/tools/pathSafety.d.ts +1 -0
  40. package/dist/tools/pathSafety.js +9 -0
  41. package/dist/tools/readFile.d.ts +2 -0
  42. package/dist/tools/readFile.js +35 -0
  43. package/dist/tools/types.d.ts +9 -0
  44. package/dist/tools/types.js +4 -0
  45. package/dist/tools/writeFile.d.ts +2 -0
  46. package/dist/tools/writeFile.js +28 -0
  47. package/package.json +36 -0
@@ -0,0 +1,336 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Seller daemon — poll for tasks, run agent loop, deliver results
3
+ // ---------------------------------------------------------------------------
4
+ import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail } from './connection.js';
5
+ import { runAgentLoop } from './agentLoop.js';
6
+ import { createAnthropicProvider } from './providers/anthropic.js';
7
+ import { createOpenAIProvider } from './providers/openai.js';
8
+ import { createOllamaProvider } from './providers/ollama.js';
9
+ import { runClaudeCodeTask } from './providers/claudeCode.js';
10
+ import { createReadFileTool } from './tools/readFile.js';
11
+ import { createWriteFileTool } from './tools/writeFile.js';
12
+ import { createBashTool } from './tools/bash.js';
13
+ import { createGlobTool } from './tools/glob.js';
14
+ import { createGrepTool } from './tools/grep.js';
15
+ import { createAskBuyerTool } from './tools/askBuyer.js';
16
+ import { loadState, saveState, deleteState, loadSession, saveSession } from './state.js';
17
+ const POLL_INTERVAL_MS = 25_000;
18
+ const HEARTBEAT_INTERVAL_MS = 45_000;
19
+ const defaultLogger = {
20
+ info: (msg) => process.stdout.write(`[zyndo] ${msg}\n`),
21
+ error: (msg) => process.stderr.write(`[zyndo] ERROR: ${msg}\n`)
22
+ };
23
+ // ---------------------------------------------------------------------------
24
+ // Provider factory
25
+ // ---------------------------------------------------------------------------
26
+ function createProvider(name, model, apiKey) {
27
+ switch (name) {
28
+ case 'anthropic': return createAnthropicProvider(apiKey ?? '', model);
29
+ case 'openai': return createOpenAIProvider(apiKey ?? '', model);
30
+ case 'ollama': return createOllamaProvider(model);
31
+ case 'claude-code': throw new Error('createProvider should not be called for claude-code provider.');
32
+ }
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Tool factory
36
+ // ---------------------------------------------------------------------------
37
+ function loadTools(allowedTools, workingDirectory) {
38
+ const allTools = {
39
+ read_file: () => createReadFileTool(workingDirectory),
40
+ write_file: () => createWriteFileTool(workingDirectory),
41
+ bash: () => createBashTool(workingDirectory),
42
+ glob: () => createGlobTool(workingDirectory),
43
+ grep: () => createGrepTool(workingDirectory),
44
+ ask_buyer: () => createAskBuyerTool()
45
+ };
46
+ return allowedTools
47
+ .filter((name) => allTools[name] !== undefined)
48
+ .map((name) => allTools[name]());
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Start daemon
52
+ // ---------------------------------------------------------------------------
53
+ export async function startSellerDaemon(config, opts) {
54
+ const logger = opts?.logger ?? defaultLogger;
55
+ const signal = opts?.signal;
56
+ // Try to reconnect as the same agent from a previous session
57
+ let session;
58
+ const previousSession = loadSession();
59
+ if (previousSession !== undefined) {
60
+ logger.info(`Found previous session (agentId=${previousSession.agentId}), reconnecting...`);
61
+ try {
62
+ const tempSession = {
63
+ agentId: previousSession.agentId,
64
+ token: '',
65
+ reconnectToken: previousSession.reconnectToken,
66
+ bridgeUrl: config.bridgeUrl
67
+ };
68
+ session = await reconnect(tempSession, {
69
+ role: 'seller',
70
+ name: config.name,
71
+ description: config.description
72
+ });
73
+ logger.info(`Reconnected: agentId=${session.agentId} (same identity preserved)`);
74
+ }
75
+ catch {
76
+ logger.info('Previous session expired, creating new registration...');
77
+ }
78
+ }
79
+ // Fresh connect if no previous session or reconnect failed
80
+ if (session === undefined) {
81
+ logger.info(`Connecting as seller "${config.name}"...`);
82
+ session = await connect(config.bridgeUrl, config.apiKey, {
83
+ role: 'seller',
84
+ name: config.name,
85
+ description: config.description,
86
+ skills: [...config.skills],
87
+ categories: [...config.categories],
88
+ maxConcurrentTasks: config.maxConcurrentTasks
89
+ });
90
+ logger.info(`Connected: agentId=${session.agentId}`);
91
+ }
92
+ // Persist session for future restarts
93
+ saveSession(session.agentId, session.reconnectToken);
94
+ let lastEventId = 0;
95
+ let lastHeartbeat = Date.now();
96
+ const activeTasks = new Set();
97
+ while (signal === undefined || !signal.aborted) {
98
+ try {
99
+ // Heartbeat
100
+ if (Date.now() - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
101
+ try {
102
+ await heartbeat(session);
103
+ lastHeartbeat = Date.now();
104
+ }
105
+ catch {
106
+ logger.info('Heartbeat failed, attempting reconnect...');
107
+ try {
108
+ session = await reconnect(session, { role: 'seller', name: config.name, description: config.description });
109
+ saveSession(session.agentId, session.reconnectToken);
110
+ logger.info('Reconnected successfully.');
111
+ lastHeartbeat = Date.now();
112
+ }
113
+ catch (reconnectError) {
114
+ logger.error(`Reconnect failed: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`);
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ // Poll events
120
+ const events = await pollEvents(session, lastEventId);
121
+ let minDeferredEventId;
122
+ for (const event of events) {
123
+ if (event.type === 'task.assigned') {
124
+ const taskId = event.payload.taskId;
125
+ if (activeTasks.has(taskId)) {
126
+ if (event.eventId > lastEventId)
127
+ lastEventId = event.eventId;
128
+ continue;
129
+ }
130
+ if (activeTasks.size >= config.maxConcurrentTasks) {
131
+ logger.info(`Task ${taskId}: at capacity (${activeTasks.size}/${config.maxConcurrentTasks}), deferring.`);
132
+ // Don't advance cursor past deferred tasks so they are retried
133
+ if (minDeferredEventId === undefined || event.eventId < minDeferredEventId) {
134
+ minDeferredEventId = event.eventId;
135
+ }
136
+ continue;
137
+ }
138
+ activeTasks.add(taskId);
139
+ if (event.eventId > lastEventId)
140
+ lastEventId = event.eventId;
141
+ logger.info(`Task assigned: ${taskId} (active: ${activeTasks.size}/${config.maxConcurrentTasks})`);
142
+ handleTask(session, taskId, config, logger)
143
+ .catch((err) => {
144
+ logger.error(`Task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
145
+ })
146
+ .finally(() => activeTasks.delete(taskId));
147
+ }
148
+ else {
149
+ // Non-task-assigned events always advance the cursor
150
+ if (event.eventId > lastEventId)
151
+ lastEventId = event.eventId;
152
+ }
153
+ if (event.type === 'task.message.received') {
154
+ const taskId = event.payload.taskId;
155
+ if (activeTasks.has(taskId))
156
+ continue; // Task handler is already running
157
+ activeTasks.add(taskId);
158
+ logger.info(`Message received for task: ${taskId}`);
159
+ handleBuyerMessage(session, taskId, config, logger)
160
+ .catch((err) => {
161
+ logger.error(`Message handling for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
162
+ })
163
+ .finally(() => activeTasks.delete(taskId));
164
+ }
165
+ if (event.type === 'task.revision.requested') {
166
+ const taskId = event.payload.taskId;
167
+ const feedback = event.payload.feedback ?? '';
168
+ if (activeTasks.has(taskId))
169
+ continue;
170
+ activeTasks.add(taskId);
171
+ logger.info(`Revision requested for task: ${taskId}`);
172
+ handleRevision(session, taskId, feedback, config, logger)
173
+ .catch((err) => {
174
+ logger.error(`Revision for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
175
+ })
176
+ .finally(() => activeTasks.delete(taskId));
177
+ }
178
+ // Clean up state on terminal events
179
+ if (event.type === 'task.completed' || event.type === 'task.rejected' || event.type === 'task.failed' || event.type === 'task.canceled') {
180
+ const taskId = event.payload.taskId;
181
+ if (taskId !== undefined)
182
+ deleteState(taskId);
183
+ }
184
+ }
185
+ // Cap cursor below deferred events so they are retried next poll
186
+ if (minDeferredEventId !== undefined && lastEventId >= minDeferredEventId) {
187
+ lastEventId = minDeferredEventId - 1;
188
+ }
189
+ }
190
+ catch (error) {
191
+ logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ await sleep(POLL_INTERVAL_MS);
194
+ }
195
+ logger.info('Daemon stopped.');
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Task handler
199
+ // ---------------------------------------------------------------------------
200
+ async function handleTask(session, taskId, config, logger) {
201
+ await acceptTask(session, taskId);
202
+ logger.info(`Task ${taskId}: accepted, starting work...`);
203
+ // Get task context from task detail first, then messages as supplement
204
+ const detail = await getTaskDetail(session, taskId);
205
+ const taskContext = detail?.context ?? '';
206
+ const messages = await getTaskMessages(session, taskId);
207
+ const messageContext = messages.map((m) => m.content).join('\n');
208
+ const context = taskContext || messageContext || 'Task assigned. No additional context provided.';
209
+ const result = config.provider === 'claude-code'
210
+ ? await runClaudeCodeTask(context, config, logger)
211
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: config.systemPrompt, taskId });
212
+ if (result.timedOut === true) {
213
+ logger.error(`Task ${taskId}: agent timed out or errored, notifying buyer.`);
214
+ await sendTaskMessage(session, taskId, 'info', 'The seller agent encountered a timeout processing this task. Please allow more time or simplify the request. The task remains assigned.');
215
+ saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context });
216
+ return;
217
+ }
218
+ if (result.paused) {
219
+ logger.info(`Task ${taskId}: paused, asking buyer: "${result.pendingQuestion?.slice(0, 100)}..."`);
220
+ saveState({ taskId, messages: [], claudeCodeContext: context, pendingQuestion: result.pendingQuestion });
221
+ await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
222
+ return;
223
+ }
224
+ logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
225
+ await deliverTask(session, taskId, result.output);
226
+ // Preserve state for potential revision — only deleted on terminal events
227
+ saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: result.output });
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Revision handler
231
+ // ---------------------------------------------------------------------------
232
+ async function handleRevision(session, taskId, feedback, config, logger) {
233
+ const savedState = loadState(taskId);
234
+ // Use original task context (not compounded revision context) to avoid quadratic growth
235
+ const originalContext = savedState?.originalContext ?? savedState?.claudeCodeContext ?? '';
236
+ const lastDelivery = savedState?.lastDelivery ?? '';
237
+ const truncatedDelivery = lastDelivery.length > 10_000
238
+ ? lastDelivery.slice(0, 10_000) + `\n... [truncated, original was ${lastDelivery.length} chars]`
239
+ : lastDelivery;
240
+ const revisionContext = [
241
+ originalContext,
242
+ '',
243
+ '[Your previous delivery]:',
244
+ truncatedDelivery,
245
+ '',
246
+ '[Buyer revision request]:',
247
+ feedback,
248
+ '',
249
+ 'Revise your work based on the buyer feedback above. Output the complete updated deliverable.'
250
+ ].join('\n');
251
+ const result = config.provider === 'claude-code'
252
+ ? await runClaudeCodeTask(revisionContext, config, logger)
253
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: config.systemPrompt, taskId });
254
+ if (result.timedOut === true) {
255
+ logger.error(`Task ${taskId}: revision timed out, notifying buyer.`);
256
+ await sendTaskMessage(session, taskId, 'info', 'The seller agent timed out while working on the revision. Please allow more time or simplify the request.');
257
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext });
258
+ return;
259
+ }
260
+ if (result.paused) {
261
+ logger.info(`Task ${taskId}: revision paused, asking buyer...`);
262
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, pendingQuestion: result.pendingQuestion });
263
+ await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
264
+ return;
265
+ }
266
+ logger.info(`Task ${taskId}: delivering revision (${result.output.length} chars)`);
267
+ await deliverTask(session, taskId, result.output);
268
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: result.output });
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // Buyer message handler (resume paused loop)
272
+ // ---------------------------------------------------------------------------
273
+ function buildResumedContext(priorContext, priorQuestion, buyerAnswer) {
274
+ return [
275
+ priorContext,
276
+ '',
277
+ `[You previously asked the buyer]: ${priorQuestion ?? '(question not recorded)'}`,
278
+ `[Buyer answered]: ${buyerAnswer}`,
279
+ '',
280
+ 'Continue the task with this new information.'
281
+ ].join('\n');
282
+ }
283
+ async function handleBuyerMessage(session, taskId, config, logger) {
284
+ const savedState = loadState(taskId);
285
+ if (savedState === undefined) {
286
+ logger.info(`Task ${taskId}: no saved state found, ignoring message.`);
287
+ return;
288
+ }
289
+ // Post-delivery message: buyer sent a message after receiving delivery.
290
+ // Guide them to use zyndo_request_revision for the formal revision flow.
291
+ if (savedState.lastDelivery !== undefined && savedState.pendingQuestion === undefined) {
292
+ logger.info(`Task ${taskId}: buyer sent message after delivery, guiding to zyndo_request_revision.`);
293
+ await sendTaskMessage(session, taskId, 'info', 'I received your message. To get a revised delivery, please use the revision action (zyndo_request_revision) with your feedback. I will then rework and re-deliver.');
294
+ return;
295
+ }
296
+ const messages = await getTaskMessages(session, taskId);
297
+ const lastMessage = messages[messages.length - 1];
298
+ if (lastMessage === undefined)
299
+ return;
300
+ logger.info(`Task ${taskId}: resuming with buyer's answer.`);
301
+ let result;
302
+ if (config.provider === 'claude-code') {
303
+ const enrichedContext = buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content);
304
+ result = await runClaudeCodeTask(enrichedContext, config, logger);
305
+ }
306
+ else {
307
+ if (savedState.pendingToolCallId === undefined) {
308
+ logger.info(`Task ${taskId}: no pending tool call, ignoring.`);
309
+ return;
310
+ }
311
+ result = await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), lastMessage.content, { systemPrompt: config.systemPrompt, taskId });
312
+ }
313
+ if (result.timedOut === true) {
314
+ logger.error(`Task ${taskId}: message-resume timed out, notifying buyer.`);
315
+ await sendTaskMessage(session, taskId, 'info', 'The seller agent timed out while processing your response. Please allow more time or simplify the request.');
316
+ return;
317
+ }
318
+ if (result.paused) {
319
+ logger.info(`Task ${taskId}: paused again, asking buyer: "${result.pendingQuestion?.slice(0, 100)}..."`);
320
+ const updatedContext = config.provider === 'claude-code'
321
+ ? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
322
+ : savedState.claudeCodeContext ?? '';
323
+ saveState({ taskId, messages: [], claudeCodeContext: updatedContext, pendingQuestion: result.pendingQuestion });
324
+ await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
325
+ return;
326
+ }
327
+ logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
328
+ await deliverTask(session, taskId, result.output);
329
+ const resumedContext = config.provider === 'claude-code'
330
+ ? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
331
+ : savedState.claudeCodeContext ?? '';
332
+ saveState({ taskId, messages: [], claudeCodeContext: resumedContext, lastDelivery: result.output });
333
+ }
334
+ function sleep(ms) {
335
+ return new Promise((resolve) => setTimeout(resolve, ms));
336
+ }
@@ -0,0 +1,21 @@
1
+ import type { Message } from './providers/types.js';
2
+ export type ConversationState = Readonly<{
3
+ taskId: string;
4
+ messages: ReadonlyArray<Message>;
5
+ pendingToolCallId?: string;
6
+ claudeCodeContext?: string;
7
+ pendingQuestion?: string;
8
+ lastDelivery?: string;
9
+ originalContext?: string;
10
+ }>;
11
+ export declare function saveState(state: ConversationState): void;
12
+ export declare function loadState(taskId: string): ConversationState | undefined;
13
+ export declare function deleteState(taskId: string): void;
14
+ export type PersistedSession = Readonly<{
15
+ agentId: string;
16
+ reconnectToken: string;
17
+ savedAt: string;
18
+ }>;
19
+ export declare function saveSession(agentId: string, reconnectToken: string): void;
20
+ export declare function loadSession(): PersistedSession | undefined;
21
+ export declare function clearSession(): void;
package/dist/state.js ADDED
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Conversation state persistence — save/load to disk
3
+ // ---------------------------------------------------------------------------
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { resolveConfigDir } from './config.js';
7
+ function getBaseDir() {
8
+ return resolveConfigDir().dir;
9
+ }
10
+ const STATE_DIR = resolve(getBaseDir(), 'state');
11
+ function sanitizeTaskId(taskId) {
12
+ return taskId.replace(/[^a-zA-Z0-9_-]/g, '_');
13
+ }
14
+ function stateFilePath(taskId) {
15
+ return resolve(STATE_DIR, `task-${sanitizeTaskId(taskId)}.json`);
16
+ }
17
+ export function saveState(state) {
18
+ mkdirSync(STATE_DIR, { recursive: true });
19
+ writeFileSync(stateFilePath(state.taskId), JSON.stringify(state, null, 2), 'utf-8');
20
+ }
21
+ export function loadState(taskId) {
22
+ const path = stateFilePath(taskId);
23
+ if (!existsSync(path))
24
+ return undefined;
25
+ try {
26
+ const raw = readFileSync(path, 'utf-8');
27
+ return JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ export function deleteState(taskId) {
34
+ const path = stateFilePath(taskId);
35
+ if (existsSync(path)) {
36
+ unlinkSync(path);
37
+ }
38
+ }
39
+ function getSessionFile() {
40
+ return resolve(getBaseDir(), 'session.json');
41
+ }
42
+ export function saveSession(agentId, reconnectToken) {
43
+ const baseDir = getBaseDir();
44
+ mkdirSync(baseDir, { recursive: true });
45
+ const data = { agentId, reconnectToken, savedAt: new Date().toISOString() };
46
+ writeFileSync(getSessionFile(), JSON.stringify(data, null, 2), 'utf-8');
47
+ }
48
+ export function loadSession() {
49
+ if (!existsSync(getSessionFile()))
50
+ return undefined;
51
+ try {
52
+ const raw = readFileSync(getSessionFile(), 'utf-8');
53
+ return JSON.parse(raw);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ export function clearSession() {
60
+ if (existsSync(getSessionFile())) {
61
+ unlinkSync(getSessionFile());
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createAskBuyerTool(): Tool;
@@ -0,0 +1,19 @@
1
+ export function createAskBuyerTool() {
2
+ return {
3
+ name: 'ask_buyer',
4
+ description: 'Ask the buyer a question. This pauses the agent loop and sends the question to the buyer. The agent will resume when the buyer responds.',
5
+ inputSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ question: { type: 'string', description: 'The question to ask the buyer' }
9
+ },
10
+ required: ['question']
11
+ },
12
+ async execute(input) {
13
+ return {
14
+ output: input.question,
15
+ pause: true
16
+ };
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createBashTool(workingDirectory: string): Tool;
@@ -0,0 +1,35 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function createBashTool(workingDirectory) {
3
+ return {
4
+ name: 'bash',
5
+ description: 'Execute a shell command and return its output. Use for running tests, builds, git commands, etc.',
6
+ inputSchema: {
7
+ type: 'object',
8
+ properties: {
9
+ command: { type: 'string', description: 'The shell command to execute' },
10
+ timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)' }
11
+ },
12
+ required: ['command']
13
+ },
14
+ async execute(input) {
15
+ try {
16
+ const timeout = typeof input.timeout === 'number' ? input.timeout : 120_000;
17
+ const output = execSync(input.command, {
18
+ cwd: workingDirectory,
19
+ timeout,
20
+ encoding: 'utf-8',
21
+ maxBuffer: 10 * 1024 * 1024,
22
+ stdio: ['pipe', 'pipe', 'pipe']
23
+ });
24
+ return { output: output.slice(0, 50_000) };
25
+ }
26
+ catch (error) {
27
+ const execError = error;
28
+ const stdout = execError.stdout ?? '';
29
+ const stderr = execError.stderr ?? '';
30
+ const combined = `${stdout}\n${stderr}`.trim().slice(0, 50_000);
31
+ return { output: combined || (execError.message ?? 'Command failed'), isError: true };
32
+ }
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createGlobTool(workingDirectory: string): Tool;
@@ -0,0 +1,28 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function createGlobTool(workingDirectory) {
3
+ return {
4
+ name: 'glob',
5
+ description: 'Find files matching a glob pattern. Returns matching file paths.',
6
+ inputSchema: {
7
+ type: 'object',
8
+ properties: {
9
+ pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts", "src/**/*.test.ts")' }
10
+ },
11
+ required: ['pattern']
12
+ },
13
+ async execute(input) {
14
+ try {
15
+ const pattern = input.pattern;
16
+ const output = execSync(`find . -path './${pattern}' -type f 2>/dev/null | head -100`, {
17
+ cwd: workingDirectory,
18
+ encoding: 'utf-8',
19
+ timeout: 10_000
20
+ });
21
+ return { output: output.trim() || 'No files found matching pattern.' };
22
+ }
23
+ catch {
24
+ return { output: 'No files found matching pattern.' };
25
+ }
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createGrepTool(workingDirectory: string): Tool;
@@ -0,0 +1,36 @@
1
+ import { execSync } from 'node:child_process';
2
+ function escapeShellArg(arg) {
3
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
4
+ }
5
+ export function createGrepTool(workingDirectory) {
6
+ return {
7
+ name: 'grep',
8
+ description: 'Search file contents for a pattern. Returns matching lines with file paths and line numbers.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ pattern: { type: 'string', description: 'Search pattern (regex supported)' },
13
+ glob: { type: 'string', description: 'Glob filter for files (e.g., "*.ts")' },
14
+ context: { type: 'number', description: 'Lines of context around each match' }
15
+ },
16
+ required: ['pattern']
17
+ },
18
+ async execute(input) {
19
+ try {
20
+ const pattern = escapeShellArg(input.pattern);
21
+ const glob = typeof input.glob === 'string' ? `--include=${escapeShellArg(input.glob)}` : '';
22
+ const context = typeof input.context === 'number' ? `-C ${Math.min(Math.max(0, input.context), 20)}` : '';
23
+ const cmd = `grep -rn ${context} ${glob} ${pattern} . 2>/dev/null | head -100`;
24
+ const output = execSync(cmd, {
25
+ cwd: workingDirectory,
26
+ encoding: 'utf-8',
27
+ timeout: 10_000
28
+ });
29
+ return { output: output.trim() || 'No matches found.' };
30
+ }
31
+ catch {
32
+ return { output: 'No matches found.' };
33
+ }
34
+ }
35
+ };
36
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveSafe(workingDirectory: string, userPath: string): string;
@@ -0,0 +1,9 @@
1
+ import { resolve } from 'node:path';
2
+ export function resolveSafe(workingDirectory, userPath) {
3
+ const base = resolve(workingDirectory);
4
+ const resolved = resolve(workingDirectory, userPath);
5
+ if (resolved !== base && !resolved.startsWith(base + '/')) {
6
+ throw new Error(`Path escapes working directory: ${userPath}`);
7
+ }
8
+ return resolved;
9
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createReadFileTool(workingDirectory: string): Tool;
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolveSafe } from './pathSafety.js';
3
+ export function createReadFileTool(workingDirectory) {
4
+ return {
5
+ name: 'read_file',
6
+ description: 'Read the contents of a file. Returns the file contents as text.',
7
+ inputSchema: {
8
+ type: 'object',
9
+ properties: {
10
+ path: { type: 'string', description: 'Path to the file (relative to working directory)' },
11
+ offset: { type: 'number', description: 'Line number to start reading from (0-based)' },
12
+ limit: { type: 'number', description: 'Maximum number of lines to read' }
13
+ },
14
+ required: ['path']
15
+ },
16
+ async execute(input) {
17
+ try {
18
+ const filePath = resolveSafe(workingDirectory, input.path);
19
+ if (!existsSync(filePath)) {
20
+ return { output: `File not found: ${input.path}`, isError: true };
21
+ }
22
+ let content = readFileSync(filePath, 'utf-8');
23
+ const lines = content.split('\n');
24
+ const offset = typeof input.offset === 'number' ? input.offset : 0;
25
+ const limit = typeof input.limit === 'number' ? input.limit : lines.length;
26
+ const sliced = lines.slice(offset, offset + limit);
27
+ content = sliced.map((line, i) => `${offset + i + 1}\t${line}`).join('\n');
28
+ return { output: content };
29
+ }
30
+ catch (error) {
31
+ return { output: `Error reading file: ${error instanceof Error ? error.message : String(error)}`, isError: true };
32
+ }
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,9 @@
1
+ import type { ToolDefinition } from '../providers/types.js';
2
+ export type ToolResult = Readonly<{
3
+ output: string;
4
+ isError?: boolean;
5
+ pause?: boolean;
6
+ }>;
7
+ export type Tool = ToolDefinition & Readonly<{
8
+ execute: (input: Record<string, unknown>) => Promise<ToolResult>;
9
+ }>;
@@ -0,0 +1,4 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Tool interface for the agent loop
3
+ // ---------------------------------------------------------------------------
4
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { Tool } from './types.js';
2
+ export declare function createWriteFileTool(workingDirectory: string): Tool;