clearclaw 0.5.0 → 0.6.1
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/channel/slack.d.ts +8 -3
- package/dist/channel/slack.js +111 -23
- package/dist/channel/telegram.d.ts +9 -3
- package/dist/channel/telegram.js +91 -54
- package/dist/config.d.ts +56 -9
- package/dist/config.js +215 -44
- package/dist/engine/claude-code.js +3 -2
- package/dist/format.d.ts +26 -0
- package/dist/format.js +51 -0
- package/dist/index.js +141 -39
- package/dist/orchestrator.d.ts +15 -11
- package/dist/orchestrator.js +399 -155
- package/dist/tool-handlers.d.ts +19 -0
- package/dist/tool-handlers.js +73 -0
- package/dist/types.d.ts +16 -1
- package/package.json +8 -6
- package/dist/db.d.ts +0 -6
- package/dist/db.js +0 -42
- package/dist/workspace-store.d.ts +0 -11
- package/dist/workspace-store.js +0 -45
package/dist/orchestrator.js
CHANGED
|
@@ -4,7 +4,11 @@ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import log from "./logger.js";
|
|
6
6
|
import { saveFile } from "./files.js";
|
|
7
|
-
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, timeAgo } from "./format.js";
|
|
7
|
+
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, formatTodoList, timeAgo } from "./format.js";
|
|
8
|
+
import { permissionHandlers, displayHandledTools } from "./tool-handlers.js";
|
|
9
|
+
function isTask(ctx) {
|
|
10
|
+
return "prompt" in ctx;
|
|
11
|
+
}
|
|
8
12
|
const MODE_OPTIONS = [
|
|
9
13
|
{ label: "Default", value: "default" },
|
|
10
14
|
{ label: "Accept Edits", value: "acceptEdits" },
|
|
@@ -14,25 +18,21 @@ const MODE_OPTIONS = [
|
|
|
14
18
|
export class Orchestrator {
|
|
15
19
|
channel;
|
|
16
20
|
engine;
|
|
17
|
-
|
|
18
|
-
permissionMode;
|
|
19
|
-
defaultPromptPath;
|
|
20
|
-
filesPath;
|
|
21
|
+
config;
|
|
21
22
|
chats = new Map();
|
|
23
|
+
tasks = new Map();
|
|
22
24
|
constructor(opts) {
|
|
23
25
|
this.channel = opts.channel;
|
|
24
26
|
this.engine = opts.engine;
|
|
25
|
-
this.
|
|
26
|
-
this.permissionMode = opts.permissionMode;
|
|
27
|
-
this.defaultPromptPath = opts.defaultPromptPath;
|
|
28
|
-
this.filesPath = opts.filesPath;
|
|
27
|
+
this.config = opts.config;
|
|
29
28
|
}
|
|
30
29
|
chat(chatId) {
|
|
31
30
|
let s = this.chats.get(chatId);
|
|
32
31
|
if (!s) {
|
|
33
32
|
s = {
|
|
34
33
|
busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
|
|
35
|
-
toolCallHandle: null,
|
|
34
|
+
toolCallHandle: null, todoHandle: null,
|
|
35
|
+
messageQueue: [], debounceTimer: null,
|
|
36
36
|
};
|
|
37
37
|
this.chats.set(chatId, s);
|
|
38
38
|
}
|
|
@@ -40,7 +40,7 @@ export class Orchestrator {
|
|
|
40
40
|
}
|
|
41
41
|
async start() {
|
|
42
42
|
this.channel.on("message", (msg) => {
|
|
43
|
-
this.
|
|
43
|
+
this.routeMessage(msg).catch((err) => {
|
|
44
44
|
log.error({ err }, "[orchestrator] unhandled message error");
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -56,13 +56,191 @@ export class Orchestrator {
|
|
|
56
56
|
async stop() {
|
|
57
57
|
await this.channel.disconnect();
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
/** Effective behavior: tasks→assistant, workspace→explicit setting or home→assistant / project→relay. */
|
|
60
|
+
effectiveBehavior(ctx) {
|
|
61
|
+
if (isTask(ctx))
|
|
62
|
+
return "assistant";
|
|
63
|
+
if (ctx.behavior !== undefined)
|
|
64
|
+
return ctx.behavior;
|
|
65
|
+
return ctx.cwd === path.dirname(this.config.defaultPromptPath) ? "assistant" : "relay";
|
|
66
|
+
}
|
|
67
|
+
/** Enqueue a message and drain — immediately for relay, debounced for assistant/task. */
|
|
68
|
+
enqueueMessage(msg, ctx, state) {
|
|
69
|
+
state.messageQueue.push(msg);
|
|
70
|
+
if (state.busy)
|
|
71
|
+
return;
|
|
72
|
+
if (this.effectiveBehavior(ctx) === "assistant") {
|
|
73
|
+
if (state.debounceTimer)
|
|
74
|
+
clearTimeout(state.debounceTimer);
|
|
75
|
+
this.scheduleDebounce(msg.chatId);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.processQueuedMessages(msg.chatId).catch((err) => {
|
|
79
|
+
log.error({ err }, "[orchestrator] drain error");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
scheduleDebounce(chatId) {
|
|
84
|
+
const state = this.chat(chatId);
|
|
85
|
+
state.debounceTimer = setTimeout(() => {
|
|
86
|
+
state.debounceTimer = null;
|
|
87
|
+
this.processQueuedMessages(chatId).catch((err) => {
|
|
88
|
+
log.error({ err }, "[orchestrator] drain error");
|
|
89
|
+
});
|
|
90
|
+
}, 1000);
|
|
91
|
+
}
|
|
92
|
+
async processQueuedMessages(chatId) {
|
|
93
|
+
const state = this.chat(chatId);
|
|
94
|
+
if (state.messageQueue.length === 0 || state.busy)
|
|
95
|
+
return;
|
|
96
|
+
const ctx = this.tasks.get(chatId) ?? this.config.workspaceByChat(chatId);
|
|
97
|
+
if (!ctx)
|
|
98
|
+
return;
|
|
99
|
+
const messages = [...state.messageQueue];
|
|
100
|
+
state.messageQueue = [];
|
|
101
|
+
try {
|
|
102
|
+
await this.executeTurn(chatId, messages, ctx, state);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
log.error({ err }, "[fatal]");
|
|
106
|
+
await this.channel.sendMessage(chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Post-turn drain: new messages may have arrived while the turn was running
|
|
110
|
+
if (state.messageQueue.length > 0) {
|
|
111
|
+
if (this.effectiveBehavior(ctx) === "assistant") {
|
|
112
|
+
this.scheduleDebounce(chatId);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
this.processQueuedMessages(chatId).catch((err) => {
|
|
116
|
+
log.error({ err }, "[orchestrator] drain error");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async executeTurn(chatId, messages, ctx, state) {
|
|
122
|
+
const task = isTask(ctx) ? ctx : undefined;
|
|
123
|
+
const ws = isTask(ctx) ? undefined : ctx;
|
|
124
|
+
state.busy = true;
|
|
125
|
+
const abort = new AbortController();
|
|
126
|
+
state.abort = abort;
|
|
127
|
+
const behavior = this.effectiveBehavior(ctx);
|
|
128
|
+
const turnState = { staySilent: false, replyToMessageId: null };
|
|
129
|
+
const sessionId = task ? task.sessionId : ws.current_session_id;
|
|
130
|
+
const cwd = task ? task.cwd : ws.cwd;
|
|
131
|
+
const logPrefix = task ? "[task-turn]" : "[turn]";
|
|
132
|
+
log.info("%s start session=%s msgs=%d cwd=%s", logPrefix, sessionId ?? "new", messages.length, cwd);
|
|
133
|
+
await this.channel.setTyping(chatId, true);
|
|
134
|
+
const isHomeWorkspace = ws && ws.cwd === path.dirname(this.config.defaultPromptPath);
|
|
135
|
+
const appendSystemPrompt = task
|
|
136
|
+
? task.prompt
|
|
137
|
+
: (isHomeWorkspace ? undefined : this.readDefaultPrompt());
|
|
138
|
+
const prompt = buildPrompt(messages);
|
|
139
|
+
// Save attachments for workspace turns
|
|
140
|
+
const allAttachments = messages.flatMap((m) => m.attachments ?? []);
|
|
141
|
+
if (ws && allAttachments.length) {
|
|
142
|
+
const results = await Promise.allSettled(allAttachments.map((att) => saveFile(att, ws.name, this.config.filesPath)));
|
|
143
|
+
for (const r of results) {
|
|
144
|
+
if (r.status === "rejected")
|
|
145
|
+
log.warn({ err: r.reason }, "[turn] failed to save attachment");
|
|
146
|
+
}
|
|
147
|
+
const saved = results.filter((r) => r.status === "fulfilled").length;
|
|
148
|
+
if (saved > 0)
|
|
149
|
+
log.info("[turn] saved %d/%d attachment(s) for workspace %s", saved, allAttachments.length, ws.name);
|
|
150
|
+
}
|
|
151
|
+
const mcpServer = createSdkMcpServer({
|
|
152
|
+
name: "clearclaw",
|
|
153
|
+
tools: this.buildMcpTools(chatId, behavior, turnState),
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
for await (const event of this.engine.runTurn({
|
|
157
|
+
sessionId,
|
|
158
|
+
cwd,
|
|
159
|
+
prompt,
|
|
160
|
+
attachments: ws && allAttachments.length > 0 ? allAttachments : undefined,
|
|
161
|
+
permissionMode: state.permissionMode ?? (behavior === "assistant" ? "bypassPermissions" : this.config.permissionMode),
|
|
162
|
+
appendSystemPrompt,
|
|
163
|
+
mcpServers: { clearclaw: mcpServer },
|
|
164
|
+
signal: abort.signal,
|
|
165
|
+
onPermissionRequest: (req) => this.handlePermission(req, chatId),
|
|
166
|
+
})) {
|
|
167
|
+
// Handle done event inline — task vs workspace need different session storage
|
|
168
|
+
if (event.type === "done") {
|
|
169
|
+
log.info("%s done session=%s", logPrefix, event.sessionId);
|
|
170
|
+
if (task) {
|
|
171
|
+
const currentTask = this.tasks.get(chatId);
|
|
172
|
+
if (currentTask)
|
|
173
|
+
currentTask.sessionId = event.sessionId;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.config.setSession(ws.name, event.sessionId);
|
|
177
|
+
}
|
|
178
|
+
if (event.stats)
|
|
179
|
+
state.stats = event.stats;
|
|
180
|
+
if (behavior === "relay" && state.toolCallHandle && event.stats) {
|
|
181
|
+
const summary = formatToolCallSummary(event.stats.toolCalls);
|
|
182
|
+
if (summary) {
|
|
183
|
+
try {
|
|
184
|
+
await this.channel.editMessage(chatId, state.toolCallHandle, summary);
|
|
185
|
+
}
|
|
186
|
+
catch { /* */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
state.toolCallHandle = null;
|
|
190
|
+
state.todoHandle = null;
|
|
191
|
+
await this.updateStatusMessage(chatId, state);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
await this.routeEngineEvent(chatId, event, ws?.name ?? "", behavior, turnState);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
const cancelled = abort.signal.aborted;
|
|
199
|
+
state.busy = false;
|
|
200
|
+
state.abort = null;
|
|
201
|
+
if (!turnState.staySilent) {
|
|
202
|
+
await this.channel.setTyping(chatId, false);
|
|
203
|
+
}
|
|
204
|
+
// Task cancel already sent "Setup cancelled" from /cancel handler
|
|
205
|
+
if (cancelled && !task) {
|
|
206
|
+
await this.channel.sendMessage(chatId, "Turn cancelled.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async routeMessage(msg) {
|
|
60
211
|
try {
|
|
61
212
|
log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
|
|
62
213
|
const state = this.chat(msg.chatId);
|
|
214
|
+
// /cancel — abort running turn or clear active task (must come before task routing)
|
|
215
|
+
if (msg.text === "/cancel") {
|
|
216
|
+
const task = this.tasks.get(msg.chatId);
|
|
217
|
+
if (task) {
|
|
218
|
+
this.tasks.delete(msg.chatId);
|
|
219
|
+
if (state.abort)
|
|
220
|
+
state.abort.abort();
|
|
221
|
+
log.info("[cmd] task cancelled for chat %s", msg.chatId);
|
|
222
|
+
await this.channel.sendMessage(msg.chatId, "Setup cancelled.");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (state.abort) {
|
|
226
|
+
state.abort.abort();
|
|
227
|
+
log.info("[cmd] turn cancelled");
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await this.channel.sendMessage(msg.chatId, "Nothing to cancel.");
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Task routing — takes priority over workspace and all other commands
|
|
235
|
+
const existingTask = this.tasks.get(msg.chatId);
|
|
236
|
+
if (existingTask) {
|
|
237
|
+
this.enqueueMessage(msg, existingTask, state);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const ws = this.config.workspaceByChat(msg.chatId);
|
|
63
241
|
// /mode — switch permission mode (works even during active turns)
|
|
64
242
|
if (msg.text === "/mode") {
|
|
65
|
-
const currentMode = state.permissionMode ?? this.permissionMode;
|
|
243
|
+
const currentMode = state.permissionMode ?? this.config.permissionMode;
|
|
66
244
|
const buttons = [
|
|
67
245
|
MODE_OPTIONS.slice(0, 2).map((opt) => ({
|
|
68
246
|
label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
|
|
@@ -83,12 +261,11 @@ export class Orchestrator {
|
|
|
83
261
|
}
|
|
84
262
|
// /new — reset session
|
|
85
263
|
if (msg.text === "/new") {
|
|
86
|
-
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
87
264
|
if (!ws) {
|
|
88
265
|
await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
|
|
89
266
|
return;
|
|
90
267
|
}
|
|
91
|
-
this.
|
|
268
|
+
this.config.clearSession(ws.name);
|
|
92
269
|
state.permissionMode = null;
|
|
93
270
|
state.stats = null;
|
|
94
271
|
await this.updateStatusMessage(msg.chatId, state);
|
|
@@ -102,7 +279,6 @@ export class Orchestrator {
|
|
|
102
279
|
await this.channel.sendMessage(msg.chatId, "A turn is in progress. Wait for it to finish before switching sessions.");
|
|
103
280
|
return;
|
|
104
281
|
}
|
|
105
|
-
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
106
282
|
if (!ws) {
|
|
107
283
|
await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
|
|
108
284
|
return;
|
|
@@ -134,149 +310,100 @@ export class Orchestrator {
|
|
|
134
310
|
});
|
|
135
311
|
const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
|
|
136
312
|
if (resp.value) {
|
|
137
|
-
this.
|
|
313
|
+
this.config.setSession(ws.name, resp.value);
|
|
138
314
|
const picked = stripped.find((s) => s.sessionId === resp.value);
|
|
139
315
|
await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
|
|
140
316
|
log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
|
|
141
317
|
}
|
|
142
318
|
return;
|
|
143
319
|
}
|
|
144
|
-
// /
|
|
145
|
-
if (msg.text === "/
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
320
|
+
// /behavior — switch workspace behavior (assistant or relay)
|
|
321
|
+
if (msg.text === "/behavior") {
|
|
322
|
+
if (!ws) {
|
|
323
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.");
|
|
324
|
+
return;
|
|
149
325
|
}
|
|
150
|
-
|
|
151
|
-
|
|
326
|
+
const current = this.effectiveBehavior(ws);
|
|
327
|
+
const resp = await this.channel.sendInteractive(msg.chatId, `Current behavior: ${current}`, [
|
|
328
|
+
[
|
|
329
|
+
{ label: current === "assistant" ? "✓ Assistant" : "Assistant", value: "assistant" },
|
|
330
|
+
{ label: current === "relay" ? "✓ Relay" : "Relay", value: "relay" },
|
|
331
|
+
],
|
|
332
|
+
]);
|
|
333
|
+
if (resp.value === "assistant" || resp.value === "relay") {
|
|
334
|
+
this.config.setBehavior(ws.name, resp.value);
|
|
335
|
+
await this.updateStatusMessage(msg.chatId, this.chat(msg.chatId));
|
|
336
|
+
log.info("[cmd] workspace %s behavior → %s", ws.name, resp.value);
|
|
152
337
|
}
|
|
153
338
|
return;
|
|
154
339
|
}
|
|
155
|
-
// Workspace lookup — guard all turn logic below
|
|
156
|
-
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
157
340
|
if (!ws) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
log.info(`[turn] start session=${ws.current_session_id ?? "new"} cwd=${ws.cwd}`);
|
|
172
|
-
await this.channel.setTyping(msg.chatId, true);
|
|
173
|
-
// Append default workspace CLAUDE.md to non-default workspace sessions
|
|
174
|
-
const isDefaultWorkspace = ws.cwd === path.dirname(this.defaultPromptPath);
|
|
175
|
-
const appendSystemPrompt = isDefaultWorkspace ? undefined : this.readDefaultPrompt();
|
|
176
|
-
// In group workspaces, prepend sender identity so the engine knows who's talking
|
|
177
|
-
const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
|
|
178
|
-
const prompt = isDefaultWorkspace ? msg.text : `[${sender}]: ${msg.text}`;
|
|
179
|
-
// Save attachments to disk for the audit log (failures must not block the turn)
|
|
180
|
-
if (msg.attachments?.length) {
|
|
181
|
-
const results = await Promise.allSettled(msg.attachments.map((att) => saveFile(att, ws.name, this.filesPath)));
|
|
182
|
-
for (const r of results) {
|
|
183
|
-
if (r.status === "rejected")
|
|
184
|
-
log.warn({ err: r.reason }, "[turn] failed to save attachment");
|
|
341
|
+
if (this.config.isAuthorized(msg.user.id)) {
|
|
342
|
+
const newTask = {
|
|
343
|
+
sessionId: null,
|
|
344
|
+
cwd: path.dirname(this.config.defaultPromptPath),
|
|
345
|
+
prompt: [
|
|
346
|
+
"THIS IS A TASK SESSION — not a regular conversation.",
|
|
347
|
+
"Do NOT follow the 'Every Session' startup routine. Do NOT read MEMORY.md or daily notes. Do NOT greet the user.",
|
|
348
|
+
"Your only job: help set up a workspace for this chat. Use the onboarding skill.",
|
|
349
|
+
].join("\n"),
|
|
350
|
+
};
|
|
351
|
+
this.tasks.set(msg.chatId, newTask);
|
|
352
|
+
log.info("[task] onboarding started for chat %s", msg.chatId);
|
|
353
|
+
this.enqueueMessage(msg, newTask, state);
|
|
185
354
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
const mcpServer = createSdkMcpServer({
|
|
191
|
-
name: "clearclaw",
|
|
192
|
-
tools: [
|
|
193
|
-
tool("send_file", "Send a file or image to the current chat conversation", {
|
|
194
|
-
file_path: z.string().optional().describe("Absolute path to the file to send"),
|
|
195
|
-
data: z.string().optional().describe("Base64-encoded file data (alternative to file_path)"),
|
|
196
|
-
filename: z.string().optional().describe("Filename (required when using data, optional with file_path)"),
|
|
197
|
-
caption: z.string().optional().describe("Optional caption to accompany the file"),
|
|
198
|
-
}, async (args) => {
|
|
199
|
-
if (!args.file_path && !args.data)
|
|
200
|
-
throw new Error("Either file_path or data must be provided");
|
|
201
|
-
const buffer = args.file_path
|
|
202
|
-
? await fs.promises.readFile(args.file_path)
|
|
203
|
-
: Buffer.from(args.data, "base64");
|
|
204
|
-
const name = args.filename ?? path.basename(args.file_path ?? "file");
|
|
205
|
-
await this.channel.sendFile(msg.chatId, buffer, name, { caption: args.caption });
|
|
206
|
-
return { content: [{ type: "text", text: `Sent ${name} to chat` }] };
|
|
207
|
-
}),
|
|
208
|
-
],
|
|
209
|
-
});
|
|
210
|
-
try {
|
|
211
|
-
for await (const event of this.engine.runTurn({
|
|
212
|
-
sessionId: ws.current_session_id,
|
|
213
|
-
cwd: ws.cwd,
|
|
214
|
-
prompt,
|
|
215
|
-
attachments: msg.attachments,
|
|
216
|
-
permissionMode: state.permissionMode ?? this.permissionMode,
|
|
217
|
-
appendSystemPrompt,
|
|
218
|
-
mcpServers: { clearclaw: mcpServer },
|
|
219
|
-
signal: abort.signal,
|
|
220
|
-
onPermissionRequest: async (req) => {
|
|
221
|
-
// Auto-allow ClearClaw's own MCP tools — no chat prompt needed
|
|
222
|
-
if (req.toolName.startsWith("mcp__clearclaw__")) {
|
|
223
|
-
log.info(`[perm] ${req.toolName} → auto-allow`);
|
|
224
|
-
return { decision: "allow" };
|
|
225
|
-
}
|
|
226
|
-
log.info(`[perm] ${req.toolName}`);
|
|
227
|
-
try {
|
|
228
|
-
const promptText = formatPermissionPrompt(req.toolName, req.input, req.description);
|
|
229
|
-
const resp = await this.channel.sendInteractive(msg.chatId, promptText, [
|
|
230
|
-
[
|
|
231
|
-
{ label: "👍 Allow", value: "allow" },
|
|
232
|
-
{ label: "👎 Deny", value: "deny" },
|
|
233
|
-
],
|
|
234
|
-
[
|
|
235
|
-
{ label: "📝 Deny + Note", value: "deny", requestText: true },
|
|
236
|
-
],
|
|
237
|
-
]);
|
|
238
|
-
log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
|
|
239
|
-
return {
|
|
240
|
-
decision: resp.value === "allow" ? "allow" : "deny",
|
|
241
|
-
message: resp.text,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
catch (err) {
|
|
245
|
-
log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
|
|
246
|
-
return {
|
|
247
|
-
decision: "deny",
|
|
248
|
-
message: "Permission prompt could not be delivered to chat — denied automatically.",
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
|
-
})) {
|
|
253
|
-
await this.routeEngineEvent(msg.chatId, event, ws.name);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
finally {
|
|
257
|
-
const cancelled = abort.signal.aborted;
|
|
258
|
-
state.busy = false;
|
|
259
|
-
state.abort = null;
|
|
260
|
-
await this.channel.setTyping(msg.chatId, false);
|
|
261
|
-
if (cancelled) {
|
|
262
|
-
await this.channel.sendMessage(msg.chatId, "Turn cancelled.");
|
|
355
|
+
else {
|
|
356
|
+
log.info("[msg] no workspace for chat %s", msg.chatId);
|
|
357
|
+
await this.channel.sendMessage(msg.chatId, "No workspace linked to this chat.");
|
|
263
358
|
}
|
|
359
|
+
return;
|
|
264
360
|
}
|
|
361
|
+
this.enqueueMessage(msg, ws, state);
|
|
265
362
|
}
|
|
266
363
|
catch (err) {
|
|
267
364
|
log.error({ err }, "[fatal]");
|
|
268
365
|
await this.channel.sendMessage(msg.chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
269
366
|
}
|
|
270
367
|
}
|
|
271
|
-
async routeEngineEvent(chatId, event, workspaceName) {
|
|
368
|
+
async routeEngineEvent(chatId, event, workspaceName, behavior, turnState) {
|
|
272
369
|
const state = this.chat(chatId);
|
|
273
370
|
try {
|
|
274
371
|
switch (event.type) {
|
|
275
372
|
case "text":
|
|
276
|
-
|
|
373
|
+
if (turnState.staySilent)
|
|
374
|
+
break;
|
|
375
|
+
await this.channel.sendMessage(chatId, event.text, {
|
|
376
|
+
replyToMessageId: turnState.replyToMessageId ?? undefined,
|
|
377
|
+
});
|
|
277
378
|
break;
|
|
278
379
|
case "tool_use": {
|
|
279
|
-
//
|
|
380
|
+
// In assistant behavior, all tool status is suppressed (no rolling status, no plan mode notifications)
|
|
381
|
+
if (behavior === "assistant")
|
|
382
|
+
break;
|
|
383
|
+
// Relay behavior: existing display logic
|
|
384
|
+
if (event.toolName === "TodoWrite") {
|
|
385
|
+
const text = formatTodoList(event.input);
|
|
386
|
+
if (state.todoHandle) {
|
|
387
|
+
try {
|
|
388
|
+
await this.channel.editMessage(chatId, state.todoHandle, text);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
const handles = await this.channel.sendMessage(chatId, text, { consumeTyping: false });
|
|
392
|
+
state.todoHandle = handles[0];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const handles = await this.channel.sendMessage(chatId, text, { consumeTyping: false });
|
|
397
|
+
state.todoHandle = handles[0];
|
|
398
|
+
}
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (event.toolName === "EnterPlanMode") {
|
|
402
|
+
await this.channel.sendMessage(chatId, "📋 Planning");
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (displayHandledTools.has(event.toolName))
|
|
406
|
+
break;
|
|
280
407
|
const line = formatToolStatusLine(event.toolName, event.input);
|
|
281
408
|
if (state.toolCallHandle) {
|
|
282
409
|
try {
|
|
@@ -294,7 +421,6 @@ export class Orchestrator {
|
|
|
294
421
|
break;
|
|
295
422
|
}
|
|
296
423
|
case "tool_result":
|
|
297
|
-
// All tool results suppressed — Claude summarizes in its text response
|
|
298
424
|
break;
|
|
299
425
|
case "rate_limit": {
|
|
300
426
|
const resetMsg = event.resetsAt
|
|
@@ -304,26 +430,9 @@ export class Orchestrator {
|
|
|
304
430
|
await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
|
|
305
431
|
break;
|
|
306
432
|
}
|
|
307
|
-
case "done":
|
|
308
|
-
//
|
|
309
|
-
log.info(`[turn] done session=${event.sessionId}`);
|
|
310
|
-
this.workspaceStore.setSession(workspaceName, event.sessionId);
|
|
311
|
-
if (event.stats)
|
|
312
|
-
state.stats = event.stats;
|
|
313
|
-
// Best-effort: update tool summary and status message
|
|
314
|
-
if (state.toolCallHandle && event.stats) {
|
|
315
|
-
const summary = formatToolCallSummary(event.stats.toolCalls);
|
|
316
|
-
if (summary) {
|
|
317
|
-
try {
|
|
318
|
-
await this.channel.editMessage(chatId, state.toolCallHandle, summary);
|
|
319
|
-
}
|
|
320
|
-
catch { /* edit failed, leave as-is */ }
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
state.toolCallHandle = null;
|
|
324
|
-
await this.updateStatusMessage(chatId, state);
|
|
433
|
+
case "done":
|
|
434
|
+
// Handled inline in executeTurn (task vs workspace need different session storage)
|
|
325
435
|
break;
|
|
326
|
-
}
|
|
327
436
|
case "error":
|
|
328
437
|
log.error(`[turn] error: ${event.message}`);
|
|
329
438
|
await this.channel.sendMessage(chatId, `Error: ${event.message}`);
|
|
@@ -336,8 +445,114 @@ export class Orchestrator {
|
|
|
336
445
|
log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
|
|
337
446
|
}
|
|
338
447
|
}
|
|
448
|
+
buildMcpTools(chatId, behavior, turnState) {
|
|
449
|
+
const tools = [
|
|
450
|
+
tool("send_file", "Send a file or image to the current chat conversation", {
|
|
451
|
+
file_path: z.string().optional().describe("Absolute path to the file to send"),
|
|
452
|
+
data: z.string().optional().describe("Base64-encoded file data (alternative to file_path)"),
|
|
453
|
+
filename: z.string().optional().describe("Filename (required when using data, optional with file_path)"),
|
|
454
|
+
caption: z.string().optional().describe("Optional caption to accompany the file"),
|
|
455
|
+
}, async (args) => {
|
|
456
|
+
if (!args.file_path && !args.data)
|
|
457
|
+
throw new Error("Either file_path or data must be provided");
|
|
458
|
+
const buffer = args.file_path
|
|
459
|
+
? await fs.promises.readFile(args.file_path)
|
|
460
|
+
: Buffer.from(args.data, "base64");
|
|
461
|
+
const name = args.filename ?? path.basename(args.file_path ?? "file");
|
|
462
|
+
await this.channel.sendFile(chatId, buffer, name, { caption: args.caption });
|
|
463
|
+
return { content: [{ type: "text", text: `Sent ${name} to chat` }] };
|
|
464
|
+
}),
|
|
465
|
+
];
|
|
466
|
+
// Task tools — only available during task turns
|
|
467
|
+
if (this.tasks.has(chatId)) {
|
|
468
|
+
tools.push(tool("workspace_create", "Create a new workspace and link it to the current chat", {
|
|
469
|
+
name: z.string().describe("Workspace name (unique, e.g. 'myproject')"),
|
|
470
|
+
cwd: z.string().describe("Absolute path to the workspace directory"),
|
|
471
|
+
behavior: z.enum(["assistant", "relay"]).optional()
|
|
472
|
+
.describe("Workspace behavior mode"),
|
|
473
|
+
}, async (args) => {
|
|
474
|
+
if (this.config.workspaceByName(args.name)) {
|
|
475
|
+
throw new Error(`Workspace "${args.name}" already exists. Choose a different name.`);
|
|
476
|
+
}
|
|
477
|
+
fs.mkdirSync(args.cwd, { recursive: true });
|
|
478
|
+
this.config.upsertWorkspace({
|
|
479
|
+
name: args.name,
|
|
480
|
+
cwd: args.cwd,
|
|
481
|
+
chat_id: chatId,
|
|
482
|
+
current_session_id: null,
|
|
483
|
+
behavior: args.behavior,
|
|
484
|
+
});
|
|
485
|
+
log.info("[tool] workspace_create: %s → %s (chat %s)", args.name, args.cwd, chatId);
|
|
486
|
+
return { content: [{ type: "text", text: `Workspace "${args.name}" created at ${args.cwd}, linked to this chat.` }] };
|
|
487
|
+
}), tool("task_complete", "Signal that the current task is complete", {
|
|
488
|
+
message: z.string().optional().describe("Summary of what was accomplished"),
|
|
489
|
+
}, async (args) => {
|
|
490
|
+
this.tasks.delete(chatId);
|
|
491
|
+
log.info("[tool] task_complete: chat %s — %s", chatId, args.message ?? "done");
|
|
492
|
+
return { content: [{ type: "text", text: "Task completed." }] };
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
if (behavior === "assistant") {
|
|
496
|
+
tools.push(tool("stay_silent", "Stay silent — do not send any text response for this turn", {}, async () => {
|
|
497
|
+
turnState.staySilent = true;
|
|
498
|
+
await this.channel.setTyping(chatId, false); // suppress typing indicator immediately
|
|
499
|
+
return { content: [{ type: "text", text: "Silent turn" }] };
|
|
500
|
+
}), tool("react", "React to a specific message with an emoji", {
|
|
501
|
+
message: z.string().describe("Platform message ID from [msg:N] tag"),
|
|
502
|
+
emoji: z.string().describe("Single emoji character"),
|
|
503
|
+
}, async (args) => {
|
|
504
|
+
await this.channel.reactToMessage(chatId, args.message, args.emoji);
|
|
505
|
+
return { content: [{ type: "text", text: `Reacted to ${args.message} with ${args.emoji}` }] };
|
|
506
|
+
}), tool("reply_to", "Thread the text response as a reply to a specific message", {
|
|
507
|
+
message: z.string().describe("Platform message ID from [msg:N] tag"),
|
|
508
|
+
}, async (args) => {
|
|
509
|
+
turnState.replyToMessageId = args.message;
|
|
510
|
+
return { content: [{ type: "text", text: `Will reply to message ${args.message}` }] };
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
return tools;
|
|
514
|
+
}
|
|
515
|
+
async handlePermission(req, chatId) {
|
|
516
|
+
// Always auto-allow ClearClaw's own MCP tools
|
|
517
|
+
if (req.toolName.startsWith("mcp__clearclaw__")) {
|
|
518
|
+
log.info(`[perm] ${req.toolName} → auto-allow`);
|
|
519
|
+
return { decision: "allow" };
|
|
520
|
+
}
|
|
521
|
+
log.info(`[perm] ${req.toolName}`);
|
|
522
|
+
// Custom tool handler
|
|
523
|
+
const handler = permissionHandlers.get(req.toolName);
|
|
524
|
+
if (handler) {
|
|
525
|
+
const result = handler(req.input, req.description);
|
|
526
|
+
if (result === null) {
|
|
527
|
+
log.info(`[perm] ${req.toolName} → auto-allow (handler)`);
|
|
528
|
+
await this.channel.sendMessage(chatId, "📋 Entering plan mode");
|
|
529
|
+
return { decision: "allow" };
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const resp = await this.channel.sendInteractive(chatId, result.text, result.buttons);
|
|
533
|
+
log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
|
|
534
|
+
return result.mapResponse(resp);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
|
|
538
|
+
return { decision: "deny", message: "Permission prompt could not be delivered to chat — denied automatically." };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const resp = await this.channel.sendInteractive(chatId, formatPermissionPrompt(req.toolName, req.input, req.description), [
|
|
543
|
+
[{ label: "👍 Allow", value: "allow" }, { label: "👎 Deny", value: "deny" }],
|
|
544
|
+
[{ label: "📝 Deny + Note", value: "deny", requestText: true }],
|
|
545
|
+
]);
|
|
546
|
+
log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
|
|
547
|
+
return { decision: resp.value === "allow" ? "allow" : "deny", message: resp.text };
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
|
|
551
|
+
return { decision: "deny", message: "Permission prompt could not be delivered to chat — denied automatically." };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
339
554
|
async updateStatusMessage(chatId, state) {
|
|
340
|
-
const mode = state.permissionMode ?? this.permissionMode;
|
|
555
|
+
const mode = state.permissionMode ?? this.config.permissionMode;
|
|
341
556
|
const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
|
|
342
557
|
let text;
|
|
343
558
|
if (state.stats) {
|
|
@@ -357,7 +572,7 @@ export class Orchestrator {
|
|
|
357
572
|
/** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
|
|
358
573
|
readDefaultPrompt() {
|
|
359
574
|
try {
|
|
360
|
-
const content = fs.readFileSync(this.defaultPromptPath, "utf-8").trim();
|
|
575
|
+
const content = fs.readFileSync(this.config.defaultPromptPath, "utf-8").trim();
|
|
361
576
|
return content || undefined;
|
|
362
577
|
}
|
|
363
578
|
catch {
|
|
@@ -369,4 +584,33 @@ export class Orchestrator {
|
|
|
369
584
|
function formatModelName(modelId) {
|
|
370
585
|
return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
|
|
371
586
|
}
|
|
587
|
+
/** Format reply context as a bracketed prefix line for the LLM prompt. */
|
|
588
|
+
function formatReplyLine(replyTo) {
|
|
589
|
+
if (!replyTo)
|
|
590
|
+
return "";
|
|
591
|
+
const parts = [];
|
|
592
|
+
if (replyTo.senderName)
|
|
593
|
+
parts.push(replyTo.senderName);
|
|
594
|
+
parts.push(`msg:${replyTo.messageId}`);
|
|
595
|
+
if (replyTo.mediaType)
|
|
596
|
+
parts.push(`[${replyTo.mediaType}]`);
|
|
597
|
+
if (replyTo.text)
|
|
598
|
+
parts.push(`"${replyTo.text}"`);
|
|
599
|
+
return `[Replying to ${parts.join(" ")}]\n`;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Build the turn prompt: `[msg:N] sender: text`, one line per message.
|
|
603
|
+
* Works for both single-message (relay) and batched (assistant) turns.
|
|
604
|
+
*/
|
|
605
|
+
function buildPrompt(messages) {
|
|
606
|
+
return messages.map((msg) => {
|
|
607
|
+
const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
|
|
608
|
+
const msgIdPrefix = msg.messageId ? `[msg:${msg.messageId}] ` : "";
|
|
609
|
+
const replyLine = formatReplyLine(msg.replyTo);
|
|
610
|
+
const attachmentNote = msg.attachments?.length
|
|
611
|
+
? ` [${msg.attachments.length} attachment(s)]`
|
|
612
|
+
: "";
|
|
613
|
+
return `${replyLine}${msgIdPrefix}${sender}: ${msg.text}${attachmentNote}`;
|
|
614
|
+
}).join("\n");
|
|
615
|
+
}
|
|
372
616
|
//# sourceMappingURL=orchestrator.js.map
|