clearclaw 0.7.0 → 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,4 +1,4 @@
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
3
  /** Map Claude Code SDK tool call to ToolCall. */
4
4
  function toToolCall(toolName, input, toolUseId) {
@@ -49,12 +49,36 @@ function toToolCall(toolName, input, toolUseId) {
49
49
  }
50
50
  export class ClaudeCodeEngine {
51
51
  executablePath;
52
+ configPath;
52
53
  name = "claude-code";
53
- constructor(executablePath) {
54
+ planUsageWindows = CLAUDE_PLAN_WINDOWS;
55
+ constructor(executablePath, configPath) {
54
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
+ });
55
76
  }
56
77
  async listSessions(cwd) {
57
- 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 });
58
82
  return sessions
59
83
  .sort((a, b) => b.lastModified - a.lastModified)
60
84
  .map((s) => ({
@@ -65,7 +89,7 @@ export class ClaudeCodeEngine {
65
89
  }));
66
90
  }
67
91
  async *runTurn(opts) {
68
- 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;
69
93
  const abortController = new AbortController();
70
94
  // Wire external signal to our abort controller
71
95
  if (signal) {
@@ -106,11 +130,13 @@ export class ClaudeCodeEngine {
106
130
  ...sessionOpts,
107
131
  cwd,
108
132
  permissionMode,
133
+ ...(model ? { model } : {}),
109
134
  ...(this.executablePath ? { pathToClaudeCodeExecutable: this.executablePath } : {}),
110
135
  allowDangerouslySkipPermissions: permissionMode === "bypassPermissions" ? true : undefined,
111
136
  canUseTool: permissionMode === "bypassPermissions" ? undefined : canUseTool,
112
137
  abortController,
113
138
  settingSources: ["user", "project", "local"],
139
+ ...(this.configPath ? { settings: this.configPath } : {}),
114
140
  ...(mcpServers ? { mcpServers } : {}),
115
141
  ...(appendSystemPrompt
116
142
  ? {
@@ -126,6 +152,7 @@ export class ClaudeCodeEngine {
126
152
  let resultSessionId;
127
153
  let turnStats;
128
154
  let lastInputTokens = 0;
155
+ let lastModel;
129
156
  const toolUseIdToName = new Map();
130
157
  try {
131
158
  for await (const msg of q) {
@@ -133,12 +160,22 @@ export class ClaudeCodeEngine {
133
160
  const sub = msg.type === "result" ? ` (${msg.subtype})` : "";
134
161
  log.info(`[sdk] ${msg.type}${sub}`);
135
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
+ }
136
169
  // Extract text and tool_use from assistant messages
137
170
  if (msg.type === "assistant") {
138
171
  // BetaMessage type isn't directly importable (@anthropic-ai/sdk not installed);
139
172
  // .message is typed as `any` so property access works without casting.
140
173
  const betaMsg = msg.message;
141
- 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) {
142
179
  const u = betaMsg.usage;
143
180
  lastInputTokens = (u.input_tokens ?? 0)
144
181
  + (u.cache_read_input_tokens ?? 0)
@@ -177,13 +214,7 @@ export class ClaudeCodeEngine {
177
214
  // Relay rate limit events
178
215
  if (msg.type === "rate_limit_event") {
179
216
  const { rate_limit_info } = msg;
180
- if (rate_limit_info.status !== "allowed") {
181
- yield {
182
- type: "rate_limit",
183
- status: rate_limit_info.status,
184
- resetsAt: rate_limit_info.resetsAt,
185
- };
186
- }
217
+ yield claudePlanUsage(rate_limit_info);
187
218
  }
188
219
  // Relay local command output (e.g. /compact, /cost, /model)
189
220
  if (msg.type === "system" && msg.subtype === "local_command_output") {
@@ -196,9 +227,10 @@ export class ClaudeCodeEngine {
196
227
  if (msg.type === "result") {
197
228
  const result = msg;
198
229
  resultSessionId = result.session_id;
199
- const models = Object.keys(result.modelUsage);
200
- if (models.length > 0) {
201
- 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) {
202
234
  const mu = result.modelUsage[model];
203
235
  // Build per-tool call counts from accumulated map
204
236
  const toolCalls = {};
@@ -207,8 +239,9 @@ export class ClaudeCodeEngine {
207
239
  }
208
240
  turnStats = {
209
241
  model,
242
+ modelLabel: model.replace(/^claude-/, "").replace(/-\d{8}$/, ""),
210
243
  contextUsed: lastInputTokens,
211
- contextWindow: mu.contextWindow,
244
+ contextWindow: mu?.contextWindow ?? 0,
212
245
  toolCalls,
213
246
  };
214
247
  }
@@ -234,12 +267,15 @@ export class ClaudeCodeEngine {
234
267
  }
235
268
  }
236
269
  }
270
+ function isRecord(value) {
271
+ return typeof value === "object" && value !== null && !Array.isArray(value);
272
+ }
237
273
  /**
238
274
  * Build an AsyncIterable<SDKUserMessage> that yields a single user message
239
275
  * with content blocks for attachments + text.
240
276
  */
241
277
  function buildAttachmentPrompt(text, attachments) {
242
- const contentBlocks = attachments.map(encodeAttachment);
278
+ const contentBlocks = attachments.flatMap(encodeAttachment);
243
279
  // Text prompt always comes last
244
280
  if (text) {
245
281
  contentBlocks.push({ type: "text", text });
@@ -262,34 +298,42 @@ function buildAttachmentPrompt(text, attachments) {
262
298
  const SUPPORTED_IMAGE_TYPES = new Set([
263
299
  "image/jpeg", "image/png", "image/gif", "image/webp",
264
300
  ]);
265
- /** 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. */
266
310
  function encodeAttachment(att) {
267
311
  const data = att.buffer.toString("base64");
312
+ const label = attachmentLabel(att);
268
313
  if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
269
- return {
270
- type: "image",
271
- source: {
272
- type: "base64",
273
- media_type: att.mimeType,
274
- data,
275
- },
276
- };
314
+ return [{
315
+ type: "image",
316
+ source: {
317
+ type: "base64",
318
+ media_type: att.mimeType,
319
+ data,
320
+ },
321
+ }, label];
277
322
  }
278
323
  if (att.mimeType === "application/pdf") {
279
- return {
280
- type: "document",
281
- source: { type: "base64", media_type: "application/pdf", data },
282
- };
324
+ return [{
325
+ type: "document",
326
+ source: { type: "base64", media_type: "application/pdf", data },
327
+ }, label];
283
328
  }
284
329
  if (att.mimeType.startsWith("text/")) {
285
330
  const content = att.buffer.toString("utf-8");
286
- const header = att.filename ? `--- ${att.filename} ---\n` : "";
287
- return { type: "text", text: `${header}${content}` };
331
+ return [{ type: "text", text: content }, label];
288
332
  }
289
333
  // Unsupported (includes non-standard image types like svg, bmp, tiff)
290
334
  const name = att.filename ?? att.mimeType;
291
335
  log.warn("[engine] unsupported attachment type: %s", att.mimeType);
292
- return { type: "text", text: `[Unsupported file: ${name}]` };
336
+ return [{ type: "text", text: `[Unsupported file type: ${name}]` }, label];
293
337
  }
294
338
  /** Pull plain text out of a tool_result content block. */
295
339
  function extractToolResultText(content) {
@@ -303,4 +347,48 @@ function extractToolResultText(content) {
303
347
  }
304
348
  return "";
305
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
+ }
306
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
@@ -1,16 +1,19 @@
1
- import type { Engine } from "../types.js";
2
- export interface SpawnConfig {
3
- command: string;
4
- args: string[];
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);
5
6
  }
6
7
  /** All known engine names (for validation / setup prompts). */
7
8
  export declare const ENGINE_NAMES: readonly ["claude-code", ...string[]];
8
9
  /** Return the CLI command an engine needs on PATH. */
9
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;
10
13
  /**
11
- * Build the engine map: claude-code (Agent SDK) + known ACP agents.
14
+ * Build the engine map: claude-code (Agent SDK) + known ACP engines.
12
15
  * Engines are lightweight — they store config, not running processes.
13
16
  *
14
- * @param enginePaths - resolved executable paths from config (e.g. { "claude-code": "/usr/local/bin/claude" })
17
+ * @param engineConfigs - executable and settings-file overrides from config
15
18
  */
16
- export declare function createEngineMap(enginePaths?: Record<string, string>): Map<string, Engine>;
19
+ export declare function createEngineMap(engineConfigs?: Record<string, Pick<EngineEntry, "path" | "configPath">>): Map<string, Engine>;
@@ -1,31 +1,114 @@
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";
1
5
  import { ClaudeCodeEngine } from "./claude-code.js";
2
6
  import { AcpEngine } from "./acp.js";
3
- /** Known ACP agent spawn configurations. */
4
- const KNOWN_ACP_AGENTS = {
5
- kiro: { command: "kiro-cli", args: ["acp"] },
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
+ },
6
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
+ }
7
66
  /** All known engine names (for validation / setup prompts). */
8
- export const ENGINE_NAMES = ["claude-code", ...Object.keys(KNOWN_ACP_AGENTS)];
9
- /** CLI binary each engine needs on PATH. */
10
- const ENGINE_COMMANDS = {
11
- "claude-code": "claude",
12
- ...Object.fromEntries(Object.entries(KNOWN_ACP_AGENTS).map(([name, cfg]) => [name, cfg.command])),
13
- };
67
+ export const ENGINE_NAMES = ["claude-code", ...Object.keys(KNOWN_ACP_ENGINES)];
14
68
  /** Return the CLI command an engine needs on PATH. */
15
69
  export function engineCommand(name) {
16
- return ENGINE_COMMANDS[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
+ }
17
85
  }
18
86
  /**
19
- * Build the engine map: claude-code (Agent SDK) + known ACP agents.
87
+ * Build the engine map: claude-code (Agent SDK) + known ACP engines.
20
88
  * Engines are lightweight — they store config, not running processes.
21
89
  *
22
- * @param enginePaths - resolved executable paths from config (e.g. { "claude-code": "/usr/local/bin/claude" })
90
+ * @param engineConfigs - executable and settings-file overrides from config
23
91
  */
24
- export function createEngineMap(enginePaths = {}) {
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
+ }
25
100
  const engines = new Map();
26
- engines.set("claude-code", new ClaudeCodeEngine(enginePaths["claude-code"]));
27
- for (const [name, config] of Object.entries(KNOWN_ACP_AGENTS)) {
28
- engines.set(name, new AcpEngine(name, config));
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
+ }));
29
112
  }
30
113
  return engines;
31
114
  }
@@ -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,7 +1,7 @@
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
6
  import { type ToolCall } from "./types.js";
7
7
  /**
package/dist/format.js CHANGED
@@ -1,9 +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 { createTwoFilesPatch } from "diff";
6
+ import { diffLines } from "diff";
7
7
  import { isKnownToolCall } from "./types.js";
8
8
  const MAX_LINES = 200;
9
9
  const MAX_STATUS_LEN = 60;
@@ -50,11 +50,8 @@ function formatToolDetail(tool) {
50
50
  case "edit": {
51
51
  if (!tool.before && !tool.after)
52
52
  return tool.path;
53
- const patch = createTwoFilesPatch(`a/${tool.path}`, `b/${tool.path}`, tool.before, tool.after, "", "", { context: 3 });
54
- const lines = patch.split("\n");
55
- const start = lines.findIndex((l) => l.startsWith("---"));
56
- const diffLines = start >= 0 ? lines.slice(start) : lines;
57
- return `${tool.path}\n\`\`\`diff\n${truncateLines(diffLines, MAX_LINES)}\n\`\`\``;
53
+ const lines = formatInterleavedDiff(tool.before, tool.after);
54
+ return `${tool.path}\n\`\`\`\n${truncateLines(lines, MAX_LINES)}\n\`\`\``;
58
55
  }
59
56
  case "write": {
60
57
  const lines = tool.content.split("\n");
@@ -180,4 +177,42 @@ function truncateLines(lines, max) {
180
177
  const remaining = lines.length - max;
181
178
  return `${shown}\n... (${remaining} more lines)`;
182
179
  }
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
+ }
215
+ }
216
+ return lines;
217
+ }
183
218
  //# sourceMappingURL=format.js.map