clearclaw 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/format.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Formatting utilities for Telegram display.
3
3
  *
4
- * Uses the `diff` library for unified diffs in Edit permission prompts.
4
+ * Uses the `diff` library for interleaved diffs in Edit permission prompts.
5
5
  */
6
- import { createTwoFilesPatch } from "diff";
6
+ import { diffLines } from "diff";
7
+ import { isKnownToolCall } from "./types.js";
7
8
  const MAX_LINES = 200;
8
9
  const MAX_STATUS_LEN = 60;
9
10
  /**
@@ -24,82 +25,82 @@ export function formatToolCallSummary(toolCalls) {
24
25
  * Short one-liner for the rolling tool message. Includes key detail
25
26
  * but truncated to stay compact.
26
27
  */
27
- export function formatToolStatusLine(toolName, input) {
28
- const full = `🔧 ${formatToolStatus(toolName, input)}`;
28
+ export function formatToolStatusLine(tool) {
29
+ const full = `🔧 ${formatToolStatus(tool)}`;
29
30
  if (full.length <= MAX_STATUS_LEN)
30
31
  return full;
31
32
  return `${full.slice(0, MAX_STATUS_LEN - 1)}…`;
32
33
  }
33
34
  /**
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.
35
+ * Format a permission prompt for display.
36
+ * Switches on action type: edit shows diff, write shows preview,
37
+ * execute shows command, others show available detail.
36
38
  */
37
- export function formatPermissionPrompt(toolName, input, description) {
38
- if (toolName === "Edit") {
39
- const edit = input;
40
- const patch = createTwoFilesPatch(`a/${edit.file_path}`, `b/${edit.file_path}`, edit.old_string, edit.new_string, "", "", { context: 3 });
41
- const lines = patch.split("\n");
42
- const start = lines.findIndex((l) => l.startsWith("---"));
43
- const diffLines = start >= 0 ? lines.slice(start) : lines;
44
- const body = truncateLines(diffLines, MAX_LINES);
45
- return `🔐 Allow Edit? ${edit.file_path}\n\`\`\`diff\n${body}\n\`\`\``;
46
- }
47
- if (toolName === "Write") {
48
- const write = input;
49
- const lines = write.content.split("\n");
50
- const body = truncateLines(lines, MAX_LINES);
51
- return `🔐 Allow Write? ${write.file_path} (${lines.length} lines)\n\`\`\`\n${body}\n\`\`\``;
52
- }
53
- // Non-Edit/Write tools: emoji header + detail in code block
54
- const detail = formatToolDetail(toolName, input);
55
- const body = truncateLines(detail.split("\n"), MAX_LINES);
56
- return `🔐 Allow ${toolName}?\n\`\`\`\n${body}\n\`\`\``;
39
+ export function formatPermissionPrompt(tool) {
40
+ const detail = formatToolDetail(tool);
41
+ if (!detail)
42
+ return `🔐 Allow ${tool.toolName}?`;
43
+ return `🔐 Allow ${tool.toolName}?\n${detail}`;
57
44
  }
58
- function formatToolStatus(toolName, input) {
59
- switch (toolName) {
60
- case "Bash":
61
- return `Bash: ${input.command ?? "(unknown)"}`;
62
- case "Read":
63
- return `Read: ${input.file_path ?? "(unknown)"}`;
64
- case "Edit":
65
- return `Edit: ${input.file_path ?? "(unknown)"}`;
66
- case "Write":
67
- return `Write: ${input.file_path ?? "(unknown)"}`;
68
- case "Glob":
69
- return `Glob: ${input.pattern ?? "(unknown)"}`;
70
- case "Grep":
71
- return `Grep: ${input.pattern ?? "(unknown)"}`;
72
- case "WebFetch":
73
- return `WebFetch: ${input.url ?? "(unknown)"}`;
74
- case "WebSearch":
75
- return `WebSearch: ${input.query ?? "(unknown)"}`;
76
- case "Agent":
77
- return `Agent: ${input.description ?? "(unknown)"}`;
78
- default:
79
- return toolName;
45
+ /** Multi-line detail for permission prompts (diff, content preview, command, etc.). */
46
+ function formatToolDetail(tool) {
47
+ if (!isKnownToolCall(tool))
48
+ return formatUnknownDetail(tool);
49
+ switch (tool.action) {
50
+ case "edit": {
51
+ if (!tool.before && !tool.after)
52
+ return tool.path;
53
+ const lines = formatInterleavedDiff(tool.before, tool.after);
54
+ return `${tool.path}\n\`\`\`\n${truncateLines(lines, MAX_LINES)}\n\`\`\``;
55
+ }
56
+ case "write": {
57
+ const lines = tool.content.split("\n");
58
+ return `${tool.path} (${lines.length} lines)\n\`\`\`\n${truncateLines(lines, MAX_LINES)}\n\`\`\``;
59
+ }
60
+ case "execute":
61
+ return `\`\`\`\n${truncateLines(tool.command.split("\n"), MAX_LINES)}\n\`\`\``;
62
+ case "read":
63
+ return `\`\`\`\n${tool.paths.join("\n")}\n\`\`\``;
64
+ case "search":
65
+ return `\`\`\`\n${tool.pattern}\n\`\`\``;
66
+ case "fetch":
67
+ return `\`\`\`\n${tool.url}\n\`\`\``;
80
68
  }
81
69
  }
82
- /** Extract the key detail for a tool's permission prompt (command, query, URL, etc.). */
83
- function formatToolDetail(toolName, input) {
84
- switch (toolName) {
85
- case "Bash":
86
- return String(input.command ?? "");
87
- case "Read":
88
- case "Edit":
89
- case "Write":
90
- return String(input.file_path ?? "");
91
- case "Glob":
92
- case "Grep":
93
- return String(input.pattern ?? "");
94
- case "WebSearch":
95
- return String(input.query ?? "");
96
- case "WebFetch":
97
- return String(input.url ?? "");
98
- case "Agent":
99
- return String(input.description ?? "");
100
- default:
101
- return JSON.stringify(input, null, 2);
70
+ /** One-liner summary for tool status display. */
71
+ function formatToolStatus(tool) {
72
+ if (!isKnownToolCall(tool)) {
73
+ const summary = formatUnknownSummary(tool);
74
+ return summary ? `${tool.toolName}: ${summary}` : tool.toolName;
102
75
  }
76
+ switch (tool.action) {
77
+ case "edit":
78
+ case "write":
79
+ return `${tool.toolName}: ${tool.path}`;
80
+ case "execute":
81
+ return `${tool.toolName}: ${tool.command}`;
82
+ case "read":
83
+ return `${tool.toolName}: ${tool.paths.join(", ")}`;
84
+ case "search":
85
+ return `${tool.toolName}: ${tool.pattern}`;
86
+ case "fetch":
87
+ return `${tool.toolName}: ${tool.url}`;
88
+ }
89
+ }
90
+ /** Extract displayable info from an unknown tool call's generic fields. */
91
+ function formatUnknownSummary(tool) {
92
+ const parts = [];
93
+ if ("paths" in tool && Array.isArray(tool.paths))
94
+ parts.push(...tool.paths);
95
+ if ("detail" in tool && tool.detail)
96
+ parts.push(String(tool.detail));
97
+ return parts.length > 0 ? parts.join(", ") : null;
98
+ }
99
+ function formatUnknownDetail(tool) {
100
+ const summary = formatUnknownSummary(tool);
101
+ if (!summary)
102
+ return null;
103
+ return `\`\`\`\n${truncateLines(summary.split("\n"), MAX_LINES)}\n\`\`\``;
103
104
  }
104
105
  /**
105
106
  * Relative time string: "just now", "5m ago", "2h ago", "3d ago".
@@ -145,7 +146,7 @@ export function truncateButtonLabel(index, label) {
145
146
  * Format an ExitPlanMode permission prompt.
146
147
  * Renders plan text from the description or input fields.
147
148
  */
148
- export function formatExitPlanMode(input, description) {
149
+ export function formatExitPlanMode(input) {
149
150
  const plan = (typeof input.plan === "string" ? input.plan : null)
150
151
  || JSON.stringify(input, null, 2);
151
152
  const lines = plan.split("\n");
@@ -176,19 +177,42 @@ function truncateLines(lines, max) {
176
177
  const remaining = lines.length - max;
177
178
  return `${shown}\n... (${remaining} more lines)`;
178
179
  }
179
- /**
180
- * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
181
- */
182
- export function formatToolDescription(toolName, input) {
183
- switch (toolName) {
184
- case "Bash":
185
- return `Bash: ${input.command ?? "(unknown command)"}`;
186
- case "Edit":
187
- case "Write":
188
- case "Read":
189
- return `${toolName}: ${input.file_path ?? "(unknown file)"}`;
190
- default:
191
- return `Allow ${toolName}?`;
180
+ /** Build an interleaved diff with clear separator between markers and content. */
181
+ function formatInterleavedDiff(before, after) {
182
+ const changes = diffLines(before, after);
183
+ const lines = [];
184
+ const splitValue = (value) => value.replace(/\n$/, "").split("\n");
185
+ for (let i = 0; i < changes.length; i++) {
186
+ const c = changes[i];
187
+ if (c.removed) {
188
+ const next = changes[i + 1];
189
+ if (next?.added) {
190
+ // Interleave paired removals and additions line by line
191
+ const remLines = splitValue(c.value);
192
+ const addLines = splitValue(next.value);
193
+ const max = Math.max(remLines.length, addLines.length);
194
+ for (let j = 0; j < max; j++) {
195
+ if (j < remLines.length)
196
+ lines.push(`- │ ${remLines[j]}`);
197
+ if (j < addLines.length)
198
+ lines.push(`+ │ ${addLines[j]}`);
199
+ }
200
+ i++; // skip the paired added chunk
201
+ }
202
+ else {
203
+ for (const l of splitValue(c.value))
204
+ lines.push(`- │ ${l}`);
205
+ }
206
+ }
207
+ else if (c.added) {
208
+ for (const l of splitValue(c.value))
209
+ lines.push(`+ │ ${l}`);
210
+ }
211
+ else {
212
+ for (const l of splitValue(c.value))
213
+ lines.push(`· │ ${l}`);
214
+ }
192
215
  }
216
+ return lines;
193
217
  }
194
218
  //# sourceMappingURL=format.js.map
package/dist/index.js CHANGED
@@ -2,13 +2,12 @@
2
2
  import readline from "node:readline";
3
3
  import { Config } from "./config.js";
4
4
  import log, { initLogger } from "./logger.js";
5
- import { ClaudeCodeEngine } from "./engine/claude-code.js";
5
+ import { createEngineMap, ENGINE_NAMES, resolveEnginePath } from "./engine/registry.js";
6
6
  import { TelegramChannel } from "./channel/telegram.js";
7
7
  import { SlackChannel } from "./channel/slack.js";
8
8
  import { Orchestrator } from "./orchestrator.js";
9
9
  async function runDaemon() {
10
10
  const config = new Config().resolve();
11
- config.syncSkills();
12
11
  initLogger(config.logPath);
13
12
  // The Claude Agent SDK has a bug: handleControlRequest() writes to the
14
13
  // subprocess stdin without a .catch(), so aborting during a permission
@@ -41,8 +40,8 @@ async function runDaemon() {
41
40
  log.warn({ err }, "[pairing] failed to send pairing message");
42
41
  });
43
42
  };
44
- const channel = createChannel(config.channel, (userId) => config.isAuthorized(userId), onUnauthorizedDM);
45
- await new Orchestrator({ channel, engine: new ClaudeCodeEngine(), config }).start();
43
+ const channel = createChannel(config.channel, (userId) => config.isAuthorized(userId), onUnauthorizedDM, () => config.listAuthorizedUserIds());
44
+ await new Orchestrator({ channel, engines: createEngineMap(config.engines), config }).start();
46
45
  }
47
46
  async function runApprove(args) {
48
47
  const code = args[0];
@@ -56,7 +55,9 @@ async function runApprove(args) {
56
55
  console.error("Invalid or expired pairing code.");
57
56
  process.exit(1);
58
57
  }
59
- config.approveUser(pairing.userId, pairing.userName, pairing.chatId);
58
+ config.approveUser(pairing.userId, pairing.userName);
59
+ // Pairings only originate from a root DM, so this chat is home's destination.
60
+ config.connectHomeWorkspace(pairing.chatId);
60
61
  console.log(`Approved ${pairing.userName} (${pairing.userId})`);
61
62
  }
62
63
  const SETUP_TIMEOUT_MS = 5 * 60 * 1000;
@@ -87,15 +88,36 @@ async function runSetup() {
87
88
  throw new Error("Bot token required.");
88
89
  channelConfig = { type: "telegram", botToken };
89
90
  }
91
+ let engineName;
92
+ let resolvedPath;
93
+ const engineList = ENGINE_NAMES.join("/");
94
+ while (true) {
95
+ engineName = (await ask(`Default engine [${engineList}]: `)).trim().toLowerCase();
96
+ if (!ENGINE_NAMES.includes(engineName)) {
97
+ console.log(` Please enter one of: ${engineList}`);
98
+ continue;
99
+ }
100
+ try {
101
+ resolvedPath = resolveEnginePath(engineName);
102
+ break;
103
+ }
104
+ catch (err) {
105
+ console.log(` ${err.message}`);
106
+ }
107
+ }
90
108
  const config = new Config();
91
109
  config.setChannel(channelConfig);
110
+ config.setEngines([
111
+ { name: engineName, default: true, path: resolvedPath },
112
+ ]);
92
113
  console.log("Saved to ~/.clearclaw/config.json\n");
93
114
  config.resolve();
115
+ config.ensureHomeWorkspace();
94
116
  let resolveFirstDM;
95
117
  const firstDM = new Promise((r) => {
96
118
  resolveFirstDM = r;
97
119
  });
98
- channel = createChannel(config.channel, (userId) => config.isAuthorized(userId), (chatId, user) => resolveFirstDM({ chatId, user }));
120
+ channel = createChannel(config.channel, (userId) => config.isAuthorized(userId), (chatId, user) => resolveFirstDM({ chatId, user }), () => config.listAuthorizedUserIds());
99
121
  console.log("Connecting...");
100
122
  await channel.connect();
101
123
  console.log("Connected! Waiting for a DM — message the bot from your chat app.\n");
@@ -118,7 +140,9 @@ async function runSetup() {
118
140
  await channel.sendMessage(chatId, "Pairing code was invalid. Please try again.");
119
141
  return;
120
142
  }
121
- config.approveUser(pairing.userId, pairing.userName, pairing.chatId);
143
+ config.approveUser(pairing.userId, pairing.userName);
144
+ // Pairings only originate from a root DM, so this chat is home's destination.
145
+ config.connectHomeWorkspace(pairing.chatId);
122
146
  console.log(` Approved ${pairing.userName} (${pairing.userId})`);
123
147
  await channel.sendMessage(chatId, "You're approved! The bot is being set up — you'll be able to chat soon.");
124
148
  console.log("\nSetup complete! Start the daemon:\n clearclaw daemon\n");
@@ -129,9 +153,9 @@ async function runSetup() {
129
153
  rl.close();
130
154
  }
131
155
  }
132
- function createChannel(ch, isAuthorized, onUnauthorizedDM) {
156
+ function createChannel(ch, isAuthorized, onUnauthorizedDM, authorizedUserIds) {
133
157
  if (ch.type === "slack") {
134
- return new SlackChannel(ch.botToken, ch.appToken, isAuthorized, onUnauthorizedDM);
158
+ return new SlackChannel(ch.botToken, ch.appToken, isAuthorized, onUnauthorizedDM, authorizedUserIds);
135
159
  }
136
160
  return new TelegramChannel(ch.botToken, isAuthorized, onUnauthorizedDM);
137
161
  }
@@ -1,32 +1,58 @@
1
1
  import type { Config } from "./config.js";
2
- import type { Channel, Engine } from "./types.js";
2
+ import type { Channel, Engine, InboundMessage, MessageOrigin } from "./types.js";
3
3
  export interface OrchestratorOpts {
4
4
  channel: Channel;
5
- engine: Engine;
5
+ engines: Map<string, Engine>;
6
6
  config: Config;
7
7
  }
8
8
  export declare class Orchestrator {
9
9
  private channel;
10
- private engine;
10
+ private engines;
11
11
  private config;
12
12
  private chats;
13
- private tasks;
13
+ private creatingWorkspaces;
14
+ private scheduler;
15
+ private planUsage;
14
16
  constructor(opts: OrchestratorOpts);
15
17
  private chat;
18
+ private engineFor;
16
19
  start(): Promise<void>;
17
20
  stop(): Promise<void>;
18
- /** Effective behavior: tasks→assistant, workspace→explicit setting or home→assistant / project→relay. */
21
+ /** Deliver a synthetic message to a named workspace and trigger its turn. */
22
+ deliverToWorkspace(workspaceName: string, origin: MessageOrigin, text: string): boolean;
23
+ /**
24
+ * Everything creation can reject without touching the platform. Runs before the
25
+ * confirmation, so the user is never asked to approve a doomed spawn, and again
26
+ * inside creation, where it is the authoritative check.
27
+ */
28
+ private validateNewWorkspace;
29
+ /** Shared creation path for standalone projects and peers, with optional manual binding. */
30
+ private createWorkspace;
31
+ /** Grouping is best-effort: it never fails a lifecycle operation, but it is never silent either. */
32
+ private groupChat;
33
+ private deliverInitialBrief;
34
+ /** An unbound chat can connect without starting a temporary agent or sharing home's session. */
35
+ private connectWorkspace;
36
+ private peerRuntime;
37
+ /** Effective behavior: Explicit setting or home→assistant / project→relay. */
19
38
  private effectiveBehavior;
20
- /** Enqueue a message and drain — immediately for relay, debounced for assistant/task. */
39
+ /** Permission mode a chat runs in until /mode picks one. */
40
+ private baseMode;
41
+ /** Enqueue a message and drain — immediately for relay, debounced for assistant. */
21
42
  private enqueueMessage;
22
43
  private scheduleDebounce;
23
44
  private processQueuedMessages;
24
45
  private executeTurn;
46
+ private persistSessionId;
47
+ /** Native selection works even when the workspace's engine cannot run. */
48
+ private selectEngine;
25
49
  private routeMessage;
26
50
  private routeEngineEvent;
51
+ private routeTextChunk;
52
+ private publishTextSegment;
53
+ private flushTextSegment;
27
54
  private buildMcpTools;
28
55
  private handlePermission;
29
56
  private updateStatusMessage;
30
- /** Read default workspace CLAUDE.md to append to non-default workspace sessions. */
31
- private readDefaultPrompt;
32
57
  }
58
+ export declare function buildPrompt(messages: InboundMessage[]): string;