clearclaw 0.1.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 +10 -5
- package/dist/channel/telegram.js +95 -22
- package/dist/config.d.ts +2 -1
- package/dist/config.js +6 -5
- package/dist/engine/claude-code.js +67 -11
- package/dist/format.d.ts +14 -9
- package/dist/format.js +80 -47
- package/dist/index.js +17 -1
- package/dist/orchestrator.d.ts +8 -1
- package/dist/orchestrator.js +194 -30
- package/dist/types.d.ts +28 -2
- package/package.json +22 -2
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,17 +1,22 @@
|
|
|
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;
|
|
6
|
-
private
|
|
6
|
+
private allowedUserIds;
|
|
7
7
|
private typingIntervals;
|
|
8
8
|
private pendingCallbacks;
|
|
9
|
-
|
|
9
|
+
private pendingTextResolvers;
|
|
10
|
+
constructor(botToken: string, allowedUserIds: Set<string>);
|
|
10
11
|
connect(): Promise<void>;
|
|
11
12
|
disconnect(): Promise<void>;
|
|
12
13
|
ownsId(chatId: string): boolean;
|
|
13
|
-
sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<
|
|
14
|
-
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>;
|
|
15
20
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
16
21
|
private numericId;
|
|
17
22
|
}
|
package/dist/channel/telegram.js
CHANGED
|
@@ -4,22 +4,36 @@ import log from "../logger.js";
|
|
|
4
4
|
export class TelegramChannel extends EventEmitter {
|
|
5
5
|
name = "telegram";
|
|
6
6
|
bot;
|
|
7
|
-
|
|
7
|
+
allowedUserIds;
|
|
8
8
|
typingIntervals = new Map();
|
|
9
9
|
// Pending interactive responses: callbackId → resolve function
|
|
10
10
|
pendingCallbacks = new Map();
|
|
11
|
-
|
|
11
|
+
// Pending text input: chatId → resolve function (for requestText follow-ups)
|
|
12
|
+
pendingTextResolvers = new Map();
|
|
13
|
+
constructor(botToken, allowedUserIds) {
|
|
12
14
|
super();
|
|
13
|
-
this.
|
|
15
|
+
this.allowedUserIds = allowedUserIds;
|
|
14
16
|
this.bot = new Bot(botToken);
|
|
15
17
|
}
|
|
16
18
|
async connect() {
|
|
17
19
|
// Auth check: only respond to allowed user
|
|
18
20
|
this.bot.on("message:text", (ctx) => {
|
|
19
|
-
if (ctx.from?.id
|
|
21
|
+
if (!ctx.from?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
|
|
20
22
|
return;
|
|
21
23
|
const chatId = `tg:${ctx.chat.id}`;
|
|
22
|
-
|
|
24
|
+
// Intercept for requestText follow-ups before emitting as a new message
|
|
25
|
+
const textResolver = this.pendingTextResolvers.get(chatId);
|
|
26
|
+
if (textResolver) {
|
|
27
|
+
this.pendingTextResolvers.delete(chatId);
|
|
28
|
+
textResolver(ctx.message.text);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const user = {
|
|
32
|
+
id: `tg:${ctx.from.id}`,
|
|
33
|
+
name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
|
|
34
|
+
...(ctx.from.username && { handle: ctx.from.username }),
|
|
35
|
+
};
|
|
36
|
+
this.emit("message", { chatId, user, text: ctx.message.text });
|
|
23
37
|
});
|
|
24
38
|
// Handle inline keyboard button presses
|
|
25
39
|
this.bot.on("callback_query:data", async (ctx) => {
|
|
@@ -56,58 +70,117 @@ export class TelegramChannel extends EventEmitter {
|
|
|
56
70
|
async sendMessage(chatId, text, opts) {
|
|
57
71
|
const numId = this.numericId(chatId);
|
|
58
72
|
const chunks = splitMessage(text);
|
|
73
|
+
const handles = [];
|
|
59
74
|
for (const chunk of chunks) {
|
|
60
75
|
try {
|
|
61
|
-
await this.bot.api.sendMessage(numId, chunk, {
|
|
76
|
+
const sent = await this.bot.api.sendMessage(numId, chunk, {
|
|
62
77
|
parse_mode: opts?.parseMode,
|
|
63
78
|
});
|
|
79
|
+
handles.push(String(sent.message_id));
|
|
64
80
|
}
|
|
65
81
|
catch (err) {
|
|
66
82
|
if (opts?.parseMode) {
|
|
67
83
|
log.warn("[channel] sendMessage failed with %s, retrying as plain text", opts.parseMode);
|
|
68
|
-
await this.bot.api.sendMessage(numId, chunk);
|
|
84
|
+
const sent = await this.bot.api.sendMessage(numId, chunk);
|
|
85
|
+
handles.push(String(sent.message_id));
|
|
69
86
|
}
|
|
70
87
|
else {
|
|
71
88
|
throw err;
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
}
|
|
92
|
+
return handles;
|
|
75
93
|
}
|
|
76
|
-
async sendInteractive(chatId, text, buttons) {
|
|
94
|
+
async sendInteractive(chatId, text, buttons, opts) {
|
|
77
95
|
const numId = this.numericId(chatId);
|
|
78
96
|
const callbackId = crypto.randomUUID().slice(0, 8);
|
|
79
|
-
// Pre-compute callback data keys
|
|
80
|
-
const cbEntries =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
106
|
const keyboard = new InlineKeyboard();
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
88
132
|
}
|
|
89
|
-
await this.bot.api.sendMessage(numId, text, {
|
|
90
|
-
reply_markup: keyboard,
|
|
91
|
-
});
|
|
92
133
|
const cleanupAll = () => {
|
|
93
134
|
for (const { cbData } of cbEntries) {
|
|
94
135
|
this.pendingCallbacks.delete(cbData);
|
|
95
136
|
}
|
|
96
137
|
};
|
|
97
138
|
// Wait for button press or timeout (30 minutes)
|
|
98
|
-
|
|
139
|
+
const pressed = await new Promise((resolve) => {
|
|
99
140
|
const timeout = setTimeout(() => {
|
|
100
141
|
cleanupAll();
|
|
101
|
-
resolve(
|
|
142
|
+
resolve(null);
|
|
102
143
|
}, 30 * 60 * 1000);
|
|
103
144
|
for (const { btn, cbData } of cbEntries) {
|
|
104
145
|
this.pendingCallbacks.set(cbData, () => {
|
|
105
146
|
clearTimeout(timeout);
|
|
106
147
|
cleanupAll();
|
|
107
|
-
resolve(
|
|
148
|
+
resolve(btn);
|
|
108
149
|
});
|
|
109
150
|
}
|
|
110
151
|
});
|
|
152
|
+
if (!pressed)
|
|
153
|
+
return { value: "" };
|
|
154
|
+
// If the button requests text, prompt for follow-up input
|
|
155
|
+
if (pressed.requestText) {
|
|
156
|
+
await this.bot.api.sendMessage(numId, "Add your feedback:", {
|
|
157
|
+
reply_markup: { force_reply: true },
|
|
158
|
+
reply_parameters: { message_id: sent.message_id },
|
|
159
|
+
});
|
|
160
|
+
const followUpText = await new Promise((resolve) => {
|
|
161
|
+
this.pendingTextResolvers.set(chatId, resolve);
|
|
162
|
+
});
|
|
163
|
+
return { value: pressed.value, text: followUpText };
|
|
164
|
+
}
|
|
165
|
+
return { value: pressed.value };
|
|
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);
|
|
111
184
|
}
|
|
112
185
|
async setTyping(chatId, isTyping) {
|
|
113
186
|
if (isTyping) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { PermissionMode } from "./types.js";
|
|
2
2
|
export interface Config {
|
|
3
3
|
botToken: string;
|
|
4
|
-
|
|
4
|
+
allowedUserIds: Set<string>;
|
|
5
5
|
permissionMode: PermissionMode;
|
|
6
6
|
dataDir: string;
|
|
7
|
+
defaultPromptPath: string;
|
|
7
8
|
workspacesPath: string;
|
|
8
9
|
logPath: string;
|
|
9
10
|
}
|
package/dist/config.js
CHANGED
|
@@ -5,10 +5,10 @@ export function loadConfig() {
|
|
|
5
5
|
const dataDir = process.env.CLEARCLAW_HOME ?? path.join(os.homedir(), ".clearclaw");
|
|
6
6
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
7
7
|
const botToken = requireEnv("TELEGRAM_BOT_TOKEN");
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
throw new Error("
|
|
11
|
-
|
|
8
|
+
const rawIds = process.env.ALLOWED_USER_IDS ?? process.env.ALLOWED_USER_ID;
|
|
9
|
+
if (!rawIds)
|
|
10
|
+
throw new Error("Missing required env var: ALLOWED_USER_IDS");
|
|
11
|
+
const allowedUserIds = new Set(rawIds.split(",").map((s) => s.trim()).filter(Boolean));
|
|
12
12
|
const permissionMode = (process.env.PERMISSION_MODE ?? "default");
|
|
13
13
|
const valid = [
|
|
14
14
|
"default",
|
|
@@ -22,9 +22,10 @@ export function loadConfig() {
|
|
|
22
22
|
}
|
|
23
23
|
return {
|
|
24
24
|
botToken,
|
|
25
|
-
|
|
25
|
+
allowedUserIds,
|
|
26
26
|
permissionMode,
|
|
27
27
|
dataDir,
|
|
28
|
+
defaultPromptPath: path.join(dataDir, "workspace", "CLAUDE.md"),
|
|
28
29
|
workspacesPath: path.join(dataDir, "workspaces.json"),
|
|
29
30
|
logPath: path.join(dataDir, "clearclaw.log"),
|
|
30
31
|
};
|
|
@@ -4,7 +4,7 @@ import { formatToolDescription } from "../format.js";
|
|
|
4
4
|
export class ClaudeCodeEngine {
|
|
5
5
|
name = "claude-code";
|
|
6
6
|
async *runTurn(opts) {
|
|
7
|
-
const { sessionId, cwd, prompt, permissionMode, onPermissionRequest, signal, } = opts;
|
|
7
|
+
const { sessionId, cwd, prompt, permissionMode, onPermissionRequest, appendSystemPrompt, signal, } = opts;
|
|
8
8
|
const abortController = new AbortController();
|
|
9
9
|
// Wire external signal to our abort controller
|
|
10
10
|
if (signal) {
|
|
@@ -22,11 +22,24 @@ 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 };
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
const denyMessage = resp.message
|
|
31
|
+
? `User denied this action with feedback: ${resp.message}`
|
|
32
|
+
: "User denied";
|
|
33
|
+
// Deny with note: let the model read the feedback and adjust.
|
|
34
|
+
// Plain deny: interrupt to stop the turn immediately.
|
|
35
|
+
// (The SDK has a bug where interrupt causes an unhandled rejection —
|
|
36
|
+
// handleControlRequest writes to stdin after the subprocess exits.
|
|
37
|
+
// Suppressed by the unhandledRejection handler in index.ts.)
|
|
38
|
+
return {
|
|
39
|
+
behavior: "deny",
|
|
40
|
+
message: denyMessage,
|
|
41
|
+
interrupt: !resp.message,
|
|
42
|
+
};
|
|
30
43
|
};
|
|
31
44
|
const q = query({
|
|
32
45
|
prompt,
|
|
@@ -38,9 +51,20 @@ export class ClaudeCodeEngine {
|
|
|
38
51
|
canUseTool,
|
|
39
52
|
abortController,
|
|
40
53
|
settingSources: ["user", "project", "local"],
|
|
54
|
+
...(appendSystemPrompt
|
|
55
|
+
? {
|
|
56
|
+
systemPrompt: {
|
|
57
|
+
type: "preset",
|
|
58
|
+
preset: "claude_code",
|
|
59
|
+
append: appendSystemPrompt,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
41
63
|
},
|
|
42
64
|
});
|
|
43
65
|
let resultSessionId;
|
|
66
|
+
let turnStats;
|
|
67
|
+
let lastInputTokens = 0;
|
|
44
68
|
const toolUseIdToName = new Map();
|
|
45
69
|
try {
|
|
46
70
|
for await (const msg of q) {
|
|
@@ -50,17 +74,28 @@ export class ClaudeCodeEngine {
|
|
|
50
74
|
}
|
|
51
75
|
// Extract text and tool_use from assistant messages
|
|
52
76
|
if (msg.type === "assistant") {
|
|
53
|
-
|
|
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;
|
|
54
87
|
for (const block of content) {
|
|
55
88
|
if (block.type === "text" && block.text) {
|
|
56
89
|
yield { type: "text", text: block.text };
|
|
57
90
|
}
|
|
58
91
|
if (block.type === "tool_use") {
|
|
59
|
-
|
|
92
|
+
const toolUseId = block.id;
|
|
93
|
+
toolUseIdToName.set(toolUseId, block.name);
|
|
60
94
|
yield {
|
|
61
95
|
type: "tool_use",
|
|
62
96
|
toolName: block.name,
|
|
63
97
|
input: block.input,
|
|
98
|
+
toolUseId,
|
|
64
99
|
};
|
|
65
100
|
}
|
|
66
101
|
}
|
|
@@ -78,7 +113,7 @@ export class ClaudeCodeEngine {
|
|
|
78
113
|
if (output) {
|
|
79
114
|
yield { type: "tool_result", toolName, output };
|
|
80
115
|
}
|
|
81
|
-
|
|
116
|
+
// Retain for turn-level stats (map is GC'd when generator ends)
|
|
82
117
|
}
|
|
83
118
|
}
|
|
84
119
|
}
|
|
@@ -101,10 +136,26 @@ export class ClaudeCodeEngine {
|
|
|
101
136
|
yield { type: "text", text: cmdOut.content };
|
|
102
137
|
}
|
|
103
138
|
}
|
|
104
|
-
// Capture result
|
|
139
|
+
// Capture result and build turn stats
|
|
105
140
|
if (msg.type === "result") {
|
|
106
141
|
const result = msg;
|
|
107
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
|
+
}
|
|
108
159
|
if (result.subtype !== "success" && result.errors.length) {
|
|
109
160
|
yield { type: "error", message: result.errors.join("\n") };
|
|
110
161
|
}
|
|
@@ -112,13 +163,18 @@ export class ClaudeCodeEngine {
|
|
|
112
163
|
}
|
|
113
164
|
}
|
|
114
165
|
catch (err) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
166
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
167
|
+
log.info("[sdk] turn aborted");
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
yield {
|
|
171
|
+
type: "error",
|
|
172
|
+
message: err instanceof Error ? err.message : String(err),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
119
175
|
}
|
|
120
176
|
if (resultSessionId) {
|
|
121
|
-
yield { type: "done", sessionId: resultSessionId };
|
|
177
|
+
yield { type: "done", sessionId: resultSessionId, stats: turnStats };
|
|
122
178
|
}
|
|
123
179
|
}
|
|
124
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/index.js
CHANGED
|
@@ -5,17 +5,33 @@ import { WorkspaceStore } from "./workspace-store.js";
|
|
|
5
5
|
import { ClaudeCodeEngine } from "./engine/claude-code.js";
|
|
6
6
|
import { TelegramChannel } from "./channel/telegram.js";
|
|
7
7
|
import { Orchestrator } from "./orchestrator.js";
|
|
8
|
+
// The Claude Agent SDK has a bug: handleControlRequest() writes to the
|
|
9
|
+
// subprocess stdin without a .catch(), so aborting during a permission
|
|
10
|
+
// callback causes an unhandled rejection (write to dead process).
|
|
11
|
+
// Suppress that specific error so we can use abort for deny + /cancel.
|
|
12
|
+
process.on("unhandledRejection", (reason) => {
|
|
13
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
14
|
+
if (msg.includes("Operation aborted") ||
|
|
15
|
+
msg.includes("Failed to write to process stdin") ||
|
|
16
|
+
msg.includes("Cannot write to terminated process")) {
|
|
17
|
+
log.warn("[sdk] suppressed write error (known SDK bug): %s", msg);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
log.fatal({ err: reason }, "Unhandled rejection");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
8
23
|
async function main() {
|
|
9
24
|
const config = loadConfig();
|
|
10
25
|
initLogger(config.logPath);
|
|
11
26
|
const workspaceStore = new WorkspaceStore(config.workspacesPath);
|
|
12
|
-
const channel = new TelegramChannel(config.botToken, config.
|
|
27
|
+
const channel = new TelegramChannel(config.botToken, config.allowedUserIds);
|
|
13
28
|
const engine = new ClaudeCodeEngine();
|
|
14
29
|
const orchestrator = new Orchestrator({
|
|
15
30
|
channel,
|
|
16
31
|
engine,
|
|
17
32
|
workspaceStore,
|
|
18
33
|
permissionMode: config.permissionMode,
|
|
34
|
+
defaultPromptPath: config.defaultPromptPath,
|
|
19
35
|
});
|
|
20
36
|
await orchestrator.start();
|
|
21
37
|
}
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -5,16 +5,23 @@ export interface OrchestratorOpts {
|
|
|
5
5
|
engine: Engine;
|
|
6
6
|
workspaceStore: WorkspaceStore;
|
|
7
7
|
permissionMode: PermissionMode;
|
|
8
|
+
defaultPromptPath: string;
|
|
8
9
|
}
|
|
9
10
|
export declare class Orchestrator {
|
|
10
11
|
private channel;
|
|
11
12
|
private engine;
|
|
12
13
|
private workspaceStore;
|
|
13
14
|
private permissionMode;
|
|
14
|
-
private
|
|
15
|
+
private defaultPromptPath;
|
|
16
|
+
private chats;
|
|
15
17
|
constructor(opts: OrchestratorOpts);
|
|
18
|
+
private chat;
|
|
16
19
|
start(): Promise<void>;
|
|
17
20
|
stop(): Promise<void>;
|
|
18
21
|
private handleMessage;
|
|
19
22
|
private routeEngineEvent;
|
|
23
|
+
private updateStatusMessage;
|
|
24
|
+
private clearStatusMessage;
|
|
25
|
+
/** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
|
|
26
|
+
private readDefaultPrompt;
|
|
20
27
|
}
|
package/dist/orchestrator.js
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import log from "./logger.js";
|
|
2
|
-
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
|
+
];
|
|
3
11
|
export class Orchestrator {
|
|
4
12
|
channel;
|
|
5
13
|
engine;
|
|
6
14
|
workspaceStore;
|
|
7
15
|
permissionMode;
|
|
8
|
-
|
|
16
|
+
defaultPromptPath;
|
|
17
|
+
chats = new Map();
|
|
9
18
|
constructor(opts) {
|
|
10
19
|
this.channel = opts.channel;
|
|
11
20
|
this.engine = opts.engine;
|
|
12
21
|
this.workspaceStore = opts.workspaceStore;
|
|
13
22
|
this.permissionMode = opts.permissionMode;
|
|
23
|
+
this.defaultPromptPath = opts.defaultPromptPath;
|
|
24
|
+
}
|
|
25
|
+
chat(chatId) {
|
|
26
|
+
let s = this.chats.get(chatId);
|
|
27
|
+
if (!s) {
|
|
28
|
+
s = {
|
|
29
|
+
busy: false, abort: null, permissionMode: null, statusHandle: null, stats: null,
|
|
30
|
+
toolCallHandle: null,
|
|
31
|
+
};
|
|
32
|
+
this.chats.set(chatId, s);
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
14
35
|
}
|
|
15
36
|
async start() {
|
|
16
37
|
this.channel.on("message", (msg) => {
|
|
@@ -32,7 +53,29 @@ export class Orchestrator {
|
|
|
32
53
|
}
|
|
33
54
|
async handleMessage(msg) {
|
|
34
55
|
try {
|
|
35
|
-
log.info(`[msg] ${msg.text.slice(0, 80)}`);
|
|
56
|
+
log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
|
|
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
|
+
}
|
|
36
79
|
// /new — reset session
|
|
37
80
|
if (msg.text === "/new") {
|
|
38
81
|
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
@@ -41,41 +84,72 @@ export class Orchestrator {
|
|
|
41
84
|
return;
|
|
42
85
|
}
|
|
43
86
|
this.workspaceStore.clearSession(ws.name);
|
|
87
|
+
state.permissionMode = null;
|
|
88
|
+
state.stats = null;
|
|
89
|
+
await this.updateStatusMessage(msg.chatId, state);
|
|
44
90
|
log.info("[cmd] session cleared for workspace %s", ws.name);
|
|
45
91
|
await this.channel.sendMessage(msg.chatId, "Session cleared.");
|
|
46
92
|
return;
|
|
47
93
|
}
|
|
48
|
-
//
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
94
|
+
// /cancel — abort the running turn
|
|
95
|
+
if (msg.text === "/cancel") {
|
|
96
|
+
if (state.abort) {
|
|
97
|
+
state.abort.abort();
|
|
98
|
+
log.info("[cmd] turn cancelled");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await this.channel.sendMessage(msg.chatId, "Nothing to cancel.");
|
|
102
|
+
}
|
|
52
103
|
return;
|
|
53
104
|
}
|
|
54
|
-
|
|
105
|
+
// Workspace lookup — guard all turn logic below
|
|
55
106
|
const ws = this.workspaceStore.byChat(msg.chatId);
|
|
56
107
|
if (!ws) {
|
|
57
108
|
log.info("[msg] no workspace for chat %s", msg.chatId);
|
|
58
|
-
this.busyChats.delete(msg.chatId);
|
|
59
109
|
await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
|
|
60
110
|
return;
|
|
61
111
|
}
|
|
112
|
+
// Reject during active turn (per-chat)
|
|
113
|
+
if (state.busy) {
|
|
114
|
+
log.info("[msg] rejected (busy)");
|
|
115
|
+
await this.channel.sendMessage(msg.chatId, "Still working on the previous message...");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
state.busy = true;
|
|
119
|
+
const abort = new AbortController();
|
|
120
|
+
state.abort = abort;
|
|
62
121
|
log.info(`[turn] start session=${ws.current_session_id ?? "new"} cwd=${ws.cwd}`);
|
|
63
122
|
await this.channel.setTyping(msg.chatId, true);
|
|
123
|
+
// Append default workspace CLAUDE.md to non-default workspace sessions
|
|
124
|
+
const isDefaultWorkspace = ws.cwd === path.dirname(this.defaultPromptPath);
|
|
125
|
+
const appendSystemPrompt = isDefaultWorkspace ? undefined : this.readDefaultPrompt();
|
|
126
|
+
// In group workspaces, prepend sender identity so the engine knows who's talking
|
|
127
|
+
const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
|
|
128
|
+
const prompt = isDefaultWorkspace ? msg.text : `[${sender}]: ${msg.text}`;
|
|
64
129
|
try {
|
|
65
130
|
for await (const event of this.engine.runTurn({
|
|
66
131
|
sessionId: ws.current_session_id,
|
|
67
132
|
cwd: ws.cwd,
|
|
68
|
-
prompt
|
|
69
|
-
permissionMode: this.permissionMode,
|
|
133
|
+
prompt,
|
|
134
|
+
permissionMode: state.permissionMode ?? this.permissionMode,
|
|
135
|
+
appendSystemPrompt,
|
|
136
|
+
signal: abort.signal,
|
|
70
137
|
onPermissionRequest: async (req) => {
|
|
71
138
|
log.info(`[perm] ${req.toolName}`);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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" });
|
|
149
|
+
log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
|
|
77
150
|
return {
|
|
78
151
|
decision: resp.value === "allow" ? "allow" : "deny",
|
|
152
|
+
message: resp.text,
|
|
79
153
|
};
|
|
80
154
|
},
|
|
81
155
|
})) {
|
|
@@ -83,8 +157,13 @@ export class Orchestrator {
|
|
|
83
157
|
}
|
|
84
158
|
}
|
|
85
159
|
finally {
|
|
86
|
-
|
|
160
|
+
const cancelled = abort.signal.aborted;
|
|
161
|
+
state.busy = false;
|
|
162
|
+
state.abort = null;
|
|
87
163
|
await this.channel.setTyping(msg.chatId, false);
|
|
164
|
+
if (cancelled) {
|
|
165
|
+
await this.channel.sendMessage(msg.chatId, "Turn cancelled.");
|
|
166
|
+
}
|
|
88
167
|
}
|
|
89
168
|
}
|
|
90
169
|
catch (err) {
|
|
@@ -93,26 +172,32 @@ export class Orchestrator {
|
|
|
93
172
|
}
|
|
94
173
|
}
|
|
95
174
|
async routeEngineEvent(chatId, event, workspaceName) {
|
|
175
|
+
const state = this.chat(chatId);
|
|
96
176
|
switch (event.type) {
|
|
97
177
|
case "text":
|
|
98
178
|
await this.channel.sendMessage(chatId, event.text);
|
|
99
179
|
break;
|
|
100
180
|
case "tool_use": {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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];
|
|
113
195
|
}
|
|
114
196
|
break;
|
|
115
197
|
}
|
|
198
|
+
case "tool_result":
|
|
199
|
+
// All tool results suppressed — Claude summarizes in its text response
|
|
200
|
+
break;
|
|
116
201
|
case "rate_limit": {
|
|
117
202
|
const resetMsg = event.resetsAt
|
|
118
203
|
? ` Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`
|
|
@@ -121,15 +206,94 @@ export class Orchestrator {
|
|
|
121
206
|
await this.channel.sendMessage(chatId, `⚠️ Rate limited (${event.status}).${resetMsg}`);
|
|
122
207
|
break;
|
|
123
208
|
}
|
|
124
|
-
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;
|
|
125
221
|
log.info(`[turn] done session=${event.sessionId}`);
|
|
126
222
|
this.workspaceStore.setSession(workspaceName, event.sessionId);
|
|
223
|
+
if (event.stats)
|
|
224
|
+
state.stats = event.stats;
|
|
225
|
+
await this.updateStatusMessage(chatId, state);
|
|
127
226
|
break;
|
|
227
|
+
}
|
|
128
228
|
case "error":
|
|
129
229
|
log.error(`[turn] error: ${event.message}`);
|
|
130
230
|
await this.channel.sendMessage(chatId, `Error: ${event.message}`);
|
|
131
231
|
break;
|
|
132
232
|
}
|
|
133
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
|
+
}
|
|
284
|
+
/** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
|
|
285
|
+
readDefaultPrompt() {
|
|
286
|
+
try {
|
|
287
|
+
const content = fs.readFileSync(this.defaultPromptPath, "utf-8").trim();
|
|
288
|
+
return content || undefined;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
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}$/, "");
|
|
134
298
|
}
|
|
135
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;
|
|
@@ -17,13 +21,18 @@ export interface Channel {
|
|
|
17
21
|
export interface Button {
|
|
18
22
|
label: string;
|
|
19
23
|
value: string;
|
|
24
|
+
requestText?: boolean;
|
|
20
25
|
}
|
|
21
26
|
export interface ButtonResponse {
|
|
22
27
|
value: string;
|
|
28
|
+
text?: string;
|
|
23
29
|
}
|
|
24
30
|
export interface SendMessageOpts {
|
|
25
31
|
parseMode?: "MarkdownV2" | "HTML";
|
|
26
32
|
}
|
|
33
|
+
export interface SendInteractiveOpts {
|
|
34
|
+
parseMode?: "MarkdownV2" | "HTML";
|
|
35
|
+
}
|
|
27
36
|
export interface Engine {
|
|
28
37
|
name: string;
|
|
29
38
|
runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
|
|
@@ -34,6 +43,7 @@ export interface RunTurnOpts {
|
|
|
34
43
|
prompt: string;
|
|
35
44
|
permissionMode: PermissionMode;
|
|
36
45
|
onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResponse>;
|
|
46
|
+
appendSystemPrompt?: string;
|
|
37
47
|
signal?: AbortSignal;
|
|
38
48
|
}
|
|
39
49
|
export interface PermissionRequest {
|
|
@@ -41,9 +51,17 @@ export interface PermissionRequest {
|
|
|
41
51
|
input: Record<string, unknown>;
|
|
42
52
|
description: string;
|
|
43
53
|
reason?: string;
|
|
54
|
+
toolUseId: string;
|
|
44
55
|
}
|
|
45
56
|
export interface PermissionResponse {
|
|
46
57
|
decision: "allow" | "deny";
|
|
58
|
+
message?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface TurnStats {
|
|
61
|
+
model: string;
|
|
62
|
+
contextUsed: number;
|
|
63
|
+
contextWindow: number;
|
|
64
|
+
toolCalls: Record<string, number>;
|
|
47
65
|
}
|
|
48
66
|
export type EngineEvent = {
|
|
49
67
|
type: "text";
|
|
@@ -52,6 +70,7 @@ export type EngineEvent = {
|
|
|
52
70
|
type: "tool_use";
|
|
53
71
|
toolName: string;
|
|
54
72
|
input: Record<string, unknown>;
|
|
73
|
+
toolUseId: string;
|
|
55
74
|
} | {
|
|
56
75
|
type: "tool_result";
|
|
57
76
|
toolName: string;
|
|
@@ -63,6 +82,7 @@ export type EngineEvent = {
|
|
|
63
82
|
} | {
|
|
64
83
|
type: "done";
|
|
65
84
|
sessionId: string;
|
|
85
|
+
stats?: TurnStats;
|
|
66
86
|
} | {
|
|
67
87
|
type: "error";
|
|
68
88
|
message: string;
|
|
@@ -73,8 +93,14 @@ export interface Workspace {
|
|
|
73
93
|
chat_id: string;
|
|
74
94
|
current_session_id: string | null;
|
|
75
95
|
}
|
|
96
|
+
export interface UserInfo {
|
|
97
|
+
id: string;
|
|
98
|
+
name: string;
|
|
99
|
+
handle?: string;
|
|
100
|
+
}
|
|
76
101
|
export interface InboundMessage {
|
|
77
102
|
chatId: string;
|
|
103
|
+
user: UserInfo;
|
|
78
104
|
text: string;
|
|
79
105
|
}
|
|
80
106
|
export type { PermissionMode };
|
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
|
},
|
|
@@ -11,7 +30,8 @@
|
|
|
11
30
|
],
|
|
12
31
|
"scripts": {
|
|
13
32
|
"start": "tsx src/index.ts",
|
|
14
|
-
"dev": "
|
|
33
|
+
"dev": "tsx --watch src/index.ts",
|
|
34
|
+
"dev:relay": "nodemon --watch dist --exec 'node dist/index.js'",
|
|
15
35
|
"build": "tsc",
|
|
16
36
|
"check": "tsc --noEmit"
|
|
17
37
|
},
|