clearclaw 0.2.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/format.d.ts CHANGED
@@ -1,19 +1,28 @@
1
1
  /**
2
- * Diff formatting utilities for tool_use display in Telegram.
2
+ * Formatting utilities for Telegram display.
3
3
  *
4
- * Uses the `diff` library for proper unified diffs with context lines.
4
+ * Uses the `diff` library for unified diffs in Edit permission prompts.
5
5
  */
6
6
  /**
7
- * Format a tool_use event for display. Returns a MarkdownV2 string.
8
- * Edit/Write get rich formatting; all others get a one-line status.
7
+ * Summary line for the rolling tool message after a turn completes.
8
+ * e.g. "🔧 3× Read, 2× Grep, 1× Bash"
9
9
  */
10
- export declare function formatToolUse(toolName: string, input: Record<string, unknown>): string;
10
+ export declare function formatToolCallSummary(toolCalls: Record<string, number>): string | null;
11
11
  /**
12
- * Format a tool_result event for display. Returns a MarkdownV2 code block
13
- * with input + output, or null if suppressed/empty.
12
+ * Short one-liner for the rolling tool message. Includes key detail
13
+ * but truncated to stay compact.
14
14
  */
15
- export declare function formatToolResult(toolName: string, output: string): string | null;
15
+ export declare function formatToolStatusLine(toolName: string, input: Record<string, unknown>): string;
16
16
  /**
17
- * Human-readable one-liner for a tool invocation (used in permission prompts).
17
+ * Format a permission prompt for display. Returns a MarkdownV2 string.
18
+ * Edit/Write include the diff/preview; other tools get a one-line description.
19
+ */
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;
25
+ /**
26
+ * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
18
27
  */
19
28
  export declare function formatToolDescription(toolName: string, input: Record<string, unknown>): string;
package/dist/format.js CHANGED
@@ -1,22 +1,59 @@
1
1
  /**
2
- * Diff formatting utilities for tool_use display in Telegram.
2
+ * Formatting utilities for Telegram display.
3
3
  *
4
- * Uses the `diff` library for proper unified diffs with context lines.
4
+ * Uses the `diff` library for unified diffs in Edit permission prompts.
5
5
  */
6
6
  import { createTwoFilesPatch } from "diff";
7
7
  const MAX_LINES = 200;
8
+ const MAX_STATUS_LEN = 60;
8
9
  /**
9
- * Format a tool_use event for display. Returns a MarkdownV2 string.
10
- * Edit/Write get rich formatting; all others get a one-line status.
10
+ * Summary line for the rolling tool message after a turn completes.
11
+ * e.g. "🔧 3× Read, 2× Grep, 1× Bash"
11
12
  */
12
- export function formatToolUse(toolName, input) {
13
+ export function formatToolCallSummary(toolCalls) {
14
+ const entries = Object.entries(toolCalls);
15
+ if (entries.length === 0)
16
+ return null;
17
+ const breakdown = entries
18
+ .sort(([, a], [, b]) => b - a)
19
+ .map(([name, count]) => `${count}× ${name}`)
20
+ .join(", ");
21
+ return `🔧 ${breakdown}`;
22
+ }
23
+ /**
24
+ * Short one-liner for the rolling tool message. Includes key detail
25
+ * but truncated to stay compact.
26
+ */
27
+ export function formatToolStatusLine(toolName, input) {
28
+ const full = `🔧 ${formatToolStatus(toolName, input)}`;
29
+ if (full.length <= MAX_STATUS_LEN)
30
+ return full;
31
+ return `${full.slice(0, MAX_STATUS_LEN - 1)}…`;
32
+ }
33
+ /**
34
+ * Format a permission prompt for display. Returns a MarkdownV2 string.
35
+ * Edit/Write include the diff/preview; other tools get a one-line description.
36
+ */
37
+ export function formatPermissionPrompt(toolName, input, description) {
13
38
  if (toolName === "Edit") {
14
- return formatEditDiff(input);
39
+ const edit = input;
40
+ const patch = createTwoFilesPatch(`a/${edit.file_path}`, `b/${edit.file_path}`, edit.old_string, edit.new_string, "", "", { context: 3 });
41
+ const lines = patch.split("\n");
42
+ const start = lines.findIndex((l) => l.startsWith("---"));
43
+ const diffLines = start >= 0 ? lines.slice(start) : lines;
44
+ const body = truncateLines(diffLines, MAX_LINES);
45
+ return `🔐 Allow Edit? ${edit.file_path}\n\`\`\`diff\n${body}\n\`\`\``;
15
46
  }
16
47
  if (toolName === "Write") {
17
- return formatWritePreview(input);
48
+ const write = input;
49
+ const lines = write.content.split("\n");
50
+ const body = truncateLines(lines, MAX_LINES);
51
+ return `🔐 Allow Write? ${write.file_path} (${lines.length} lines)\n\`\`\`\n${body}\n\`\`\``;
18
52
  }
19
- return escapeMarkdownV2(`🔧 ${formatToolStatus(toolName, input)}`);
53
+ // Non-Edit/Write tools: emoji header + detail in code block
54
+ const detail = formatToolDetail(toolName, input);
55
+ const body = truncateLines(detail.split("\n"), MAX_LINES);
56
+ return `🔐 Allow ${toolName}?\n\`\`\`\n${body}\n\`\`\``;
20
57
  }
21
58
  function formatToolStatus(toolName, input) {
22
59
  switch (toolName) {
@@ -24,6 +61,10 @@ function formatToolStatus(toolName, input) {
24
61
  return `Bash: ${input.command ?? "(unknown)"}`;
25
62
  case "Read":
26
63
  return `Read: ${input.file_path ?? "(unknown)"}`;
64
+ case "Edit":
65
+ return `Edit: ${input.file_path ?? "(unknown)"}`;
66
+ case "Write":
67
+ return `Write: ${input.file_path ?? "(unknown)"}`;
27
68
  case "Glob":
28
69
  return `Glob: ${input.pattern ?? "(unknown)"}`;
29
70
  case "Grep":
@@ -38,22 +79,43 @@ function formatToolStatus(toolName, input) {
38
79
  return toolName;
39
80
  }
40
81
  }
41
- function formatEditDiff(input) {
42
- const patch = createTwoFilesPatch(`a/${input.file_path}`, `b/${input.file_path}`, input.old_string, input.new_string, "", "", { context: 3 });
43
- // Strip the "Index:" / "===" preamble, keep --- / +++ / @@ lines onward.
44
- const lines = patch.split("\n");
45
- const start = lines.findIndex((l) => l.startsWith("---"));
46
- const diffLines = start >= 0 ? lines.slice(start) : lines;
47
- const body = truncateLines(diffLines, MAX_LINES);
48
- const header = escapeMarkdownV2(`\u270f\ufe0f Edit: ${input.file_path}`);
49
- return `${header}\n\`\`\`diff\n${body}\n\`\`\``;
82
+ /** Extract the key detail for a tool's permission prompt (command, query, URL, etc.). */
83
+ function formatToolDetail(toolName, input) {
84
+ switch (toolName) {
85
+ case "Bash":
86
+ return String(input.command ?? "");
87
+ case "Read":
88
+ case "Edit":
89
+ case "Write":
90
+ return String(input.file_path ?? "");
91
+ case "Glob":
92
+ case "Grep":
93
+ return String(input.pattern ?? "");
94
+ case "WebSearch":
95
+ return String(input.query ?? "");
96
+ case "WebFetch":
97
+ return String(input.url ?? "");
98
+ case "Agent":
99
+ return String(input.description ?? "");
100
+ default:
101
+ return JSON.stringify(input, null, 2);
102
+ }
50
103
  }
51
- function formatWritePreview(input) {
52
- const lines = input.content.split("\n");
53
- const totalLines = lines.length;
54
- const body = truncateLines(lines, MAX_LINES);
55
- const header = escapeMarkdownV2(`\ud83d\udcc4 Write: ${input.file_path} (${totalLines} lines)`);
56
- return `${header}\n\`\`\`\n${body}\n\`\`\``;
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`;
57
119
  }
58
120
  function truncateLines(lines, max) {
59
121
  if (lines.length <= max) {
@@ -63,33 +125,8 @@ function truncateLines(lines, max) {
63
125
  const remaining = lines.length - max;
64
126
  return `${shown}\n... (${remaining} more lines)`;
65
127
  }
66
- // Tools whose output is noisy or redundant (tool_use already shows input).
67
- // New/unknown tools show by default.
68
- const SUPPRESSED_RESULTS = new Set([
69
- "Read", "Edit", "Write", "Glob", "Agent",
70
- "TodoWrite", "NotebookEdit", "EnterPlanMode", "ExitPlanMode",
71
- ]);
72
- /**
73
- * Format a tool_result event for display. Returns a MarkdownV2 code block
74
- * with input + output, or null if suppressed/empty.
75
- */
76
- export function formatToolResult(toolName, output) {
77
- if (SUPPRESSED_RESULTS.has(toolName))
78
- return null;
79
- if (!output.trim())
80
- return null;
81
- const body = truncateLines(output.split("\n"), MAX_LINES);
82
- return `\`\`\`\n${body}\n\`\`\``;
83
- }
84
- /**
85
- * Escape special characters for MarkdownV2 (outside code blocks).
86
- * Inside ``` blocks, no escaping is needed.
87
- */
88
- function escapeMarkdownV2(text) {
89
- return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
90
- }
91
128
  /**
92
- * Human-readable one-liner for a tool invocation (used in permission prompts).
129
+ * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
93
130
  */
94
131
  export function formatToolDescription(toolName, input) {
95
132
  switch (toolName) {
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;
@@ -20,6 +22,7 @@ export declare class Orchestrator {
20
22
  stop(): Promise<void>;
21
23
  private handleMessage;
22
24
  private routeEngineEvent;
25
+ private updateStatusMessage;
23
26
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
24
27
  private readDefaultPrompt;
25
28
  }
@@ -1,13 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import log from "./logger.js";
4
- import { formatToolUse, formatToolResult } from "./format.js";
4
+ import { saveFile } from "./files.js";
5
+ import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, timeAgo } from "./format.js";
6
+ const MODE_OPTIONS = [
7
+ { label: "Default", value: "default" },
8
+ { label: "Accept Edits", value: "acceptEdits" },
9
+ { label: "Plan", value: "plan" },
10
+ { label: "Bypass", value: "bypassPermissions" },
11
+ ];
5
12
  export class Orchestrator {
6
13
  channel;
7
14
  engine;
8
15
  workspaceStore;
9
16
  permissionMode;
10
17
  defaultPromptPath;
18
+ filesPath;
11
19
  chats = new Map();
12
20
  constructor(opts) {
13
21
  this.channel = opts.channel;
@@ -15,11 +23,15 @@ export class Orchestrator {
15
23
  this.workspaceStore = opts.workspaceStore;
16
24
  this.permissionMode = opts.permissionMode;
17
25
  this.defaultPromptPath = opts.defaultPromptPath;
26
+ this.filesPath = opts.filesPath;
18
27
  }
19
28
  chat(chatId) {
20
29
  let s = this.chats.get(chatId);
21
30
  if (!s) {
22
- s = { busy: false, abort: null };
31
+ s = {
32
+ busy: false, abort: null, permissionMode: null, stats: null, lastStatusText: null,
33
+ toolCallHandle: null,
34
+ };
23
35
  this.chats.set(chatId, s);
24
36
  }
25
37
  return s;
@@ -46,6 +58,27 @@ export class Orchestrator {
46
58
  try {
47
59
  log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
48
60
  const state = this.chat(msg.chatId);
61
+ // /mode — switch permission mode (works even during active turns)
62
+ if (msg.text === "/mode") {
63
+ const currentMode = state.permissionMode ?? this.permissionMode;
64
+ const buttons = [
65
+ MODE_OPTIONS.slice(0, 2).map((opt) => ({
66
+ label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
67
+ value: opt.value,
68
+ })),
69
+ MODE_OPTIONS.slice(2).map((opt) => ({
70
+ label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
71
+ value: opt.value,
72
+ })),
73
+ ];
74
+ const resp = await this.channel.sendInteractive(msg.chatId, `Current mode: ${MODE_OPTIONS.find((o) => o.value === currentMode)?.label ?? currentMode}`, buttons);
75
+ if (resp.value) {
76
+ state.permissionMode = resp.value;
77
+ await this.updateStatusMessage(msg.chatId, state);
78
+ log.info("[cmd] mode → %s", resp.value);
79
+ }
80
+ return;
81
+ }
49
82
  // /new — reset session
50
83
  if (msg.text === "/new") {
51
84
  const ws = this.workspaceStore.byChat(msg.chatId);
@@ -54,10 +87,58 @@ export class Orchestrator {
54
87
  return;
55
88
  }
56
89
  this.workspaceStore.clearSession(ws.name);
90
+ state.permissionMode = null;
91
+ state.stats = null;
92
+ await this.updateStatusMessage(msg.chatId, state);
57
93
  log.info("[cmd] session cleared for workspace %s", ws.name);
58
94
  await this.channel.sendMessage(msg.chatId, "Session cleared.");
59
95
  return;
60
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
+ }
61
142
  // /cancel — abort the running turn
62
143
  if (msg.text === "/cancel") {
63
144
  if (state.abort) {
@@ -93,26 +174,52 @@ export class Orchestrator {
93
174
  // In group workspaces, prepend sender identity so the engine knows who's talking
94
175
  const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
95
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
+ }
96
188
  try {
97
189
  for await (const event of this.engine.runTurn({
98
190
  sessionId: ws.current_session_id,
99
191
  cwd: ws.cwd,
100
192
  prompt,
101
- permissionMode: this.permissionMode,
193
+ attachments: msg.attachments,
194
+ permissionMode: state.permissionMode ?? this.permissionMode,
102
195
  appendSystemPrompt,
103
196
  signal: abort.signal,
104
197
  onPermissionRequest: async (req) => {
105
198
  log.info(`[perm] ${req.toolName}`);
106
- const resp = await this.channel.sendInteractive(msg.chatId, `Allow ${req.toolName}?\n${req.description}`, [
107
- { label: "Allow", value: "allow" },
108
- { label: "Deny", value: "deny" },
109
- { label: "Deny+Note", value: "deny", requestText: true },
110
- ]);
111
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
112
- return {
113
- decision: resp.value === "allow" ? "allow" : "deny",
114
- message: resp.text,
115
- };
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
+ }
116
223
  },
117
224
  })) {
118
225
  await this.routeEngineEvent(msg.chatId, event, ws.name);
@@ -134,44 +241,91 @@ export class Orchestrator {
134
241
  }
135
242
  }
136
243
  async routeEngineEvent(chatId, event, workspaceName) {
137
- switch (event.type) {
138
- case "text":
139
- await this.channel.sendMessage(chatId, event.text);
140
- break;
141
- case "tool_use": {
142
- const formatted = formatToolUse(event.toolName, event.input);
143
- await this.channel.sendMessage(chatId, formatted, {
144
- parseMode: "MarkdownV2",
145
- });
146
- break;
147
- }
148
- case "tool_result": {
149
- const formatted = formatToolResult(event.toolName, event.output);
150
- if (formatted) {
151
- await this.channel.sendMessage(chatId, formatted, {
152
- parseMode: "MarkdownV2",
153
- });
244
+ const state = this.chat(chatId);
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
+ }
261
+ }
262
+ else {
263
+ const handles = await this.channel.sendMessage(chatId, line, { consumeTyping: false });
264
+ state.toolCallHandle = handles[0];
265
+ }
266
+ break;
154
267
  }
155
- break;
156
- }
157
- case "rate_limit": {
158
- const resetMsg = event.resetsAt
159
- ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
160
- : "";
161
- log.warn(`[turn] rate limit: ${event.status}${resetMsg}`);
162
- await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
163
- break;
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;
278
+ }
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 */ }
293
+ }
294
+ }
295
+ state.toolCallHandle = null;
296
+ await this.updateStatusMessage(chatId, state);
297
+ break;
298
+ }
299
+ case "error":
300
+ log.error(`[turn] error: ${event.message}`);
301
+ await this.channel.sendMessage(chatId, `Error: ${event.message}`);
302
+ break;
164
303
  }
165
- case "done":
166
- log.info(`[turn] done session=${event.sessionId}`);
167
- this.workspaceStore.setSession(workspaceName, event.sessionId);
168
- break;
169
- case "error":
170
- log.error(`[turn] error: ${event.message}`);
171
- await this.channel.sendMessage(chatId, `Error: ${event.message}`);
172
- 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);
173
309
  }
174
310
  }
311
+ async updateStatusMessage(chatId, state) {
312
+ const mode = state.permissionMode ?? this.permissionMode;
313
+ const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
314
+ let text;
315
+ if (state.stats) {
316
+ const pct = state.stats.contextWindow > 0
317
+ ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
318
+ : 0;
319
+ text = `🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`;
320
+ }
321
+ else {
322
+ text = `🔒 ${modeLabel}`;
323
+ }
324
+ if (text === state.lastStatusText)
325
+ return;
326
+ await this.channel.updateStatus(chatId, text);
327
+ state.lastStatusText = text;
328
+ }
175
329
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
176
330
  readDefaultPrompt() {
177
331
  try {
@@ -183,4 +337,8 @@ export class Orchestrator {
183
337
  }
184
338
  }
185
339
  }
340
+ /** Strip "claude-" prefix and date suffix from model ID. e.g. "claude-opus-4-6-20250514" → "opus-4-6" */
341
+ function formatModelName(modelId) {
342
+ return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
343
+ }
186
344
  //# sourceMappingURL=orchestrator.js.map