clearclaw 0.3.0 → 0.5.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.
@@ -6,6 +6,7 @@ export interface OrchestratorOpts {
6
6
  workspaceStore: WorkspaceStore;
7
7
  permissionMode: PermissionMode;
8
8
  defaultPromptPath: string;
9
+ filesPath: string;
9
10
  }
10
11
  export declare class Orchestrator {
11
12
  private channel;
@@ -13,6 +14,7 @@ export declare class Orchestrator {
13
14
  private workspaceStore;
14
15
  private permissionMode;
15
16
  private defaultPromptPath;
17
+ private filesPath;
16
18
  private chats;
17
19
  constructor(opts: OrchestratorOpts);
18
20
  private chat;
@@ -21,7 +23,6 @@ export declare class Orchestrator {
21
23
  private handleMessage;
22
24
  private routeEngineEvent;
23
25
  private updateStatusMessage;
24
- private clearStatusMessage;
25
26
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
26
27
  private readDefaultPrompt;
27
28
  }
@@ -1,7 +1,10 @@
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
- import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt } from "./format.js";
6
+ import { saveFile } from "./files.js";
7
+ import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, timeAgo } from "./format.js";
5
8
  const MODE_OPTIONS = [
6
9
  { label: "Default", value: "default" },
7
10
  { label: "Accept Edits", value: "acceptEdits" },
@@ -14,6 +17,7 @@ export class Orchestrator {
14
17
  workspaceStore;
15
18
  permissionMode;
16
19
  defaultPromptPath;
20
+ filesPath;
17
21
  chats = new Map();
18
22
  constructor(opts) {
19
23
  this.channel = opts.channel;
@@ -21,12 +25,13 @@ export class Orchestrator {
21
25
  this.workspaceStore = opts.workspaceStore;
22
26
  this.permissionMode = opts.permissionMode;
23
27
  this.defaultPromptPath = opts.defaultPromptPath;
28
+ this.filesPath = opts.filesPath;
24
29
  }
25
30
  chat(chatId) {
26
31
  let s = this.chats.get(chatId);
27
32
  if (!s) {
28
33
  s = {
29
- busy: false, abort: null, permissionMode: null, statusHandle: null, stats: null,
34
+ busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
30
35
  toolCallHandle: null,
31
36
  };
32
37
  this.chats.set(chatId, s);
@@ -91,6 +96,51 @@ export class Orchestrator {
91
96
  await this.channel.sendMessage(msg.chatId, "Session cleared.");
92
97
  return;
93
98
  }
99
+ // /resume — switch to a previous session
100
+ if (msg.text === "/resume") {
101
+ if (state.busy) {
102
+ await this.channel.sendMessage(msg.chatId, "A turn is in progress. Wait for it to finish before switching sessions.");
103
+ return;
104
+ }
105
+ const ws = this.workspaceStore.byChat(msg.chatId);
106
+ if (!ws) {
107
+ await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
108
+ return;
109
+ }
110
+ const sessions = await this.engine.listSessions(ws.cwd);
111
+ if (sessions.length === 0) {
112
+ await this.channel.sendMessage(msg.chatId, "No sessions found for this workspace.");
113
+ return;
114
+ }
115
+ const MAX_BTN = 45;
116
+ // Strip ClearClaw's "[User (@handle)]: " prefix from SDK summaries
117
+ const stripped = sessions.map((s) => ({
118
+ ...s,
119
+ summary: s.summary.replace(/^\[.*?\]:\s*/, ""),
120
+ }));
121
+ // Build detailed list for message body
122
+ const listing = stripped.map((s, i) => {
123
+ const current = s.sessionId === ws.current_session_id ? " ✅" : "";
124
+ const meta = [timeAgo(s.lastModified), s.gitBranch].filter(Boolean).join(" · ");
125
+ return `${i + 1}. ${s.summary}${current}\n ${meta}`;
126
+ }).join("\n");
127
+ // Concise button labels (single line)
128
+ const buttons = stripped.map((s, i) => {
129
+ const label = `${i + 1}. ${s.summary}`;
130
+ const truncated = label.length > MAX_BTN
131
+ ? label.slice(0, MAX_BTN - 1) + "…"
132
+ : label;
133
+ return [{ label: truncated, value: s.sessionId }];
134
+ });
135
+ const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
136
+ if (resp.value) {
137
+ this.workspaceStore.setSession(ws.name, resp.value);
138
+ const picked = stripped.find((s) => s.sessionId === resp.value);
139
+ await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
140
+ log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
141
+ }
142
+ return;
143
+ }
94
144
  // /cancel — abort the running turn
95
145
  if (msg.text === "/cancel") {
96
146
  if (state.abort) {
@@ -126,31 +176,78 @@ export class Orchestrator {
126
176
  // In group workspaces, prepend sender identity so the engine knows who's talking
127
177
  const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
128
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");
185
+ }
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
+ });
129
210
  try {
130
211
  for await (const event of this.engine.runTurn({
131
212
  sessionId: ws.current_session_id,
132
213
  cwd: ws.cwd,
133
214
  prompt,
215
+ attachments: msg.attachments,
134
216
  permissionMode: state.permissionMode ?? this.permissionMode,
135
217
  appendSystemPrompt,
218
+ mcpServers: { clearclaw: mcpServer },
136
219
  signal: abort.signal,
137
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
+ }
138
226
  log.info(`[perm] ${req.toolName}`);
139
- const promptText = formatPermissionPrompt(req.toolName, req.input, req.description);
140
- const resp = await this.channel.sendInteractive(msg.chatId, promptText, [
141
- [
142
- { label: "👍 Allow", value: "allow" },
143
- { label: "👎 Deny", value: "deny" },
144
- ],
145
- [
146
- { label: "📝 Deny + Note", value: "deny", requestText: true },
147
- ],
148
- ], { parseMode: "MarkdownV2" });
149
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
150
- return {
151
- decision: resp.value === "allow" ? "allow" : "deny",
152
- message: resp.text,
153
- };
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
+ }
154
251
  },
155
252
  })) {
156
253
  await this.routeEngineEvent(msg.chatId, event, ws.name);
@@ -173,113 +270,89 @@ export class Orchestrator {
173
270
  }
174
271
  async routeEngineEvent(chatId, event, workspaceName) {
175
272
  const state = this.chat(chatId);
176
- switch (event.type) {
177
- case "text":
178
- await this.channel.sendMessage(chatId, event.text);
179
- break;
180
- case "tool_use": {
181
- // Rolling message: each tool_use replaces the single message's content
182
- const line = formatToolStatusLine(event.toolName, event.input);
183
- if (state.toolCallHandle) {
184
- try {
185
- await this.channel.editMessage(chatId, state.toolCallHandle, line);
273
+ try {
274
+ switch (event.type) {
275
+ case "text":
276
+ await this.channel.sendMessage(chatId, event.text);
277
+ break;
278
+ case "tool_use": {
279
+ // Rolling message: each tool_use replaces the single message's content
280
+ const line = formatToolStatusLine(event.toolName, event.input);
281
+ if (state.toolCallHandle) {
282
+ try {
283
+ await this.channel.editMessage(chatId, state.toolCallHandle, line);
284
+ }
285
+ catch {
286
+ const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
287
+ state.toolCallHandle = handles[0];
288
+ }
186
289
  }
187
- catch {
188
- const handles = await this.channel.sendMessage(chatId, line);
290
+ else {
291
+ const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
189
292
  state.toolCallHandle = handles[0];
190
293
  }
294
+ break;
191
295
  }
192
- else {
193
- const handles = await this.channel.sendMessage(chatId, line);
194
- state.toolCallHandle = handles[0];
296
+ case "tool_result":
297
+ // All tool results suppressed — Claude summarizes in its text response
298
+ break;
299
+ case "rate_limit": {
300
+ const resetMsg = event.resetsAt
301
+ ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
302
+ : "";
303
+ log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
304
+ await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
305
+ break;
195
306
  }
196
- break;
197
- }
198
- case "tool_result":
199
- // All tool results suppressed — Claude summarizes in its text response
200
- break;
201
- case "rate_limit": {
202
- const resetMsg = event.resetsAt
203
- ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
204
- : "";
205
- log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
206
- await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
207
- break;
208
- }
209
- case "done": {
210
- // Edit rolling tool message to show summary
211
- if (state.toolCallHandle && event.stats) {
212
- const summary = formatToolCallSummary(event.stats.toolCalls);
213
- if (summary) {
214
- try {
215
- await this.channel.editMessage(chatId, state.toolCallHandle, summary);
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 */ }
216
321
  }
217
- catch { /* edit failed, leave as-is */ }
218
322
  }
323
+ state.toolCallHandle = null;
324
+ await this.updateStatusMessage(chatId, state);
325
+ break;
219
326
  }
220
- state.toolCallHandle = null;
221
- log.info(`[turn] done session=${event.sessionId}`);
222
- this.workspaceStore.setSession(workspaceName, event.sessionId);
223
- if (event.stats)
224
- state.stats = event.stats;
225
- await this.updateStatusMessage(chatId, state);
226
- break;
327
+ case "error":
328
+ log.error(`[turn] error: ${event.message}`);
329
+ await this.channel.sendMessage(chatId, `Error: ${event.message}`);
330
+ break;
227
331
  }
228
- case "error":
229
- log.error(`[turn] error: ${event.message}`);
230
- await this.channel.sendMessage(chatId, `Error: ${event.message}`);
231
- break;
332
+ }
333
+ catch (err) {
334
+ // Channel errors must never propagate — they'd crash the engine turn loop,
335
+ // killing the SDK session and losing the session ID.
336
+ log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
232
337
  }
233
338
  }
234
339
  async updateStatusMessage(chatId, state) {
235
340
  const mode = state.permissionMode ?? this.permissionMode;
236
341
  const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
237
- const parts = [];
342
+ let text;
238
343
  if (state.stats) {
239
344
  const pct = state.stats.contextWindow > 0
240
345
  ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
241
346
  : 0;
242
- parts.push(`🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`);
347
+ text = `🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`;
243
348
  }
244
349
  else {
245
- parts.push(`🔒 ${modeLabel}`);
246
- }
247
- const text = parts.join("\n");
248
- if (state.statusHandle) {
249
- try {
250
- await this.channel.editMessage(chatId, state.statusHandle, text);
251
- }
252
- catch (err) {
253
- if (!(err instanceof Error && err.message.includes("message is not modified")))
254
- throw err;
255
- }
350
+ text = `🔒 ${modeLabel}`;
256
351
  }
257
- else {
258
- // Clear stale pins from previous server runs
259
- try {
260
- await this.channel.unpinAllMessages(chatId);
261
- }
262
- catch { /* not admin */ }
263
- const handles = await this.channel.sendMessage(chatId, text);
264
- state.statusHandle = handles[0];
265
- try {
266
- await this.channel.pinMessage(chatId, state.statusHandle);
267
- }
268
- catch (err) {
269
- log.warn("[cmd] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
270
- }
271
- }
272
- }
273
- async clearStatusMessage(chatId, state) {
274
- if (!state.statusHandle)
352
+ if (text === state.lastStatusText)
275
353
  return;
276
- try {
277
- await this.channel.deleteMessage(chatId, state.statusHandle);
278
- }
279
- catch (err) {
280
- log.warn("[cmd] failed to delete status message: %s", err instanceof Error ? err.message : String(err));
281
- }
282
- state.statusHandle = null;
354
+ await this.channel.updateStatus(chatId, text);
355
+ state.lastStatusText = text;
283
356
  }
284
357
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
285
358
  readDefaultPrompt() {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
1
+ import type { McpServerConfig, PermissionMode } from "@anthropic-ai/claude-agent-sdk";
2
2
  export interface ChannelEvents {
3
3
  message: [msg: InboundMessage];
4
4
  }
@@ -8,12 +8,14 @@ export interface Channel {
8
8
  disconnect(): Promise<void>;
9
9
  ownsId(chatId: string): boolean;
10
10
  sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
11
- sendInteractive(chatId: string, text: string, buttons: Button[][], opts?: SendInteractiveOpts): Promise<ButtonResponse>;
11
+ sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
12
12
  editMessage(chatId: string, handle: string, text: string): Promise<void>;
13
13
  deleteMessage(chatId: string, handle: string): Promise<void>;
14
14
  pinMessage(chatId: string, handle: string): Promise<void>;
15
15
  unpinAllMessages(chatId: string): Promise<void>;
16
+ updateStatus(chatId: string, text: string): Promise<void>;
16
17
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
18
+ sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
17
19
  on<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
18
20
  off<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
19
21
  emit<K extends keyof ChannelEvents>(event: K, ...args: ChannelEvents[K]): boolean;
@@ -29,21 +31,33 @@ export interface ButtonResponse {
29
31
  }
30
32
  export interface SendMessageOpts {
31
33
  parseMode?: "MarkdownV2" | "HTML";
34
+ /** When false, skip consuming the typing placeholder (e.g. for tool status messages). */
35
+ consumeTyping?: boolean;
32
36
  }
33
- export interface SendInteractiveOpts {
34
- parseMode?: "MarkdownV2" | "HTML";
37
+ export interface SendFileOpts {
38
+ caption?: string;
39
+ mimeType?: string;
40
+ }
41
+ export interface SessionInfo {
42
+ sessionId: string;
43
+ summary: string;
44
+ lastModified: number;
45
+ gitBranch?: string;
35
46
  }
36
47
  export interface Engine {
37
48
  name: string;
38
49
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
50
+ listSessions(cwd: string): Promise<SessionInfo[]>;
39
51
  }
40
52
  export interface RunTurnOpts {
41
53
  sessionId: string | null;
42
54
  cwd: string;
43
55
  prompt: string;
56
+ attachments?: Attachment[];
44
57
  permissionMode: PermissionMode;
45
58
  onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResponse>;
46
59
  appendSystemPrompt?: string;
60
+ mcpServers?: Record<string, McpServerConfig>;
47
61
  signal?: AbortSignal;
48
62
  }
49
63
  export interface PermissionRequest {
@@ -98,9 +112,16 @@ export interface UserInfo {
98
112
  name: string;
99
113
  handle?: string;
100
114
  }
115
+ /** File data from a channel, kept in memory for engine consumption. */
116
+ export interface Attachment {
117
+ buffer: Buffer;
118
+ mimeType: string;
119
+ filename?: string;
120
+ }
101
121
  export interface InboundMessage {
102
122
  chatId: string;
103
123
  user: UserInfo;
104
124
  text: string;
125
+ attachments?: Attachment[];
105
126
  }
106
127
  export type { PermissionMode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clearclaw",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Your coding agent, from your phone — personal assistant + project workspaces on your machine, built on the CLI's permissions, config, and memory instead of replacing them.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,13 +37,16 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@anthropic-ai/claude-agent-sdk": "^0.2.50",
40
+ "@slack/bolt": "^4.6.0",
40
41
  "diff": "^8.0.3",
41
42
  "grammy": "^1.30.0",
43
+ "mime": "^4.1.0",
42
44
  "pino": "^10.3.1",
43
45
  "pino-pretty": "^13.1.3",
44
46
  "pino-roll": "^4.0.0"
45
47
  },
46
48
  "devDependencies": {
49
+ "@anthropic-ai/sdk": "^0.78.0",
47
50
  "@types/diff": "^7.0.2",
48
51
  "@types/node": "^22.0.0",
49
52
  "nodemon": "^3.1.14",