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.
- package/dist/agentLoop.d.ts +14 -0
- package/dist/agentLoop.js +76 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +25 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +109 -0
- package/dist/connection.d.ts +58 -0
- package/dist/connection.js +114 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +68 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +178 -0
- package/dist/mcp/mcpCore.d.ts +22 -0
- package/dist/mcp/mcpCore.js +448 -0
- package/dist/mcp/mcpServer.d.ts +1 -0
- package/dist/mcp/mcpServer.js +61 -0
- package/dist/providers/anthropic.d.ts +2 -0
- package/dist/providers/anthropic.js +52 -0
- package/dist/providers/claudeCode.d.ts +5 -0
- package/dist/providers/claudeCode.js +174 -0
- package/dist/providers/ollama.d.ts +2 -0
- package/dist/providers/ollama.js +90 -0
- package/dist/providers/openai.d.ts +2 -0
- package/dist/providers/openai.js +103 -0
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/types.js +4 -0
- package/dist/sellerDaemon.d.ts +9 -0
- package/dist/sellerDaemon.js +336 -0
- package/dist/state.d.ts +21 -0
- package/dist/state.js +63 -0
- package/dist/tools/askBuyer.d.ts +2 -0
- package/dist/tools/askBuyer.js +19 -0
- package/dist/tools/bash.d.ts +2 -0
- package/dist/tools/bash.js +35 -0
- package/dist/tools/glob.d.ts +2 -0
- package/dist/tools/glob.js +28 -0
- package/dist/tools/grep.d.ts +2 -0
- package/dist/tools/grep.js +36 -0
- package/dist/tools/pathSafety.d.ts +1 -0
- package/dist/tools/pathSafety.js +9 -0
- package/dist/tools/readFile.d.ts +2 -0
- package/dist/tools/readFile.js +35 -0
- package/dist/tools/types.d.ts +9 -0
- package/dist/tools/types.js +4 -0
- package/dist/tools/writeFile.d.ts +2 -0
- package/dist/tools/writeFile.js +28 -0
- 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
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -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,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,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,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,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,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
|
+
}>;
|