clearclaw 0.4.0 → 0.6.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.
@@ -1,8 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
4
+ import { z } from "zod";
3
5
  import log from "./logger.js";
4
6
  import { saveFile } from "./files.js";
5
- 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
+ }
6
12
  const MODE_OPTIONS = [
7
13
  { label: "Default", value: "default" },
8
14
  { label: "Accept Edits", value: "acceptEdits" },
@@ -12,25 +18,21 @@ const MODE_OPTIONS = [
12
18
  export class Orchestrator {
13
19
  channel;
14
20
  engine;
15
- workspaceStore;
16
- permissionMode;
17
- defaultPromptPath;
18
- filesPath;
21
+ config;
19
22
  chats = new Map();
23
+ tasks = new Map();
20
24
  constructor(opts) {
21
25
  this.channel = opts.channel;
22
26
  this.engine = opts.engine;
23
- this.workspaceStore = opts.workspaceStore;
24
- this.permissionMode = opts.permissionMode;
25
- this.defaultPromptPath = opts.defaultPromptPath;
26
- this.filesPath = opts.filesPath;
27
+ this.config = opts.config;
27
28
  }
28
29
  chat(chatId) {
29
30
  let s = this.chats.get(chatId);
30
31
  if (!s) {
31
32
  s = {
32
33
  busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
33
- toolCallHandle: null,
34
+ toolCallHandle: null, todoHandle: null,
35
+ messageQueue: [], debounceTimer: null,
34
36
  };
35
37
  this.chats.set(chatId, s);
36
38
  }
@@ -38,7 +40,7 @@ export class Orchestrator {
38
40
  }
39
41
  async start() {
40
42
  this.channel.on("message", (msg) => {
41
- this.handleMessage(msg).catch((err) => {
43
+ this.routeMessage(msg).catch((err) => {
42
44
  log.error({ err }, "[orchestrator] unhandled message error");
43
45
  });
44
46
  });
@@ -54,13 +56,191 @@ export class Orchestrator {
54
56
  async stop() {
55
57
  await this.channel.disconnect();
56
58
  }
57
- 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) {
58
211
  try {
59
212
  log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
60
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);
61
241
  // /mode — switch permission mode (works even during active turns)
62
242
  if (msg.text === "/mode") {
63
- const currentMode = state.permissionMode ?? this.permissionMode;
243
+ const currentMode = state.permissionMode ?? this.config.permissionMode;
64
244
  const buttons = [
65
245
  MODE_OPTIONS.slice(0, 2).map((opt) => ({
66
246
  label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
@@ -81,12 +261,11 @@ export class Orchestrator {
81
261
  }
82
262
  // /new — reset session
83
263
  if (msg.text === "/new") {
84
- const ws = this.workspaceStore.byChat(msg.chatId);
85
264
  if (!ws) {
86
265
  await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
87
266
  return;
88
267
  }
89
- this.workspaceStore.clearSession(ws.name);
268
+ this.config.clearSession(ws.name);
90
269
  state.permissionMode = null;
91
270
  state.stats = null;
92
271
  await this.updateStatusMessage(msg.chatId, state);
@@ -100,7 +279,6 @@ export class Orchestrator {
100
279
  await this.channel.sendMessage(msg.chatId, "A turn is in progress. Wait for it to finish before switching sessions.");
101
280
  return;
102
281
  }
103
- const ws = this.workspaceStore.byChat(msg.chatId);
104
282
  if (!ws) {
105
283
  await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
106
284
  return;
@@ -132,123 +310,100 @@ export class Orchestrator {
132
310
  });
133
311
  const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
134
312
  if (resp.value) {
135
- this.workspaceStore.setSession(ws.name, resp.value);
313
+ this.config.setSession(ws.name, resp.value);
136
314
  const picked = stripped.find((s) => s.sessionId === resp.value);
137
315
  await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
138
316
  log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
139
317
  }
140
318
  return;
141
319
  }
142
- // /cancel — abort the running turn
143
- if (msg.text === "/cancel") {
144
- if (state.abort) {
145
- state.abort.abort();
146
- 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;
147
325
  }
148
- else {
149
- 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);
150
337
  }
151
338
  return;
152
339
  }
153
- // Workspace lookup — guard all turn logic below
154
- const ws = this.workspaceStore.byChat(msg.chatId);
155
340
  if (!ws) {
156
- log.info("[msg] no workspace for chat %s", msg.chatId);
157
- await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
158
- return;
159
- }
160
- // Reject during active turn (per-chat)
161
- if (state.busy) {
162
- log.info("[msg] rejected (busy)");
163
- await this.channel.sendMessage(msg.chatId, "Still working on the previous message...");
164
- return;
165
- }
166
- state.busy = true;
167
- const abort = new AbortController();
168
- state.abort = abort;
169
- log.info(`[turn] start session=${ws.current_session_id ?? "new"} cwd=${ws.cwd}`);
170
- await this.channel.setTyping(msg.chatId, true);
171
- // Append default workspace CLAUDE.md to non-default workspace sessions
172
- const isDefaultWorkspace = ws.cwd === path.dirname(this.defaultPromptPath);
173
- const appendSystemPrompt = isDefaultWorkspace ? undefined : this.readDefaultPrompt();
174
- // In group workspaces, prepend sender identity so the engine knows who's talking
175
- const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
176
- const prompt = isDefaultWorkspace ? msg.text : `[${sender}]: ${msg.text}`;
177
- // Save attachments to disk for the audit log (failures must not block the turn)
178
- if (msg.attachments?.length) {
179
- const results = await Promise.allSettled(msg.attachments.map((att) => saveFile(att, ws.name, this.filesPath)));
180
- for (const r of results) {
181
- if (r.status === "rejected")
182
- 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);
183
354
  }
184
- const saved = results.filter((r) => r.status === "fulfilled").length;
185
- if (saved > 0)
186
- log.info("[turn] saved %d/%d attachment(s) for workspace %s", saved, msg.attachments.length, ws.name);
187
- }
188
- try {
189
- for await (const event of this.engine.runTurn({
190
- sessionId: ws.current_session_id,
191
- cwd: ws.cwd,
192
- prompt,
193
- attachments: msg.attachments,
194
- permissionMode: state.permissionMode ?? this.permissionMode,
195
- appendSystemPrompt,
196
- signal: abort.signal,
197
- onPermissionRequest: async (req) => {
198
- log.info(`[perm] ${req.toolName}`);
199
- try {
200
- const promptText = formatPermissionPrompt(req.toolName, req.input, req.description);
201
- const resp = await this.channel.sendInteractive(msg.chatId, promptText, [
202
- [
203
- { label: "👍 Allow", value: "allow" },
204
- { label: "👎 Deny", value: "deny" },
205
- ],
206
- [
207
- { label: "📝 Deny + Note", value: "deny", requestText: true },
208
- ],
209
- ]);
210
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
211
- return {
212
- decision: resp.value === "allow" ? "allow" : "deny",
213
- message: resp.text,
214
- };
215
- }
216
- catch (err) {
217
- log.warn({ err }, "[perm] failed to send prompt for %s, auto-denying", req.toolName);
218
- return {
219
- decision: "deny",
220
- message: "Permission prompt could not be delivered to chat — denied automatically.",
221
- };
222
- }
223
- },
224
- })) {
225
- await this.routeEngineEvent(msg.chatId, event, ws.name);
226
- }
227
- }
228
- finally {
229
- const cancelled = abort.signal.aborted;
230
- state.busy = false;
231
- state.abort = null;
232
- await this.channel.setTyping(msg.chatId, false);
233
- if (cancelled) {
234
- 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.");
235
358
  }
359
+ return;
236
360
  }
361
+ this.enqueueMessage(msg, ws, state);
237
362
  }
238
363
  catch (err) {
239
364
  log.error({ err }, "[fatal]");
240
365
  await this.channel.sendMessage(msg.chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
241
366
  }
242
367
  }
243
- async routeEngineEvent(chatId, event, workspaceName) {
368
+ async routeEngineEvent(chatId, event, workspaceName, behavior, turnState) {
244
369
  const state = this.chat(chatId);
245
370
  try {
246
371
  switch (event.type) {
247
372
  case "text":
248
- 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
+ });
249
378
  break;
250
379
  case "tool_use": {
251
- // 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;
252
407
  const line = formatToolStatusLine(event.toolName, event.input);
253
408
  if (state.toolCallHandle) {
254
409
  try {
@@ -266,7 +421,6 @@ export class Orchestrator {
266
421
  break;
267
422
  }
268
423
  case "tool_result":
269
- // All tool results suppressed — Claude summarizes in its text response
270
424
  break;
271
425
  case "rate_limit": {
272
426
  const resetMsg = event.resetsAt
@@ -276,26 +430,9 @@ export class Orchestrator {
276
430
  await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
277
431
  break;
278
432
  }
279
- case "done": {
280
- // Persist state first — channel calls below are best-effort
281
- log.info(`[turn] done session=${event.sessionId}`);
282
- this.workspaceStore.setSession(workspaceName, event.sessionId);
283
- if (event.stats)
284
- state.stats = event.stats;
285
- // Best-effort: update tool summary and status message
286
- if (state.toolCallHandle && event.stats) {
287
- const summary = formatToolCallSummary(event.stats.toolCalls);
288
- if (summary) {
289
- try {
290
- await this.channel.editMessage(chatId, state.toolCallHandle, summary);
291
- }
292
- catch { /* edit failed, leave as-is */ }
293
- }
294
- }
295
- state.toolCallHandle = null;
296
- await this.updateStatusMessage(chatId, state);
433
+ case "done":
434
+ // Handled inline in executeTurn (task vs workspace need different session storage)
297
435
  break;
298
- }
299
436
  case "error":
300
437
  log.error(`[turn] error: ${event.message}`);
301
438
  await this.channel.sendMessage(chatId, `Error: ${event.message}`);
@@ -308,8 +445,114 @@ export class Orchestrator {
308
445
  log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
309
446
  }
310
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
+ }
311
554
  async updateStatusMessage(chatId, state) {
312
- const mode = state.permissionMode ?? this.permissionMode;
555
+ const mode = state.permissionMode ?? this.config.permissionMode;
313
556
  const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
314
557
  let text;
315
558
  if (state.stats) {
@@ -329,7 +572,7 @@ export class Orchestrator {
329
572
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
330
573
  readDefaultPrompt() {
331
574
  try {
332
- const content = fs.readFileSync(this.defaultPromptPath, "utf-8").trim();
575
+ const content = fs.readFileSync(this.config.defaultPromptPath, "utf-8").trim();
333
576
  return content || undefined;
334
577
  }
335
578
  catch {
@@ -341,4 +584,33 @@ export class Orchestrator {
341
584
  function formatModelName(modelId) {
342
585
  return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
343
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
+ }
344
616
  //# sourceMappingURL=orchestrator.js.map