clearclaw 0.6.0 → 0.7.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/channel/slack.d.ts +3 -3
- package/dist/channel/slack.js +60 -27
- package/dist/channel/telegram.d.ts +3 -3
- package/dist/channel/telegram.js +30 -12
- package/dist/config.d.ts +13 -5
- package/dist/config.js +41 -33
- package/dist/engine/acp.d.ts +9 -0
- package/dist/engine/acp.js +247 -0
- package/dist/engine/async-queue.d.ts +14 -0
- package/dist/engine/async-queue.js +45 -0
- package/dist/engine/claude-code.d.ts +2 -0
- package/dist/engine/claude-code.js +54 -12
- package/dist/engine/registry.d.ts +16 -0
- package/dist/engine/registry.js +32 -0
- package/dist/format.d.ts +7 -9
- package/dist/format.js +71 -82
- package/dist/index.js +34 -5
- package/dist/orchestrator.d.ts +3 -4
- package/dist/orchestrator.js +99 -41
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +35 -0
- package/dist/tool-handlers.d.ts +1 -1
- package/dist/tool-handlers.js +2 -2
- package/dist/types.d.ts +51 -12
- package/dist/types.js +12 -1
- package/package.json +4 -2
- package/prompts/ONBOARDING.md +56 -0
- package/prompts/SYSTEM.md +164 -0
package/dist/channel/slack.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type { Channel, Button, ButtonResponse, SendFileOpts,
|
|
2
|
+
import type { Channel, Button, ButtonResponse, SendFileOpts, MessageOpts, UserInfo } from "../types.js";
|
|
3
3
|
export declare class SlackChannel extends EventEmitter implements Channel {
|
|
4
4
|
name: string;
|
|
5
5
|
private static readonly EMOJI_TO_SLACK;
|
|
@@ -16,9 +16,9 @@ export declare class SlackChannel extends EventEmitter implements Channel {
|
|
|
16
16
|
connect(): Promise<void>;
|
|
17
17
|
disconnect(): Promise<void>;
|
|
18
18
|
ownsId(chatId: string): boolean;
|
|
19
|
-
sendMessage(chatId: string, text: string, opts?:
|
|
19
|
+
sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
|
|
20
20
|
sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
|
|
21
|
-
editMessage(chatId: string, handle: string, text: string): Promise<void>;
|
|
21
|
+
editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
|
|
22
22
|
deleteMessage(chatId: string, handle: string): Promise<void>;
|
|
23
23
|
pinMessage(chatId: string, handle: string): Promise<void>;
|
|
24
24
|
unpinAllMessages(chatId: string): Promise<void>;
|
package/dist/channel/slack.js
CHANGED
|
@@ -168,36 +168,60 @@ export class SlackChannel extends EventEmitter {
|
|
|
168
168
|
const chunks = splitMessage(text);
|
|
169
169
|
const handles = [];
|
|
170
170
|
const threadTs = opts?.replyToMessageId;
|
|
171
|
+
const plain = opts?.format === "plain";
|
|
172
|
+
/** Post a chunk, with mrkdwn blocks unless plain format requested. */
|
|
173
|
+
const postChunk = async (chunk, extra = {}) => {
|
|
174
|
+
if (plain) {
|
|
175
|
+
const r = await this.app.client.chat.postMessage({ channel, text: chunk, ...extra });
|
|
176
|
+
return r.ts;
|
|
177
|
+
}
|
|
178
|
+
const blocks = mrkdwnBlocks(chunk);
|
|
179
|
+
try {
|
|
180
|
+
const r = await this.app.client.chat.postMessage({ channel, text: chunk, blocks, ...extra });
|
|
181
|
+
return r.ts;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
log.warn("[channel] postMessage failed with mrkdwn, retrying as plain text");
|
|
185
|
+
const r = await this.app.client.chat.postMessage({ channel, text: chunk, ...extra });
|
|
186
|
+
return r.ts;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
171
189
|
// Consume typing placeholder only when not posting as a thread reply
|
|
172
190
|
const consumeTyping = !threadTs && opts?.consumeTyping !== false;
|
|
173
191
|
const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
|
|
174
192
|
if (typingTs && chunks.length > 0) {
|
|
175
193
|
this.typingMessageTs.delete(chatId);
|
|
176
194
|
const firstChunk = chunks.shift();
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
195
|
+
if (plain) {
|
|
196
|
+
try {
|
|
197
|
+
await this.app.client.chat.update({ channel, ts: typingTs, text: firstChunk });
|
|
198
|
+
handles.push(typingTs);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
const ts = await postChunk(firstChunk);
|
|
202
|
+
if (ts)
|
|
203
|
+
handles.push(ts);
|
|
204
|
+
}
|
|
183
205
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
handles.push(
|
|
206
|
+
else {
|
|
207
|
+
const blocks = mrkdwnBlocks(firstChunk);
|
|
208
|
+
try {
|
|
209
|
+
await this.app.client.chat.update({
|
|
210
|
+
channel, ts: typingTs, text: firstChunk, blocks,
|
|
211
|
+
});
|
|
212
|
+
handles.push(typingTs);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
const ts = await postChunk(firstChunk);
|
|
216
|
+
if (ts)
|
|
217
|
+
handles.push(ts);
|
|
218
|
+
}
|
|
191
219
|
}
|
|
192
220
|
}
|
|
193
221
|
for (const chunk of chunks) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
...(threadTs && { thread_ts: threadTs }),
|
|
198
|
-
});
|
|
199
|
-
if (result.ts)
|
|
200
|
-
handles.push(result.ts);
|
|
222
|
+
const ts = await postChunk(chunk, threadTs ? { thread_ts: threadTs } : {});
|
|
223
|
+
if (ts)
|
|
224
|
+
handles.push(ts);
|
|
201
225
|
}
|
|
202
226
|
return handles;
|
|
203
227
|
}
|
|
@@ -304,14 +328,23 @@ export class SlackChannel extends EventEmitter {
|
|
|
304
328
|
}
|
|
305
329
|
return { value: pressed.value };
|
|
306
330
|
}
|
|
307
|
-
async editMessage(chatId, handle, text) {
|
|
331
|
+
async editMessage(chatId, handle, text, opts) {
|
|
308
332
|
const chunk = splitMessage(text)[0];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
333
|
+
if (opts?.format === "plain") {
|
|
334
|
+
await this.app.client.chat.update({
|
|
335
|
+
channel: this.slackId(chatId),
|
|
336
|
+
ts: handle,
|
|
337
|
+
text: chunk,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
await this.app.client.chat.update({
|
|
342
|
+
channel: this.slackId(chatId),
|
|
343
|
+
ts: handle,
|
|
344
|
+
text: chunk,
|
|
345
|
+
blocks: mrkdwnBlocks(chunk),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
315
348
|
}
|
|
316
349
|
async deleteMessage(chatId, handle) {
|
|
317
350
|
await this.app.client.chat.delete({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type { Channel, Button, ButtonResponse, SendFileOpts,
|
|
2
|
+
import type { Channel, Button, ButtonResponse, SendFileOpts, MessageOpts, UserInfo } from "../types.js";
|
|
3
3
|
export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
4
4
|
name: string;
|
|
5
5
|
private bot;
|
|
@@ -14,9 +14,9 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
|
14
14
|
connect(): Promise<void>;
|
|
15
15
|
disconnect(): Promise<void>;
|
|
16
16
|
ownsId(chatId: string): boolean;
|
|
17
|
-
sendMessage(chatId: string, text: string, opts?:
|
|
17
|
+
sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
|
|
18
18
|
sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
|
|
19
|
-
editMessage(chatId: string, handle: string, text: string): Promise<void>;
|
|
19
|
+
editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
|
|
20
20
|
deleteMessage(chatId: string, handle: string): Promise<void>;
|
|
21
21
|
pinMessage(chatId: string, handle: string): Promise<void>;
|
|
22
22
|
unpinAllMessages(chatId: string): Promise<void>;
|
package/dist/channel/telegram.js
CHANGED
|
@@ -110,24 +110,31 @@ export class TelegramChannel extends EventEmitter {
|
|
|
110
110
|
const numId = this.numericId(chatId);
|
|
111
111
|
const chunks = splitMessage(text);
|
|
112
112
|
const handles = [];
|
|
113
|
+
const plain = opts?.format === "plain";
|
|
113
114
|
let firstChunk = true;
|
|
114
115
|
for (const chunk of chunks) {
|
|
115
116
|
const replyParams = firstChunk && opts?.replyToMessageId
|
|
116
117
|
? { reply_parameters: { message_id: Number(opts.replyToMessageId) } }
|
|
117
118
|
: {};
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const sent = await this.bot.api.sendMessage(numId, rendered, {
|
|
121
|
-
parse_mode: "MarkdownV2",
|
|
122
|
-
...replyParams,
|
|
123
|
-
});
|
|
124
|
-
handles.push(String(sent.message_id));
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
|
|
119
|
+
if (plain) {
|
|
128
120
|
const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
|
|
129
121
|
handles.push(String(sent.message_id));
|
|
130
122
|
}
|
|
123
|
+
else {
|
|
124
|
+
const rendered = telegramifyMarkdown(chunk, "escape");
|
|
125
|
+
try {
|
|
126
|
+
const sent = await this.bot.api.sendMessage(numId, rendered, {
|
|
127
|
+
parse_mode: "MarkdownV2",
|
|
128
|
+
...replyParams,
|
|
129
|
+
});
|
|
130
|
+
handles.push(String(sent.message_id));
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
|
|
134
|
+
const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
|
|
135
|
+
handles.push(String(sent.message_id));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
131
138
|
firstChunk = false;
|
|
132
139
|
}
|
|
133
140
|
return handles;
|
|
@@ -212,9 +219,20 @@ export class TelegramChannel extends EventEmitter {
|
|
|
212
219
|
}
|
|
213
220
|
return { value: pressed.value };
|
|
214
221
|
}
|
|
215
|
-
async editMessage(chatId, handle, text) {
|
|
222
|
+
async editMessage(chatId, handle, text, opts) {
|
|
216
223
|
const numId = this.numericId(chatId);
|
|
217
|
-
|
|
224
|
+
if (opts?.format === "plain") {
|
|
225
|
+
await this.bot.api.editMessageText(numId, Number(handle), text);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const rendered = telegramifyMarkdown(text, "escape");
|
|
229
|
+
try {
|
|
230
|
+
await this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" });
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
await this.bot.api.editMessageText(numId, Number(handle), text);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
218
236
|
}
|
|
219
237
|
async deleteMessage(chatId, handle) {
|
|
220
238
|
const numId = this.numericId(chatId);
|
package/dist/config.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface PendingPairing {
|
|
|
21
21
|
chatId: string;
|
|
22
22
|
expiresAt: number;
|
|
23
23
|
}
|
|
24
|
+
export interface EngineEntry {
|
|
25
|
+
name: string;
|
|
26
|
+
default?: boolean;
|
|
27
|
+
path: string;
|
|
28
|
+
}
|
|
24
29
|
export declare function resolveDataDir(): string;
|
|
25
30
|
/**
|
|
26
31
|
* Unified config: env var resolution + persistent store backed by config.json.
|
|
@@ -33,20 +38,26 @@ export declare function resolveDataDir(): string;
|
|
|
33
38
|
*/
|
|
34
39
|
export declare class Config {
|
|
35
40
|
channel?: ChannelConfig;
|
|
41
|
+
engines: Record<string, EngineEntry>;
|
|
42
|
+
defaultEngine: string;
|
|
36
43
|
permissionMode: PermissionMode;
|
|
37
44
|
private envUserIds;
|
|
38
|
-
readonly
|
|
45
|
+
readonly homeWorkspacePath: string;
|
|
39
46
|
readonly filesPath: string;
|
|
40
47
|
readonly logPath: string;
|
|
48
|
+
readonly instructionsDir: string;
|
|
49
|
+
readonly frameworkPromptDir: string;
|
|
41
50
|
private readonly dataDir;
|
|
42
51
|
private readonly filePath;
|
|
43
52
|
constructor();
|
|
44
53
|
/** Resolve channel + env vars. Throws if no channel config found. */
|
|
45
54
|
resolve(): this;
|
|
55
|
+
private readRaw;
|
|
46
56
|
private read;
|
|
47
57
|
private write;
|
|
48
58
|
getChannel(): ChannelConfig | undefined;
|
|
49
59
|
setChannel(config: ChannelConfig): void;
|
|
60
|
+
setEngines(engines: EngineEntry[]): void;
|
|
50
61
|
hasUser(userId: string): boolean;
|
|
51
62
|
addUser(user: AuthorizedUser): void;
|
|
52
63
|
listUsers(): AuthorizedUser[];
|
|
@@ -61,8 +72,5 @@ export declare class Config {
|
|
|
61
72
|
setSession(name: string, sessionId: string): void;
|
|
62
73
|
clearSession(name: string): void;
|
|
63
74
|
setBehavior(name: string, behavior: "assistant" | "relay"): void;
|
|
64
|
-
|
|
65
|
-
/** Copy bundled skills from the repo's skills/ directory to the home workspace's .claude/skills/. */
|
|
66
|
-
syncSkills(): void;
|
|
67
|
-
approveUser(userId: string, userName: string, chatId: string): void;
|
|
75
|
+
approveUser(userId: string, userName: string): void;
|
|
68
76
|
}
|
package/dist/config.js
CHANGED
|
@@ -28,19 +28,25 @@ export function resolveDataDir() {
|
|
|
28
28
|
export class Config {
|
|
29
29
|
// Resolved settings — populated by resolve()
|
|
30
30
|
channel;
|
|
31
|
+
engines;
|
|
32
|
+
defaultEngine; // derived: name of the engine with default: true
|
|
31
33
|
permissionMode = "default";
|
|
32
34
|
envUserIds = new Set();
|
|
33
35
|
// Derived paths
|
|
34
|
-
|
|
36
|
+
homeWorkspacePath;
|
|
35
37
|
filesPath;
|
|
36
38
|
logPath;
|
|
39
|
+
instructionsDir;
|
|
40
|
+
frameworkPromptDir;
|
|
37
41
|
dataDir = resolveDataDir();
|
|
38
42
|
filePath;
|
|
39
43
|
constructor() {
|
|
40
44
|
this.filePath = path.join(this.dataDir, "config.json");
|
|
41
|
-
this.
|
|
45
|
+
this.homeWorkspacePath = path.join(this.dataDir, "workspace");
|
|
42
46
|
this.filesPath = path.join(this.dataDir, "files");
|
|
43
47
|
this.logPath = path.join(this.dataDir, "clearclaw.log");
|
|
48
|
+
this.instructionsDir = path.join(this.dataDir, "workspace", "instructions");
|
|
49
|
+
this.frameworkPromptDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts");
|
|
44
50
|
}
|
|
45
51
|
/** Resolve channel + env vars. Throws if no channel config found. */
|
|
46
52
|
resolve() {
|
|
@@ -70,26 +76,40 @@ export class Config {
|
|
|
70
76
|
throw new Error(`PERMISSION_MODE must be one of: ${valid.join(", ")}`);
|
|
71
77
|
}
|
|
72
78
|
this.permissionMode = pm;
|
|
79
|
+
const data = this.read();
|
|
80
|
+
if (!data.engines?.length) {
|
|
81
|
+
throw new Error("No engines configured. Run `clearclaw setup`.");
|
|
82
|
+
}
|
|
83
|
+
this.engines = Object.fromEntries(data.engines.map((e) => [e.name, e]));
|
|
84
|
+
const defaultEntry = data.engines.find((e) => e.default);
|
|
85
|
+
if (!defaultEntry) {
|
|
86
|
+
throw new Error("No default engine configured. Run `clearclaw setup`.");
|
|
87
|
+
}
|
|
88
|
+
this.defaultEngine = defaultEntry.name;
|
|
73
89
|
return this;
|
|
74
90
|
}
|
|
75
91
|
// --- File I/O (internal) ---
|
|
76
|
-
|
|
77
|
-
if (!fs.existsSync(this.filePath))
|
|
78
|
-
return {
|
|
79
|
-
}
|
|
92
|
+
readRaw() {
|
|
93
|
+
if (!fs.existsSync(this.filePath))
|
|
94
|
+
return {};
|
|
80
95
|
try {
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
channel: raw.channel,
|
|
84
|
-
authorizedUsers: raw.authorizedUsers ?? [],
|
|
85
|
-
pendingPairings: raw.pendingPairings ?? [],
|
|
86
|
-
workspaces: raw.workspaces ?? [],
|
|
87
|
-
};
|
|
96
|
+
return JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
|
|
88
97
|
}
|
|
89
98
|
catch {
|
|
90
|
-
return {
|
|
99
|
+
return {};
|
|
91
100
|
}
|
|
92
101
|
}
|
|
102
|
+
read() {
|
|
103
|
+
const raw = this.readRaw();
|
|
104
|
+
return {
|
|
105
|
+
...raw,
|
|
106
|
+
channel: raw.channel,
|
|
107
|
+
engines: raw.engines,
|
|
108
|
+
authorizedUsers: (raw.authorizedUsers ?? []),
|
|
109
|
+
pendingPairings: (raw.pendingPairings ?? []),
|
|
110
|
+
workspaces: (raw.workspaces ?? []),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
93
113
|
write(data) {
|
|
94
114
|
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
95
115
|
}
|
|
@@ -102,6 +122,12 @@ export class Config {
|
|
|
102
122
|
data.channel = config;
|
|
103
123
|
this.write(data);
|
|
104
124
|
}
|
|
125
|
+
// --- Engines ---
|
|
126
|
+
setEngines(engines) {
|
|
127
|
+
const data = this.read();
|
|
128
|
+
data.engines = engines;
|
|
129
|
+
this.write(data);
|
|
130
|
+
}
|
|
105
131
|
// --- Authorized users ---
|
|
106
132
|
hasUser(userId) {
|
|
107
133
|
return this.read().authorizedUsers.some((u) => u.id === userId);
|
|
@@ -199,26 +225,8 @@ export class Config {
|
|
|
199
225
|
}
|
|
200
226
|
}
|
|
201
227
|
// --- Approval ---
|
|
202
|
-
|
|
203
|
-
if (this.workspaceByChat(chatId) || this.workspaceByName("default"))
|
|
204
|
-
return;
|
|
205
|
-
const defaultCwd = path.join(this.dataDir, "workspace");
|
|
206
|
-
fs.mkdirSync(defaultCwd, { recursive: true });
|
|
207
|
-
this.upsertWorkspace({ name: "default", cwd: defaultCwd, chat_id: chatId, current_session_id: null });
|
|
208
|
-
this.syncSkills();
|
|
209
|
-
}
|
|
210
|
-
/** Copy bundled skills from the repo's skills/ directory to the home workspace's .claude/skills/. */
|
|
211
|
-
syncSkills() {
|
|
212
|
-
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
213
|
-
const srcSkills = path.join(pkgRoot, "skills");
|
|
214
|
-
if (!fs.existsSync(srcSkills))
|
|
215
|
-
return;
|
|
216
|
-
const destSkills = path.join(this.dataDir, "workspace", ".claude", "skills");
|
|
217
|
-
fs.cpSync(srcSkills, destSkills, { recursive: true, force: true });
|
|
218
|
-
}
|
|
219
|
-
approveUser(userId, userName, chatId) {
|
|
228
|
+
approveUser(userId, userName) {
|
|
220
229
|
this.addUser({ id: userId, name: userName, approvedAt: Date.now() });
|
|
221
|
-
this.ensureDefaultWorkspace(chatId);
|
|
222
230
|
}
|
|
223
231
|
}
|
|
224
232
|
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SpawnConfig } from "./registry.js";
|
|
2
|
+
import type { Engine, EngineEvent, RunTurnOpts, SessionInfo } from "../types.js";
|
|
3
|
+
export declare class AcpEngine implements Engine {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
private readonly spawnConfig;
|
|
6
|
+
constructor(name: string, spawnConfig: SpawnConfig);
|
|
7
|
+
runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
|
|
8
|
+
listSessions(_cwd: string): Promise<SessionInfo[]>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
|
|
4
|
+
import log from "../logger.js";
|
|
5
|
+
import { AsyncQueue } from "./async-queue.js";
|
|
6
|
+
export class AcpEngine {
|
|
7
|
+
name;
|
|
8
|
+
spawnConfig;
|
|
9
|
+
constructor(name, spawnConfig) {
|
|
10
|
+
this.name = name;
|
|
11
|
+
this.spawnConfig = spawnConfig;
|
|
12
|
+
}
|
|
13
|
+
async *runTurn(opts) {
|
|
14
|
+
const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, signal, } = opts;
|
|
15
|
+
let proc;
|
|
16
|
+
const queue = new AsyncQueue();
|
|
17
|
+
const toolCalls = {};
|
|
18
|
+
// tool_call events carry content that requestPermission lacks — cache by ID
|
|
19
|
+
const pendingTools = new Map();
|
|
20
|
+
try {
|
|
21
|
+
proc = spawnAgent(this.spawnConfig);
|
|
22
|
+
// Log stderr for debugging
|
|
23
|
+
proc.stderr?.on("data", (chunk) => {
|
|
24
|
+
log.debug("[acp:%s] %s", this.name, chunk.toString().trim());
|
|
25
|
+
});
|
|
26
|
+
// If the process dies unexpectedly, close the queue
|
|
27
|
+
proc.on("exit", (code) => {
|
|
28
|
+
log.info("[acp:%s] process exited with code %d", this.name, code ?? -1);
|
|
29
|
+
queue.close();
|
|
30
|
+
});
|
|
31
|
+
// Build Client — sessionUpdate pushes to queue after session setup
|
|
32
|
+
let live = false;
|
|
33
|
+
const client = {
|
|
34
|
+
requestPermission: async (params) => {
|
|
35
|
+
return handlePermission(params, permissionMode, onPermissionRequest, pendingTools);
|
|
36
|
+
},
|
|
37
|
+
sessionUpdate: async (notification) => {
|
|
38
|
+
if (!live)
|
|
39
|
+
return; // Suppress replay events from loadSession
|
|
40
|
+
const event = mapSessionUpdate(notification, toolCalls, pendingTools);
|
|
41
|
+
if (event)
|
|
42
|
+
queue.push(event);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
// Create ACP connection over ndjson stdio
|
|
46
|
+
const stream = ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout));
|
|
47
|
+
const conn = new ClientSideConnection((_agent) => client, stream);
|
|
48
|
+
// Initialize and read agent capabilities
|
|
49
|
+
const initResponse = await conn.initialize({
|
|
50
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
51
|
+
clientInfo: { name: "clearclaw", version: "0.4.0" },
|
|
52
|
+
clientCapabilities: {},
|
|
53
|
+
});
|
|
54
|
+
const supportsImages = initResponse.agentCapabilities?.promptCapabilities?.image ?? false;
|
|
55
|
+
// Create or resume session
|
|
56
|
+
let acpSessionId;
|
|
57
|
+
if (sessionId) {
|
|
58
|
+
await conn.loadSession({ sessionId, cwd, mcpServers: [] });
|
|
59
|
+
acpSessionId = sessionId;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const newSession = await conn.newSession({ cwd, mcpServers: [] });
|
|
63
|
+
acpSessionId = newSession.sessionId;
|
|
64
|
+
}
|
|
65
|
+
// Now start accepting live events
|
|
66
|
+
live = true;
|
|
67
|
+
// Wire cancellation
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
conn.cancel({ sessionId: acpSessionId }).catch(() => { });
|
|
70
|
+
};
|
|
71
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
72
|
+
// Build prompt content blocks: text + any image attachments
|
|
73
|
+
const promptBlocks = [
|
|
74
|
+
{ type: "text", text: textPrompt },
|
|
75
|
+
];
|
|
76
|
+
const imageAttachments = attachments?.filter((a) => a.mimeType.startsWith("image/")) ?? [];
|
|
77
|
+
if (imageAttachments.length > 0) {
|
|
78
|
+
if (supportsImages) {
|
|
79
|
+
for (const att of imageAttachments) {
|
|
80
|
+
promptBlocks.push({ type: "image", data: att.buffer.toString("base64"), mimeType: att.mimeType });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
log.warn("[acp:%s] dropping %d image attachment(s) — agent does not support images", this.name, imageAttachments.length);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Send prompt — resolves when turn completes
|
|
88
|
+
const promptResponse = conn.prompt({
|
|
89
|
+
sessionId: acpSessionId,
|
|
90
|
+
prompt: promptBlocks,
|
|
91
|
+
});
|
|
92
|
+
// When prompt completes, push done event and close queue
|
|
93
|
+
promptResponse
|
|
94
|
+
.then(() => {
|
|
95
|
+
queue.push({
|
|
96
|
+
type: "done",
|
|
97
|
+
sessionId: acpSessionId,
|
|
98
|
+
stats: { model: null, contextUsed: 0, contextWindow: 0, toolCalls },
|
|
99
|
+
});
|
|
100
|
+
queue.close();
|
|
101
|
+
})
|
|
102
|
+
.catch((err) => {
|
|
103
|
+
queue.push({
|
|
104
|
+
type: "error",
|
|
105
|
+
message: err instanceof Error ? err.message : String(err),
|
|
106
|
+
});
|
|
107
|
+
queue.close();
|
|
108
|
+
});
|
|
109
|
+
// Yield events as they arrive
|
|
110
|
+
yield* queue;
|
|
111
|
+
// Cleanup
|
|
112
|
+
signal?.removeEventListener("abort", onAbort);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
116
|
+
yield {
|
|
117
|
+
type: "error",
|
|
118
|
+
message: err instanceof Error ? err.message : String(err),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
queue.close();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (proc && !proc.killed) {
|
|
125
|
+
proc.kill();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async listSessions(_cwd) {
|
|
130
|
+
// Fast follow: spawn agent process and call conn.listSessions()
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// --- Helpers ---
|
|
135
|
+
function spawnAgent(config) {
|
|
136
|
+
const proc = spawn(config.command, config.args, {
|
|
137
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
138
|
+
});
|
|
139
|
+
proc.on("error", (err) => {
|
|
140
|
+
log.error({ err }, "[acp] failed to spawn %s", config.command);
|
|
141
|
+
});
|
|
142
|
+
if (!proc.stdin || !proc.stdout) {
|
|
143
|
+
throw new Error(`Failed to spawn ${config.command} — stdin/stdout not available`);
|
|
144
|
+
}
|
|
145
|
+
return proc;
|
|
146
|
+
}
|
|
147
|
+
function mapSessionUpdate(notification, toolCalls, pendingTools) {
|
|
148
|
+
const update = notification.update;
|
|
149
|
+
switch (update.sessionUpdate) {
|
|
150
|
+
case "agent_message_chunk": {
|
|
151
|
+
const content = update.content;
|
|
152
|
+
if (content.type === "text") {
|
|
153
|
+
return { type: "text_chunk", text: content.text };
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
case "tool_call": {
|
|
158
|
+
const kind = update.kind ?? "tool";
|
|
159
|
+
toolCalls[kind] = (toolCalls[kind] ?? 0) + 1;
|
|
160
|
+
const tool = acpToolCall(update.title, update.toolCallId, kind, update.locations, update.content);
|
|
161
|
+
pendingTools.set(update.toolCallId, tool);
|
|
162
|
+
return { type: "tool_use", tool };
|
|
163
|
+
}
|
|
164
|
+
case "tool_call_update":
|
|
165
|
+
return null;
|
|
166
|
+
default:
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Map ACP tool call fields to ToolCall. */
|
|
171
|
+
function acpToolCall(title, toolCallId, kind, locations, content) {
|
|
172
|
+
const toolName = title;
|
|
173
|
+
const toolUseId = toolCallId;
|
|
174
|
+
const paths = locations?.map((l) => l.line ? `${l.path}:${l.line}` : l.path);
|
|
175
|
+
switch (kind) {
|
|
176
|
+
case "edit": {
|
|
177
|
+
const diff = findDiff(content);
|
|
178
|
+
return {
|
|
179
|
+
action: "edit", toolName, toolUseId,
|
|
180
|
+
path: diff?.path ?? paths?.[0] ?? "",
|
|
181
|
+
before: diff?.oldText ?? "",
|
|
182
|
+
after: diff?.newText ?? "",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
case "write": {
|
|
186
|
+
const diff = findDiff(content);
|
|
187
|
+
return {
|
|
188
|
+
action: "write", toolName, toolUseId,
|
|
189
|
+
path: diff?.path ?? paths?.[0] ?? "",
|
|
190
|
+
content: diff?.newText ?? "",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
case "read":
|
|
194
|
+
return { action: "read", toolName, toolUseId, paths: paths ?? [] };
|
|
195
|
+
case "execute":
|
|
196
|
+
return { action: "execute", toolName, toolUseId, command: title };
|
|
197
|
+
case "search":
|
|
198
|
+
return { action: "search", toolName, toolUseId, pattern: title, paths };
|
|
199
|
+
case "fetch":
|
|
200
|
+
return { action: "fetch", toolName, toolUseId, url: title };
|
|
201
|
+
default: {
|
|
202
|
+
const detail = findTextContent(content);
|
|
203
|
+
return { action: kind, toolName, toolUseId, paths, detail };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Find the first diff content block from ACP tool call content. */
|
|
208
|
+
function findDiff(content) {
|
|
209
|
+
const entry = content?.find((c) => c.type === "diff");
|
|
210
|
+
return entry?.type === "diff" ? entry : undefined;
|
|
211
|
+
}
|
|
212
|
+
/** Extract text from the first content block. */
|
|
213
|
+
function findTextContent(content) {
|
|
214
|
+
const entry = content?.find((c) => c.type === "content");
|
|
215
|
+
if (entry?.type === "content" && entry.content.type === "text")
|
|
216
|
+
return entry.content.text;
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
async function handlePermission(params, permissionMode, onPermissionRequest, pendingTools) {
|
|
220
|
+
const allowOption = params.options.find((o) => o.kind === "allow_once");
|
|
221
|
+
const rejectOption = params.options.find((o) => o.kind === "reject_once");
|
|
222
|
+
// Auto-approve in bypass mode
|
|
223
|
+
if (permissionMode === "bypassPermissions" || permissionMode === "dontAsk") {
|
|
224
|
+
return {
|
|
225
|
+
outcome: allowOption
|
|
226
|
+
? { outcome: "selected", optionId: allowOption.optionId }
|
|
227
|
+
: { outcome: "cancelled" },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Use cached tool_call data (has content/locations) over the sparse permission request
|
|
231
|
+
const toolCall = params.toolCall;
|
|
232
|
+
const tool = pendingTools.get(toolCall.toolCallId)
|
|
233
|
+
?? acpToolCall(toolCall.title ?? "Unknown tool", toolCall.toolCallId, toolCall.kind ?? "other", toolCall.locations, toolCall.content);
|
|
234
|
+
const resp = await onPermissionRequest(tool);
|
|
235
|
+
if (resp.decision === "allow" && allowOption) {
|
|
236
|
+
return {
|
|
237
|
+
outcome: { outcome: "selected", optionId: allowOption.optionId },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (rejectOption) {
|
|
241
|
+
return {
|
|
242
|
+
outcome: { outcome: "selected", optionId: rejectOption.optionId },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { outcome: { outcome: "cancelled" } };
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=acp.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges callback-driven event producers to AsyncIterable consumers.
|
|
3
|
+
*
|
|
4
|
+
* Used by AcpEngine to convert ACP's sessionUpdate callbacks into
|
|
5
|
+
* the AsyncIterable<EngineEvent> that ClearClaw's Engine interface requires.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AsyncQueue<T> implements AsyncIterable<T> {
|
|
8
|
+
private buffer;
|
|
9
|
+
private waiting;
|
|
10
|
+
private closed;
|
|
11
|
+
push(value: T): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
14
|
+
}
|