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.
@@ -1,40 +1,65 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { Bot, InlineKeyboard } from "grammy";
2
+ import { Bot, InlineKeyboard, InputFile } from "grammy";
3
+ import mime from "mime";
3
4
  import log from "../logger.js";
4
5
  export class TelegramChannel extends EventEmitter {
5
6
  name = "telegram";
6
7
  bot;
8
+ botToken;
7
9
  allowedUserIds;
8
10
  typingIntervals = new Map();
11
+ statusHandles = new Map(); // chatId → message handle for pinned status
9
12
  // Pending interactive responses: callbackId → resolve function
10
13
  pendingCallbacks = new Map();
11
14
  // Pending text input: chatId → resolve function (for requestText follow-ups)
12
15
  pendingTextResolvers = new Map();
13
16
  constructor(botToken, allowedUserIds) {
14
17
  super();
18
+ this.botToken = botToken;
15
19
  this.allowedUserIds = allowedUserIds;
16
20
  this.bot = new Bot(botToken);
17
21
  }
18
22
  async connect() {
19
- // Auth check: only respond to allowed user
20
23
  this.bot.on("message:text", (ctx) => {
21
- if (!ctx.from?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
24
+ const sender = this.extractSender(ctx);
25
+ if (!sender)
22
26
  return;
23
- const chatId = `tg:${ctx.chat.id}`;
24
27
  // Intercept for requestText follow-ups before emitting as a new message
25
- const textResolver = this.pendingTextResolvers.get(chatId);
28
+ const textResolver = this.pendingTextResolvers.get(sender.chatId);
26
29
  if (textResolver) {
27
- this.pendingTextResolvers.delete(chatId);
30
+ this.pendingTextResolvers.delete(sender.chatId);
28
31
  textResolver(ctx.message.text);
29
32
  return;
30
33
  }
31
- const user = {
32
- id: `tg:${ctx.from.id}`,
33
- name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
34
- ...(ctx.from.username && { handle: ctx.from.username }),
35
- };
36
- this.emit("message", { chatId, user, text: ctx.message.text });
34
+ this.emit("message", { ...sender, text: ctx.message.text });
35
+ });
36
+ // Photos: Telegram sends each photo as an array of PhotoSize objects
37
+ // (same image at different resolutions, sorted smallest → largest).
38
+ // We grab the last element for the highest resolution version.
39
+ this.bot.on("message:photo", async (ctx) => {
40
+ const photo = ctx.message.photo;
41
+ const largest = photo[photo.length - 1];
42
+ await this.handleFileMessage(ctx, largest.file_id, "image/jpeg", undefined, ctx.message.caption);
37
43
  });
44
+ // Documents: download with original MIME + filename
45
+ this.bot.on("message:document", async (ctx) => {
46
+ const doc = ctx.message.document;
47
+ await this.handleFileMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
48
+ });
49
+ // Placeholder handlers for unsupported media types
50
+ for (const [filter, label] of [
51
+ ["message:voice", "Voice message"],
52
+ ["message:audio", "Audio message"],
53
+ ["message:video", "Video"],
54
+ ["message:sticker", "Sticker"],
55
+ ]) {
56
+ this.bot.on(filter, (ctx) => {
57
+ const sender = this.extractSender(ctx);
58
+ if (!sender)
59
+ return;
60
+ this.emit("message", { ...sender, text: `[${label} — not supported]` });
61
+ });
62
+ }
38
63
  // Handle inline keyboard button presses
39
64
  this.bot.on("callback_query:data", async (ctx) => {
40
65
  const data = ctx.callbackQuery.data;
@@ -62,6 +87,7 @@ export class TelegramChannel extends EventEmitter {
62
87
  clearInterval(interval);
63
88
  }
64
89
  this.typingIntervals.clear();
90
+ this.statusHandles.clear();
65
91
  await this.bot.stop();
66
92
  }
67
93
  ownsId(chatId) {
@@ -91,7 +117,7 @@ export class TelegramChannel extends EventEmitter {
91
117
  }
92
118
  return handles;
93
119
  }
94
- async sendInteractive(chatId, text, buttons, opts) {
120
+ async sendInteractive(chatId, text, buttons) {
95
121
  const numId = this.numericId(chatId);
96
122
  const callbackId = crypto.randomUUID().slice(0, 8);
97
123
  // Pre-compute callback data keys (use flat index to avoid collisions when buttons share values)
@@ -112,45 +138,52 @@ export class TelegramChannel extends EventEmitter {
112
138
  }
113
139
  keyboard.row();
114
140
  }
141
+ // Convert universal markdown to MarkdownV2, fall back to plain text on failure
142
+ const mdv2Text = convertToMarkdownV2(text);
115
143
  let sent;
116
144
  try {
117
- sent = await this.bot.api.sendMessage(numId, text, {
145
+ sent = await this.bot.api.sendMessage(numId, mdv2Text, {
118
146
  reply_markup: keyboard,
119
- ...(opts?.parseMode && { parse_mode: opts.parseMode }),
147
+ parse_mode: "MarkdownV2",
120
148
  });
121
149
  }
122
- catch (err) {
123
- if (opts?.parseMode) {
124
- log.warn("[channel] sendInteractive failed with %s, retrying as plain text", opts.parseMode);
125
- sent = await this.bot.api.sendMessage(numId, text, {
126
- reply_markup: keyboard,
127
- });
128
- }
129
- else {
130
- throw err;
131
- }
150
+ catch {
151
+ log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
152
+ sent = await this.bot.api.sendMessage(numId, text, {
153
+ reply_markup: keyboard,
154
+ });
132
155
  }
133
156
  const cleanupAll = () => {
134
157
  for (const { cbData } of cbEntries) {
135
158
  this.pendingCallbacks.delete(cbData);
136
159
  }
137
160
  };
138
- // Wait for button press or timeout (30 minutes)
161
+ // Wait for button press (no timeout — matches CLI behavior)
139
162
  const pressed = await new Promise((resolve) => {
140
- const timeout = setTimeout(() => {
141
- cleanupAll();
142
- resolve(null);
143
- }, 30 * 60 * 1000);
144
163
  for (const { btn, cbData } of cbEntries) {
145
164
  this.pendingCallbacks.set(cbData, () => {
146
- clearTimeout(timeout);
147
165
  cleanupAll();
148
166
  resolve(btn);
149
167
  });
150
168
  }
151
169
  });
152
- if (!pressed)
153
- return { value: "" };
170
+ // Update keyboard to highlight the selected button (others become inert — no callbacks)
171
+ const feedbackKeyboard = new InlineKeyboard();
172
+ let entryIndex = 0;
173
+ for (const row of buttons) {
174
+ for (const button of row) {
175
+ const entry = cbEntries[entryIndex++];
176
+ const label = button === pressed ? `✅ ${button.label}` : button.label;
177
+ feedbackKeyboard.text(label, entry.cbData);
178
+ }
179
+ feedbackKeyboard.row();
180
+ }
181
+ try {
182
+ await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
183
+ reply_markup: feedbackKeyboard,
184
+ });
185
+ }
186
+ catch { /* best-effort */ }
154
187
  // If the button requests text, prompt for follow-up input
155
188
  if (pressed.requestText) {
156
189
  await this.bot.api.sendMessage(numId, "Add your feedback:", {
@@ -182,6 +215,39 @@ export class TelegramChannel extends EventEmitter {
182
215
  const numId = this.numericId(chatId);
183
216
  await this.bot.api.unpinAllChatMessages(numId);
184
217
  }
218
+ async updateStatus(chatId, text) {
219
+ const existing = this.statusHandles.get(chatId);
220
+ if (existing) {
221
+ try {
222
+ await this.editMessage(chatId, existing, text);
223
+ return;
224
+ }
225
+ catch (err) {
226
+ if (!(err instanceof Error && err.message.includes("message is not modified"))) {
227
+ // Edit failed for a real reason — fall through to create new
228
+ log.warn("[channel] failed to edit status message, will recreate: %s", err instanceof Error ? err.message : String(err));
229
+ this.statusHandles.delete(chatId);
230
+ }
231
+ else {
232
+ return; // text unchanged, nothing to do
233
+ }
234
+ }
235
+ }
236
+ // No existing handle, or edit failed — create new pinned message
237
+ try {
238
+ await this.unpinAllMessages(chatId);
239
+ }
240
+ catch { /* not admin */ }
241
+ const handles = await this.sendMessage(chatId, text);
242
+ const handle = handles[0];
243
+ this.statusHandles.set(chatId, handle);
244
+ try {
245
+ await this.pinMessage(chatId, handle);
246
+ }
247
+ catch (err) {
248
+ log.warn("[channel] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
249
+ }
250
+ }
185
251
  async setTyping(chatId, isTyping) {
186
252
  if (isTyping) {
187
253
  if (this.typingIntervals.has(chatId))
@@ -204,10 +270,107 @@ export class TelegramChannel extends EventEmitter {
204
270
  }
205
271
  }
206
272
  }
273
+ /** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
274
+ extractSender(ctx) {
275
+ if (!ctx.from?.id || !ctx.chat?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
276
+ return null;
277
+ return {
278
+ chatId: `tg:${ctx.chat.id}`,
279
+ user: {
280
+ id: `tg:${ctx.from.id}`,
281
+ name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
282
+ ...(ctx.from.username && { handle: ctx.from.username }),
283
+ },
284
+ };
285
+ }
286
+ /** Shared handler for photo/document messages: download, emit attachment or fallback. */
287
+ async handleFileMessage(ctx, fileId, mimeType, filename, caption) {
288
+ const sender = this.extractSender(ctx);
289
+ if (!sender)
290
+ return;
291
+ try {
292
+ const buffer = await this.downloadFile(fileId);
293
+ this.emit("message", {
294
+ ...sender,
295
+ text: caption ?? "",
296
+ attachments: [{ buffer, mimeType, filename }],
297
+ });
298
+ }
299
+ catch (err) {
300
+ log.warn({ err }, "[channel] failed to download file %s", filename ?? fileId);
301
+ this.emit("message", {
302
+ ...sender,
303
+ text: caption
304
+ ? `${caption}\n[File download failed: ${filename ?? mimeType}]`
305
+ : `[File download failed: ${filename ?? mimeType}]`,
306
+ });
307
+ }
308
+ }
309
+ async sendFile(chatId, buffer, filename, opts) {
310
+ const id = this.numericId(chatId);
311
+ const mimeType = opts?.mimeType ?? mime.getType(filename) ?? "application/octet-stream";
312
+ const file = new InputFile(buffer, filename);
313
+ const caption = opts?.caption;
314
+ if (mimeType.startsWith("image/")) {
315
+ await this.bot.api.sendPhoto(id, file, { caption });
316
+ }
317
+ else if (mimeType.startsWith("video/")) {
318
+ await this.bot.api.sendVideo(id, file, { caption });
319
+ }
320
+ else if (mimeType.startsWith("audio/")) {
321
+ await this.bot.api.sendAudio(id, file, { caption });
322
+ }
323
+ else {
324
+ await this.bot.api.sendDocument(id, file, { caption });
325
+ }
326
+ }
327
+ /** Download a Telegram file by file_id, returning the raw buffer. */
328
+ async downloadFile(fileId) {
329
+ const file = await this.bot.api.getFile(fileId);
330
+ if (!file.file_path)
331
+ throw new Error("Telegram returned no file_path");
332
+ const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
333
+ const resp = await fetch(url);
334
+ if (!resp.ok)
335
+ throw new Error(`File download failed: ${resp.status}`);
336
+ return Buffer.from(await resp.arrayBuffer());
337
+ }
207
338
  numericId(chatId) {
208
339
  return Number(chatId.replace(/^tg:/, ""));
209
340
  }
210
341
  }
342
+ /**
343
+ * Escape for Telegram MarkdownV2.
344
+ * Outside code blocks: all special chars. Inside: only backtick and backslash.
345
+ */
346
+ function escapeMarkdownV2(text, codeBlock = false) {
347
+ const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
348
+ return text.replace(pattern, "\\$&");
349
+ }
350
+ /**
351
+ * Convert universal markdown (with code fences) to Telegram MarkdownV2.
352
+ * Handles code blocks specially: fence markers stay literal, inner content
353
+ * only escapes ` and \, outer text gets full MarkdownV2 escaping.
354
+ */
355
+ function convertToMarkdownV2(markdown) {
356
+ const result = [];
357
+ let pos = 0;
358
+ const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
359
+ let match;
360
+ while ((match = fenceRegex.exec(markdown)) !== null) {
361
+ if (match.index > pos) {
362
+ result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
363
+ }
364
+ result.push("```" + match[1] + "\n");
365
+ result.push(escapeMarkdownV2(match[2], true));
366
+ result.push("```");
367
+ pos = match.index + match[0].length;
368
+ }
369
+ if (pos < markdown.length) {
370
+ result.push(escapeMarkdownV2(markdown.slice(pos)));
371
+ }
372
+ return result.join("");
373
+ }
211
374
  const MAX_MSG_LEN = 4096;
212
375
  /** Split text into ≤4096-char chunks, preferring newline boundaries. */
213
376
  function splitMessage(text) {
package/dist/config.d.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import type { PermissionMode } from "./types.js";
2
- export interface Config {
2
+ export interface TelegramConfig {
3
+ type: "telegram";
4
+ botToken: string;
5
+ }
6
+ export interface SlackConfig {
7
+ type: "slack";
3
8
  botToken: string;
9
+ appToken: string;
10
+ }
11
+ export type ChannelConfig = TelegramConfig | SlackConfig;
12
+ export interface Config {
13
+ channel: ChannelConfig;
4
14
  allowedUserIds: Set<string>;
5
15
  permissionMode: PermissionMode;
6
- dataDir: string;
7
16
  defaultPromptPath: string;
8
- workspacesPath: string;
17
+ workspaceStorePath: string;
18
+ filesPath: string;
9
19
  logPath: string;
10
20
  }
11
21
  export declare function loadConfig(): Config;
package/dist/config.js CHANGED
@@ -4,7 +4,21 @@ import path from "node:path";
4
4
  export function loadConfig() {
5
5
  const dataDir = process.env.CLEARCLAW_HOME ?? path.join(os.homedir(), ".clearclaw");
6
6
  fs.mkdirSync(dataDir, { recursive: true });
7
- const botToken = requireEnv("TELEGRAM_BOT_TOKEN");
7
+ fs.mkdirSync(path.join(dataDir, "files"), { recursive: true });
8
+ // Channel config: Slack takes priority if both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are set
9
+ const slackBotToken = process.env.SLACK_BOT_TOKEN;
10
+ const slackAppToken = process.env.SLACK_APP_TOKEN;
11
+ const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
12
+ let channel;
13
+ if (slackBotToken && slackAppToken) {
14
+ channel = { type: "slack", botToken: slackBotToken, appToken: slackAppToken };
15
+ }
16
+ else if (telegramBotToken) {
17
+ channel = { type: "telegram", botToken: telegramBotToken };
18
+ }
19
+ else {
20
+ throw new Error("Missing channel config: set TELEGRAM_BOT_TOKEN or both SLACK_BOT_TOKEN + SLACK_APP_TOKEN");
21
+ }
8
22
  const rawIds = process.env.ALLOWED_USER_IDS ?? process.env.ALLOWED_USER_ID;
9
23
  if (!rawIds)
10
24
  throw new Error("Missing required env var: ALLOWED_USER_IDS");
@@ -21,12 +35,12 @@ export function loadConfig() {
21
35
  throw new Error(`PERMISSION_MODE must be one of: ${valid.join(", ")}`);
22
36
  }
23
37
  return {
24
- botToken,
38
+ channel,
25
39
  allowedUserIds,
26
40
  permissionMode,
27
- dataDir,
28
41
  defaultPromptPath: path.join(dataDir, "workspace", "CLAUDE.md"),
29
- workspacesPath: path.join(dataDir, "workspaces.json"),
42
+ workspaceStorePath: path.join(dataDir, "workspaces.json"),
43
+ filesPath: path.join(dataDir, "files"),
30
44
  logPath: path.join(dataDir, "clearclaw.log"),
31
45
  };
32
46
  }
@@ -1,5 +1,6 @@
1
- import type { Engine, EngineEvent, RunTurnOpts } from "../types.js";
1
+ import type { Engine, EngineEvent, RunTurnOpts, SessionInfo } from "../types.js";
2
2
  export declare class ClaudeCodeEngine implements Engine {
3
3
  name: string;
4
+ listSessions(cwd: string): Promise<SessionInfo[]>;
4
5
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
5
6
  }
@@ -1,10 +1,21 @@
1
- import { query, } from "@anthropic-ai/claude-agent-sdk";
1
+ import { query, listSessions, } from "@anthropic-ai/claude-agent-sdk";
2
2
  import log from "../logger.js";
3
3
  import { formatToolDescription } from "../format.js";
4
4
  export class ClaudeCodeEngine {
5
5
  name = "claude-code";
6
+ async listSessions(cwd) {
7
+ const sessions = await listSessions({ dir: cwd, limit: 10 });
8
+ return sessions
9
+ .sort((a, b) => b.lastModified - a.lastModified)
10
+ .map((s) => ({
11
+ sessionId: s.sessionId,
12
+ summary: s.summary,
13
+ lastModified: s.lastModified,
14
+ gitBranch: s.gitBranch,
15
+ }));
16
+ }
6
17
  async *runTurn(opts) {
7
- const { sessionId, cwd, prompt, permissionMode, onPermissionRequest, appendSystemPrompt, signal, } = opts;
18
+ const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, appendSystemPrompt, mcpServers, signal, } = opts;
8
19
  const abortController = new AbortController();
9
20
  // Wire external signal to our abort controller
10
21
  if (signal) {
@@ -41,6 +52,10 @@ export class ClaudeCodeEngine {
41
52
  interrupt: !resp.message,
42
53
  };
43
54
  };
55
+ // Plain string when no attachments, content blocks when attachments present
56
+ const prompt = attachments?.length
57
+ ? buildAttachmentPrompt(textPrompt, attachments)
58
+ : textPrompt;
44
59
  const q = query({
45
60
  prompt,
46
61
  options: {
@@ -51,6 +66,7 @@ export class ClaudeCodeEngine {
51
66
  canUseTool,
52
67
  abortController,
53
68
  settingSources: ["user", "project", "local"],
69
+ ...(mcpServers ? { mcpServers } : {}),
54
70
  ...(appendSystemPrompt
55
71
  ? {
56
72
  systemPrompt: {
@@ -105,15 +121,12 @@ export class ClaudeCodeEngine {
105
121
  const content = msg.message.content;
106
122
  if (Array.isArray(content)) {
107
123
  for (const block of content) {
108
- const b = block;
109
- if (b.type === "tool_result" &&
110
- typeof b.tool_use_id === "string") {
111
- const toolName = toolUseIdToName.get(b.tool_use_id) ?? "unknown";
112
- const output = extractToolResultText(b.content);
124
+ if (block.type === "tool_result") {
125
+ const toolName = toolUseIdToName.get(block.tool_use_id) ?? "unknown";
126
+ const output = extractToolResultText(block.content);
113
127
  if (output) {
114
128
  yield { type: "tool_result", toolName, output };
115
129
  }
116
- // Retain for turn-level stats (map is GC'd when generator ends)
117
130
  }
118
131
  }
119
132
  }
@@ -178,6 +191,63 @@ export class ClaudeCodeEngine {
178
191
  }
179
192
  }
180
193
  }
194
+ /**
195
+ * Build an AsyncIterable<SDKUserMessage> that yields a single user message
196
+ * with content blocks for attachments + text.
197
+ */
198
+ function buildAttachmentPrompt(text, attachments) {
199
+ const contentBlocks = attachments.map(encodeAttachment);
200
+ // Text prompt always comes last
201
+ if (text) {
202
+ contentBlocks.push({ type: "text", text });
203
+ }
204
+ const message = { role: "user", content: contentBlocks };
205
+ // SDKUserMessage requires these fields for type satisfaction, but they're
206
+ // not semantically meaningful here — we're injecting a top-level prompt,
207
+ // not replaying a conversation. Session management is handled by the
208
+ // `resume` option passed to query().
209
+ const msg = {
210
+ type: "user",
211
+ message,
212
+ parent_tool_use_id: null,
213
+ session_id: "",
214
+ };
215
+ return (async function* () {
216
+ yield msg;
217
+ })();
218
+ }
219
+ const SUPPORTED_IMAGE_TYPES = new Set([
220
+ "image/jpeg", "image/png", "image/gif", "image/webp",
221
+ ]);
222
+ /** Encode an attachment as an SDK content block using its in-memory buffer. */
223
+ function encodeAttachment(att) {
224
+ const data = att.buffer.toString("base64");
225
+ if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
226
+ return {
227
+ type: "image",
228
+ source: {
229
+ type: "base64",
230
+ media_type: att.mimeType,
231
+ data,
232
+ },
233
+ };
234
+ }
235
+ if (att.mimeType === "application/pdf") {
236
+ return {
237
+ type: "document",
238
+ source: { type: "base64", media_type: "application/pdf", data },
239
+ };
240
+ }
241
+ if (att.mimeType.startsWith("text/")) {
242
+ const content = att.buffer.toString("utf-8");
243
+ const header = att.filename ? `--- ${att.filename} ---\n` : "";
244
+ return { type: "text", text: `${header}${content}` };
245
+ }
246
+ // Unsupported (includes non-standard image types like svg, bmp, tiff)
247
+ const name = att.filename ?? att.mimeType;
248
+ log.warn("[engine] unsupported attachment type: %s", att.mimeType);
249
+ return { type: "text", text: `[Unsupported file: ${name}]` };
250
+ }
181
251
  /** Pull plain text out of a tool_result content block. */
182
252
  function extractToolResultText(content) {
183
253
  if (typeof content === "string")
@@ -0,0 +1,3 @@
1
+ import type { Attachment } from "./types.js";
2
+ /** Save an attachment to disk for audit logging. Returns the saved file path. */
3
+ export declare function saveFile(att: Attachment, workspace: string, filesPath: string): Promise<string>;
package/dist/files.js ADDED
@@ -0,0 +1,24 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import mime from "mime";
4
+ import path from "node:path";
5
+ /** Save an attachment to disk for audit logging. Returns the saved file path. */
6
+ export async function saveFile(att, workspace, filesPath) {
7
+ const epoch = Math.floor(Date.now() / 1000);
8
+ const hex = crypto.randomBytes(2).toString("hex");
9
+ const name = att.filename
10
+ ? sanitizeFilename(att.filename)
11
+ : `file${att.mimeType ? `.${mime.getExtension(att.mimeType) ?? "bin"}` : ""}`;
12
+ const filename = `${workspace}-${epoch}-${hex}-${name}`;
13
+ const filePath = path.join(filesPath, filename);
14
+ await fs.writeFile(filePath, att.buffer);
15
+ return filePath;
16
+ }
17
+ /** Strip path separators and null bytes, collapse whitespace. */
18
+ function sanitizeFilename(name) {
19
+ return name
20
+ .replace(/[/\\\0]/g, "_")
21
+ .replace(/\s+/g, "_")
22
+ .slice(0, 200);
23
+ }
24
+ //# sourceMappingURL=files.js.map
package/dist/format.d.ts CHANGED
@@ -18,6 +18,10 @@ export declare function formatToolStatusLine(toolName: string, input: Record<str
18
18
  * Edit/Write include the diff/preview; other tools get a one-line description.
19
19
  */
20
20
  export declare function formatPermissionPrompt(toolName: string, input: Record<string, unknown>, description: string): string;
21
+ /**
22
+ * Relative time string: "just now", "5m ago", "2h ago", "3d ago".
23
+ */
24
+ export declare function timeAgo(epochMs: number): string;
21
25
  /**
22
26
  * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
23
27
  */
package/dist/format.js CHANGED
@@ -41,22 +41,19 @@ export function formatPermissionPrompt(toolName, input, description) {
41
41
  const lines = patch.split("\n");
42
42
  const start = lines.findIndex((l) => l.startsWith("---"));
43
43
  const diffLines = start >= 0 ? lines.slice(start) : lines;
44
- const body = escapeMarkdownV2(truncateLines(diffLines, MAX_LINES), true);
45
- const header = escapeMarkdownV2(`🔐 Allow Edit? ${edit.file_path}`);
46
- return `${header}\n\`\`\`diff\n${body}\n\`\`\``;
44
+ const body = truncateLines(diffLines, MAX_LINES);
45
+ return `🔐 Allow Edit? ${edit.file_path}\n\`\`\`diff\n${body}\n\`\`\``;
47
46
  }
48
47
  if (toolName === "Write") {
49
48
  const write = input;
50
49
  const lines = write.content.split("\n");
51
- const body = escapeMarkdownV2(truncateLines(lines, MAX_LINES), true);
52
- const header = escapeMarkdownV2(`🔐 Allow Write? ${write.file_path} (${lines.length} lines)`);
53
- return `${header}\n\`\`\`\n${body}\n\`\`\``;
50
+ const body = truncateLines(lines, MAX_LINES);
51
+ return `🔐 Allow Write? ${write.file_path} (${lines.length} lines)\n\`\`\`\n${body}\n\`\`\``;
54
52
  }
55
53
  // Non-Edit/Write tools: emoji header + detail in code block
56
54
  const detail = formatToolDetail(toolName, input);
57
- const header = escapeMarkdownV2(`🔐 Allow ${toolName}?`);
58
- const body = escapeMarkdownV2(truncateLines(detail.split("\n"), MAX_LINES), true);
59
- return `${header}\n\`\`\`\n${body}\n\`\`\``;
55
+ const body = truncateLines(detail.split("\n"), MAX_LINES);
56
+ return `🔐 Allow ${toolName}?\n\`\`\`\n${body}\n\`\`\``;
60
57
  }
61
58
  function formatToolStatus(toolName, input) {
62
59
  switch (toolName) {
@@ -104,6 +101,22 @@ function formatToolDetail(toolName, input) {
104
101
  return JSON.stringify(input, null, 2);
105
102
  }
106
103
  }
104
+ /**
105
+ * Relative time string: "just now", "5m ago", "2h ago", "3d ago".
106
+ */
107
+ export function timeAgo(epochMs) {
108
+ const seconds = Math.floor((Date.now() - epochMs) / 1000);
109
+ if (seconds < 60)
110
+ return "just now";
111
+ const minutes = Math.floor(seconds / 60);
112
+ if (minutes < 60)
113
+ return `${minutes}m ago`;
114
+ const hours = Math.floor(minutes / 60);
115
+ if (hours < 24)
116
+ return `${hours}h ago`;
117
+ const days = Math.floor(hours / 24);
118
+ return `${days}d ago`;
119
+ }
107
120
  function truncateLines(lines, max) {
108
121
  if (lines.length <= max) {
109
122
  return lines.join("\n");
@@ -112,15 +125,6 @@ function truncateLines(lines, max) {
112
125
  const remaining = lines.length - max;
113
126
  return `${shown}\n... (${remaining} more lines)`;
114
127
  }
115
- /**
116
- * Escape for Telegram MarkdownV2.
117
- * Outside code blocks: all special chars. Inside: only backtick and backslash
118
- * (other chars are literal inside pre blocks, escaping them adds visible backslashes).
119
- */
120
- function escapeMarkdownV2(text, codeBlock = false) {
121
- const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
122
- return text.replace(pattern, "\\$&");
123
- }
124
128
  /**
125
129
  * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
126
130
  */
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import log, { initLogger } from "./logger.js";
4
4
  import { WorkspaceStore } from "./workspace-store.js";
5
5
  import { ClaudeCodeEngine } from "./engine/claude-code.js";
6
6
  import { TelegramChannel } from "./channel/telegram.js";
7
+ import { SlackChannel } from "./channel/slack.js";
7
8
  import { Orchestrator } from "./orchestrator.js";
8
9
  // The Claude Agent SDK has a bug: handleControlRequest() writes to the
9
10
  // subprocess stdin without a .catch(), so aborting during a permission
@@ -23,8 +24,14 @@ process.on("unhandledRejection", (reason) => {
23
24
  async function main() {
24
25
  const config = loadConfig();
25
26
  initLogger(config.logPath);
26
- const workspaceStore = new WorkspaceStore(config.workspacesPath);
27
- const channel = new TelegramChannel(config.botToken, config.allowedUserIds);
27
+ const workspaceStore = new WorkspaceStore(config.workspaceStorePath);
28
+ let channel;
29
+ if (config.channel.type === "slack") {
30
+ channel = new SlackChannel(config.channel.botToken, config.channel.appToken, config.allowedUserIds);
31
+ }
32
+ else {
33
+ channel = new TelegramChannel(config.channel.botToken, config.allowedUserIds);
34
+ }
28
35
  const engine = new ClaudeCodeEngine();
29
36
  const orchestrator = new Orchestrator({
30
37
  channel,
@@ -32,6 +39,7 @@ async function main() {
32
39
  workspaceStore,
33
40
  permissionMode: config.permissionMode,
34
41
  defaultPromptPath: config.defaultPromptPath,
42
+ filesPath: config.filesPath,
35
43
  });
36
44
  await orchestrator.start();
37
45
  }
package/dist/logger.js CHANGED
@@ -16,7 +16,7 @@ export function initLogger(logPath) {
16
16
  file: logPath,
17
17
  frequency: "daily",
18
18
  size: "10m",
19
- limit: { count: 5 },
19
+ limit: { count: 5, removeOtherLogFiles: true },
20
20
  },
21
21
  level: process.env.LOG_LEVEL ?? "info",
22
22
  },