clearclaw 0.7.0 → 0.9.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,6 +3,7 @@ import { Readable, Writable } from "node:stream";
3
3
  import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
4
4
  import log from "../logger.js";
5
5
  import { AsyncQueue } from "./async-queue.js";
6
+ import { serveMcpOverHttp } from "./mcp-http.js";
6
7
  export class AcpEngine {
7
8
  name;
8
9
  spawnConfig;
@@ -13,12 +14,29 @@ export class AcpEngine {
13
14
  async *runTurn(opts) {
14
15
  const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, signal, } = opts;
15
16
  let proc;
17
+ const bridges = [];
16
18
  const queue = new AsyncQueue();
17
19
  const toolCalls = {};
20
+ let contextUsed = 0;
21
+ let contextWindow = 0;
22
+ let model = null;
18
23
  // tool_call events carry content that requestPermission lacks — cache by ID
19
24
  const pendingTools = new Map();
25
+ // ACP 0.16 does not reject pending setup requests when the process exits.
26
+ let stop;
27
+ const stopped = new Promise((_resolve, reject) => { stop = reject; });
28
+ void stopped.catch(() => { });
29
+ let cancelSession;
30
+ const onAbort = () => {
31
+ cancelSession?.();
32
+ stop(new DOMException("Turn cancelled", "AbortError"));
33
+ queue.close();
34
+ };
20
35
  try {
21
- proc = spawnAgent(this.spawnConfig);
36
+ signal?.throwIfAborted();
37
+ signal?.addEventListener("abort", onAbort, { once: true });
38
+ proc = spawnAgent(this.spawnConfig, opts);
39
+ proc.once("error", stop);
22
40
  // Log stderr for debugging
23
41
  proc.stderr?.on("data", (chunk) => {
24
42
  log.debug("[acp:%s] %s", this.name, chunk.toString().trim());
@@ -26,15 +44,35 @@ export class AcpEngine {
26
44
  // If the process dies unexpectedly, close the queue
27
45
  proc.on("exit", (code) => {
28
46
  log.info("[acp:%s] process exited with code %d", this.name, code ?? -1);
47
+ stop(new Error(`ACP process exited during session setup (${code ?? "signal"})`));
29
48
  queue.close();
30
49
  });
31
50
  // Build Client — sessionUpdate pushes to queue after session setup
32
51
  let live = false;
33
52
  const client = {
53
+ // Newer adapters send optional auth/status extensions. Ignore unsupported
54
+ // notifications instead of letting the SDK print account metadata.
55
+ extNotification: async () => { },
34
56
  requestPermission: async (params) => {
35
57
  return handlePermission(params, permissionMode, onPermissionRequest, pendingTools);
36
58
  },
37
59
  sessionUpdate: async (notification) => {
60
+ if (notification.update.sessionUpdate === "config_option_update") {
61
+ model = modelConfigOption(notification.update.configOptions)?.currentValue ?? null;
62
+ return;
63
+ }
64
+ // Usage is session state, including updates sent during session loading.
65
+ if (notification.update.sessionUpdate === "usage_update") {
66
+ contextUsed = notification.update.used;
67
+ contextWindow = notification.update.size;
68
+ // Loaded history is not a fresh account observation.
69
+ if (live) {
70
+ const windows = codexPlanUsage(notification.update._meta);
71
+ if (windows.length)
72
+ queue.push({ type: "plan_usage", windows });
73
+ }
74
+ return;
75
+ }
38
76
  if (!live)
39
77
  return; // Suppress replay events from loadSession
40
78
  const event = mapSessionUpdate(notification, toolCalls, pendingTools);
@@ -46,42 +84,98 @@ export class AcpEngine {
46
84
  const stream = ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout));
47
85
  const conn = new ClientSideConnection((_agent) => client, stream);
48
86
  // 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
- });
87
+ const initResponse = await Promise.race([conn.initialize({
88
+ protocolVersion: PROTOCOL_VERSION,
89
+ clientInfo: { name: "clearclaw", version: "0.4.0" },
90
+ clientCapabilities: {},
91
+ }), stopped]);
54
92
  const supportsImages = initResponse.agentCapabilities?.promptCapabilities?.image ?? false;
93
+ // Hand the agent every server it advertises a transport for; skip the rest.
94
+ const httpCapable = initResponse.agentCapabilities?.mcpCapabilities?.http ?? false;
95
+ const mcpServers = [];
96
+ for (const [name, server] of Object.entries(opts.mcpServers ?? {})) {
97
+ signal?.throwIfAborted();
98
+ if (server.type === "sdk" || server.type === "http") {
99
+ // ponytail: agents without HTTP MCP run toolless, as every ACP engine did before.
100
+ if (!httpCapable) {
101
+ log.warn("[acp:%s] skipping MCP server %s: agent does not support HTTP", this.name, name);
102
+ continue;
103
+ }
104
+ if (server.type === "sdk") {
105
+ const bridge = await serveMcpOverHttp(name, server);
106
+ bridges.push(bridge);
107
+ mcpServers.push(bridge.config);
108
+ }
109
+ else {
110
+ mcpServers.push({ type: "http", name, url: server.url,
111
+ headers: Object.entries(server.headers ?? {}).map(([name, value]) => ({ name, value })) });
112
+ }
113
+ }
114
+ else if (server.type === "sse") {
115
+ // ClearClaw never configures SSE; no reason to build a passthrough for it.
116
+ log.warn("[acp:%s] skipping MCP server %s: SSE is not supported", this.name, name);
117
+ }
118
+ else {
119
+ mcpServers.push({ name, command: server.command, args: server.args ?? [],
120
+ env: Object.entries(server.env ?? {}).map(([name, value]) => ({ name, value })) });
121
+ }
122
+ }
123
+ signal?.throwIfAborted();
55
124
  // Create or resume session
56
125
  let acpSessionId;
126
+ let configOptions;
57
127
  if (sessionId) {
58
- await conn.loadSession({ sessionId, cwd, mcpServers: [] });
128
+ const loadedSession = await Promise.race([conn.loadSession({ sessionId, cwd, mcpServers }), stopped]);
129
+ configOptions = loadedSession.configOptions;
59
130
  acpSessionId = sessionId;
60
131
  }
61
132
  else {
62
- const newSession = await conn.newSession({ cwd, mcpServers: [] });
133
+ const newSession = await Promise.race([conn.newSession({ cwd, mcpServers }), stopped]);
63
134
  acpSessionId = newSession.sessionId;
135
+ configOptions = newSession.configOptions;
136
+ }
137
+ if (configOptions)
138
+ model = modelConfigOption(configOptions)?.currentValue ?? null;
139
+ // Persist before model selection so a rejected selection doesn't lose the session.
140
+ yield { type: "session", sessionId: acpSessionId };
141
+ if (opts.model) {
142
+ signal?.throwIfAborted();
143
+ const modelOption = modelConfigOption(configOptions);
144
+ if (!modelOption)
145
+ throw new Error(`${this.name} did not advertise a model selector`);
146
+ const selected = await Promise.race([conn.setSessionConfigOption({
147
+ sessionId: acpSessionId, configId: modelOption.id, value: opts.model,
148
+ }), stopped]);
149
+ model = modelConfigOption(selected.configOptions)?.currentValue ?? null;
64
150
  }
65
151
  // Now start accepting live events
66
152
  live = true;
67
153
  // Wire cancellation
68
- const onAbort = () => {
154
+ cancelSession = () => {
69
155
  conn.cancel({ sessionId: acpSessionId }).catch(() => { });
70
156
  };
71
- signal?.addEventListener("abort", onAbort, { once: true });
157
+ signal?.throwIfAborted();
72
158
  // Build prompt content blocks: text + any image attachments
73
159
  const promptBlocks = [
74
160
  { type: "text", text: textPrompt },
75
161
  ];
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
- }
162
+ for (const att of attachments ?? []) {
163
+ const name = att.filename ?? att.mimeType;
164
+ const isImage = att.mimeType.startsWith("image/");
165
+ if (isImage && supportsImages) {
166
+ promptBlocks.push({ type: "image", data: att.buffer.toString("base64"), mimeType: att.mimeType });
167
+ const label = att.savedAs
168
+ ? `[Attachment: ${name} — saved to ${att.savedAs}. Content already included; do not re-read from disk.]`
169
+ : `[Attachment: ${name}]`;
170
+ promptBlocks.push({ type: "text", text: label });
82
171
  }
83
172
  else {
84
- log.warn("[acp:%s] dropping %d image attachment(s) — agent does not support images", this.name, imageAttachments.length);
173
+ // Content not included (unsupported format or agent lacks image support)
174
+ const reason = isImage ? "agent does not support images" : "unsupported format";
175
+ const label = att.savedAs
176
+ ? `[Attachment: ${name} (${reason}) — saved to ${att.savedAs}]`
177
+ : `[Attachment: ${name} (${reason})]`;
178
+ promptBlocks.push({ type: "text", text: label });
85
179
  }
86
180
  }
87
181
  // Send prompt — resolves when turn completes
@@ -95,7 +189,7 @@ export class AcpEngine {
95
189
  queue.push({
96
190
  type: "done",
97
191
  sessionId: acpSessionId,
98
- stats: { model: null, contextUsed: 0, contextWindow: 0, toolCalls },
192
+ stats: { model, contextUsed, contextWindow, toolCalls },
99
193
  });
100
194
  queue.close();
101
195
  })
@@ -108,39 +202,179 @@ export class AcpEngine {
108
202
  });
109
203
  // Yield events as they arrive
110
204
  yield* queue;
111
- // Cleanup
112
- signal?.removeEventListener("abort", onAbort);
113
205
  }
114
206
  catch (err) {
115
- if (!(err instanceof Error && err.name === "AbortError")) {
207
+ if (!signal?.aborted && !(err instanceof Error && err.name === "AbortError")) {
116
208
  yield {
117
209
  type: "error",
118
- message: err instanceof Error ? err.message : String(err),
210
+ message: errorMessage(err),
119
211
  };
120
212
  }
121
213
  queue.close();
122
214
  }
123
215
  finally {
216
+ signal?.removeEventListener("abort", onAbort);
124
217
  if (proc && !proc.killed) {
125
218
  proc.kill();
126
219
  }
220
+ const closed = await Promise.allSettled(bridges.map((bridge) => bridge.close()));
221
+ for (const result of closed) {
222
+ if (result.status === "rejected")
223
+ log.error({ err: result.reason }, "[mcp] failed to close turn transport");
224
+ }
127
225
  }
128
226
  }
129
- async listSessions(_cwd) {
130
- // Fast follow: spawn agent process and call conn.listSessions()
131
- return [];
227
+ async getSessionMessages(opts) {
228
+ const messages = [];
229
+ let previousId;
230
+ let previousRole;
231
+ const client = {
232
+ extNotification: async () => { },
233
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
234
+ sessionUpdate: async ({ sessionId, update }) => {
235
+ if (sessionId !== opts.sessionId)
236
+ return;
237
+ if (update.sessionUpdate !== "user_message_chunk" && update.sessionUpdate !== "agent_message_chunk") {
238
+ // A tool/thought between text chunks ends the adjacent text segment.
239
+ previousRole = undefined;
240
+ return;
241
+ }
242
+ const role = update.sessionUpdate === "user_message_chunk" ? "user" : "assistant";
243
+ if (update.content.type !== "text") {
244
+ previousRole = undefined;
245
+ previousId = undefined;
246
+ return;
247
+ }
248
+ const last = messages.at(-1);
249
+ // ACP message IDs preserve separate consecutive messages from the same role.
250
+ // Older agents without IDs can only be normalized into adjacent role segments.
251
+ if (last && previousRole === role && previousId === update.messageId) {
252
+ last.text += update.content.text;
253
+ }
254
+ else {
255
+ messages.push({ role, text: update.content.text });
256
+ }
257
+ previousId = update.messageId;
258
+ previousRole = role;
259
+ },
260
+ };
261
+ await this.readSessionData(opts, client, async (conn, init) => {
262
+ if (!init.agentCapabilities?.loadSession) {
263
+ throw new Error(`${this.name} does not support loading session history`);
264
+ }
265
+ await conn.loadSession({ sessionId: opts.sessionId, cwd: opts.cwd, mcpServers: [] });
266
+ });
267
+ return messages.filter((message) => message.text.length > 0);
268
+ }
269
+ async listSessions(cwd) {
270
+ return this.readSessionData({ cwd }, {
271
+ extNotification: async () => { },
272
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
273
+ sessionUpdate: async () => { },
274
+ }, async (conn, init, signal) => {
275
+ // Listing is optional in ACP. Unsupported agents deliberately have no picker entries.
276
+ if (!init.agentCapabilities?.sessionCapabilities?.list)
277
+ return [];
278
+ // ACP does not promise page ordering; sort only after exhausting the cursor.
279
+ const sessions = new Map();
280
+ const cursors = new Set();
281
+ let cursor;
282
+ do {
283
+ signal.throwIfAborted();
284
+ const page = await conn.listSessions({ cwd, ...(cursor !== undefined ? { cursor } : {}) });
285
+ for (const session of page.sessions) {
286
+ if (session.cwd !== cwd)
287
+ continue; // Never mix sibling worktrees into this picker.
288
+ const timestamp = session.updatedAt ? Date.parse(session.updatedAt) : NaN;
289
+ sessions.set(session.sessionId, {
290
+ sessionId: session.sessionId,
291
+ // ACP offers a title, not a last-prompt summary or a standard git branch.
292
+ summary: session.title?.trim() || session.sessionId,
293
+ lastModified: Number.isFinite(timestamp) ? timestamp : 0,
294
+ });
295
+ }
296
+ cursor = page.nextCursor ?? undefined;
297
+ if (cursor === undefined)
298
+ break;
299
+ if (cursors.has(cursor))
300
+ throw new Error(`${this.name} repeated a session list cursor`);
301
+ cursors.add(cursor);
302
+ } while (cursor !== undefined);
303
+ return [...sessions.values()].sort((a, b) => b.lastModified - a.lastModified).slice(0, 10);
304
+ });
305
+ }
306
+ /** Short-lived, read-only ACP queries share one deadline and always release the child. */
307
+ async readSessionData(opts, client, query) {
308
+ opts.signal?.throwIfAborted();
309
+ const signal = AbortSignal.any([
310
+ ...(opts.signal ? [opts.signal] : []),
311
+ AbortSignal.timeout(30_000),
312
+ ]);
313
+ const proc = spawnAgent(this.spawnConfig, {
314
+ ...opts,
315
+ sessionId: opts.sessionId ?? null,
316
+ prompt: "",
317
+ permissionMode: "default",
318
+ onPermissionRequest: async () => ({ decision: "deny", message: "Session queries cannot run tools" }),
319
+ });
320
+ let rejectStopped;
321
+ const stopped = new Promise((_resolve, reject) => { rejectStopped = reject; });
322
+ const onAbort = () => rejectStopped(signal.reason);
323
+ const onError = (err) => rejectStopped(err);
324
+ const onExit = () => rejectStopped(new Error(`${this.name} exited while querying sessions`));
325
+ signal.addEventListener("abort", onAbort, { once: true });
326
+ proc.once("error", onError);
327
+ proc.once("exit", onExit);
328
+ // Drain stderr so verbose adapters cannot block waiting for a full pipe.
329
+ proc.stderr?.resume();
330
+ try {
331
+ const conn = new ClientSideConnection(() => client, ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout)));
332
+ const init = await Promise.race([stopped, conn.initialize({
333
+ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {},
334
+ })]);
335
+ signal.throwIfAborted();
336
+ const result = await Promise.race([stopped, query(conn, init, signal)]);
337
+ signal.throwIfAborted();
338
+ return result;
339
+ }
340
+ catch (err) {
341
+ if (err instanceof Error)
342
+ throw err;
343
+ throw new Error(errorMessage(err));
344
+ }
345
+ finally {
346
+ signal.removeEventListener("abort", onAbort);
347
+ proc.removeListener("error", onError);
348
+ proc.removeListener("exit", onExit);
349
+ proc.stdin?.end();
350
+ proc.kill();
351
+ }
132
352
  }
133
353
  }
134
354
  // --- Helpers ---
135
- function spawnAgent(config) {
136
- const proc = spawn(config.command, config.args, {
355
+ function modelConfigOption(options) {
356
+ // Categories are optional; accept an advertised "model" ID as a fallback.
357
+ const option = options?.find((option) => option.category === "model")
358
+ ?? options?.find((option) => option.id === "model" && !option.category);
359
+ return option?.type === "select" ? option : undefined;
360
+ }
361
+ function errorMessage(err) {
362
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string")
363
+ return err.message;
364
+ return String(err);
365
+ }
366
+ function spawnAgent(config, opts) {
367
+ const extraEnv = typeof config.env === "function" ? config.env(opts) : config.env;
368
+ const [command, ...args] = config.command;
369
+ const proc = spawn(command, args, {
137
370
  stdio: ["pipe", "pipe", "pipe"],
371
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
138
372
  });
139
373
  proc.on("error", (err) => {
140
- log.error({ err }, "[acp] failed to spawn %s", config.command);
374
+ log.error({ err }, "[acp] failed to spawn %s", command);
141
375
  });
142
376
  if (!proc.stdin || !proc.stdout) {
143
- throw new Error(`Failed to spawn ${config.command} — stdin/stdout not available`);
377
+ throw new Error(`Failed to spawn ${command} — stdin/stdout not available`);
144
378
  }
145
379
  return proc;
146
380
  }
@@ -244,4 +478,42 @@ async function handlePermission(params, permissionMode, onPermissionRequest, pen
244
478
  }
245
479
  return { outcome: { outcome: "cancelled" } };
246
480
  }
481
+ function object(value) {
482
+ return value !== null && typeof value === "object" && !Array.isArray(value)
483
+ ? value : undefined;
484
+ }
485
+ function finite(value) {
486
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
487
+ }
488
+ /** PR #334's provider extension. Never treat standard ACP used/size as plan usage. */
489
+ export function codexPlanUsage(meta) {
490
+ const limits = object(meta)?.["_codex/rateLimits"];
491
+ if (!Array.isArray(limits))
492
+ return [];
493
+ const windows = [];
494
+ for (const value of limits) {
495
+ const limit = object(value);
496
+ if (!limit || typeof limit.limitId !== "string")
497
+ continue;
498
+ const prefix = limit.limitId === "codex" ? "" : `${limit.limitId.replace(/[\r\n|]/g, " ").slice(0, 24)} `;
499
+ for (const key of ["primary", "secondary"]) {
500
+ const window = object(limit[key]);
501
+ if (!window)
502
+ continue;
503
+ const minutes = finite(window.windowDurationMins);
504
+ const percent = finite(window.usedPercent);
505
+ const reset = finite(window.resetsAt);
506
+ if (minutes === undefined || minutes <= 0)
507
+ continue;
508
+ const duration = minutes % 1440 === 0 ? `${minutes / 1440}d`
509
+ : minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
510
+ windows.push({
511
+ id: `${limit.limitId}/${key}`, label: `${prefix}${duration}`,
512
+ usedPercent: percent !== undefined && percent >= 0 && percent <= 100 ? percent : undefined,
513
+ resetsAt: reset !== undefined && reset > 0 ? reset : undefined,
514
+ });
515
+ }
516
+ }
517
+ return windows;
518
+ }
247
519
  //# sourceMappingURL=acp.js.map
@@ -1,8 +1,25 @@
1
- import type { Engine, EngineEvent, RunTurnOpts, SessionInfo } from "../types.js";
1
+ import { type SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { Engine, EngineEvent, PlanUsageWindow, RunTurnOpts, SessionInfo, SessionHistoryOpts, SessionMessage } from "../types.js";
2
3
  export declare class ClaudeCodeEngine implements Engine {
3
4
  private readonly executablePath?;
5
+ private readonly configPath?;
4
6
  name: string;
5
- constructor(executablePath?: string | undefined);
7
+ readonly planUsageWindows: readonly PlanUsageWindow[];
8
+ constructor(executablePath?: string | undefined, configPath?: string | undefined);
9
+ getSessionMessages({ sessionId, cwd, signal }: SessionHistoryOpts): Promise<SessionMessage[]>;
6
10
  listSessions(cwd: string): Promise<SessionInfo[]>;
7
11
  runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
8
12
  }
13
+ /** Claude runtime fields newer than the bundled SDK declarations. */
14
+ type ClaudeRateLimitInfo = Omit<SDKRateLimitInfo, "rateLimitType"> & {
15
+ rateLimitType?: string;
16
+ unifiedWindows?: Record<string, {
17
+ utilization: number;
18
+ resetsAt: number;
19
+ }>;
20
+ };
21
+ export declare const CLAUDE_PLAN_WINDOWS: readonly PlanUsageWindow[];
22
+ export declare function claudePlanUsage(info: ClaudeRateLimitInfo): Extract<EngineEvent, {
23
+ type: "plan_usage";
24
+ }>;
25
+ export {};