clearclaw 0.3.0 → 0.4.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,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import log from "./logger.js";
4
- import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt } from "./format.js";
4
+ import { saveFile } from "./files.js";
5
+ import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, timeAgo } from "./format.js";
5
6
  const MODE_OPTIONS = [
6
7
  { label: "Default", value: "default" },
7
8
  { label: "Accept Edits", value: "acceptEdits" },
@@ -14,6 +15,7 @@ export class Orchestrator {
14
15
  workspaceStore;
15
16
  permissionMode;
16
17
  defaultPromptPath;
18
+ filesPath;
17
19
  chats = new Map();
18
20
  constructor(opts) {
19
21
  this.channel = opts.channel;
@@ -21,12 +23,13 @@ export class Orchestrator {
21
23
  this.workspaceStore = opts.workspaceStore;
22
24
  this.permissionMode = opts.permissionMode;
23
25
  this.defaultPromptPath = opts.defaultPromptPath;
26
+ this.filesPath = opts.filesPath;
24
27
  }
25
28
  chat(chatId) {
26
29
  let s = this.chats.get(chatId);
27
30
  if (!s) {
28
31
  s = {
29
- busy: false, abort: null, permissionMode: null, statusHandle: null, stats: null,
32
+ busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
30
33
  toolCallHandle: null,
31
34
  };
32
35
  this.chats.set(chatId, s);
@@ -91,6 +94,51 @@ export class Orchestrator {
91
94
  await this.channel.sendMessage(msg.chatId, "Session cleared.");
92
95
  return;
93
96
  }
97
+ // /resume — switch to a previous session
98
+ if (msg.text === "/resume") {
99
+ if (state.busy) {
100
+ await this.channel.sendMessage(msg.chatId, "A turn is in progress. Wait for it to finish before switching sessions.");
101
+ return;
102
+ }
103
+ const ws = this.workspaceStore.byChat(msg.chatId);
104
+ if (!ws) {
105
+ await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
106
+ return;
107
+ }
108
+ const sessions = await this.engine.listSessions(ws.cwd);
109
+ if (sessions.length === 0) {
110
+ await this.channel.sendMessage(msg.chatId, "No sessions found for this workspace.");
111
+ return;
112
+ }
113
+ const MAX_BTN = 45;
114
+ // Strip ClearClaw's "[User (@handle)]: " prefix from SDK summaries
115
+ const stripped = sessions.map((s) => ({
116
+ ...s,
117
+ summary: s.summary.replace(/^\[.*?\]:\s*/, ""),
118
+ }));
119
+ // Build detailed list for message body
120
+ const listing = stripped.map((s, i) => {
121
+ const current = s.sessionId === ws.current_session_id ? " ✅" : "";
122
+ const meta = [timeAgo(s.lastModified), s.gitBranch].filter(Boolean).join(" · ");
123
+ return `${i + 1}. ${s.summary}${current}\n ${meta}`;
124
+ }).join("\n");
125
+ // Concise button labels (single line)
126
+ const buttons = stripped.map((s, i) => {
127
+ const label = `${i + 1}. ${s.summary}`;
128
+ const truncated = label.length > MAX_BTN
129
+ ? label.slice(0, MAX_BTN - 1) + "…"
130
+ : label;
131
+ return [{ label: truncated, value: s.sessionId }];
132
+ });
133
+ const resp = await this.channel.sendInteractive(msg.chatId, `Pick a session to resume:\n\n${listing}`, buttons);
134
+ if (resp.value) {
135
+ this.workspaceStore.setSession(ws.name, resp.value);
136
+ const picked = stripped.find((s) => s.sessionId === resp.value);
137
+ await this.channel.sendMessage(msg.chatId, `Resumed session: ${picked?.summary ?? resp.value}`);
138
+ log.info("[cmd] resumed session %s for workspace %s", resp.value, ws.name);
139
+ }
140
+ return;
141
+ }
94
142
  // /cancel — abort the running turn
95
143
  if (msg.text === "/cancel") {
96
144
  if (state.abort) {
@@ -126,31 +174,52 @@ export class Orchestrator {
126
174
  // In group workspaces, prepend sender identity so the engine knows who's talking
127
175
  const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
128
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");
183
+ }
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
+ }
129
188
  try {
130
189
  for await (const event of this.engine.runTurn({
131
190
  sessionId: ws.current_session_id,
132
191
  cwd: ws.cwd,
133
192
  prompt,
193
+ attachments: msg.attachments,
134
194
  permissionMode: state.permissionMode ?? this.permissionMode,
135
195
  appendSystemPrompt,
136
196
  signal: abort.signal,
137
197
  onPermissionRequest: async (req) => {
138
198
  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
- };
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
+ }
154
223
  },
155
224
  })) {
156
225
  await this.routeEngineEvent(msg.chatId, event, ws.name);
@@ -173,113 +242,89 @@ export class Orchestrator {
173
242
  }
174
243
  async routeEngineEvent(chatId, event, workspaceName) {
175
244
  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);
245
+ try {
246
+ switch (event.type) {
247
+ case "text":
248
+ await this.channel.sendMessage(chatId, event.text);
249
+ break;
250
+ case "tool_use": {
251
+ // Rolling message: each tool_use replaces the single message's content
252
+ const line = formatToolStatusLine(event.toolName, event.input);
253
+ if (state.toolCallHandle) {
254
+ try {
255
+ await this.channel.editMessage(chatId, state.toolCallHandle, line);
256
+ }
257
+ catch {
258
+ const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
259
+ state.toolCallHandle = handles[0];
260
+ }
186
261
  }
187
- catch {
188
- const handles = await this.channel.sendMessage(chatId, line);
262
+ else {
263
+ const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
189
264
  state.toolCallHandle = handles[0];
190
265
  }
266
+ break;
191
267
  }
192
- else {
193
- const handles = await this.channel.sendMessage(chatId, line);
194
- state.toolCallHandle = handles[0];
268
+ case "tool_result":
269
+ // All tool results suppressed — Claude summarizes in its text response
270
+ break;
271
+ case "rate_limit": {
272
+ const resetMsg = event.resetsAt
273
+ ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
274
+ : "";
275
+ log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
276
+ await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
277
+ break;
195
278
  }
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);
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 */ }
216
293
  }
217
- catch { /* edit failed, leave as-is */ }
218
294
  }
295
+ state.toolCallHandle = null;
296
+ await this.updateStatusMessage(chatId, state);
297
+ break;
219
298
  }
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;
299
+ case "error":
300
+ log.error(`[turn] error: ${event.message}`);
301
+ await this.channel.sendMessage(chatId, `Error: ${event.message}`);
302
+ break;
227
303
  }
228
- case "error":
229
- log.error(`[turn] error: ${event.message}`);
230
- await this.channel.sendMessage(chatId, `Error: ${event.message}`);
231
- break;
304
+ }
305
+ catch (err) {
306
+ // Channel errors must never propagate — they'd crash the engine turn loop,
307
+ // killing the SDK session and losing the session ID.
308
+ log.warn({ err }, "[route] failed to relay %s event to channel", event.type);
232
309
  }
233
310
  }
234
311
  async updateStatusMessage(chatId, state) {
235
312
  const mode = state.permissionMode ?? this.permissionMode;
236
313
  const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
237
- const parts = [];
314
+ let text;
238
315
  if (state.stats) {
239
316
  const pct = state.stats.contextWindow > 0
240
317
  ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
241
318
  : 0;
242
- parts.push(`🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`);
243
- }
244
- 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
- }
319
+ text = `🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`;
256
320
  }
257
321
  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
- }
322
+ text = `🔒 ${modeLabel}`;
271
323
  }
272
- }
273
- async clearStatusMessage(chatId, state) {
274
- if (!state.statusHandle)
324
+ if (text === state.lastStatusText)
275
325
  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;
326
+ await this.channel.updateStatus(chatId, text);
327
+ state.lastStatusText = text;
283
328
  }
284
329
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
285
330
  readDefaultPrompt() {
package/dist/types.d.ts CHANGED
@@ -8,11 +8,12 @@ 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>;
17
18
  on<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
18
19
  off<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
@@ -29,18 +30,25 @@ export interface ButtonResponse {
29
30
  }
30
31
  export interface SendMessageOpts {
31
32
  parseMode?: "MarkdownV2" | "HTML";
33
+ /** When false, skip consuming the typing placeholder (e.g. for tool status messages). */
34
+ consumeTyping?: boolean;
32
35
  }
33
- export interface SendInteractiveOpts {
34
- parseMode?: "MarkdownV2" | "HTML";
36
+ export interface SessionInfo {
37
+ sessionId: string;
38
+ summary: string;
39
+ lastModified: number;
40
+ gitBranch?: string;
35
41
  }
36
42
  export interface Engine {
37
43
  name: string;
38
44
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
45
+ listSessions(cwd: string): Promise<SessionInfo[]>;
39
46
  }
40
47
  export interface RunTurnOpts {
41
48
  sessionId: string | null;
42
49
  cwd: string;
43
50
  prompt: string;
51
+ attachments?: Attachment[];
44
52
  permissionMode: PermissionMode;
45
53
  onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResponse>;
46
54
  appendSystemPrompt?: string;
@@ -98,9 +106,16 @@ export interface UserInfo {
98
106
  name: string;
99
107
  handle?: string;
100
108
  }
109
+ /** File data from a channel, kept in memory for engine consumption. */
110
+ export interface Attachment {
111
+ buffer: Buffer;
112
+ mimeType: string;
113
+ filename?: string;
114
+ }
101
115
  export interface InboundMessage {
102
116
  chatId: string;
103
117
  user: UserInfo;
104
118
  text: string;
119
+ attachments?: Attachment[];
105
120
  }
106
121
  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.4.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,6 +37,7 @@
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",
42
43
  "pino": "^10.3.1",
@@ -44,6 +45,7 @@
44
45
  "pino-roll": "^4.0.0"
45
46
  },
46
47
  "devDependencies": {
48
+ "@anthropic-ai/sdk": "^0.78.0",
47
49
  "@types/diff": "^7.0.2",
48
50
  "@types/node": "^22.0.0",
49
51
  "nodemon": "^3.1.14",