clearclaw 0.2.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alleriasun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # ClearClaw
2
+
3
+ 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.
4
+
5
+ ## Why
6
+
7
+ You want to talk to your coding agent from your phone. Not a chatbot — the actual agent on your actual machine, with your files, your tools, your everything.
8
+
9
+ Most projects in this space add their own permission system, config format, plugin model. You end up managing two systems: the CLI you already configured, and the middleware on top.
10
+
11
+ ClearClaw reuses what the CLI already has:
12
+
13
+ - **No duplicate permissions.** The CLI has `settings.json` with allow/deny rules. ClearClaw relays prompts as buttons — the CLI's rules apply as-is.
14
+ - **No duplicate config.** Your settings, MCP servers, tool allowlists — all loaded automatically. Nothing to re-configure.
15
+ - **No duplicate memory.** `CLAUDE.md` lives in your project. The CLI loads it. ClearClaw doesn't touch it.
16
+ - **Small and auditable.** ~1500 lines, 7 files. No plugin system, no eval, no embedded runtime.
17
+
18
+ ## What It Does
19
+
20
+ - Routes messages between your phone and your coding agent (Telegram today, Slack planned)
21
+ - Permission prompts with diffs, tool status, and feedback — like the terminal, on your phone
22
+ - Maps chat groups to project workspaces — each group = a working directory
23
+ - Personal assistant workspace for non-project conversations
24
+ - Session continuity between terminal and phone
25
+
26
+ ## What It Doesn't Do
27
+
28
+ - Own permissions, config, memory, or system prompts — the CLI owns those
29
+ - Run inference or embed an agent runtime
30
+ - Require containers, VMs, or cloud services
31
+ - Provide a plugin or extension system
32
+
33
+ ## Quick Start
34
+
35
+ ```bash
36
+ export TELEGRAM_BOT_TOKEN="your-bot-token"
37
+ export ALLOWED_USER_IDS="tg:your-telegram-id"
38
+
39
+ npx clearclaw
40
+ ```
41
+
42
+ Or install globally: `npm install -g clearclaw`
43
+
44
+ Optional: `PERMISSION_MODE` (`default` | `acceptEdits` | `bypassPermissions` | `plan`), `CLEARCLAW_HOME` (default `~/.clearclaw`).
45
+
46
+ ## Architecture
47
+
48
+ Two interfaces keep the core thin: **Channels** (Telegram, Slack, ...) handle messaging, **Engines** (Claude Code, Codex, Kiro, ...) handle the agent. The orchestrator routes between them — adding a channel or engine means adding a file. Details in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
49
+
50
+ ## Prior Art
51
+
52
+ | Project | Description |
53
+ |---------|-------------|
54
+ | [Claude Code Remote Control](https://docs.anthropic.com/en/docs/claude-code/remote-control) | Anthropic's first-party `/rc`. Max-only, single session, no daemon. |
55
+ | [OpenClaw](https://github.com/openclaw/openclaw) | Full monolith with plugin system. [Security concerns](https://github.com/openclaw/openclaw/security). |
56
+ | [NanoClaw](https://github.com/qwibitai/nanoclaw) | Container-isolated, multi-channel. |
57
+ | [remotecode](https://github.com/kcisoul/remotecode) | Telegram relay with HITL permissions. |
58
+ | [Claude-Code-Remote](https://github.com/JessyTsui/Claude-Code-Remote) | Multi-channel: Telegram, Discord, Email, LINE. |
59
+ | [claude-telegram-relay](https://github.com/godagoo/claude-telegram-relay) | Minimal Telegram daemon. |
60
+ | [Happy Coder](https://happy.engineering/) | Native apps, E2EE, multi-session. Polished, closed-source. |
61
+ | [Paseo](https://paseo.sh/) | Agent-agnostic daemon (Claude Code, Codex, OpenCode). E2EE. |
62
+ | [yottoCode](https://yottocode.com/) | Native macOS + Telegram, voice I/O. |
63
+
64
+ ## License
65
+
66
+ [MIT](LICENSE)
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.js";
2
+ import type { Channel, Button, ButtonResponse, SendMessageOpts, SendInteractiveOpts } from "../types.js";
3
3
  export declare class TelegramChannel extends EventEmitter implements Channel {
4
4
  name: string;
5
5
  private bot;
@@ -11,8 +11,12 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
11
11
  connect(): Promise<void>;
12
12
  disconnect(): Promise<void>;
13
13
  ownsId(chatId: string): boolean;
14
- sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<void>;
15
- sendInteractive(chatId: string, text: string, buttons: Button[]): Promise<ButtonResponse>;
14
+ sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
15
+ sendInteractive(chatId: string, text: string, buttons: Button[][], opts?: SendInteractiveOpts): Promise<ButtonResponse>;
16
+ editMessage(chatId: string, handle: string, text: string): Promise<void>;
17
+ deleteMessage(chatId: string, handle: string): Promise<void>;
18
+ pinMessage(chatId: string, handle: string): Promise<void>;
19
+ unpinAllMessages(chatId: string): Promise<void>;
16
20
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
17
21
  private numericId;
18
22
  }
@@ -70,39 +70,66 @@ export class TelegramChannel extends EventEmitter {
70
70
  async sendMessage(chatId, text, opts) {
71
71
  const numId = this.numericId(chatId);
72
72
  const chunks = splitMessage(text);
73
+ const handles = [];
73
74
  for (const chunk of chunks) {
74
75
  try {
75
- await this.bot.api.sendMessage(numId, chunk, {
76
+ const sent = await this.bot.api.sendMessage(numId, chunk, {
76
77
  parse_mode: opts?.parseMode,
77
78
  });
79
+ handles.push(String(sent.message_id));
78
80
  }
79
81
  catch (err) {
80
82
  if (opts?.parseMode) {
81
83
  log.warn("[channel] sendMessage failed with %s, retrying as plain text", opts.parseMode);
82
- await this.bot.api.sendMessage(numId, chunk);
84
+ const sent = await this.bot.api.sendMessage(numId, chunk);
85
+ handles.push(String(sent.message_id));
83
86
  }
84
87
  else {
85
88
  throw err;
86
89
  }
87
90
  }
88
91
  }
92
+ return handles;
89
93
  }
90
- async sendInteractive(chatId, text, buttons) {
94
+ async sendInteractive(chatId, text, buttons, opts) {
91
95
  const numId = this.numericId(chatId);
92
96
  const callbackId = crypto.randomUUID().slice(0, 8);
93
- // Pre-compute callback data keys (use index to avoid collisions when buttons share values)
94
- const cbEntries = buttons.map((btn, i) => ({
95
- btn,
96
- cbData: `${callbackId}:${i}`,
97
- }));
98
- // Build inline keyboard
97
+ // Pre-compute callback data keys (use flat index to avoid collisions when buttons share values)
98
+ const cbEntries = [];
99
+ let idx = 0;
100
+ for (const row of buttons) {
101
+ for (const btn of row) {
102
+ cbEntries.push({ btn, cbData: `${callbackId}:${idx++}` });
103
+ }
104
+ }
105
+ // Build inline keyboard — each inner array is a row
99
106
  const keyboard = new InlineKeyboard();
100
- for (const { btn, cbData } of cbEntries) {
101
- keyboard.text(btn.label, cbData);
107
+ let flatIdx = 0;
108
+ for (const row of buttons) {
109
+ for (const _btn of row) {
110
+ const { cbData } = cbEntries[flatIdx++];
111
+ keyboard.text(_btn.label, cbData);
112
+ }
113
+ keyboard.row();
114
+ }
115
+ let sent;
116
+ try {
117
+ sent = await this.bot.api.sendMessage(numId, text, {
118
+ reply_markup: keyboard,
119
+ ...(opts?.parseMode && { parse_mode: opts.parseMode }),
120
+ });
121
+ }
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
+ }
102
132
  }
103
- await this.bot.api.sendMessage(numId, text, {
104
- reply_markup: keyboard,
105
- });
106
133
  const cleanupAll = () => {
107
134
  for (const { cbData } of cbEntries) {
108
135
  this.pendingCallbacks.delete(cbData);
@@ -126,8 +153,9 @@ export class TelegramChannel extends EventEmitter {
126
153
  return { value: "" };
127
154
  // If the button requests text, prompt for follow-up input
128
155
  if (pressed.requestText) {
129
- await this.bot.api.sendMessage(numId, "Type your note:", {
156
+ await this.bot.api.sendMessage(numId, "Add your feedback:", {
130
157
  reply_markup: { force_reply: true },
158
+ reply_parameters: { message_id: sent.message_id },
131
159
  });
132
160
  const followUpText = await new Promise((resolve) => {
133
161
  this.pendingTextResolvers.set(chatId, resolve);
@@ -136,6 +164,24 @@ export class TelegramChannel extends EventEmitter {
136
164
  }
137
165
  return { value: pressed.value };
138
166
  }
167
+ async editMessage(chatId, handle, text) {
168
+ const numId = this.numericId(chatId);
169
+ await this.bot.api.editMessageText(numId, Number(handle), text);
170
+ }
171
+ async deleteMessage(chatId, handle) {
172
+ const numId = this.numericId(chatId);
173
+ await this.bot.api.deleteMessage(numId, Number(handle));
174
+ }
175
+ async pinMessage(chatId, handle) {
176
+ const numId = this.numericId(chatId);
177
+ await this.bot.api.pinChatMessage(numId, Number(handle), {
178
+ disable_notification: true,
179
+ });
180
+ }
181
+ async unpinAllMessages(chatId) {
182
+ const numId = this.numericId(chatId);
183
+ await this.bot.api.unpinAllChatMessages(numId);
184
+ }
139
185
  async setTyping(chatId, isTyping) {
140
186
  if (isTyping) {
141
187
  if (this.typingIntervals.has(chatId))
@@ -22,6 +22,7 @@ export class ClaudeCodeEngine {
22
22
  input,
23
23
  description,
24
24
  reason: options.decisionReason,
25
+ toolUseId: options.toolUseID,
25
26
  });
26
27
  if (resp.decision === "allow") {
27
28
  return { behavior: "allow", updatedInput: input };
@@ -62,6 +63,8 @@ export class ClaudeCodeEngine {
62
63
  },
63
64
  });
64
65
  let resultSessionId;
66
+ let turnStats;
67
+ let lastInputTokens = 0;
65
68
  const toolUseIdToName = new Map();
66
69
  try {
67
70
  for await (const msg of q) {
@@ -71,17 +74,28 @@ export class ClaudeCodeEngine {
71
74
  }
72
75
  // Extract text and tool_use from assistant messages
73
76
  if (msg.type === "assistant") {
74
- const { content } = msg.message;
77
+ // BetaMessage type isn't directly importable (@anthropic-ai/sdk not installed);
78
+ // .message is typed as `any` so property access works without casting.
79
+ const betaMsg = msg.message;
80
+ if (betaMsg.usage) {
81
+ const u = betaMsg.usage;
82
+ lastInputTokens = (u.input_tokens ?? 0)
83
+ + (u.cache_read_input_tokens ?? 0)
84
+ + (u.cache_creation_input_tokens ?? 0);
85
+ }
86
+ const { content } = betaMsg;
75
87
  for (const block of content) {
76
88
  if (block.type === "text" && block.text) {
77
89
  yield { type: "text", text: block.text };
78
90
  }
79
91
  if (block.type === "tool_use") {
80
- toolUseIdToName.set(block.id, block.name);
92
+ const toolUseId = block.id;
93
+ toolUseIdToName.set(toolUseId, block.name);
81
94
  yield {
82
95
  type: "tool_use",
83
96
  toolName: block.name,
84
97
  input: block.input,
98
+ toolUseId,
85
99
  };
86
100
  }
87
101
  }
@@ -99,7 +113,7 @@ export class ClaudeCodeEngine {
99
113
  if (output) {
100
114
  yield { type: "tool_result", toolName, output };
101
115
  }
102
- toolUseIdToName.delete(b.tool_use_id);
116
+ // Retain for turn-level stats (map is GC'd when generator ends)
103
117
  }
104
118
  }
105
119
  }
@@ -122,10 +136,26 @@ export class ClaudeCodeEngine {
122
136
  yield { type: "text", text: cmdOut.content };
123
137
  }
124
138
  }
125
- // Capture result
139
+ // Capture result and build turn stats
126
140
  if (msg.type === "result") {
127
141
  const result = msg;
128
142
  resultSessionId = result.session_id;
143
+ const models = Object.keys(result.modelUsage);
144
+ if (models.length > 0) {
145
+ const model = models[0];
146
+ const mu = result.modelUsage[model];
147
+ // Build per-tool call counts from accumulated map
148
+ const toolCalls = {};
149
+ for (const name of toolUseIdToName.values()) {
150
+ toolCalls[name] = (toolCalls[name] ?? 0) + 1;
151
+ }
152
+ turnStats = {
153
+ model,
154
+ contextUsed: lastInputTokens,
155
+ contextWindow: mu.contextWindow,
156
+ toolCalls,
157
+ };
158
+ }
129
159
  if (result.subtype !== "success" && result.errors.length) {
130
160
  yield { type: "error", message: result.errors.join("\n") };
131
161
  }
@@ -144,7 +174,7 @@ export class ClaudeCodeEngine {
144
174
  }
145
175
  }
146
176
  if (resultSessionId) {
147
- yield { type: "done", sessionId: resultSessionId };
177
+ yield { type: "done", sessionId: resultSessionId, stats: turnStats };
148
178
  }
149
179
  }
150
180
  }
package/dist/format.d.ts CHANGED
@@ -1,19 +1,24 @@
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
+ * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
18
23
  */
19
24
  export declare function formatToolDescription(toolName: string, input: Record<string, unknown>): string;
package/dist/format.js CHANGED
@@ -1,22 +1,62 @@
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 = escapeMarkdownV2(truncateLines(diffLines, MAX_LINES), true);
45
+ const header = escapeMarkdownV2(`🔐 Allow Edit? ${edit.file_path}`);
46
+ return `${header}\n\`\`\`diff\n${body}\n\`\`\``;
15
47
  }
16
48
  if (toolName === "Write") {
17
- return formatWritePreview(input);
49
+ const write = input;
50
+ 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\`\`\``;
18
54
  }
19
- return escapeMarkdownV2(`🔧 ${formatToolStatus(toolName, input)}`);
55
+ // Non-Edit/Write tools: emoji header + detail in code block
56
+ 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\`\`\``;
20
60
  }
21
61
  function formatToolStatus(toolName, input) {
22
62
  switch (toolName) {
@@ -24,6 +64,10 @@ function formatToolStatus(toolName, input) {
24
64
  return `Bash: ${input.command ?? "(unknown)"}`;
25
65
  case "Read":
26
66
  return `Read: ${input.file_path ?? "(unknown)"}`;
67
+ case "Edit":
68
+ return `Edit: ${input.file_path ?? "(unknown)"}`;
69
+ case "Write":
70
+ return `Write: ${input.file_path ?? "(unknown)"}`;
27
71
  case "Glob":
28
72
  return `Glob: ${input.pattern ?? "(unknown)"}`;
29
73
  case "Grep":
@@ -38,22 +82,27 @@ function formatToolStatus(toolName, input) {
38
82
  return toolName;
39
83
  }
40
84
  }
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\`\`\``;
50
- }
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\`\`\``;
85
+ /** Extract the key detail for a tool's permission prompt (command, query, URL, etc.). */
86
+ function formatToolDetail(toolName, input) {
87
+ switch (toolName) {
88
+ case "Bash":
89
+ return String(input.command ?? "");
90
+ case "Read":
91
+ case "Edit":
92
+ case "Write":
93
+ return String(input.file_path ?? "");
94
+ case "Glob":
95
+ case "Grep":
96
+ return String(input.pattern ?? "");
97
+ case "WebSearch":
98
+ return String(input.query ?? "");
99
+ case "WebFetch":
100
+ return String(input.url ?? "");
101
+ case "Agent":
102
+ return String(input.description ?? "");
103
+ default:
104
+ return JSON.stringify(input, null, 2);
105
+ }
57
106
  }
58
107
  function truncateLines(lines, max) {
59
108
  if (lines.length <= max) {
@@ -63,33 +112,17 @@ function truncateLines(lines, max) {
63
112
  const remaining = lines.length - max;
64
113
  return `${shown}\n... (${remaining} more lines)`;
65
114
  }
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
115
  /**
85
- * Escape special characters for MarkdownV2 (outside code blocks).
86
- * Inside ``` blocks, no escaping is needed.
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).
87
119
  */
88
- function escapeMarkdownV2(text) {
89
- return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
120
+ function escapeMarkdownV2(text, codeBlock = false) {
121
+ const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
122
+ return text.replace(pattern, "\\$&");
90
123
  }
91
124
  /**
92
- * Human-readable one-liner for a tool invocation (used in permission prompts).
125
+ * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
93
126
  */
94
127
  export function formatToolDescription(toolName, input) {
95
128
  switch (toolName) {
@@ -20,6 +20,8 @@ export declare class Orchestrator {
20
20
  stop(): Promise<void>;
21
21
  private handleMessage;
22
22
  private routeEngineEvent;
23
+ private updateStatusMessage;
24
+ private clearStatusMessage;
23
25
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
24
26
  private readDefaultPrompt;
25
27
  }
@@ -1,7 +1,13 @@
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 { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt } from "./format.js";
5
+ const MODE_OPTIONS = [
6
+ { label: "Default", value: "default" },
7
+ { label: "Accept Edits", value: "acceptEdits" },
8
+ { label: "Plan", value: "plan" },
9
+ { label: "Bypass", value: "bypassPermissions" },
10
+ ];
5
11
  export class Orchestrator {
6
12
  channel;
7
13
  engine;
@@ -19,7 +25,10 @@ export class Orchestrator {
19
25
  chat(chatId) {
20
26
  let s = this.chats.get(chatId);
21
27
  if (!s) {
22
- s = { busy: false, abort: null };
28
+ s = {
29
+ busy: false, abort: null, permissionMode: null, statusHandle: null, stats: null,
30
+ toolCallHandle: null,
31
+ };
23
32
  this.chats.set(chatId, s);
24
33
  }
25
34
  return s;
@@ -46,6 +55,27 @@ export class Orchestrator {
46
55
  try {
47
56
  log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
48
57
  const state = this.chat(msg.chatId);
58
+ // /mode — switch permission mode (works even during active turns)
59
+ if (msg.text === "/mode") {
60
+ const currentMode = state.permissionMode ?? this.permissionMode;
61
+ const buttons = [
62
+ MODE_OPTIONS.slice(0, 2).map((opt) => ({
63
+ label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
64
+ value: opt.value,
65
+ })),
66
+ MODE_OPTIONS.slice(2).map((opt) => ({
67
+ label: opt.value === currentMode ? `✓ ${opt.label}` : opt.label,
68
+ value: opt.value,
69
+ })),
70
+ ];
71
+ const resp = await this.channel.sendInteractive(msg.chatId, `Current mode: ${MODE_OPTIONS.find((o) => o.value === currentMode)?.label ?? currentMode}`, buttons);
72
+ if (resp.value) {
73
+ state.permissionMode = resp.value;
74
+ await this.updateStatusMessage(msg.chatId, state);
75
+ log.info("[cmd] mode → %s", resp.value);
76
+ }
77
+ return;
78
+ }
49
79
  // /new — reset session
50
80
  if (msg.text === "/new") {
51
81
  const ws = this.workspaceStore.byChat(msg.chatId);
@@ -54,6 +84,9 @@ export class Orchestrator {
54
84
  return;
55
85
  }
56
86
  this.workspaceStore.clearSession(ws.name);
87
+ state.permissionMode = null;
88
+ state.stats = null;
89
+ await this.updateStatusMessage(msg.chatId, state);
57
90
  log.info("[cmd] session cleared for workspace %s", ws.name);
58
91
  await this.channel.sendMessage(msg.chatId, "Session cleared.");
59
92
  return;
@@ -98,16 +131,21 @@ export class Orchestrator {
98
131
  sessionId: ws.current_session_id,
99
132
  cwd: ws.cwd,
100
133
  prompt,
101
- permissionMode: this.permissionMode,
134
+ permissionMode: state.permissionMode ?? this.permissionMode,
102
135
  appendSystemPrompt,
103
136
  signal: abort.signal,
104
137
  onPermissionRequest: async (req) => {
105
138
  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
- ]);
139
+ const promptText = formatPermissionPrompt(req.toolName, req.input, req.description);
140
+ const resp = await this.channel.sendInteractive(msg.chatId, promptText, [
141
+ [
142
+ { label: "👍 Allow", value: "allow" },
143
+ { label: "👎 Deny", value: "deny" },
144
+ ],
145
+ [
146
+ { label: "📝 Deny + Note", value: "deny", requestText: true },
147
+ ],
148
+ ], { parseMode: "MarkdownV2" });
111
149
  log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
112
150
  return {
113
151
  decision: resp.value === "allow" ? "allow" : "deny",
@@ -134,26 +172,32 @@ export class Orchestrator {
134
172
  }
135
173
  }
136
174
  async routeEngineEvent(chatId, event, workspaceName) {
175
+ const state = this.chat(chatId);
137
176
  switch (event.type) {
138
177
  case "text":
139
178
  await this.channel.sendMessage(chatId, event.text);
140
179
  break;
141
180
  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
- });
181
+ // Rolling message: each tool_use replaces the single message's content
182
+ const line = formatToolStatusLine(event.toolName, event.input);
183
+ if (state.toolCallHandle) {
184
+ try {
185
+ await this.channel.editMessage(chatId, state.toolCallHandle, line);
186
+ }
187
+ catch {
188
+ const handles = await this.channel.sendMessage(chatId, line);
189
+ state.toolCallHandle = handles[0];
190
+ }
191
+ }
192
+ else {
193
+ const handles = await this.channel.sendMessage(chatId, line);
194
+ state.toolCallHandle = handles[0];
154
195
  }
155
196
  break;
156
197
  }
198
+ case "tool_result":
199
+ // All tool results suppressed — Claude summarizes in its text response
200
+ break;
157
201
  case "rate_limit": {
158
202
  const resetMsg = event.resetsAt
159
203
  ? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
@@ -162,16 +206,81 @@ export class Orchestrator {
162
206
  await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
163
207
  break;
164
208
  }
165
- case "done":
209
+ case "done": {
210
+ // Edit rolling tool message to show summary
211
+ if (state.toolCallHandle && event.stats) {
212
+ const summary = formatToolCallSummary(event.stats.toolCalls);
213
+ if (summary) {
214
+ try {
215
+ await this.channel.editMessage(chatId, state.toolCallHandle, summary);
216
+ }
217
+ catch { /* edit failed, leave as-is */ }
218
+ }
219
+ }
220
+ state.toolCallHandle = null;
166
221
  log.info(`[turn] done session=${event.sessionId}`);
167
222
  this.workspaceStore.setSession(workspaceName, event.sessionId);
223
+ if (event.stats)
224
+ state.stats = event.stats;
225
+ await this.updateStatusMessage(chatId, state);
168
226
  break;
227
+ }
169
228
  case "error":
170
229
  log.error(`[turn] error: ${event.message}`);
171
230
  await this.channel.sendMessage(chatId, `Error: ${event.message}`);
172
231
  break;
173
232
  }
174
233
  }
234
+ async updateStatusMessage(chatId, state) {
235
+ const mode = state.permissionMode ?? this.permissionMode;
236
+ const modeLabel = MODE_OPTIONS.find((o) => o.value === mode)?.label ?? mode;
237
+ const parts = [];
238
+ if (state.stats) {
239
+ const pct = state.stats.contextWindow > 0
240
+ ? Math.round((state.stats.contextUsed / state.stats.contextWindow) * 100)
241
+ : 0;
242
+ parts.push(`🤖 ${formatModelName(state.stats.model)} ${pct}% | 🔒 ${modeLabel}`);
243
+ }
244
+ else {
245
+ parts.push(`🔒 ${modeLabel}`);
246
+ }
247
+ const text = parts.join("\n");
248
+ if (state.statusHandle) {
249
+ try {
250
+ await this.channel.editMessage(chatId, state.statusHandle, text);
251
+ }
252
+ catch (err) {
253
+ if (!(err instanceof Error && err.message.includes("message is not modified")))
254
+ throw err;
255
+ }
256
+ }
257
+ else {
258
+ // Clear stale pins from previous server runs
259
+ try {
260
+ await this.channel.unpinAllMessages(chatId);
261
+ }
262
+ catch { /* not admin */ }
263
+ const handles = await this.channel.sendMessage(chatId, text);
264
+ state.statusHandle = handles[0];
265
+ try {
266
+ await this.channel.pinMessage(chatId, state.statusHandle);
267
+ }
268
+ catch (err) {
269
+ log.warn("[cmd] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
270
+ }
271
+ }
272
+ }
273
+ async clearStatusMessage(chatId, state) {
274
+ if (!state.statusHandle)
275
+ return;
276
+ try {
277
+ await this.channel.deleteMessage(chatId, state.statusHandle);
278
+ }
279
+ catch (err) {
280
+ log.warn("[cmd] failed to delete status message: %s", err instanceof Error ? err.message : String(err));
281
+ }
282
+ state.statusHandle = null;
283
+ }
175
284
  /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
176
285
  readDefaultPrompt() {
177
286
  try {
@@ -183,4 +292,8 @@ export class Orchestrator {
183
292
  }
184
293
  }
185
294
  }
295
+ /** Strip "claude-" prefix and date suffix from model ID. e.g. "claude-opus-4-6-20250514" → "opus-4-6" */
296
+ function formatModelName(modelId) {
297
+ return modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "");
298
+ }
186
299
  //# sourceMappingURL=orchestrator.js.map
package/dist/types.d.ts CHANGED
@@ -7,8 +7,12 @@ export interface Channel {
7
7
  connect(): Promise<void>;
8
8
  disconnect(): Promise<void>;
9
9
  ownsId(chatId: string): boolean;
10
- sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<void>;
11
- sendInteractive(chatId: string, text: string, buttons: Button[]): Promise<ButtonResponse>;
10
+ sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
11
+ sendInteractive(chatId: string, text: string, buttons: Button[][], opts?: SendInteractiveOpts): Promise<ButtonResponse>;
12
+ editMessage(chatId: string, handle: string, text: string): Promise<void>;
13
+ deleteMessage(chatId: string, handle: string): Promise<void>;
14
+ pinMessage(chatId: string, handle: string): Promise<void>;
15
+ unpinAllMessages(chatId: string): Promise<void>;
12
16
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
13
17
  on<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
14
18
  off<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
@@ -26,6 +30,9 @@ export interface ButtonResponse {
26
30
  export interface SendMessageOpts {
27
31
  parseMode?: "MarkdownV2" | "HTML";
28
32
  }
33
+ export interface SendInteractiveOpts {
34
+ parseMode?: "MarkdownV2" | "HTML";
35
+ }
29
36
  export interface Engine {
30
37
  name: string;
31
38
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
@@ -44,11 +51,18 @@ export interface PermissionRequest {
44
51
  input: Record<string, unknown>;
45
52
  description: string;
46
53
  reason?: string;
54
+ toolUseId: string;
47
55
  }
48
56
  export interface PermissionResponse {
49
57
  decision: "allow" | "deny";
50
58
  message?: string;
51
59
  }
60
+ export interface TurnStats {
61
+ model: string;
62
+ contextUsed: number;
63
+ contextWindow: number;
64
+ toolCalls: Record<string, number>;
65
+ }
52
66
  export type EngineEvent = {
53
67
  type: "text";
54
68
  text: string;
@@ -56,6 +70,7 @@ export type EngineEvent = {
56
70
  type: "tool_use";
57
71
  toolName: string;
58
72
  input: Record<string, unknown>;
73
+ toolUseId: string;
59
74
  } | {
60
75
  type: "tool_result";
61
76
  toolName: string;
@@ -67,6 +82,7 @@ export type EngineEvent = {
67
82
  } | {
68
83
  type: "done";
69
84
  sessionId: string;
85
+ stats?: TurnStats;
70
86
  } | {
71
87
  type: "error";
72
88
  message: string;
package/package.json CHANGED
@@ -1,7 +1,26 @@
1
1
  {
2
2
  "name": "clearclaw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.",
4
5
  "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/alleriasun/clearclaw.git"
10
+ },
11
+ "homepage": "https://github.com/alleriasun/clearclaw",
12
+ "keywords": [
13
+ "claude-code",
14
+ "telegram",
15
+ "relay",
16
+ "agent-sdk",
17
+ "cli",
18
+ "remote",
19
+ "mobile",
20
+ "openclaw",
21
+ "nanoclaw",
22
+ "remotecode"
23
+ ],
5
24
  "bin": {
6
25
  "clearclaw": "dist/index.js"
7
26
  },