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.
- package/dist/channel/slack.d.ts +29 -0
- package/dist/channel/slack.js +402 -0
- package/dist/channel/telegram.d.ts +11 -2
- package/dist/channel/telegram.js +176 -32
- package/dist/config.d.ts +13 -3
- package/dist/config.js +18 -4
- package/dist/engine/claude-code.d.ts +2 -1
- package/dist/engine/claude-code.js +77 -8
- package/dist/files.d.ts +3 -0
- package/dist/files.js +33 -0
- package/dist/format.d.ts +4 -0
- package/dist/format.js +22 -18
- package/dist/index.js +10 -2
- package/dist/orchestrator.d.ts +2 -1
- package/dist/orchestrator.js +147 -102
- package/dist/types.d.ts +18 -3
- package/package.json +3 -1
package/dist/orchestrator.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
[
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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[][]
|
|
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
|
|
34
|
-
|
|
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
|
+
"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",
|