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.
@@ -4,37 +4,61 @@ import log from "../logger.js";
4
4
  export class TelegramChannel extends EventEmitter {
5
5
  name = "telegram";
6
6
  bot;
7
+ botToken;
7
8
  allowedUserIds;
8
9
  typingIntervals = new Map();
10
+ statusHandles = new Map(); // chatId → message handle for pinned status
9
11
  // Pending interactive responses: callbackId → resolve function
10
12
  pendingCallbacks = new Map();
11
13
  // Pending text input: chatId → resolve function (for requestText follow-ups)
12
14
  pendingTextResolvers = new Map();
13
15
  constructor(botToken, allowedUserIds) {
14
16
  super();
17
+ this.botToken = botToken;
15
18
  this.allowedUserIds = allowedUserIds;
16
19
  this.bot = new Bot(botToken);
17
20
  }
18
21
  async connect() {
19
- // Auth check: only respond to allowed user
20
22
  this.bot.on("message:text", (ctx) => {
21
- if (!ctx.from?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
23
+ const sender = this.extractSender(ctx);
24
+ if (!sender)
22
25
  return;
23
- const chatId = `tg:${ctx.chat.id}`;
24
26
  // Intercept for requestText follow-ups before emitting as a new message
25
- const textResolver = this.pendingTextResolvers.get(chatId);
27
+ const textResolver = this.pendingTextResolvers.get(sender.chatId);
26
28
  if (textResolver) {
27
- this.pendingTextResolvers.delete(chatId);
29
+ this.pendingTextResolvers.delete(sender.chatId);
28
30
  textResolver(ctx.message.text);
29
31
  return;
30
32
  }
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 });
33
+ this.emit("message", { ...sender, text: ctx.message.text });
34
+ });
35
+ // Photos: Telegram sends each photo as an array of PhotoSize objects
36
+ // (same image at different resolutions, sorted smallest → largest).
37
+ // We grab the last element for the highest resolution version.
38
+ this.bot.on("message:photo", async (ctx) => {
39
+ const photo = ctx.message.photo;
40
+ const largest = photo[photo.length - 1];
41
+ await this.handleMediaMessage(ctx, largest.file_id, "image/jpeg", undefined, ctx.message.caption);
37
42
  });
43
+ // Documents: download with original MIME + filename
44
+ this.bot.on("message:document", async (ctx) => {
45
+ const doc = ctx.message.document;
46
+ await this.handleMediaMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
47
+ });
48
+ // Placeholder handlers for unsupported media types
49
+ for (const [filter, label] of [
50
+ ["message:voice", "Voice message"],
51
+ ["message:audio", "Audio message"],
52
+ ["message:video", "Video"],
53
+ ["message:sticker", "Sticker"],
54
+ ]) {
55
+ this.bot.on(filter, (ctx) => {
56
+ const sender = this.extractSender(ctx);
57
+ if (!sender)
58
+ return;
59
+ this.emit("message", { ...sender, text: `[${label} — not supported]` });
60
+ });
61
+ }
38
62
  // Handle inline keyboard button presses
39
63
  this.bot.on("callback_query:data", async (ctx) => {
40
64
  const data = ctx.callbackQuery.data;
@@ -62,6 +86,7 @@ export class TelegramChannel extends EventEmitter {
62
86
  clearInterval(interval);
63
87
  }
64
88
  this.typingIntervals.clear();
89
+ this.statusHandles.clear();
65
90
  await this.bot.stop();
66
91
  }
67
92
  ownsId(chatId) {
@@ -91,7 +116,7 @@ export class TelegramChannel extends EventEmitter {
91
116
  }
92
117
  return handles;
93
118
  }
94
- async sendInteractive(chatId, text, buttons, opts) {
119
+ async sendInteractive(chatId, text, buttons) {
95
120
  const numId = this.numericId(chatId);
96
121
  const callbackId = crypto.randomUUID().slice(0, 8);
97
122
  // Pre-compute callback data keys (use flat index to avoid collisions when buttons share values)
@@ -112,45 +137,52 @@ export class TelegramChannel extends EventEmitter {
112
137
  }
113
138
  keyboard.row();
114
139
  }
140
+ // Convert universal markdown to MarkdownV2, fall back to plain text on failure
141
+ const mdv2Text = convertToMarkdownV2(text);
115
142
  let sent;
116
143
  try {
117
- sent = await this.bot.api.sendMessage(numId, text, {
144
+ sent = await this.bot.api.sendMessage(numId, mdv2Text, {
118
145
  reply_markup: keyboard,
119
- ...(opts?.parseMode && { parse_mode: opts.parseMode }),
146
+ parse_mode: "MarkdownV2",
120
147
  });
121
148
  }
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
- }
149
+ catch {
150
+ log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
151
+ sent = await this.bot.api.sendMessage(numId, text, {
152
+ reply_markup: keyboard,
153
+ });
132
154
  }
133
155
  const cleanupAll = () => {
134
156
  for (const { cbData } of cbEntries) {
135
157
  this.pendingCallbacks.delete(cbData);
136
158
  }
137
159
  };
138
- // Wait for button press or timeout (30 minutes)
160
+ // Wait for button press (no timeout — matches CLI behavior)
139
161
  const pressed = await new Promise((resolve) => {
140
- const timeout = setTimeout(() => {
141
- cleanupAll();
142
- resolve(null);
143
- }, 30 * 60 * 1000);
144
162
  for (const { btn, cbData } of cbEntries) {
145
163
  this.pendingCallbacks.set(cbData, () => {
146
- clearTimeout(timeout);
147
164
  cleanupAll();
148
165
  resolve(btn);
149
166
  });
150
167
  }
151
168
  });
152
- if (!pressed)
153
- return { value: "" };
169
+ // Update keyboard to highlight the selected button (others become inert — no callbacks)
170
+ const feedbackKeyboard = new InlineKeyboard();
171
+ let entryIndex = 0;
172
+ for (const row of buttons) {
173
+ for (const button of row) {
174
+ const entry = cbEntries[entryIndex++];
175
+ const label = button === pressed ? `✅ ${button.label}` : button.label;
176
+ feedbackKeyboard.text(label, entry.cbData);
177
+ }
178
+ feedbackKeyboard.row();
179
+ }
180
+ try {
181
+ await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
182
+ reply_markup: feedbackKeyboard,
183
+ });
184
+ }
185
+ catch { /* best-effort */ }
154
186
  // If the button requests text, prompt for follow-up input
155
187
  if (pressed.requestText) {
156
188
  await this.bot.api.sendMessage(numId, "Add your feedback:", {
@@ -182,6 +214,39 @@ export class TelegramChannel extends EventEmitter {
182
214
  const numId = this.numericId(chatId);
183
215
  await this.bot.api.unpinAllChatMessages(numId);
184
216
  }
217
+ async updateStatus(chatId, text) {
218
+ const existing = this.statusHandles.get(chatId);
219
+ if (existing) {
220
+ try {
221
+ await this.editMessage(chatId, existing, text);
222
+ return;
223
+ }
224
+ catch (err) {
225
+ if (!(err instanceof Error && err.message.includes("message is not modified"))) {
226
+ // Edit failed for a real reason — fall through to create new
227
+ log.warn("[channel] failed to edit status message, will recreate: %s", err instanceof Error ? err.message : String(err));
228
+ this.statusHandles.delete(chatId);
229
+ }
230
+ else {
231
+ return; // text unchanged, nothing to do
232
+ }
233
+ }
234
+ }
235
+ // No existing handle, or edit failed — create new pinned message
236
+ try {
237
+ await this.unpinAllMessages(chatId);
238
+ }
239
+ catch { /* not admin */ }
240
+ const handles = await this.sendMessage(chatId, text);
241
+ const handle = handles[0];
242
+ this.statusHandles.set(chatId, handle);
243
+ try {
244
+ await this.pinMessage(chatId, handle);
245
+ }
246
+ catch (err) {
247
+ log.warn("[channel] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
248
+ }
249
+ }
185
250
  async setTyping(chatId, isTyping) {
186
251
  if (isTyping) {
187
252
  if (this.typingIntervals.has(chatId))
@@ -204,10 +269,89 @@ export class TelegramChannel extends EventEmitter {
204
269
  }
205
270
  }
206
271
  }
272
+ /** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
273
+ extractSender(ctx) {
274
+ if (!ctx.from?.id || !ctx.chat?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
275
+ return null;
276
+ return {
277
+ chatId: `tg:${ctx.chat.id}`,
278
+ user: {
279
+ id: `tg:${ctx.from.id}`,
280
+ name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
281
+ ...(ctx.from.username && { handle: ctx.from.username }),
282
+ },
283
+ };
284
+ }
285
+ /** Shared handler for photo/document messages: download, emit attachment or fallback. */
286
+ async handleMediaMessage(ctx, fileId, mimeType, filename, caption) {
287
+ const sender = this.extractSender(ctx);
288
+ if (!sender)
289
+ return;
290
+ try {
291
+ const buffer = await this.downloadFile(fileId);
292
+ this.emit("message", {
293
+ ...sender,
294
+ text: caption ?? "",
295
+ attachments: [{ buffer, mimeType, filename }],
296
+ });
297
+ }
298
+ catch (err) {
299
+ log.warn({ err }, "[channel] failed to download file %s", filename ?? fileId);
300
+ this.emit("message", {
301
+ ...sender,
302
+ text: caption
303
+ ? `${caption}\n[File download failed: ${filename ?? mimeType}]`
304
+ : `[File download failed: ${filename ?? mimeType}]`,
305
+ });
306
+ }
307
+ }
308
+ /** Download a Telegram file by file_id, returning the raw buffer. */
309
+ async downloadFile(fileId) {
310
+ const file = await this.bot.api.getFile(fileId);
311
+ if (!file.file_path)
312
+ throw new Error("Telegram returned no file_path");
313
+ const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
314
+ const resp = await fetch(url);
315
+ if (!resp.ok)
316
+ throw new Error(`File download failed: ${resp.status}`);
317
+ return Buffer.from(await resp.arrayBuffer());
318
+ }
207
319
  numericId(chatId) {
208
320
  return Number(chatId.replace(/^tg:/, ""));
209
321
  }
210
322
  }
323
+ /**
324
+ * Escape for Telegram MarkdownV2.
325
+ * Outside code blocks: all special chars. Inside: only backtick and backslash.
326
+ */
327
+ function escapeMarkdownV2(text, codeBlock = false) {
328
+ const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
329
+ return text.replace(pattern, "\\$&");
330
+ }
331
+ /**
332
+ * Convert universal markdown (with code fences) to Telegram MarkdownV2.
333
+ * Handles code blocks specially: fence markers stay literal, inner content
334
+ * only escapes ` and \, outer text gets full MarkdownV2 escaping.
335
+ */
336
+ function convertToMarkdownV2(markdown) {
337
+ const result = [];
338
+ let pos = 0;
339
+ const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
340
+ let match;
341
+ while ((match = fenceRegex.exec(markdown)) !== null) {
342
+ if (match.index > pos) {
343
+ result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
344
+ }
345
+ result.push("```" + match[1] + "\n");
346
+ result.push(escapeMarkdownV2(match[2], true));
347
+ result.push("```");
348
+ pos = match.index + match[0].length;
349
+ }
350
+ if (pos < markdown.length) {
351
+ result.push(escapeMarkdownV2(markdown.slice(pos)));
352
+ }
353
+ return result.join("");
354
+ }
211
355
  const MAX_MSG_LEN = 4096;
212
356
  /** Split text into ≤4096-char chunks, preferring newline boundaries. */
213
357
  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, 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: {
@@ -105,15 +120,12 @@ export class ClaudeCodeEngine {
105
120
  const content = msg.message.content;
106
121
  if (Array.isArray(content)) {
107
122
  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);
123
+ if (block.type === "tool_result") {
124
+ const toolName = toolUseIdToName.get(block.tool_use_id) ?? "unknown";
125
+ const output = extractToolResultText(block.content);
113
126
  if (output) {
114
127
  yield { type: "tool_result", toolName, output };
115
128
  }
116
- // Retain for turn-level stats (map is GC'd when generator ends)
117
129
  }
118
130
  }
119
131
  }
@@ -178,6 +190,63 @@ export class ClaudeCodeEngine {
178
190
  }
179
191
  }
180
192
  }
193
+ /**
194
+ * Build an AsyncIterable<SDKUserMessage> that yields a single user message
195
+ * with content blocks for attachments + text.
196
+ */
197
+ function buildAttachmentPrompt(text, attachments) {
198
+ const contentBlocks = attachments.map(encodeAttachment);
199
+ // Text prompt always comes last
200
+ if (text) {
201
+ contentBlocks.push({ type: "text", text });
202
+ }
203
+ const message = { role: "user", content: contentBlocks };
204
+ // SDKUserMessage requires these fields for type satisfaction, but they're
205
+ // not semantically meaningful here — we're injecting a top-level prompt,
206
+ // not replaying a conversation. Session management is handled by the
207
+ // `resume` option passed to query().
208
+ const msg = {
209
+ type: "user",
210
+ message,
211
+ parent_tool_use_id: null,
212
+ session_id: "",
213
+ };
214
+ return (async function* () {
215
+ yield msg;
216
+ })();
217
+ }
218
+ const SUPPORTED_IMAGE_TYPES = new Set([
219
+ "image/jpeg", "image/png", "image/gif", "image/webp",
220
+ ]);
221
+ /** Encode an attachment as an SDK content block using its in-memory buffer. */
222
+ function encodeAttachment(att) {
223
+ const data = att.buffer.toString("base64");
224
+ if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
225
+ return {
226
+ type: "image",
227
+ source: {
228
+ type: "base64",
229
+ media_type: att.mimeType,
230
+ data,
231
+ },
232
+ };
233
+ }
234
+ if (att.mimeType === "application/pdf") {
235
+ return {
236
+ type: "document",
237
+ source: { type: "base64", media_type: "application/pdf", data },
238
+ };
239
+ }
240
+ if (att.mimeType.startsWith("text/")) {
241
+ const content = att.buffer.toString("utf-8");
242
+ const header = att.filename ? `--- ${att.filename} ---\n` : "";
243
+ return { type: "text", text: `${header}${content}` };
244
+ }
245
+ // Unsupported (includes non-standard image types like svg, bmp, tiff)
246
+ const name = att.filename ?? att.mimeType;
247
+ log.warn("[engine] unsupported attachment type: %s", att.mimeType);
248
+ return { type: "text", text: `[Unsupported file: ${name}]` };
249
+ }
181
250
  /** Pull plain text out of a tool_result content block. */
182
251
  function extractToolResultText(content) {
183
252
  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,33 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** Map common MIME types to file extensions. */
5
+ const MIME_TO_EXT = {
6
+ "image/jpeg": ".jpg",
7
+ "image/png": ".png",
8
+ "image/gif": ".gif",
9
+ "image/webp": ".webp",
10
+ "application/pdf": ".pdf",
11
+ "text/plain": ".txt",
12
+ "text/csv": ".csv",
13
+ };
14
+ /** Save an attachment to disk for audit logging. Returns the saved file path. */
15
+ export async function saveFile(att, workspace, filesPath) {
16
+ const epoch = Math.floor(Date.now() / 1000);
17
+ const hex = crypto.randomBytes(2).toString("hex");
18
+ const name = att.filename
19
+ ? sanitizeFilename(att.filename)
20
+ : `file${MIME_TO_EXT[att.mimeType] ?? ""}`;
21
+ const filename = `${workspace}-${epoch}-${hex}-${name}`;
22
+ const filePath = path.join(filesPath, filename);
23
+ await fs.writeFile(filePath, att.buffer);
24
+ return filePath;
25
+ }
26
+ /** Strip path separators and null bytes, collapse whitespace. */
27
+ function sanitizeFilename(name) {
28
+ return name
29
+ .replace(/[/\\\0]/g, "_")
30
+ .replace(/\s+/g, "_")
31
+ .slice(0, 200);
32
+ }
33
+ //# 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
  }
@@ -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
  }