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 +21 -0
- package/README.md +66 -0
- package/dist/channel/telegram.d.ts +7 -3
- package/dist/channel/telegram.js +61 -15
- package/dist/engine/claude-code.js +35 -5
- package/dist/format.d.ts +14 -9
- package/dist/format.js +80 -47
- package/dist/orchestrator.d.ts +2 -0
- package/dist/orchestrator.js +134 -21
- package/dist/types.d.ts +18 -2
- package/package.json +20 -1
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<
|
|
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
|
}
|
package/dist/channel/telegram.js
CHANGED
|
@@ -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 =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* Formatting utilities for Telegram display.
|
|
3
3
|
*
|
|
4
|
-
* Uses the `diff` library for
|
|
4
|
+
* Uses the `diff` library for unified diffs in Edit permission prompts.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
10
|
+
export declare function formatToolCallSummary(toolCalls: Record<string, number>): string | null;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Short one-liner for the rolling tool message. Includes key detail
|
|
13
|
+
* but truncated to stay compact.
|
|
14
14
|
*/
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function formatToolStatusLine(toolName: string, input: Record<string, unknown>): string;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
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
|
-
*
|
|
2
|
+
* Formatting utilities for Telegram display.
|
|
3
3
|
*
|
|
4
|
-
* Uses the `diff` library for
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/orchestrator.js
CHANGED
|
@@ -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 {
|
|
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 = {
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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<
|
|
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.
|
|
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
|
},
|