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.
@@ -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
- workspaceStore;
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.workspaceStore = opts.workspaceStore;
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.handleMessage(msg).catch((err) => {
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
- async handleMessage(msg) {
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.workspaceStore.clearSession(ws.name);
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.workspaceStore.setSession(ws.name, resp.value);
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
- // /cancel — abort the running turn
145
- if (msg.text === "/cancel") {
146
- if (state.abort) {
147
- state.abort.abort();
148
- log.info("[cmd] turn cancelled");
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
- else {
151
- await this.channel.sendMessage(msg.chatId, "Nothing to cancel.");
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
- log.info("[msg] no workspace for chat %s", msg.chatId);
159
- await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
160
- return;
161
- }
162
- // Reject during active turn (per-chat)
163
- if (state.busy) {
164
- log.info("[msg] rejected (busy)");
165
- await this.channel.sendMessage(msg.chatId, "Still working on the previous message...");
166
- return;
167
- }
168
- state.busy = true;
169
- const abort = new AbortController();
170
- state.abort = abort;
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
- const saved = results.filter((r) => r.status === "fulfilled").length;
187
- if (saved > 0)
188
- log.info("[turn] saved %d/%d attachment(s) for workspace %s", saved, msg.attachments.length, ws.name);
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
- await this.channel.sendMessage(chatId, event.text);
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
- // Rolling message: each tool_use replaces the single message's content
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
- // Persist state first — channel calls below are best-effort
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