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.
@@ -1,10 +1,84 @@
1
- import { query, listSessions, } from "@anthropic-ai/claude-agent-sdk";
1
+ import { query, listSessions, getSessionMessages, } from "@anthropic-ai/claude-agent-sdk";
2
2
  import log from "../logger.js";
3
- import { formatToolDescription } from "../format.js";
3
+ /** Map Claude Code SDK tool call to ToolCall. */
4
+ function toToolCall(toolName, input, toolUseId) {
5
+ switch (toolName) {
6
+ case "Edit":
7
+ return {
8
+ action: "edit", toolName, toolUseId,
9
+ path: String(input.file_path ?? ""),
10
+ before: String(input.old_string ?? ""),
11
+ after: String(input.new_string ?? ""),
12
+ };
13
+ case "Write":
14
+ return {
15
+ action: "write", toolName, toolUseId,
16
+ path: String(input.file_path ?? ""),
17
+ content: String(input.content ?? ""),
18
+ };
19
+ case "Bash":
20
+ return {
21
+ action: "execute", toolName, toolUseId,
22
+ command: String(input.command ?? ""),
23
+ };
24
+ case "Read":
25
+ case "Glob":
26
+ return {
27
+ action: "read", toolName, toolUseId,
28
+ paths: [String(input.file_path ?? input.pattern ?? "")],
29
+ };
30
+ case "Grep":
31
+ return {
32
+ action: "search", toolName, toolUseId,
33
+ pattern: String(input.pattern ?? ""),
34
+ paths: input.path ? [String(input.path)] : undefined,
35
+ };
36
+ case "WebSearch":
37
+ return {
38
+ action: "search", toolName, toolUseId,
39
+ pattern: String(input.query ?? ""),
40
+ };
41
+ case "WebFetch":
42
+ return {
43
+ action: "fetch", toolName, toolUseId,
44
+ url: String(input.url ?? ""),
45
+ };
46
+ default:
47
+ return { action: "other", toolName, toolUseId, ...input };
48
+ }
49
+ }
4
50
  export class ClaudeCodeEngine {
51
+ executablePath;
52
+ configPath;
5
53
  name = "claude-code";
54
+ planUsageWindows = CLAUDE_PLAN_WINDOWS;
55
+ constructor(executablePath, configPath) {
56
+ this.executablePath = executablePath;
57
+ this.configPath = configPath;
58
+ }
59
+ async getSessionMessages({ sessionId, cwd, signal }) {
60
+ signal?.throwIfAborted();
61
+ const messages = await getSessionMessages(sessionId, { dir: cwd });
62
+ signal?.throwIfAborted();
63
+ // The SDK returns [] for missing sessions as well as sessions with no records.
64
+ if (!messages.length)
65
+ throw new Error("No conversation history available for this session");
66
+ return messages.flatMap(({ type, message }) => {
67
+ if ((type !== "user" && type !== "assistant") || !isRecord(message))
68
+ return [];
69
+ const content = message.content;
70
+ const text = typeof content === "string" ? content : Array.isArray(content)
71
+ ? content.filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
72
+ .map((block) => block.text).join("\n")
73
+ : "";
74
+ return text.trim() ? [{ role: type, text }] : [];
75
+ });
76
+ }
6
77
  async listSessions(cwd) {
7
- const sessions = await listSessions({ dir: cwd, limit: 10 });
78
+ // includeWorktrees:false — SDK defaults to true, which merges sessions
79
+ // from all git worktrees of the same repo into one pool. We want only
80
+ // this cwd's own sessions.
81
+ const sessions = await listSessions({ dir: cwd, limit: 10, includeWorktrees: false });
8
82
  return sessions
9
83
  .sort((a, b) => b.lastModified - a.lastModified)
10
84
  .map((s) => ({
@@ -15,7 +89,7 @@ export class ClaudeCodeEngine {
15
89
  }));
16
90
  }
17
91
  async *runTurn(opts) {
18
- const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, appendSystemPrompt, mcpServers, signal, } = opts;
92
+ const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, appendSystemPrompt, mcpServers, signal, model, } = opts;
19
93
  const abortController = new AbortController();
20
94
  // Wire external signal to our abort controller
21
95
  if (signal) {
@@ -27,14 +101,7 @@ export class ClaudeCodeEngine {
27
101
  ? { resume: sessionId }
28
102
  : {};
29
103
  const canUseTool = async (toolName, input, options) => {
30
- const description = formatToolDescription(toolName, input);
31
- const resp = await onPermissionRequest({
32
- toolName,
33
- input,
34
- description,
35
- reason: options.decisionReason,
36
- toolUseId: options.toolUseID,
37
- });
104
+ const resp = await onPermissionRequest(toToolCall(toolName, input, options.toolUseID));
38
105
  if (resp.decision === "allow") {
39
106
  return { behavior: "allow", updatedInput: resp.updatedInput ?? input };
40
107
  }
@@ -63,10 +130,13 @@ export class ClaudeCodeEngine {
63
130
  ...sessionOpts,
64
131
  cwd,
65
132
  permissionMode,
133
+ ...(model ? { model } : {}),
134
+ ...(this.executablePath ? { pathToClaudeCodeExecutable: this.executablePath } : {}),
66
135
  allowDangerouslySkipPermissions: permissionMode === "bypassPermissions" ? true : undefined,
67
136
  canUseTool: permissionMode === "bypassPermissions" ? undefined : canUseTool,
68
137
  abortController,
69
138
  settingSources: ["user", "project", "local"],
139
+ ...(this.configPath ? { settings: this.configPath } : {}),
70
140
  ...(mcpServers ? { mcpServers } : {}),
71
141
  ...(appendSystemPrompt
72
142
  ? {
@@ -82,6 +152,7 @@ export class ClaudeCodeEngine {
82
152
  let resultSessionId;
83
153
  let turnStats;
84
154
  let lastInputTokens = 0;
155
+ let lastModel;
85
156
  const toolUseIdToName = new Map();
86
157
  try {
87
158
  for await (const msg of q) {
@@ -89,12 +160,22 @@ export class ClaudeCodeEngine {
89
160
  const sub = msg.type === "result" ? ` (${msg.subtype})` : "";
90
161
  log.info(`[sdk] ${msg.type}${sub}`);
91
162
  }
163
+ // Report session metadata immediately so a cancelled turn keeps its ID.
164
+ // The observed model is metadata, not a user-selected override.
165
+ if (msg.type === "system" && msg.subtype === "init") {
166
+ const init = msg;
167
+ yield { type: "session", sessionId: init.session_id, model: init.model };
168
+ }
92
169
  // Extract text and tool_use from assistant messages
93
170
  if (msg.type === "assistant") {
94
171
  // BetaMessage type isn't directly importable (@anthropic-ai/sdk not installed);
95
172
  // .message is typed as `any` so property access works without casting.
96
173
  const betaMsg = msg.message;
97
- if (betaMsg.usage) {
174
+ // Only the main thread describes the session; subagent turns carry a parent.
175
+ const mainThread = msg.parent_tool_use_id === null;
176
+ if (mainThread && betaMsg.model)
177
+ lastModel = betaMsg.model;
178
+ if (mainThread && betaMsg.usage) {
98
179
  const u = betaMsg.usage;
99
180
  lastInputTokens = (u.input_tokens ?? 0)
100
181
  + (u.cache_read_input_tokens ?? 0)
@@ -110,9 +191,7 @@ export class ClaudeCodeEngine {
110
191
  toolUseIdToName.set(toolUseId, block.name);
111
192
  yield {
112
193
  type: "tool_use",
113
- toolName: block.name,
114
- input: block.input,
115
- toolUseId,
194
+ tool: toToolCall(block.name, block.input, toolUseId),
116
195
  };
117
196
  }
118
197
  }
@@ -135,13 +214,7 @@ export class ClaudeCodeEngine {
135
214
  // Relay rate limit events
136
215
  if (msg.type === "rate_limit_event") {
137
216
  const { rate_limit_info } = msg;
138
- if (rate_limit_info.status !== "allowed") {
139
- yield {
140
- type: "rate_limit",
141
- status: rate_limit_info.status,
142
- resetsAt: rate_limit_info.resetsAt,
143
- };
144
- }
217
+ yield claudePlanUsage(rate_limit_info);
145
218
  }
146
219
  // Relay local command output (e.g. /compact, /cost, /model)
147
220
  if (msg.type === "system" && msg.subtype === "local_command_output") {
@@ -154,9 +227,10 @@ export class ClaudeCodeEngine {
154
227
  if (msg.type === "result") {
155
228
  const result = msg;
156
229
  resultSessionId = result.session_id;
157
- const models = Object.keys(result.modelUsage);
158
- if (models.length > 0) {
159
- const model = models[0];
230
+ // modelUsage also counts background calls (e.g. Haiku), so its first key
231
+ // is not necessarily the model that answered.
232
+ const model = lastModel ?? Object.keys(result.modelUsage)[0];
233
+ if (model) {
160
234
  const mu = result.modelUsage[model];
161
235
  // Build per-tool call counts from accumulated map
162
236
  const toolCalls = {};
@@ -165,8 +239,9 @@ export class ClaudeCodeEngine {
165
239
  }
166
240
  turnStats = {
167
241
  model,
242
+ modelLabel: model.replace(/^claude-/, "").replace(/-\d{8}$/, ""),
168
243
  contextUsed: lastInputTokens,
169
- contextWindow: mu.contextWindow,
244
+ contextWindow: mu?.contextWindow ?? 0,
170
245
  toolCalls,
171
246
  };
172
247
  }
@@ -192,12 +267,15 @@ export class ClaudeCodeEngine {
192
267
  }
193
268
  }
194
269
  }
270
+ function isRecord(value) {
271
+ return typeof value === "object" && value !== null && !Array.isArray(value);
272
+ }
195
273
  /**
196
274
  * Build an AsyncIterable<SDKUserMessage> that yields a single user message
197
275
  * with content blocks for attachments + text.
198
276
  */
199
277
  function buildAttachmentPrompt(text, attachments) {
200
- const contentBlocks = attachments.map(encodeAttachment);
278
+ const contentBlocks = attachments.flatMap(encodeAttachment);
201
279
  // Text prompt always comes last
202
280
  if (text) {
203
281
  contentBlocks.push({ type: "text", text });
@@ -220,34 +298,42 @@ function buildAttachmentPrompt(text, attachments) {
220
298
  const SUPPORTED_IMAGE_TYPES = new Set([
221
299
  "image/jpeg", "image/png", "image/gif", "image/webp",
222
300
  ]);
223
- /** Encode an attachment as an SDK content block using its in-memory buffer. */
301
+ /** Build a metadata label for an attachment. */
302
+ function attachmentLabel(att) {
303
+ const name = att.filename ?? att.mimeType;
304
+ if (att.savedAs) {
305
+ return { type: "text", text: `[Attachment: ${name} — saved to ${att.savedAs}. Content already included; do not re-read from disk.]` };
306
+ }
307
+ return { type: "text", text: `[Attachment: ${name}]` };
308
+ }
309
+ /** Encode an attachment as SDK content blocks using its in-memory buffer. */
224
310
  function encodeAttachment(att) {
225
311
  const data = att.buffer.toString("base64");
312
+ const label = attachmentLabel(att);
226
313
  if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
227
- return {
228
- type: "image",
229
- source: {
230
- type: "base64",
231
- media_type: att.mimeType,
232
- data,
233
- },
234
- };
314
+ return [{
315
+ type: "image",
316
+ source: {
317
+ type: "base64",
318
+ media_type: att.mimeType,
319
+ data,
320
+ },
321
+ }, label];
235
322
  }
236
323
  if (att.mimeType === "application/pdf") {
237
- return {
238
- type: "document",
239
- source: { type: "base64", media_type: "application/pdf", data },
240
- };
324
+ return [{
325
+ type: "document",
326
+ source: { type: "base64", media_type: "application/pdf", data },
327
+ }, label];
241
328
  }
242
329
  if (att.mimeType.startsWith("text/")) {
243
330
  const content = att.buffer.toString("utf-8");
244
- const header = att.filename ? `--- ${att.filename} ---\n` : "";
245
- return { type: "text", text: `${header}${content}` };
331
+ return [{ type: "text", text: content }, label];
246
332
  }
247
333
  // Unsupported (includes non-standard image types like svg, bmp, tiff)
248
334
  const name = att.filename ?? att.mimeType;
249
335
  log.warn("[engine] unsupported attachment type: %s", att.mimeType);
250
- return { type: "text", text: `[Unsupported file: ${name}]` };
336
+ return [{ type: "text", text: `[Unsupported file type: ${name}]` }, label];
251
337
  }
252
338
  /** Pull plain text out of a tool_result content block. */
253
339
  function extractToolResultText(content) {
@@ -261,4 +347,48 @@ function extractToolResultText(content) {
261
347
  }
262
348
  return "";
263
349
  }
350
+ const CLAUDE_LABELS = {
351
+ five_hour: "5h", seven_day: "7d", seven_day_opus: "Opus 7d",
352
+ seven_day_sonnet: "Sonnet 7d", overage: "extra",
353
+ seven_day_overage_included: "Fable 7d",
354
+ };
355
+ function claudeWindowLabel(id) {
356
+ return CLAUDE_LABELS[id] ?? id.replace(/[_\r\n|]/g, " ").slice(0, 32);
357
+ }
358
+ function object(value) {
359
+ return value !== null && typeof value === "object" && !Array.isArray(value)
360
+ ? value : undefined;
361
+ }
362
+ function finite(value) {
363
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
364
+ }
365
+ export const CLAUDE_PLAN_WINDOWS = [
366
+ { id: "five_hour", label: "5h" },
367
+ { id: "seven_day", label: "7d" },
368
+ { id: "seven_day_overage_included", label: "Fable 7d" },
369
+ ];
370
+ export function claudePlanUsage(info) {
371
+ const windows = [];
372
+ const utilization = finite(info.utilization);
373
+ if (info.rateLimitType)
374
+ windows.push({
375
+ id: info.rateLimitType, label: claudeWindowLabel(info.rateLimitType),
376
+ limited: info.status === "rejected",
377
+ usedPercent: utilization !== undefined && utilization >= 0
378
+ ? utilization * 100 : undefined,
379
+ resetsAt: info.resetsAt,
380
+ });
381
+ // Claude Code 2.1.251 supplies all observed windows, even while allowed.
382
+ // Prefer these percentages over the selected top-level warning window.
383
+ for (const [id, raw] of Object.entries(object(info.unifiedWindows) ?? {})) {
384
+ const window = object(raw);
385
+ const utilization = finite(window?.utilization);
386
+ const resetsAt = finite(window?.resetsAt);
387
+ if (utilization === undefined || utilization < 0 || resetsAt === undefined || resetsAt <= 0)
388
+ continue;
389
+ windows.push({ id, label: claudeWindowLabel(id), usedPercent: utilization * 100,
390
+ resetsAt, limited: id === info.rateLimitType && info.status === "rejected" });
391
+ }
392
+ return { type: "plan_usage", windows, isUsingOverage: info.isUsingOverage };
393
+ }
264
394
  //# sourceMappingURL=claude-code.js.map
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from "@agentclientprotocol/sdk";
2
+ import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
3
+ /** A turn-scoped HTTP endpoint in front of one in-process MCP server. */
4
+ export interface McpBridge {
5
+ config: McpServer;
6
+ close(): Promise<void>;
7
+ }
8
+ /** Expose the turn's existing handlers to a subprocess, without another tool process. */
9
+ export declare function serveMcpOverHttp(name: string, server: McpSdkServerConfigWithInstance): Promise<McpBridge>;
@@ -0,0 +1,59 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import log from "../logger.js";
5
+ /** Expose the turn's existing handlers to a subprocess, without another tool process. */
6
+ export async function serveMcpOverHttp(name, server) {
7
+ const authorization = `Bearer ${randomBytes(32).toString("hex")}`;
8
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
9
+ const http = createServer((req, res) => {
10
+ if (req.headers.authorization !== authorization) {
11
+ res.writeHead(401).end();
12
+ return;
13
+ }
14
+ if (req.url !== "/mcp") {
15
+ res.writeHead(404).end();
16
+ return;
17
+ }
18
+ transport.handleRequest(req, res).catch((err) => {
19
+ log.error({ err }, "[mcp] HTTP transport failed");
20
+ if (!res.headersSent)
21
+ res.writeHead(500);
22
+ res.end();
23
+ });
24
+ });
25
+ const close = async () => {
26
+ // Stop accepting requests before closing MCP streams and keep-alive sockets.
27
+ const closed = new Promise((resolve) => http.close(() => resolve()));
28
+ http.closeAllConnections();
29
+ await server.instance.close();
30
+ await closed;
31
+ };
32
+ try {
33
+ await server.instance.connect(transport);
34
+ await new Promise((resolve, reject) => {
35
+ http.once("error", reject);
36
+ http.listen(0, "127.0.0.1", () => {
37
+ http.off("error", reject);
38
+ resolve();
39
+ });
40
+ });
41
+ const address = http.address();
42
+ if (!address || typeof address === "string")
43
+ throw new Error("MCP HTTP listener has no address");
44
+ return {
45
+ config: {
46
+ type: "http",
47
+ name,
48
+ url: `http://127.0.0.1:${address.port}/mcp`,
49
+ headers: [{ name: "Authorization", value: authorization }],
50
+ },
51
+ close,
52
+ };
53
+ }
54
+ catch (err) {
55
+ await close();
56
+ throw err;
57
+ }
58
+ }
59
+ //# sourceMappingURL=mcp-http.js.map
@@ -0,0 +1,19 @@
1
+ import type { EngineEntry } from "../config.js";
2
+ import type { Engine, RunTurnOpts } from "../types.js";
3
+ export interface AcpEngineDefinition {
4
+ command: [string, ...string[]];
5
+ env?: Record<string, string> | ((opts: RunTurnOpts) => Record<string, string> | undefined);
6
+ }
7
+ /** All known engine names (for validation / setup prompts). */
8
+ export declare const ENGINE_NAMES: readonly ["claude-code", ...string[]];
9
+ /** Return the CLI command an engine needs on PATH. */
10
+ export declare function engineCommand(name: string): string | undefined;
11
+ /** Prefer an installed CLI; bundled engines can omit the override. */
12
+ export declare function resolveEnginePath(name: string): string | undefined;
13
+ /**
14
+ * Build the engine map: claude-code (Agent SDK) + known ACP engines.
15
+ * Engines are lightweight — they store config, not running processes.
16
+ *
17
+ * @param engineConfigs - executable and settings-file overrides from config
18
+ */
19
+ export declare function createEngineMap(engineConfigs?: Record<string, Pick<EngineEntry, "path" | "configPath">>): Map<string, Engine>;
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { ClaudeCodeEngine } from "./claude-code.js";
6
+ import { AcpEngine } from "./acp.js";
7
+ /** Known ACP engines: CLI for setup, optional adapter for launch. */
8
+ const require = createRequire(import.meta.url);
9
+ const KNOWN_ACP_ENGINES = {
10
+ kiro: { cliCommand: "kiro-cli", command: (cliPath) => [cliPath, "acp"] },
11
+ codex: {
12
+ cliCommand: "codex",
13
+ bundled: true,
14
+ supportsConfigPath: true,
15
+ // Resolve only when starting Codex, and always use our pinned, patched dependency.
16
+ get command() { return [process.execPath, require.resolve("@agentclientprotocol/codex-acp")]; },
17
+ env: codexEnv,
18
+ },
19
+ };
20
+ /**
21
+ * ACP has no system-prompt channel, so ClearClaw's assembled framework prompt
22
+ * reaches Codex through its config: codex-acp parses CODEX_CONFIG as a JSON
23
+ * config object and forwards it to codex core, which honors
24
+ * `developer_instructions`. An explicit JSON file supplies personal settings;
25
+ * without one, inherited CODEX_CONFIG remains the source.
26
+ * CODEX_PATH selects the underlying CLI while the pinned adapter stays fixed.
27
+ */
28
+ function codexEnv(opts, executablePath, configPath) {
29
+ const developerInstructions = opts.appendSystemPrompt?.trim();
30
+ if (!developerInstructions && !executablePath && !configPath)
31
+ return undefined;
32
+ return {
33
+ ...(executablePath ? { CODEX_PATH: executablePath } : {}),
34
+ ...(developerInstructions || configPath ? {
35
+ CODEX_CONFIG: JSON.stringify({
36
+ ...readCodexConfig(configPath),
37
+ ...(developerInstructions ? { developer_instructions: developerInstructions } : {}),
38
+ }),
39
+ } : {}),
40
+ };
41
+ }
42
+ /**
43
+ * Installation state the Codex CLI owns. codex-acp applies our config when it
44
+ * starts a thread, long after the CLI would have fetched a marketplace and
45
+ * installed a plugin, so declaring these here installs nothing: an enabled
46
+ * plugin stays missing, and a marketplace with no local snapshot fails every
47
+ * thread with an opaque config-load error. Reject them at load with the fix.
48
+ */
49
+ const CLI_OWNED_CONFIG_KEYS = ["marketplaces", "plugins"];
50
+ function readCodexConfig(configPath) {
51
+ const raw = configPath ? readFileSync(configPath, "utf8") : process.env.CODEX_CONFIG?.trim() || "{}";
52
+ const source = configPath ?? "CODEX_CONFIG";
53
+ const parsed = JSON.parse(raw);
54
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
55
+ throw new Error(`${source} must be a JSON object`);
56
+ }
57
+ const config = parsed;
58
+ const cliOwned = CLI_OWNED_CONFIG_KEYS.filter((key) => key in config);
59
+ if (cliOwned.length > 0) {
60
+ throw new Error(`${source} sets ${cliOwned.join(" and ")}, which ClearClaw cannot apply: plugin installation is Codex CLI state, ` +
61
+ `not thread settings. Install with "codex plugin marketplace add <source>" and "codex plugin add <plugin>@<marketplace>", ` +
62
+ `then remove ${cliOwned.length > 1 ? "these keys" : "the key"}.`);
63
+ }
64
+ return config;
65
+ }
66
+ /** All known engine names (for validation / setup prompts). */
67
+ export const ENGINE_NAMES = ["claude-code", ...Object.keys(KNOWN_ACP_ENGINES)];
68
+ /** Return the CLI command an engine needs on PATH. */
69
+ export function engineCommand(name) {
70
+ return name === "claude-code" ? "claude" : KNOWN_ACP_ENGINES[name]?.cliCommand;
71
+ }
72
+ /** Prefer an installed CLI; bundled engines can omit the override. */
73
+ export function resolveEnginePath(name) {
74
+ const command = engineCommand(name);
75
+ if (!command)
76
+ throw new Error(`Unknown engine: ${name}`);
77
+ try {
78
+ return execFileSync("which", [command], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
79
+ }
80
+ catch {
81
+ if (KNOWN_ACP_ENGINES[name]?.bundled)
82
+ return undefined;
83
+ throw new Error(`"${command}" not found on PATH. Install it first or choose another engine.`);
84
+ }
85
+ }
86
+ /**
87
+ * Build the engine map: claude-code (Agent SDK) + known ACP engines.
88
+ * Engines are lightweight — they store config, not running processes.
89
+ *
90
+ * @param engineConfigs - executable and settings-file overrides from config
91
+ */
92
+ export function createEngineMap(engineConfigs = {}) {
93
+ for (const [name, { configPath }] of Object.entries(engineConfigs)) {
94
+ if (configPath && !isAbsolute(configPath))
95
+ throw new Error(`${name} configPath must be absolute`);
96
+ if (configPath && name !== "claude-code" && !KNOWN_ACP_ENGINES[name]?.supportsConfigPath) {
97
+ throw new Error(`${name} does not support configPath`);
98
+ }
99
+ }
100
+ const engines = new Map();
101
+ const claude = engineConfigs["claude-code"];
102
+ engines.set("claude-code", new ClaudeCodeEngine(claude?.path, claude?.configPath));
103
+ for (const [name, config] of Object.entries(KNOWN_ACP_ENGINES)) {
104
+ const { path, configPath } = engineConfigs[name] ?? {};
105
+ engines.set(name, new AcpEngine(name, {
106
+ get command() {
107
+ const command = config.command;
108
+ return typeof command === "function" ? command(path ?? config.cliCommand) : command;
109
+ },
110
+ env: (opts) => config.env?.(opts, path, configPath),
111
+ }));
112
+ }
113
+ return engines;
114
+ }
115
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,14 @@
1
+ import type { SessionMessage } from "../types.js";
2
+ /**
3
+ * Inverse of the orchestrator's prompt framing, for showing a stored prompt back
4
+ * to a human (session summaries, catch-up blocks): drop the leading bracketed
5
+ * tags and the sender label.
6
+ * "[2026-09-10 …] [msg:10107] [user] Paddy (@handle): hi" → "hi".
7
+ * Text with no bracketed tag is left alone, so a summary that merely contains a
8
+ * colon keeps its first clause.
9
+ */
10
+ export declare function stripPromptPrefix(text: string): string;
11
+ /** Format all engine-normalized conversation text for a handoff prompt. */
12
+ export declare function formatSessionTranscript(messages: SessionMessage[]): string;
13
+ /** Replay the last three user prompts and their replies, including a pending reply. */
14
+ export declare function formatSessionRecap(messages: SessionMessage[]): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Inverse of the orchestrator's prompt framing, for showing a stored prompt back
3
+ * to a human (session summaries, catch-up blocks): drop the leading bracketed
4
+ * tags and the sender label.
5
+ * "[2026-09-10 …] [msg:10107] [user] Paddy (@handle): hi" → "hi".
6
+ * Text with no bracketed tag is left alone, so a summary that merely contains a
7
+ * colon keeps its first clause.
8
+ */
9
+ export function stripPromptPrefix(text) {
10
+ return text.replace(/^(?:\[[^\]]*\]\s*)+(?:[^:\n]{0,60}:\s*)?/, "");
11
+ }
12
+ /** Format all engine-normalized conversation text for a handoff prompt. */
13
+ export function formatSessionTranscript(messages) {
14
+ let text = "";
15
+ for (const message of messages) {
16
+ if (!message.text.trim())
17
+ continue;
18
+ text += `${text ? "\n\n" : ""}${message.role === "user" ? "User" : "Assistant"}:\n${message.text}`;
19
+ }
20
+ return text || "[No conversation text was returned for the previous session.]";
21
+ }
22
+ /** Replay the last three user prompts and their replies, including a pending reply. */
23
+ export function formatSessionRecap(messages) {
24
+ const visible = messages.flatMap((message) => {
25
+ const text = (message.role === "user" ? stripPromptPrefix(message.text) : message.text).trim();
26
+ return text ? [{ ...message, text }] : [];
27
+ });
28
+ let start = 0;
29
+ let prompts = 0;
30
+ for (let i = visible.length - 1; i >= 0; i--) {
31
+ if (visible[i].role === "user" && ++prompts === 3) {
32
+ start = i;
33
+ break;
34
+ }
35
+ }
36
+ return visible.slice(start).map((message) => `**${message.role === "user" ? "Prompt" : "Assistant"}**\n${message.text}`).join("\n\n") || "No conversation text to recap yet.";
37
+ }
38
+ //# sourceMappingURL=session-transcript.js.map
package/dist/format.d.ts CHANGED
@@ -1,8 +1,9 @@
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 { type ToolCall } from "./types.js";
6
7
  /**
7
8
  * Summary line for the rolling tool message after a turn completes.
8
9
  * e.g. "🔧 3× Read, 2× Grep, 1× Bash"
@@ -12,12 +13,13 @@ export declare function formatToolCallSummary(toolCalls: Record<string, number>)
12
13
  * Short one-liner for the rolling tool message. Includes key detail
13
14
  * but truncated to stay compact.
14
15
  */
15
- export declare function formatToolStatusLine(toolName: string, input: Record<string, unknown>): string;
16
+ export declare function formatToolStatusLine(tool: ToolCall): string;
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.
18
+ * Format a permission prompt for display.
19
+ * Switches on action type: edit shows diff, write shows preview,
20
+ * execute shows command, others show available detail.
19
21
  */
20
- export declare function formatPermissionPrompt(toolName: string, input: Record<string, unknown>, description: string): string;
22
+ export declare function formatPermissionPrompt(tool: ToolCall): string;
21
23
  /**
22
24
  * Relative time string: "just now", "5m ago", "2h ago", "3d ago".
23
25
  */
@@ -42,13 +44,9 @@ export declare function truncateButtonLabel(index: number, label: string): strin
42
44
  * Format an ExitPlanMode permission prompt.
43
45
  * Renders plan text from the description or input fields.
44
46
  */
45
- export declare function formatExitPlanMode(input: Record<string, unknown>, description: string): string;
47
+ export declare function formatExitPlanMode(input: Record<string, unknown>): string;
46
48
  /**
47
49
  * Format a TodoWrite task list for display.
48
50
  */
49
51
  export declare function formatTodoList(input: Record<string, unknown>): string;
50
- /**
51
- * Human-readable one-liner for a tool invocation (used in engine's canUseTool).
52
- */
53
- export declare function formatToolDescription(toolName: string, input: Record<string, unknown>): string;
54
52
  export {};