@slock-ai/daemon 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/package.json +40 -0
- package/src/agentProcessManager.ts +685 -0
- package/src/chat-bridge.ts +258 -0
- package/src/connection.ts +100 -0
- package/src/index.ts +154 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slock-ai/daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"slock-daemon": "src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/botiverse/slock.git",
|
|
14
|
+
"directory": "packages/daemon"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
21
|
+
"tsx": "^4.19.2",
|
|
22
|
+
"ws": "^8.18.0",
|
|
23
|
+
"zod": "^4.3.6"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.12.0",
|
|
27
|
+
"@types/ws": "^8.5.13",
|
|
28
|
+
"typescript": "^5.7.3",
|
|
29
|
+
"@slock-ai/shared": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"dev": "tsx watch src/index.ts",
|
|
33
|
+
"start": "tsx src/index.ts",
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"release:patch": "npm version patch && git push && git push --tags",
|
|
37
|
+
"release:minor": "npm version minor && git push && git push --tags",
|
|
38
|
+
"release:major": "npm version major && git push && git push --tags"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile, access, readdir, stat, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import type { AgentMessage, AgentConfig, FileNode, MachineToServerMessage, WorkspaceDirectoryInfo, TrajectoryEntry } from "@slock-ai/shared";
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = path.join(os.homedir(), ".slock", "agents");
|
|
8
|
+
|
|
9
|
+
/** Max chars for thinking/text content in trajectory entries (sent over WebSocket) */
|
|
10
|
+
const MAX_TRAJECTORY_TEXT = 2000;
|
|
11
|
+
|
|
12
|
+
interface AgentProcess {
|
|
13
|
+
process: ChildProcess;
|
|
14
|
+
inbox: AgentMessage[];
|
|
15
|
+
pendingReceive: {
|
|
16
|
+
resolve: (messages: AgentMessage[]) => void;
|
|
17
|
+
timer: ReturnType<typeof setTimeout>;
|
|
18
|
+
} | null;
|
|
19
|
+
config: AgentConfig;
|
|
20
|
+
sessionId: string | null;
|
|
21
|
+
isInReceiveMessage: boolean;
|
|
22
|
+
notificationTimer: ReturnType<typeof setTimeout> | null;
|
|
23
|
+
pendingNotificationCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AgentProcessManager {
|
|
27
|
+
private agents = new Map<string, AgentProcess>();
|
|
28
|
+
private chatBridgePath: string;
|
|
29
|
+
private sendToServer: (msg: MachineToServerMessage) => void;
|
|
30
|
+
private daemonApiKey: string;
|
|
31
|
+
|
|
32
|
+
constructor(chatBridgePath: string, sendToServer: (msg: MachineToServerMessage) => void, daemonApiKey: string) {
|
|
33
|
+
this.chatBridgePath = chatBridgePath;
|
|
34
|
+
this.sendToServer = sendToServer;
|
|
35
|
+
this.daemonApiKey = daemonApiKey;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async startAgent(agentId: string, config: AgentConfig) {
|
|
39
|
+
if (this.agents.has(agentId)) return;
|
|
40
|
+
|
|
41
|
+
const agentDataDir = path.join(DATA_DIR, agentId);
|
|
42
|
+
await mkdir(agentDataDir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
// Create MEMORY.md if not exists
|
|
45
|
+
const memoryMdPath = path.join(agentDataDir, "MEMORY.md");
|
|
46
|
+
try {
|
|
47
|
+
await access(memoryMdPath);
|
|
48
|
+
} catch {
|
|
49
|
+
const agentName = config.displayName || config.name;
|
|
50
|
+
const initialMemoryMd = `# ${agentName}\n\n## Role\n${config.description || "No role defined yet."}\n\n## Key Knowledge\n- No notes yet.\n\n## Active Context\n- First startup.\n`;
|
|
51
|
+
await writeFile(memoryMdPath, initialMemoryMd);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await mkdir(path.join(agentDataDir, "notes"), { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Build prompt
|
|
57
|
+
const isResume = !!config.sessionId;
|
|
58
|
+
const prompt = isResume
|
|
59
|
+
? "New message waiting. Call mcp__chat__receive_message(block=true) to read it, then reply with mcp__chat__send_message. After replying, call mcp__chat__receive_message(block=true) again to keep listening for more messages.\n\nNote: While you are busy, you may receive [System notification: ...] messages about new messages. Finish your current step, then call receive_message to check."
|
|
60
|
+
: this.buildSystemPrompt(config, agentId);
|
|
61
|
+
|
|
62
|
+
// Build MCP config — point to remote server
|
|
63
|
+
// Use daemon's own API key for chat-bridge authentication with /internal/* routes
|
|
64
|
+
const mcpArgs = [
|
|
65
|
+
"tsx",
|
|
66
|
+
this.chatBridgePath,
|
|
67
|
+
"--agent-id", agentId,
|
|
68
|
+
"--server-url", config.serverUrl,
|
|
69
|
+
"--auth-token", config.authToken || this.daemonApiKey,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const mcpConfig = JSON.stringify({
|
|
73
|
+
mcpServers: {
|
|
74
|
+
chat: {
|
|
75
|
+
command: "npx",
|
|
76
|
+
args: mcpArgs,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const args = [
|
|
82
|
+
"--allow-dangerously-skip-permissions",
|
|
83
|
+
"--dangerously-skip-permissions",
|
|
84
|
+
"--verbose",
|
|
85
|
+
"--output-format", "stream-json",
|
|
86
|
+
"--input-format", "stream-json",
|
|
87
|
+
"--mcp-config", mcpConfig,
|
|
88
|
+
"--model", config.model || "sonnet",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
if (config.sessionId) {
|
|
92
|
+
args.push("--resume", config.sessionId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const runtime = config.runtime || "claude";
|
|
96
|
+
const spawnEnv = { ...process.env, FORCE_COLOR: "0" };
|
|
97
|
+
delete (spawnEnv as any).CLAUDECODE;
|
|
98
|
+
const proc = spawn(runtime, args, {
|
|
99
|
+
cwd: agentDataDir,
|
|
100
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
101
|
+
env: spawnEnv,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const agentProcess: AgentProcess = {
|
|
105
|
+
process: proc,
|
|
106
|
+
inbox: [],
|
|
107
|
+
pendingReceive: null,
|
|
108
|
+
config,
|
|
109
|
+
sessionId: config.sessionId || null,
|
|
110
|
+
isInReceiveMessage: false,
|
|
111
|
+
notificationTimer: null,
|
|
112
|
+
pendingNotificationCount: 0,
|
|
113
|
+
};
|
|
114
|
+
this.agents.set(agentId, agentProcess);
|
|
115
|
+
|
|
116
|
+
// Send initial prompt via stdin (stream-json format)
|
|
117
|
+
this.writeStdinMessage(agentProcess, prompt);
|
|
118
|
+
|
|
119
|
+
// Parse stream-json output
|
|
120
|
+
let buffer = "";
|
|
121
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
122
|
+
buffer += chunk.toString();
|
|
123
|
+
const lines = buffer.split("\n");
|
|
124
|
+
buffer = lines.pop() || "";
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
try {
|
|
128
|
+
const event = JSON.parse(line);
|
|
129
|
+
this.handleStreamEvent(agentId, event);
|
|
130
|
+
} catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
135
|
+
const text = chunk.toString().trim();
|
|
136
|
+
if (text) console.error(`[Agent ${agentId} stderr]: ${text}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
proc.on("exit", (code) => {
|
|
140
|
+
console.log(`[Agent ${agentId}] Process exited with code ${code}`);
|
|
141
|
+
if (this.agents.has(agentId)) {
|
|
142
|
+
const ap = this.agents.get(agentId)!;
|
|
143
|
+
if (ap.pendingReceive) {
|
|
144
|
+
clearTimeout(ap.pendingReceive.timer);
|
|
145
|
+
ap.pendingReceive.resolve([]);
|
|
146
|
+
}
|
|
147
|
+
if (ap.notificationTimer) {
|
|
148
|
+
clearTimeout(ap.notificationTimer);
|
|
149
|
+
}
|
|
150
|
+
this.agents.delete(agentId);
|
|
151
|
+
this.sendToServer({ type: "agent:status", agentId, status: "sleeping" });
|
|
152
|
+
this.sendToServer({ type: "agent:activity", agentId, activity: "sleeping", detail: "" });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.sendToServer({ type: "agent:status", agentId, status: "active" });
|
|
157
|
+
this.sendToServer({ type: "agent:activity", agentId, activity: "working", detail: "Starting\u2026" });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async stopAgent(agentId: string) {
|
|
161
|
+
const ap = this.agents.get(agentId);
|
|
162
|
+
if (!ap) return;
|
|
163
|
+
|
|
164
|
+
if (ap.pendingReceive) {
|
|
165
|
+
clearTimeout(ap.pendingReceive.timer);
|
|
166
|
+
ap.pendingReceive.resolve([]);
|
|
167
|
+
}
|
|
168
|
+
if (ap.notificationTimer) {
|
|
169
|
+
clearTimeout(ap.notificationTimer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.agents.delete(agentId);
|
|
173
|
+
ap.process.kill("SIGTERM");
|
|
174
|
+
this.sendToServer({ type: "agent:status", agentId, status: "inactive" });
|
|
175
|
+
this.sendToServer({ type: "agent:activity", agentId, activity: "offline", detail: "" });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Hibernate: kill process but keep status as "sleeping" (auto-wakes on next message via --resume) */
|
|
179
|
+
sleepAgent(agentId: string) {
|
|
180
|
+
const ap = this.agents.get(agentId);
|
|
181
|
+
if (!ap) return;
|
|
182
|
+
|
|
183
|
+
console.log(`[Agent ${agentId}] Hibernating (sleeping)`);
|
|
184
|
+
|
|
185
|
+
if (ap.pendingReceive) {
|
|
186
|
+
clearTimeout(ap.pendingReceive.timer);
|
|
187
|
+
ap.pendingReceive.resolve([]);
|
|
188
|
+
}
|
|
189
|
+
if (ap.notificationTimer) {
|
|
190
|
+
clearTimeout(ap.notificationTimer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove from map BEFORE killing so the exit handler doesn't double-report
|
|
194
|
+
this.agents.delete(agentId);
|
|
195
|
+
ap.process.kill("SIGTERM");
|
|
196
|
+
// Status already set to "sleeping" by the server; don't override
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
deliverMessage(agentId: string, message: AgentMessage) {
|
|
200
|
+
const ap = this.agents.get(agentId);
|
|
201
|
+
if (!ap) return;
|
|
202
|
+
|
|
203
|
+
if (ap.pendingReceive) {
|
|
204
|
+
clearTimeout(ap.pendingReceive.timer);
|
|
205
|
+
ap.pendingReceive.resolve([message]);
|
|
206
|
+
ap.pendingReceive = null;
|
|
207
|
+
} else {
|
|
208
|
+
ap.inbox.push(message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Stdin notification: if agent is busy (not in receive_message), notify via stdin
|
|
212
|
+
if (ap.isInReceiveMessage) return; // message will be picked up via MCP bridge
|
|
213
|
+
if (!ap.sessionId) return; // agent not initialized yet
|
|
214
|
+
|
|
215
|
+
ap.pendingNotificationCount++;
|
|
216
|
+
if (!ap.notificationTimer) {
|
|
217
|
+
ap.notificationTimer = setTimeout(() => {
|
|
218
|
+
this.sendStdinNotification(agentId);
|
|
219
|
+
}, 3000);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async resetWorkspace(agentId: string) {
|
|
224
|
+
const agentDataDir = path.join(DATA_DIR, agentId);
|
|
225
|
+
try {
|
|
226
|
+
await rm(agentDataDir, { recursive: true, force: true });
|
|
227
|
+
console.log(`[Agent ${agentId}] Workspace deleted: ${agentDataDir}`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(`[Agent ${agentId}] Failed to delete workspace:`, err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async stopAll() {
|
|
234
|
+
const ids = [...this.agents.keys()];
|
|
235
|
+
await Promise.all(ids.map((id) => this.stopAgent(id)));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
getRunningAgentIds(): string[] {
|
|
239
|
+
return [...this.agents.keys()];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Machine-level workspace scanning
|
|
243
|
+
|
|
244
|
+
async scanAllWorkspaces(): Promise<WorkspaceDirectoryInfo[]> {
|
|
245
|
+
const results: WorkspaceDirectoryInfo[] = [];
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = await readdir(DATA_DIR, { withFileTypes: true });
|
|
249
|
+
} catch {
|
|
250
|
+
// DATA_DIR doesn't exist yet
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (!entry.isDirectory()) continue;
|
|
256
|
+
|
|
257
|
+
const dirPath = path.join(DATA_DIR, entry.name);
|
|
258
|
+
try {
|
|
259
|
+
const dirContents = await readdir(dirPath, { withFileTypes: true });
|
|
260
|
+
let totalSize = 0;
|
|
261
|
+
let latestMtime = new Date(0);
|
|
262
|
+
let fileCount = 0;
|
|
263
|
+
|
|
264
|
+
for (const item of dirContents) {
|
|
265
|
+
const itemPath = path.join(dirPath, item.name);
|
|
266
|
+
try {
|
|
267
|
+
const info = await stat(itemPath);
|
|
268
|
+
if (item.isFile()) {
|
|
269
|
+
totalSize += info.size;
|
|
270
|
+
fileCount++;
|
|
271
|
+
}
|
|
272
|
+
if (info.mtime > latestMtime) {
|
|
273
|
+
latestMtime = info.mtime;
|
|
274
|
+
}
|
|
275
|
+
} catch { continue; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
results.push({
|
|
279
|
+
directoryName: entry.name,
|
|
280
|
+
totalSizeBytes: totalSize,
|
|
281
|
+
lastModified: latestMtime.toISOString(),
|
|
282
|
+
fileCount,
|
|
283
|
+
});
|
|
284
|
+
} catch { continue; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async deleteWorkspaceDirectory(directoryName: string): Promise<boolean> {
|
|
291
|
+
// Validate: no path traversal
|
|
292
|
+
if (directoryName.includes("/") || directoryName.includes("..") || directoryName.includes("\\")) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const targetDir = path.join(DATA_DIR, directoryName);
|
|
296
|
+
try {
|
|
297
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
298
|
+
console.log(`[Workspace] Deleted directory: ${targetDir}`);
|
|
299
|
+
return true;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(`[Workspace] Failed to delete directory ${targetDir}:`, err);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Workspace file browsing
|
|
307
|
+
|
|
308
|
+
async getFileTree(agentId: string): Promise<FileNode[]> {
|
|
309
|
+
const agentDir = path.join(DATA_DIR, agentId);
|
|
310
|
+
try {
|
|
311
|
+
await stat(agentDir);
|
|
312
|
+
} catch {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
const count = { n: 0 };
|
|
316
|
+
return this.buildFileTree(agentDir, agentDir, count);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async readFile(agentId: string, filePath: string): Promise<{ content: string | null; binary: boolean }> {
|
|
320
|
+
const agentDir = path.join(DATA_DIR, agentId);
|
|
321
|
+
const resolved = path.resolve(agentDir, filePath);
|
|
322
|
+
if (!resolved.startsWith(agentDir + path.sep) && resolved !== agentDir) {
|
|
323
|
+
throw new Error("Access denied");
|
|
324
|
+
}
|
|
325
|
+
const info = await stat(resolved);
|
|
326
|
+
if (info.isDirectory()) throw new Error("Cannot read a directory");
|
|
327
|
+
|
|
328
|
+
const TEXT_EXTENSIONS = new Set([
|
|
329
|
+
".md", ".txt", ".json", ".js", ".ts", ".jsx", ".tsx", ".yaml", ".yml",
|
|
330
|
+
".toml", ".log", ".csv", ".xml", ".html", ".css", ".sh", ".py",
|
|
331
|
+
]);
|
|
332
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
333
|
+
if (!TEXT_EXTENSIONS.has(ext) && ext !== "") {
|
|
334
|
+
return { content: null, binary: true };
|
|
335
|
+
}
|
|
336
|
+
if (info.size > 1_048_576) throw new Error("File too large");
|
|
337
|
+
|
|
338
|
+
const content = await readFile(resolved, "utf-8");
|
|
339
|
+
return { content, binary: false };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Private methods
|
|
343
|
+
|
|
344
|
+
/** Write a stream-json user message to the agent's stdin */
|
|
345
|
+
private writeStdinMessage(ap: AgentProcess, text: string) {
|
|
346
|
+
const stdinMsg = JSON.stringify({
|
|
347
|
+
type: "user",
|
|
348
|
+
message: {
|
|
349
|
+
role: "user",
|
|
350
|
+
content: [{ type: "text", text }],
|
|
351
|
+
},
|
|
352
|
+
...(ap.sessionId ? { session_id: ap.sessionId } : {}),
|
|
353
|
+
});
|
|
354
|
+
ap.process.stdin?.write(stdinMsg + "\n");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Send a batched notification to the agent via stdin about pending messages */
|
|
358
|
+
private sendStdinNotification(agentId: string) {
|
|
359
|
+
const ap = this.agents.get(agentId);
|
|
360
|
+
if (!ap) return;
|
|
361
|
+
|
|
362
|
+
const count = ap.pendingNotificationCount;
|
|
363
|
+
ap.pendingNotificationCount = 0;
|
|
364
|
+
ap.notificationTimer = null;
|
|
365
|
+
|
|
366
|
+
if (count === 0) return;
|
|
367
|
+
if (ap.isInReceiveMessage) return; // agent entered receive during batch window
|
|
368
|
+
if (!ap.sessionId) return;
|
|
369
|
+
|
|
370
|
+
const notification = `[System notification: You have ${count} new message${count > 1 ? "s" : ""} waiting. Call receive_message to read ${count > 1 ? "them" : "it"} when you're ready.]`;
|
|
371
|
+
console.log(`[Agent ${agentId}] Sending stdin notification: ${count} message(s)`);
|
|
372
|
+
this.writeStdinMessage(ap, notification);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async buildFileTree(dir: string, rootDir: string, count: { n: number }): Promise<FileNode[]> {
|
|
376
|
+
let entries;
|
|
377
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
378
|
+
entries.sort((a, b) => {
|
|
379
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
380
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
381
|
+
return a.name.localeCompare(b.name);
|
|
382
|
+
});
|
|
383
|
+
const nodes: FileNode[] = [];
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
if (count.n >= 500) break;
|
|
386
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
387
|
+
const fullPath = path.join(dir, entry.name);
|
|
388
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
389
|
+
let info;
|
|
390
|
+
try { info = await stat(fullPath); } catch { continue; }
|
|
391
|
+
count.n++;
|
|
392
|
+
if (entry.isDirectory()) {
|
|
393
|
+
const children = await this.buildFileTree(fullPath, rootDir, count);
|
|
394
|
+
nodes.push({ name: entry.name, path: relativePath, isDirectory: true, size: 0, modifiedAt: info.mtime.toISOString(), children });
|
|
395
|
+
} else {
|
|
396
|
+
nodes.push({ name: entry.name, path: relativePath, isDirectory: false, size: info.size, modifiedAt: info.mtime.toISOString() });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return nodes;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private handleStreamEvent(agentId: string, event: any) {
|
|
403
|
+
const trajectory: TrajectoryEntry[] = [];
|
|
404
|
+
let activity = "";
|
|
405
|
+
let detail = "";
|
|
406
|
+
|
|
407
|
+
switch (event.type) {
|
|
408
|
+
case "system":
|
|
409
|
+
if (event.subtype === "init" && event.session_id) {
|
|
410
|
+
const ap = this.agents.get(agentId);
|
|
411
|
+
if (ap) ap.sessionId = event.session_id;
|
|
412
|
+
this.sendToServer({ type: "agent:session", agentId, sessionId: event.session_id });
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
|
|
416
|
+
case "assistant": {
|
|
417
|
+
// Claude CLI stream-json emits one "assistant" per turn with full message.content array.
|
|
418
|
+
// Each content block is complete (thinking, text, or tool_use).
|
|
419
|
+
const content = event.message?.content;
|
|
420
|
+
if (Array.isArray(content)) {
|
|
421
|
+
for (const block of content) {
|
|
422
|
+
if (block.type === "thinking" && block.thinking) {
|
|
423
|
+
const text = block.thinking.length > MAX_TRAJECTORY_TEXT
|
|
424
|
+
? block.thinking.slice(0, MAX_TRAJECTORY_TEXT) + "\u2026"
|
|
425
|
+
: block.thinking;
|
|
426
|
+
trajectory.push({ kind: "thinking", text });
|
|
427
|
+
} else if (block.type === "text" && block.text) {
|
|
428
|
+
const text = block.text.length > MAX_TRAJECTORY_TEXT
|
|
429
|
+
? block.text.slice(0, MAX_TRAJECTORY_TEXT) + "\u2026"
|
|
430
|
+
: block.text;
|
|
431
|
+
trajectory.push({ kind: "text", text });
|
|
432
|
+
} else if (block.type === "tool_use") {
|
|
433
|
+
const toolName = block.name || "unknown_tool";
|
|
434
|
+
const inputSummary = this.summarizeToolInput(toolName, block.input);
|
|
435
|
+
trajectory.push({ kind: "tool_start", toolName, toolInput: inputSummary });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// High-level activity status + track isInReceiveMessage
|
|
441
|
+
const ap = this.agents.get(agentId);
|
|
442
|
+
const toolUses = Array.isArray(content) ? content.filter((c: any) => c.type === "tool_use") : [];
|
|
443
|
+
if (toolUses.length > 0) {
|
|
444
|
+
const lastTool = toolUses[toolUses.length - 1];
|
|
445
|
+
const toolName = lastTool.name || "tool";
|
|
446
|
+
if (toolName === "mcp__chat__receive_message") {
|
|
447
|
+
activity = "online";
|
|
448
|
+
if (ap) {
|
|
449
|
+
ap.isInReceiveMessage = true;
|
|
450
|
+
// Agent is now waiting for messages via MCP bridge — clear notification state
|
|
451
|
+
ap.pendingNotificationCount = 0;
|
|
452
|
+
if (ap.notificationTimer) {
|
|
453
|
+
clearTimeout(ap.notificationTimer);
|
|
454
|
+
ap.notificationTimer = null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} else if (toolName === "mcp__chat__send_message") {
|
|
458
|
+
activity = "working";
|
|
459
|
+
detail = "Sending message\u2026";
|
|
460
|
+
if (ap) ap.isInReceiveMessage = false;
|
|
461
|
+
} else {
|
|
462
|
+
activity = "working";
|
|
463
|
+
detail = this.toolDisplayName(toolName);
|
|
464
|
+
if (ap) ap.isInReceiveMessage = false;
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
activity = "thinking";
|
|
468
|
+
if (ap) ap.isInReceiveMessage = false;
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
case "result": {
|
|
474
|
+
activity = "online";
|
|
475
|
+
const apResult = this.agents.get(agentId);
|
|
476
|
+
if (apResult) {
|
|
477
|
+
apResult.isInReceiveMessage = false;
|
|
478
|
+
if (event.session_id) apResult.sessionId = event.session_id;
|
|
479
|
+
}
|
|
480
|
+
if (event.session_id) {
|
|
481
|
+
this.sendToServer({ type: "agent:session", agentId, sessionId: event.session_id });
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Send simple activity status (existing flow)
|
|
488
|
+
if (activity) {
|
|
489
|
+
this.sendToServer({ type: "agent:activity", agentId, activity, detail });
|
|
490
|
+
trajectory.push({ kind: "status", activity: activity as any, detail });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Send trajectory entries (new flow)
|
|
494
|
+
if (trajectory.length > 0) {
|
|
495
|
+
this.sendToServer({ type: "agent:trajectory", agentId, entries: trajectory });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Map raw tool names to user-friendly labels (all end with "…") */
|
|
500
|
+
private toolDisplayName(toolName: string): string {
|
|
501
|
+
if (toolName.startsWith("mcp__chat__")) return "";
|
|
502
|
+
if (toolName === "Read" || toolName === "read_file") return "Reading file\u2026";
|
|
503
|
+
if (toolName === "Write" || toolName === "write_file") return "Writing file\u2026";
|
|
504
|
+
if (toolName === "Edit" || toolName === "edit_file") return "Editing file\u2026";
|
|
505
|
+
if (toolName === "Bash" || toolName === "bash") return "Running command\u2026";
|
|
506
|
+
if (toolName === "Glob" || toolName === "glob") return "Searching files\u2026";
|
|
507
|
+
if (toolName === "Grep" || toolName === "grep") return "Searching code\u2026";
|
|
508
|
+
if (toolName === "WebFetch" || toolName === "web_fetch") return "Fetching web\u2026";
|
|
509
|
+
if (toolName === "WebSearch" || toolName === "web_search") return "Searching web\u2026";
|
|
510
|
+
if (toolName === "TodoWrite") return "Updating tasks\u2026";
|
|
511
|
+
return `Using ${toolName.length > 20 ? toolName.slice(0, 20) + "\u2026" : toolName}\u2026`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Extract a short human-readable summary from tool input */
|
|
515
|
+
private summarizeToolInput(toolName: string, input: any): string {
|
|
516
|
+
if (!input || typeof input !== "object") return "";
|
|
517
|
+
try {
|
|
518
|
+
if (toolName === "Read" || toolName === "read_file") return input.file_path || input.path || "";
|
|
519
|
+
if (toolName === "Write" || toolName === "write_file") return input.file_path || input.path || "";
|
|
520
|
+
if (toolName === "Edit" || toolName === "edit_file") return input.file_path || input.path || "";
|
|
521
|
+
if (toolName === "Bash" || toolName === "bash") {
|
|
522
|
+
const cmd = input.command || "";
|
|
523
|
+
return cmd.length > 100 ? cmd.slice(0, 100) + "\u2026" : cmd;
|
|
524
|
+
}
|
|
525
|
+
if (toolName === "Glob" || toolName === "glob") return input.pattern || "";
|
|
526
|
+
if (toolName === "Grep" || toolName === "grep") return input.pattern || "";
|
|
527
|
+
if (toolName === "WebFetch" || toolName === "web_fetch") return input.url || "";
|
|
528
|
+
if (toolName === "WebSearch" || toolName === "web_search") return input.query || "";
|
|
529
|
+
if (toolName === "mcp__chat__send_message") {
|
|
530
|
+
return input.channel || (input.dm_to ? `DM:@${input.dm_to}` : "");
|
|
531
|
+
}
|
|
532
|
+
if (toolName === "mcp__chat__read_history") return input.channel || "";
|
|
533
|
+
return "";
|
|
534
|
+
} catch { return ""; }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private buildSystemPrompt(config: AgentConfig, agentId: string): string {
|
|
538
|
+
let systemPrompt = `You are "${config.displayName || config.name}", an AI agent in Slock — a collaborative workspace for human-AI collaboration.
|
|
539
|
+
|
|
540
|
+
## Who you are
|
|
541
|
+
|
|
542
|
+
You are a **long-running, persistent agent**. You are NOT a one-shot assistant — you live across many sessions. You will be started, put to sleep when idle, and woken up again when someone sends you a message. Your process may restart, but your memory persists through files in your workspace directory. Think of yourself as a team member who is always available, accumulates knowledge over time, and develops expertise through interactions.
|
|
543
|
+
|
|
544
|
+
## Communication — MCP tools ONLY
|
|
545
|
+
|
|
546
|
+
You have MCP tools from the "chat" server. Use ONLY these for communication:
|
|
547
|
+
|
|
548
|
+
1. **mcp__chat__receive_message** — Call with block=true to wait for messages. This is your main loop.
|
|
549
|
+
2. **mcp__chat__send_message** — Send a message to a channel or DM.
|
|
550
|
+
3. **mcp__chat__list_workspace** — List all channels, agents, and humans in the workspace.
|
|
551
|
+
4. **mcp__chat__read_history** — Read past messages from a channel or DM.
|
|
552
|
+
|
|
553
|
+
CRITICAL RULES:
|
|
554
|
+
- Do NOT output text directly. ALL communication goes through mcp__chat__send_message.
|
|
555
|
+
- Do NOT use bash/curl/sqlite to send or receive messages. The MCP tools handle everything.
|
|
556
|
+
- Do NOT explore the filesystem looking for messaging scripts. The MCP tools are already available.
|
|
557
|
+
|
|
558
|
+
## Startup sequence
|
|
559
|
+
|
|
560
|
+
1. **Read MEMORY.md** (in your cwd). This is your memory index — it tells you what you know and where to find it.
|
|
561
|
+
2. Follow the instructions in MEMORY.md to read any other memory files you need (e.g. channel summaries, role definitions, user preferences).
|
|
562
|
+
3. Call mcp__chat__receive_message(block=true) to start listening.
|
|
563
|
+
4. When you receive a message, process it and reply with mcp__chat__send_message.
|
|
564
|
+
5. After replying, call mcp__chat__receive_message(block=true) again to keep listening.
|
|
565
|
+
|
|
566
|
+
## Messaging
|
|
567
|
+
|
|
568
|
+
Messages you receive look like:
|
|
569
|
+
- **Channel message from a human**: \`[#all] @richard: hello everyone\`
|
|
570
|
+
- **Channel message from an agent**: \`[#all] (agent) @Alice: hi there\`
|
|
571
|
+
- **DM from a human**: \`[DM:@richard] @richard: hey, can you help?\`
|
|
572
|
+
|
|
573
|
+
The \`[...]\` prefix identifies where the message came from. Reuse it as the \`channel\` parameter when replying.
|
|
574
|
+
|
|
575
|
+
### Sending messages
|
|
576
|
+
|
|
577
|
+
- **Reply to a channel**: \`send_message(channel="#channel-name", content="...")\`
|
|
578
|
+
- **Reply to a DM**: \`send_message(channel="DM:@peer-name", content="...")\` — reuse the channel value from the received message
|
|
579
|
+
- **Start a NEW DM**: \`send_message(dm_to="peer-name", content="...")\` — use the human's name from list_workspace (no @ prefix)
|
|
580
|
+
|
|
581
|
+
**IMPORTANT**: To reply to any message (channel or DM), always use \`channel\` with the exact identifier from the received message. Only use \`dm_to\` when you want to start a brand new DM that doesn't exist yet.
|
|
582
|
+
|
|
583
|
+
### Discovering people and channels
|
|
584
|
+
|
|
585
|
+
Call \`list_workspace\` to see all your channels, other agents, and humans in the workspace.
|
|
586
|
+
|
|
587
|
+
### Reading history
|
|
588
|
+
|
|
589
|
+
\`read_history(channel="#channel-name")\` or \`read_history(channel="DM:@peer-name")\`
|
|
590
|
+
|
|
591
|
+
## @Mentions
|
|
592
|
+
|
|
593
|
+
In channel group chats, you can @mention people by their unique name (e.g. "@alice" or "@bob").
|
|
594
|
+
- Every human and agent has a unique \`name\` — this is their stable identifier for @mentions.
|
|
595
|
+
- @mentions do not notify people outside the channel — channels are the isolation boundary.
|
|
596
|
+
|
|
597
|
+
## Communication style
|
|
598
|
+
|
|
599
|
+
Keep the user informed. They cannot see your internal reasoning, so:
|
|
600
|
+
- When you receive a task, acknowledge it and briefly outline your plan before starting.
|
|
601
|
+
- For multi-step work, send short progress updates (e.g. "Working on step 2/3…").
|
|
602
|
+
- When done, summarize the result.
|
|
603
|
+
- Keep updates concise — one or two sentences. Don't flood the chat.
|
|
604
|
+
|
|
605
|
+
## Workspace & Memory
|
|
606
|
+
|
|
607
|
+
Your working directory (cwd) is your **persistent workspace**. Everything you write here survives across sessions.
|
|
608
|
+
|
|
609
|
+
### MEMORY.md — Your Memory Index (CRITICAL)
|
|
610
|
+
|
|
611
|
+
\`MEMORY.md\` is the **entry point** to all your knowledge. It is the first file read on every startup (including after context compression). Structure it as an index that points to everything you know. This file is called \`MEMORY.md\` (not tied to any specific runtime) — keep it updated after every significant interaction or learning.
|
|
612
|
+
|
|
613
|
+
\`\`\`markdown
|
|
614
|
+
# <Your Name>
|
|
615
|
+
|
|
616
|
+
## Role
|
|
617
|
+
<your role definition, evolved over time>
|
|
618
|
+
|
|
619
|
+
## Key Knowledge
|
|
620
|
+
- Read notes/user-preferences.md for user preferences and conventions
|
|
621
|
+
- Read notes/channels.md for what each channel is about and ongoing work
|
|
622
|
+
- Read notes/domain.md for domain-specific knowledge and conventions
|
|
623
|
+
- ...
|
|
624
|
+
|
|
625
|
+
## Active Context
|
|
626
|
+
- Currently working on: <brief summary>
|
|
627
|
+
- Last interaction: <brief summary>
|
|
628
|
+
\`\`\`
|
|
629
|
+
|
|
630
|
+
### What to memorize
|
|
631
|
+
|
|
632
|
+
**Actively observe and record** the following kinds of knowledge as you encounter them in conversations:
|
|
633
|
+
|
|
634
|
+
1. **User preferences** — How the user likes things done, communication style, coding conventions, tool preferences, recurring patterns in their requests.
|
|
635
|
+
2. **World/project context** — The project structure, tech stack, architectural decisions, team conventions, deployment patterns.
|
|
636
|
+
3. **Domain knowledge** — Domain-specific terminology, conventions, best practices you learn through tasks.
|
|
637
|
+
4. **Work history** — What has been done, decisions made and why, problems solved, approaches that worked or failed.
|
|
638
|
+
5. **Channel context** — What each channel is about, who participates, what's being discussed, ongoing tasks per channel.
|
|
639
|
+
6. **Other agents** — What other agents do, their specialties, collaboration patterns, how to work with them effectively.
|
|
640
|
+
|
|
641
|
+
### How to organize memory
|
|
642
|
+
|
|
643
|
+
- **MEMORY.md** is always the index. Keep it concise but comprehensive as a table of contents.
|
|
644
|
+
- Create a \`notes/\` directory for detailed knowledge files. Use descriptive names:
|
|
645
|
+
- \`notes/user-preferences.md\` — User's preferences and conventions
|
|
646
|
+
- \`notes/channels.md\` — Summary of each channel and its purpose
|
|
647
|
+
- \`notes/work-log.md\` — Important decisions and completed work
|
|
648
|
+
- \`notes/<domain>.md\` — Domain-specific knowledge
|
|
649
|
+
- You can also create any other files or directories for your work (scripts, notes, data, etc.)
|
|
650
|
+
- **Update notes proactively** — Don't wait to be asked. When you learn something important, write it down.
|
|
651
|
+
- **Keep MEMORY.md current** — After updating notes, update the index in MEMORY.md if new files were added.
|
|
652
|
+
|
|
653
|
+
### Compaction safety (CRITICAL)
|
|
654
|
+
|
|
655
|
+
Your context will be periodically compressed to stay within limits. When this happens, you lose your in-context conversation history but MEMORY.md is always re-read. Therefore:
|
|
656
|
+
|
|
657
|
+
- **MEMORY.md must be self-sufficient as a recovery point.** After reading it, you should be able to understand who you are, what you know, and what you were working on.
|
|
658
|
+
- **Before a long task**, write a brief "Active Context" note in MEMORY.md so you can resume if interrupted mid-task.
|
|
659
|
+
- **After completing work**, update your notes and MEMORY.md index so nothing is lost.
|
|
660
|
+
- NEVER let context compression cause you to forget: which channel is about what, what tasks are in progress, what the user has asked for, or what other agents are doing.
|
|
661
|
+
|
|
662
|
+
## Capabilities
|
|
663
|
+
|
|
664
|
+
You can work with any files or tools on this computer — you are not confined to any directory.
|
|
665
|
+
You may develop a specialized role over time through your interactions. Embrace it.
|
|
666
|
+
|
|
667
|
+
## Message Notifications
|
|
668
|
+
|
|
669
|
+
While you are busy (executing tools, thinking, etc.), new messages may arrive. When this happens, you will receive a system notification like:
|
|
670
|
+
|
|
671
|
+
\`[System notification: You have N new message(s) waiting. Call receive_message to read them when you're ready.]\`
|
|
672
|
+
|
|
673
|
+
How to handle these:
|
|
674
|
+
- **Do NOT interrupt your current work.** Finish what you're doing first.
|
|
675
|
+
- After completing your current step, call \`mcp__chat__receive_message(block=false)\` to check for messages.
|
|
676
|
+
- Do not ignore notifications for too long — acknowledge new messages in a timely manner.
|
|
677
|
+
- These notifications are batched (you won't get one per message), so the count tells you how many are waiting.`;
|
|
678
|
+
|
|
679
|
+
if (config.description) {
|
|
680
|
+
systemPrompt += `\n\n## Initial role\n${config.description}. This may evolve.`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return systemPrompt;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// Parse CLI args
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
let agentId = "";
|
|
9
|
+
let serverUrl = "http://localhost:3001";
|
|
10
|
+
let authToken = "";
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
if (args[i] === "--agent-id" && args[i + 1]) agentId = args[++i];
|
|
14
|
+
if (args[i] === "--server-url" && args[i + 1]) serverUrl = args[++i];
|
|
15
|
+
if (args[i] === "--auth-token" && args[i + 1]) authToken = args[++i];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!agentId) {
|
|
19
|
+
console.error("Missing --agent-id");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build common headers for all requests
|
|
24
|
+
const commonHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
|
25
|
+
if (authToken) {
|
|
26
|
+
commonHeaders["Authorization"] = `Bearer ${authToken}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const server = new McpServer({
|
|
30
|
+
name: "chat",
|
|
31
|
+
version: "1.0.0",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// send_message tool
|
|
35
|
+
server.tool(
|
|
36
|
+
"send_message",
|
|
37
|
+
"Send a message to a channel or DM. To reply, reuse the channel value from the received message (e.g. channel='#all' or channel='DM:@richard'). To start a NEW DM, use dm_to with the person's name.",
|
|
38
|
+
{
|
|
39
|
+
channel: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe(
|
|
43
|
+
"Where to send. Reuse the identifier from received messages: '#channel-name' for channels, 'DM:@peer-name' for DMs. Examples: '#all', '#general', 'DM:@richard'."
|
|
44
|
+
),
|
|
45
|
+
dm_to: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe(
|
|
49
|
+
"Person's name to start a NEW DM with (e.g. 'richard'). Only for starting a new DM — to reply in an existing DM, use channel instead."
|
|
50
|
+
),
|
|
51
|
+
content: z.string().describe("The message content"),
|
|
52
|
+
},
|
|
53
|
+
async ({ channel, dm_to, content }) => {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${serverUrl}/internal/agent/${agentId}/send`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: commonHeaders,
|
|
58
|
+
body: JSON.stringify({ channel, dm_to, content }),
|
|
59
|
+
});
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{ type: "text" as const, text: `Error: ${data.error}` },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text" as const,
|
|
72
|
+
text: `Message sent to ${channel || `new DM with ${dm_to}`}`,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// receive_message tool
|
|
85
|
+
server.tool(
|
|
86
|
+
"receive_message",
|
|
87
|
+
"Receive new messages. Use block=true to wait for new messages. Returns messages formatted as [#channel-name] or [DM:@peer-name] followed by the sender and content.",
|
|
88
|
+
{
|
|
89
|
+
block: z
|
|
90
|
+
.boolean()
|
|
91
|
+
.default(true)
|
|
92
|
+
.describe("Whether to block (wait) for new messages"),
|
|
93
|
+
timeout_ms: z
|
|
94
|
+
.number()
|
|
95
|
+
.default(59000)
|
|
96
|
+
.describe("How long to wait in ms when blocking (default 59s, just under MCP tool call timeout)"),
|
|
97
|
+
},
|
|
98
|
+
async ({ block, timeout_ms }) => {
|
|
99
|
+
try {
|
|
100
|
+
const params = new URLSearchParams();
|
|
101
|
+
if (block) params.set("block", "true");
|
|
102
|
+
params.set("timeout", String(timeout_ms));
|
|
103
|
+
|
|
104
|
+
const res = await fetch(
|
|
105
|
+
`${serverUrl}/internal/agent/${agentId}/receive?${params}`,
|
|
106
|
+
{ method: "GET", headers: commonHeaders }
|
|
107
|
+
);
|
|
108
|
+
const data = await res.json();
|
|
109
|
+
|
|
110
|
+
if (!data.messages || data.messages.length === 0) {
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: "text" as const, text: "No new messages." }],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Format with human-readable channel identifiers (no UUIDs)
|
|
117
|
+
const formatted = data.messages
|
|
118
|
+
.map((m: any) => {
|
|
119
|
+
const channel = m.channel_type === "dm"
|
|
120
|
+
? `DM:@${m.channel_name}`
|
|
121
|
+
: `#${m.channel_name}`;
|
|
122
|
+
const senderPrefix = m.sender_type === "agent" ? "(agent) " : "";
|
|
123
|
+
return `[${channel}] ${senderPrefix}@${m.sender_name}: ${m.content}`;
|
|
124
|
+
})
|
|
125
|
+
.join("\n");
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
content: [{ type: "text" as const, text: formatted }],
|
|
129
|
+
};
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// list_workspace tool
|
|
139
|
+
server.tool(
|
|
140
|
+
"list_workspace",
|
|
141
|
+
"List all channels you are in, all agents, and all humans in the workspace. Use this to discover who and where you can message.",
|
|
142
|
+
{},
|
|
143
|
+
async () => {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(
|
|
146
|
+
`${serverUrl}/internal/agent/${agentId}/workspace`,
|
|
147
|
+
{ method: "GET", headers: commonHeaders }
|
|
148
|
+
);
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
|
|
151
|
+
let text = "## Workspace\n\n";
|
|
152
|
+
|
|
153
|
+
text += "### Your Channels\n";
|
|
154
|
+
text += "Use `#channel-name` with send_message to post in a channel.\n";
|
|
155
|
+
if (data.channels?.length > 0) {
|
|
156
|
+
for (const t of data.channels) {
|
|
157
|
+
text += ` - #${t.name}\n`;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
text += " (none)\n";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
text += "\n### Agents\n";
|
|
164
|
+
text += "Other AI agents in this workspace.\n";
|
|
165
|
+
if (data.agents?.length > 0) {
|
|
166
|
+
for (const a of data.agents) {
|
|
167
|
+
text += ` - @${a.name} (${a.status})\n`;
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
text += " (none)\n";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
text += "\n### Humans\n";
|
|
174
|
+
text += "To start a new DM: send_message(dm_to=\"<name>\"). To reply in an existing DM: reuse channel from the received message.\n";
|
|
175
|
+
if (data.humans?.length > 0) {
|
|
176
|
+
for (const u of data.humans) {
|
|
177
|
+
text += ` - ${u.name}\n`;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
text += " (none)\n";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: "text" as const, text }],
|
|
185
|
+
};
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// read_history tool
|
|
195
|
+
server.tool(
|
|
196
|
+
"read_history",
|
|
197
|
+
"Read message history for a channel or DM. Use #channel-name for channels or DM:@name for DMs.",
|
|
198
|
+
{
|
|
199
|
+
channel: z.string().describe("The channel to read history from — e.g. '#all', '#general', 'DM:@richard'"),
|
|
200
|
+
limit: z
|
|
201
|
+
.number()
|
|
202
|
+
.default(50)
|
|
203
|
+
.describe("Max number of messages to return (default 50, max 100)"),
|
|
204
|
+
},
|
|
205
|
+
async ({ channel, limit }) => {
|
|
206
|
+
try {
|
|
207
|
+
const params = new URLSearchParams();
|
|
208
|
+
params.set("channel", channel);
|
|
209
|
+
if (limit) params.set("limit", String(limit));
|
|
210
|
+
|
|
211
|
+
const res = await fetch(
|
|
212
|
+
`${serverUrl}/internal/agent/${agentId}/history?${params}`,
|
|
213
|
+
{ method: "GET", headers: commonHeaders }
|
|
214
|
+
);
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{ type: "text" as const, text: `Error: ${data.error}` },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!data.messages || data.messages.length === 0) {
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{ type: "text" as const, text: "No messages in this channel." },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const formatted = data.messages
|
|
234
|
+
.map(
|
|
235
|
+
(m: any) =>
|
|
236
|
+
`[${new Date(m.createdAt).toLocaleTimeString()}] @${m.senderName} (${m.senderType}): ${m.content}`
|
|
237
|
+
)
|
|
238
|
+
.join("\n");
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text" as const,
|
|
244
|
+
text: `## Message History for ${channel} (${data.messages.length} messages)\n\n${formatted}`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
} catch (err: any) {
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Start MCP server
|
|
257
|
+
const transport = new StdioServerTransport();
|
|
258
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import type { ServerToMachineMessage, MachineToServerMessage } from "@slock-ai/shared";
|
|
3
|
+
|
|
4
|
+
export interface ConnectionOptions {
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
onMessage: (msg: ServerToMachineMessage) => void;
|
|
8
|
+
onConnect: () => void;
|
|
9
|
+
onDisconnect: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class DaemonConnection {
|
|
13
|
+
private ws: WebSocket | null = null;
|
|
14
|
+
private options: ConnectionOptions;
|
|
15
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
16
|
+
private reconnectDelay = 1000;
|
|
17
|
+
private maxReconnectDelay = 30000;
|
|
18
|
+
private shouldConnect = true;
|
|
19
|
+
|
|
20
|
+
constructor(options: ConnectionOptions) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
connect() {
|
|
25
|
+
this.shouldConnect = true;
|
|
26
|
+
this.doConnect();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.shouldConnect = false;
|
|
31
|
+
if (this.reconnectTimer) {
|
|
32
|
+
clearTimeout(this.reconnectTimer);
|
|
33
|
+
this.reconnectTimer = null;
|
|
34
|
+
}
|
|
35
|
+
if (this.ws) {
|
|
36
|
+
this.ws.close();
|
|
37
|
+
this.ws = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
send(msg: MachineToServerMessage) {
|
|
42
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
43
|
+
this.ws.send(JSON.stringify(msg));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get connected(): boolean {
|
|
48
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private doConnect() {
|
|
52
|
+
if (!this.shouldConnect) return;
|
|
53
|
+
|
|
54
|
+
const wsUrl = this.options.serverUrl.replace(/^http/, "ws") + `/daemon/connect?key=${this.options.apiKey}`;
|
|
55
|
+
|
|
56
|
+
console.log(`[Daemon] Connecting to ${this.options.serverUrl}...`);
|
|
57
|
+
|
|
58
|
+
this.ws = new WebSocket(wsUrl);
|
|
59
|
+
|
|
60
|
+
this.ws.on("open", () => {
|
|
61
|
+
console.log("[Daemon] Connected to server");
|
|
62
|
+
this.reconnectDelay = 1000; // Reset backoff
|
|
63
|
+
this.options.onConnect();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.ws.on("message", (data: Buffer) => {
|
|
67
|
+
try {
|
|
68
|
+
const msg: ServerToMachineMessage = JSON.parse(data.toString());
|
|
69
|
+
this.options.onMessage(msg);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error("[Daemon] Invalid message from server:", err);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.ws.on("close", () => {
|
|
76
|
+
console.log("[Daemon] Disconnected from server");
|
|
77
|
+
this.options.onDisconnect();
|
|
78
|
+
this.scheduleReconnect();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.ws.on("error", (err) => {
|
|
82
|
+
console.error("[Daemon] WebSocket error:", err.message);
|
|
83
|
+
// 'close' will fire after this
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private scheduleReconnect() {
|
|
88
|
+
if (!this.shouldConnect) return;
|
|
89
|
+
if (this.reconnectTimer) return;
|
|
90
|
+
|
|
91
|
+
console.log(`[Daemon] Reconnecting in ${this.reconnectDelay}ms...`);
|
|
92
|
+
this.reconnectTimer = setTimeout(() => {
|
|
93
|
+
this.reconnectTimer = null;
|
|
94
|
+
this.doConnect();
|
|
95
|
+
}, this.reconnectDelay);
|
|
96
|
+
|
|
97
|
+
// Exponential backoff
|
|
98
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { DaemonConnection } from "./connection.js";
|
|
7
|
+
import { AgentProcessManager } from "./agentProcessManager.js";
|
|
8
|
+
import type { ServerToMachineMessage } from "@slock-ai/shared";
|
|
9
|
+
|
|
10
|
+
/** Detect which CLI runtimes are installed on this machine */
|
|
11
|
+
function detectRuntimes(): string[] {
|
|
12
|
+
const detected: string[] = [];
|
|
13
|
+
for (const tool of ["claude", "codex", "kimi"]) {
|
|
14
|
+
try {
|
|
15
|
+
execSync(`which ${tool}`, { stdio: "pipe" });
|
|
16
|
+
detected.push(tool);
|
|
17
|
+
} catch { /* not installed */ }
|
|
18
|
+
}
|
|
19
|
+
return detected;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Parse CLI args
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
let serverUrl = "";
|
|
25
|
+
let apiKey = "";
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
if (args[i] === "--server-url" && args[i + 1]) serverUrl = args[++i];
|
|
29
|
+
if (args[i] === "--api-key" && args[i + 1]) apiKey = args[++i];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!serverUrl || !apiKey) {
|
|
33
|
+
console.error("Usage: slock-daemon --server-url <url> --api-key <key>");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolve chat-bridge path (bundled within daemon package)
|
|
38
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const chatBridgePath = path.resolve(__dirname, "chat-bridge.ts");
|
|
40
|
+
|
|
41
|
+
// Create connection and agent manager
|
|
42
|
+
let connection: DaemonConnection;
|
|
43
|
+
|
|
44
|
+
const agentManager = new AgentProcessManager(chatBridgePath, (msg) => {
|
|
45
|
+
connection.send(msg);
|
|
46
|
+
}, apiKey);
|
|
47
|
+
|
|
48
|
+
connection = new DaemonConnection({
|
|
49
|
+
serverUrl,
|
|
50
|
+
apiKey,
|
|
51
|
+
onMessage: (msg: ServerToMachineMessage) => {
|
|
52
|
+
console.log(`[Daemon] Received: ${msg.type}`, msg.type === "ping" ? "" : JSON.stringify(msg).slice(0, 200));
|
|
53
|
+
switch (msg.type) {
|
|
54
|
+
case "agent:start":
|
|
55
|
+
console.log(`[Daemon] Starting agent ${msg.agentId} (model: ${msg.config.model}, session: ${msg.config.sessionId || "new"})`);
|
|
56
|
+
agentManager.startAgent(msg.agentId, msg.config);
|
|
57
|
+
break;
|
|
58
|
+
|
|
59
|
+
case "agent:stop":
|
|
60
|
+
console.log(`[Daemon] Stopping agent ${msg.agentId}`);
|
|
61
|
+
agentManager.stopAgent(msg.agentId);
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
case "agent:sleep":
|
|
65
|
+
console.log(`[Daemon] Sleeping agent ${msg.agentId}`);
|
|
66
|
+
agentManager.sleepAgent(msg.agentId);
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case "agent:reset-workspace":
|
|
70
|
+
console.log(`[Daemon] Resetting workspace for agent ${msg.agentId}`);
|
|
71
|
+
agentManager.resetWorkspace(msg.agentId);
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case "agent:deliver":
|
|
75
|
+
console.log(`[Daemon] Delivering message to ${msg.agentId}: ${msg.message.content.slice(0, 80)}`);
|
|
76
|
+
agentManager.deliverMessage(msg.agentId, msg.message);
|
|
77
|
+
connection.send({ type: "agent:deliver:ack", agentId: msg.agentId, seq: msg.seq });
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case "agent:workspace:list":
|
|
81
|
+
agentManager.getFileTree(msg.agentId).then((files) => {
|
|
82
|
+
connection.send({ type: "agent:workspace:file_tree", agentId: msg.agentId, files });
|
|
83
|
+
});
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case "agent:workspace:read":
|
|
87
|
+
agentManager.readFile(msg.agentId, msg.path).then(({ content, binary }) => {
|
|
88
|
+
connection.send({
|
|
89
|
+
type: "agent:workspace:file_content",
|
|
90
|
+
agentId: msg.agentId,
|
|
91
|
+
requestId: msg.requestId,
|
|
92
|
+
content,
|
|
93
|
+
binary,
|
|
94
|
+
});
|
|
95
|
+
}).catch(() => {
|
|
96
|
+
connection.send({
|
|
97
|
+
type: "agent:workspace:file_content",
|
|
98
|
+
agentId: msg.agentId,
|
|
99
|
+
requestId: msg.requestId,
|
|
100
|
+
content: null,
|
|
101
|
+
binary: false,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case "machine:workspace:scan":
|
|
107
|
+
console.log("[Daemon] Scanning all workspace directories");
|
|
108
|
+
agentManager.scanAllWorkspaces().then((directories) => {
|
|
109
|
+
connection.send({ type: "machine:workspace:scan_result", directories });
|
|
110
|
+
});
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case "machine:workspace:delete":
|
|
114
|
+
console.log(`[Daemon] Deleting workspace directory: ${msg.directoryName}`);
|
|
115
|
+
agentManager.deleteWorkspaceDirectory(msg.directoryName).then((success) => {
|
|
116
|
+
connection.send({ type: "machine:workspace:delete_result", directoryName: msg.directoryName, success });
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "ping":
|
|
121
|
+
connection.send({ type: "pong" });
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
onConnect: () => {
|
|
126
|
+
const runtimes = detectRuntimes();
|
|
127
|
+
console.log(`[Daemon] Detected runtimes: ${runtimes.join(", ") || "none"}`);
|
|
128
|
+
connection.send({
|
|
129
|
+
type: "ready",
|
|
130
|
+
capabilities: ["agent:start", "agent:stop", "agent:deliver", "workspace:files"],
|
|
131
|
+
runtimes,
|
|
132
|
+
runningAgents: agentManager.getRunningAgentIds(),
|
|
133
|
+
hostname: os.hostname(),
|
|
134
|
+
os: `${os.platform()} ${os.arch()}`,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
onDisconnect: () => {
|
|
138
|
+
console.log("[Daemon] Lost connection — agents continue running locally");
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log("[Slock Daemon] Starting...");
|
|
143
|
+
connection.connect();
|
|
144
|
+
|
|
145
|
+
// Graceful shutdown
|
|
146
|
+
const shutdown = async () => {
|
|
147
|
+
console.log("[Slock Daemon] Shutting down...");
|
|
148
|
+
await agentManager.stopAll();
|
|
149
|
+
connection.disconnect();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
process.on("SIGTERM", shutdown);
|
|
154
|
+
process.on("SIGINT", shutdown);
|