clearclaw 0.1.0 → 0.2.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.
@@ -3,10 +3,11 @@ import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.
3
3
  export declare class TelegramChannel extends EventEmitter implements Channel {
4
4
  name: string;
5
5
  private bot;
6
- private allowedUserId;
6
+ private allowedUserIds;
7
7
  private typingIntervals;
8
8
  private pendingCallbacks;
9
- constructor(botToken: string, allowedUserId: number);
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;
@@ -4,22 +4,36 @@ import log from "../logger.js";
4
4
  export class TelegramChannel extends EventEmitter {
5
5
  name = "telegram";
6
6
  bot;
7
- allowedUserId;
7
+ allowedUserIds;
8
8
  typingIntervals = new Map();
9
9
  // Pending interactive responses: callbackId → resolve function
10
10
  pendingCallbacks = new Map();
11
- constructor(botToken, allowedUserId) {
11
+ // Pending text input: chatId → resolve function (for requestText follow-ups)
12
+ pendingTextResolvers = new Map();
13
+ constructor(botToken, allowedUserIds) {
12
14
  super();
13
- this.allowedUserId = allowedUserId;
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 !== this.allowedUserId)
21
+ if (!ctx.from?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
20
22
  return;
21
23
  const chatId = `tg:${ctx.chat.id}`;
22
- this.emit("message", { chatId, text: ctx.message.text });
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) => {
@@ -76,10 +90,10 @@ export class TelegramChannel extends EventEmitter {
76
90
  async sendInteractive(chatId, text, buttons) {
77
91
  const numId = this.numericId(chatId);
78
92
  const callbackId = crypto.randomUUID().slice(0, 8);
79
- // Pre-compute callback data keys
80
- const cbEntries = buttons.map((btn) => ({
93
+ // Pre-compute callback data keys (use index to avoid collisions when buttons share values)
94
+ const cbEntries = buttons.map((btn, i) => ({
81
95
  btn,
82
- cbData: `${callbackId}:${btn.value}`,
96
+ cbData: `${callbackId}:${i}`,
83
97
  }));
84
98
  // Build inline keyboard
85
99
  const keyboard = new InlineKeyboard();
@@ -95,19 +109,32 @@ export class TelegramChannel extends EventEmitter {
95
109
  }
96
110
  };
97
111
  // Wait for button press or timeout (30 minutes)
98
- return new Promise((resolve) => {
112
+ const pressed = await new Promise((resolve) => {
99
113
  const timeout = setTimeout(() => {
100
114
  cleanupAll();
101
- resolve({ value: "" });
115
+ resolve(null);
102
116
  }, 30 * 60 * 1000);
103
117
  for (const { btn, cbData } of cbEntries) {
104
118
  this.pendingCallbacks.set(cbData, () => {
105
119
  clearTimeout(timeout);
106
120
  cleanupAll();
107
- resolve({ value: btn.value });
121
+ resolve(btn);
108
122
  });
109
123
  }
110
124
  });
125
+ if (!pressed)
126
+ return { value: "" };
127
+ // If the button requests text, prompt for follow-up input
128
+ if (pressed.requestText) {
129
+ await this.bot.api.sendMessage(numId, "Type your note:", {
130
+ reply_markup: { force_reply: true },
131
+ });
132
+ const followUpText = await new Promise((resolve) => {
133
+ this.pendingTextResolvers.set(chatId, resolve);
134
+ });
135
+ return { value: pressed.value, text: followUpText };
136
+ }
137
+ return { value: pressed.value };
111
138
  }
112
139
  async setTyping(chatId, isTyping) {
113
140
  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
- allowedUserId: number;
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 allowedUserId = Number(requireEnv("ALLOWED_USER_ID"));
9
- if (Number.isNaN(allowedUserId)) {
10
- throw new Error("ALLOWED_USER_ID must be a number");
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
- allowedUserId,
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) {
@@ -26,7 +26,19 @@ export class ClaudeCodeEngine {
26
26
  if (resp.decision === "allow") {
27
27
  return { behavior: "allow", updatedInput: input };
28
28
  }
29
- return { behavior: "deny", message: "User denied" };
29
+ const denyMessage = resp.message
30
+ ? `User denied this action with feedback: ${resp.message}`
31
+ : "User denied";
32
+ // Deny with note: let the model read the feedback and adjust.
33
+ // Plain deny: interrupt to stop the turn immediately.
34
+ // (The SDK has a bug where interrupt causes an unhandled rejection —
35
+ // handleControlRequest writes to stdin after the subprocess exits.
36
+ // Suppressed by the unhandledRejection handler in index.ts.)
37
+ return {
38
+ behavior: "deny",
39
+ message: denyMessage,
40
+ interrupt: !resp.message,
41
+ };
30
42
  };
31
43
  const q = query({
32
44
  prompt,
@@ -38,6 +50,15 @@ export class ClaudeCodeEngine {
38
50
  canUseTool,
39
51
  abortController,
40
52
  settingSources: ["user", "project", "local"],
53
+ ...(appendSystemPrompt
54
+ ? {
55
+ systemPrompt: {
56
+ type: "preset",
57
+ preset: "claude_code",
58
+ append: appendSystemPrompt,
59
+ },
60
+ }
61
+ : {}),
41
62
  },
42
63
  });
43
64
  let resultSessionId;
@@ -112,10 +133,15 @@ export class ClaudeCodeEngine {
112
133
  }
113
134
  }
114
135
  catch (err) {
115
- yield {
116
- type: "error",
117
- message: err instanceof Error ? err.message : String(err),
118
- };
136
+ if (err instanceof Error && err.name === "AbortError") {
137
+ log.info("[sdk] turn aborted");
138
+ }
139
+ else {
140
+ yield {
141
+ type: "error",
142
+ message: err instanceof Error ? err.message : String(err),
143
+ };
144
+ }
119
145
  }
120
146
  if (resultSessionId) {
121
147
  yield { type: "done", sessionId: resultSessionId };
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.allowedUserId);
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
  }
@@ -5,16 +5,21 @@ 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 busyChats;
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
+ /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
24
+ private readDefaultPrompt;
20
25
  }
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import log from "./logger.js";
2
4
  import { formatToolUse, formatToolResult } from "./format.js";
3
5
  export class Orchestrator {
@@ -5,12 +7,22 @@ export class Orchestrator {
5
7
  engine;
6
8
  workspaceStore;
7
9
  permissionMode;
8
- busyChats = new Set();
10
+ defaultPromptPath;
11
+ chats = new Map();
9
12
  constructor(opts) {
10
13
  this.channel = opts.channel;
11
14
  this.engine = opts.engine;
12
15
  this.workspaceStore = opts.workspaceStore;
13
16
  this.permissionMode = opts.permissionMode;
17
+ this.defaultPromptPath = opts.defaultPromptPath;
18
+ }
19
+ chat(chatId) {
20
+ let s = this.chats.get(chatId);
21
+ if (!s) {
22
+ s = { busy: false, abort: null };
23
+ this.chats.set(chatId, s);
24
+ }
25
+ return s;
14
26
  }
15
27
  async start() {
16
28
  this.channel.on("message", (msg) => {
@@ -32,7 +44,8 @@ export class Orchestrator {
32
44
  }
33
45
  async handleMessage(msg) {
34
46
  try {
35
- log.info(`[msg] ${msg.text.slice(0, 80)}`);
47
+ log.info(`[msg] ${msg.user.name} (${msg.user.id}) ${msg.text.slice(0, 80)}`);
48
+ const state = this.chat(msg.chatId);
36
49
  // /new — reset session
37
50
  if (msg.text === "/new") {
38
51
  const ws = this.workspaceStore.byChat(msg.chatId);
@@ -45,37 +58,60 @@ export class Orchestrator {
45
58
  await this.channel.sendMessage(msg.chatId, "Session cleared.");
46
59
  return;
47
60
  }
48
- // Reject during active turn (per-workspace)
49
- if (this.busyChats.has(msg.chatId)) {
50
- log.info("[msg] rejected (busy)");
51
- await this.channel.sendMessage(msg.chatId, "Still working on the previous message...");
61
+ // /cancel — abort the running turn
62
+ if (msg.text === "/cancel") {
63
+ if (state.abort) {
64
+ state.abort.abort();
65
+ log.info("[cmd] turn cancelled");
66
+ }
67
+ else {
68
+ await this.channel.sendMessage(msg.chatId, "Nothing to cancel.");
69
+ }
52
70
  return;
53
71
  }
54
- this.busyChats.add(msg.chatId);
72
+ // Workspace lookup — guard all turn logic below
55
73
  const ws = this.workspaceStore.byChat(msg.chatId);
56
74
  if (!ws) {
57
75
  log.info("[msg] no workspace for chat %s", msg.chatId);
58
- this.busyChats.delete(msg.chatId);
59
76
  await this.channel.sendMessage(msg.chatId, "No workspace linked to this group.");
60
77
  return;
61
78
  }
79
+ // Reject during active turn (per-chat)
80
+ if (state.busy) {
81
+ log.info("[msg] rejected (busy)");
82
+ await this.channel.sendMessage(msg.chatId, "Still working on the previous message...");
83
+ return;
84
+ }
85
+ state.busy = true;
86
+ const abort = new AbortController();
87
+ state.abort = abort;
62
88
  log.info(`[turn] start session=${ws.current_session_id ?? "new"} cwd=${ws.cwd}`);
63
89
  await this.channel.setTyping(msg.chatId, true);
90
+ // Append default workspace CLAUDE.md to non-default workspace sessions
91
+ const isDefaultWorkspace = ws.cwd === path.dirname(this.defaultPromptPath);
92
+ const appendSystemPrompt = isDefaultWorkspace ? undefined : this.readDefaultPrompt();
93
+ // In group workspaces, prepend sender identity so the engine knows who's talking
94
+ const sender = msg.user.handle ? `${msg.user.name} (@${msg.user.handle})` : msg.user.name;
95
+ const prompt = isDefaultWorkspace ? msg.text : `[${sender}]: ${msg.text}`;
64
96
  try {
65
97
  for await (const event of this.engine.runTurn({
66
98
  sessionId: ws.current_session_id,
67
99
  cwd: ws.cwd,
68
- prompt: msg.text,
100
+ prompt,
69
101
  permissionMode: this.permissionMode,
102
+ appendSystemPrompt,
103
+ signal: abort.signal,
70
104
  onPermissionRequest: async (req) => {
71
105
  log.info(`[perm] ${req.toolName}`);
72
- const resp = await this.channel.sendInteractive(msg.chatId, `Allow ${req.toolName}?`, [
106
+ const resp = await this.channel.sendInteractive(msg.chatId, `Allow ${req.toolName}?\n${req.description}`, [
73
107
  { label: "Allow", value: "allow" },
74
108
  { label: "Deny", value: "deny" },
109
+ { label: "Deny+Note", value: "deny", requestText: true },
75
110
  ]);
76
- log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}`);
111
+ log.info(`[perm] ${req.toolName} → ${resp.value || "timeout"}${resp.text ? ` "${resp.text}"` : ""}`);
77
112
  return {
78
113
  decision: resp.value === "allow" ? "allow" : "deny",
114
+ message: resp.text,
79
115
  };
80
116
  },
81
117
  })) {
@@ -83,8 +119,13 @@ export class Orchestrator {
83
119
  }
84
120
  }
85
121
  finally {
86
- this.busyChats.delete(msg.chatId);
122
+ const cancelled = abort.signal.aborted;
123
+ state.busy = false;
124
+ state.abort = null;
87
125
  await this.channel.setTyping(msg.chatId, false);
126
+ if (cancelled) {
127
+ await this.channel.sendMessage(msg.chatId, "Turn cancelled.");
128
+ }
88
129
  }
89
130
  }
90
131
  catch (err) {
@@ -131,5 +172,15 @@ export class Orchestrator {
131
172
  break;
132
173
  }
133
174
  }
175
+ /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
176
+ readDefaultPrompt() {
177
+ try {
178
+ const content = fs.readFileSync(this.defaultPromptPath, "utf-8").trim();
179
+ return content || undefined;
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ }
134
185
  }
135
186
  //# sourceMappingURL=orchestrator.js.map
package/dist/types.d.ts CHANGED
@@ -17,9 +17,11 @@ export interface Channel {
17
17
  export interface Button {
18
18
  label: string;
19
19
  value: string;
20
+ requestText?: boolean;
20
21
  }
21
22
  export interface ButtonResponse {
22
23
  value: string;
24
+ text?: string;
23
25
  }
24
26
  export interface SendMessageOpts {
25
27
  parseMode?: "MarkdownV2" | "HTML";
@@ -34,6 +36,7 @@ export interface RunTurnOpts {
34
36
  prompt: string;
35
37
  permissionMode: PermissionMode;
36
38
  onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResponse>;
39
+ appendSystemPrompt?: string;
37
40
  signal?: AbortSignal;
38
41
  }
39
42
  export interface PermissionRequest {
@@ -44,6 +47,7 @@ export interface PermissionRequest {
44
47
  }
45
48
  export interface PermissionResponse {
46
49
  decision: "allow" | "deny";
50
+ message?: string;
47
51
  }
48
52
  export type EngineEvent = {
49
53
  type: "text";
@@ -73,8 +77,14 @@ export interface Workspace {
73
77
  chat_id: string;
74
78
  current_session_id: string | null;
75
79
  }
80
+ export interface UserInfo {
81
+ id: string;
82
+ name: string;
83
+ handle?: string;
84
+ }
76
85
  export interface InboundMessage {
77
86
  chatId: string;
87
+ user: UserInfo;
78
88
  text: string;
79
89
  }
80
90
  export type { PermissionMode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clearclaw",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clearclaw": "dist/index.js"
@@ -11,7 +11,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "start": "tsx src/index.ts",
14
- "dev": "tsc --watch & nodemon --watch dist --exec 'node dist/index.js'",
14
+ "dev": "tsx --watch src/index.ts",
15
+ "dev:relay": "nodemon --watch dist --exec 'node dist/index.js'",
15
16
  "build": "tsc",
16
17
  "check": "tsc --noEmit"
17
18
  },