claude-code-rust 0.5.0 → 0.6.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/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  A native Rust terminal interface for Claude Code. Drop-in replacement for Anthropic's stock Node.js/React Ink TUI, built for performance and a better user experience.
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/claude-code-rust)](https://www.npmjs.com/package/claude-code-rust)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-code-rust)](https://www.npmjs.com/package/claude-code-rust)
5
7
  [![CI](https://github.com/srothgan/claude-code-rust/actions/workflows/ci.yml/badge.svg)](https://github.com/srothgan/claude-code-rust/actions/workflows/ci.yml)
6
8
  [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
7
- [![MSRV](https://img.shields.io/badge/MSRV-1.88.0-blue.svg)](https://blog.rust-lang.org/)
9
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org/)
8
10
 
9
11
  ## About
10
12
 
@@ -56,8 +58,6 @@ Three-layer design:
56
58
 
57
59
  ## Known Limitations
58
60
 
59
- - Token usage and cost tracking can be partial when upstream runtime events do not include full usage data.
60
- - Session resume via `--resume` depends on runtime support and account/session availability.
61
61
  - `/login` and `/logout` are intentionally not offered in command discovery for this release.
62
62
 
63
63
  ## Status
@@ -81,10 +81,10 @@ export function parseCommandEnvelope(line) {
81
81
  resume: optionalString(raw, "resume", "create_session"),
82
82
  metadata: optionalMetadata(raw, "metadata"),
83
83
  };
84
- case "load_session":
84
+ case "resume_session":
85
85
  return {
86
- command: "load_session",
87
- session_id: expectString(raw, "session_id", "load_session"),
86
+ command: "resume_session",
87
+ session_id: expectString(raw, "session_id", "resume_session"),
88
88
  metadata: optionalMetadata(raw, "metadata"),
89
89
  };
90
90
  case "new_session":
@@ -1,224 +1,22 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { TOOL_RESULT_TYPES, buildToolResultFields, createToolCall, isToolUseBlockType } from "./tooling.js";
5
1
  import { asRecordOrNull } from "./shared.js";
2
+ import { TOOL_RESULT_TYPES, buildToolResultFields, createToolCall, isToolUseBlockType } from "./tooling.js";
6
3
  import { buildUsageUpdateFromResult } from "./usage.js";
7
- function normalizeUserPromptText(raw) {
8
- let text = raw.replace(/<context[\s\S]*/gi, " ");
9
- text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
10
- text = text.replace(/\s+/g, " ").trim();
11
- return text;
12
- }
13
- function truncateTextByChars(text, maxChars) {
14
- const chars = Array.from(text);
15
- if (chars.length <= maxChars) {
16
- return text;
17
- }
18
- return chars.slice(0, maxChars).join("");
19
- }
20
- function firstUserMessageTitleFromRecord(record) {
21
- if (record.type !== "user") {
22
- return undefined;
23
- }
24
- const message = asRecordOrNull(record.message);
25
- if (!message || message.role !== "user" || !Array.isArray(message.content)) {
26
- return undefined;
27
- }
28
- const parts = [];
29
- for (const item of message.content) {
30
- const block = asRecordOrNull(item);
31
- if (!block || block.type !== "text" || typeof block.text !== "string") {
32
- continue;
33
- }
34
- const cleaned = normalizeUserPromptText(block.text);
35
- if (!cleaned) {
36
- continue;
37
- }
38
- parts.push(cleaned);
39
- const combined = parts.join(" ");
40
- if (Array.from(combined).length >= 180) {
41
- return truncateTextByChars(combined, 180);
42
- }
43
- }
44
- if (parts.length === 0) {
4
+ function nonEmptyTrimmed(value) {
5
+ if (typeof value !== "string") {
45
6
  return undefined;
46
7
  }
47
- return truncateTextByChars(parts.join(" "), 180);
48
- }
49
- function extractSessionPreviewFromJsonl(filePath) {
50
- let text;
51
- try {
52
- text = fs.readFileSync(filePath, "utf8");
53
- }
54
- catch {
55
- return {};
56
- }
57
- let cwd;
58
- let title;
59
- const lines = text.split(/\r?\n/);
60
- for (const rawLine of lines) {
61
- const line = rawLine.trim();
62
- if (line.length === 0) {
63
- continue;
64
- }
65
- let parsed;
66
- try {
67
- parsed = JSON.parse(line);
68
- }
69
- catch {
70
- continue;
71
- }
72
- const record = asRecordOrNull(parsed);
73
- if (!record) {
74
- continue;
75
- }
76
- if (!cwd && typeof record.cwd === "string" && record.cwd.trim().length > 0) {
77
- cwd = record.cwd;
78
- }
79
- if (!title) {
80
- title = firstUserMessageTitleFromRecord(record);
81
- }
82
- if (cwd && title) {
83
- break;
84
- }
85
- }
86
- return {
87
- ...(cwd ? { cwd } : {}),
88
- ...(title ? { title } : {}),
89
- };
8
+ const trimmed = value.trim();
9
+ return trimmed.length > 0 ? trimmed : undefined;
90
10
  }
91
- export function listRecentPersistedSessions(limit = 8) {
92
- const root = path.join(os.homedir(), ".claude", "projects");
93
- if (!fs.existsSync(root)) {
94
- return [];
95
- }
11
+ function messageCandidates(raw) {
96
12
  const candidates = [];
97
- let projectDirs;
98
- try {
99
- projectDirs = fs.readdirSync(root, { withFileTypes: true }).filter((dirent) => dirent.isDirectory());
100
- }
101
- catch {
102
- return [];
103
- }
104
- for (const dirent of projectDirs) {
105
- const projectDir = path.join(root, dirent.name);
106
- let sessionFiles;
107
- try {
108
- sessionFiles = fs
109
- .readdirSync(projectDir, { withFileTypes: true })
110
- .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"));
111
- }
112
- catch {
113
- sessionFiles = [];
114
- }
115
- for (const sessionFile of sessionFiles) {
116
- const sessionId = sessionFile.name.slice(0, -".jsonl".length);
117
- if (!sessionId) {
118
- continue;
119
- }
120
- let mtimeMs = 0;
121
- try {
122
- mtimeMs = fs.statSync(path.join(projectDir, sessionFile.name)).mtimeMs;
123
- }
124
- catch {
125
- continue;
126
- }
127
- if (!Number.isFinite(mtimeMs) || mtimeMs <= 0) {
128
- continue;
129
- }
130
- candidates.push({
131
- session_id: sessionId,
132
- cwd: "",
133
- file_path: path.join(projectDir, sessionFile.name),
134
- updated_at: new Date(mtimeMs).toISOString(),
135
- sort_ms: mtimeMs,
136
- });
137
- }
138
- }
139
- candidates.sort((a, b) => b.sort_ms - a.sort_ms);
140
- const deduped = [];
141
- const seen = new Set();
142
- for (const candidate of candidates) {
143
- if (seen.has(candidate.session_id)) {
144
- continue;
145
- }
146
- seen.add(candidate.session_id);
147
- const preview = extractSessionPreviewFromJsonl(candidate.file_path);
148
- const cwd = preview.cwd?.trim();
149
- if (!cwd) {
150
- continue;
151
- }
152
- deduped.push({
153
- session_id: candidate.session_id,
154
- cwd,
155
- file_path: candidate.file_path,
156
- ...(preview.title ? { title: preview.title } : {}),
157
- ...(candidate.updated_at ? { updated_at: candidate.updated_at } : {}),
158
- sort_ms: candidate.sort_ms,
159
- });
160
- if (deduped.length >= limit) {
161
- break;
162
- }
163
- }
164
- return deduped;
165
- }
166
- export function resolvePersistedSessionEntry(sessionId) {
167
- if (sessionId.trim().length === 0 ||
168
- sessionId.includes("/") ||
169
- sessionId.includes("\\") ||
170
- sessionId.includes("..")) {
171
- return null;
172
- }
173
- const root = path.join(os.homedir(), ".claude", "projects");
174
- if (!fs.existsSync(root)) {
175
- return null;
176
- }
177
- let projectDirs;
178
- try {
179
- projectDirs = fs.readdirSync(root, { withFileTypes: true }).filter((dirent) => dirent.isDirectory());
180
- }
181
- catch {
182
- return null;
183
- }
184
- let best = null;
185
- for (const dirent of projectDirs) {
186
- const filePath = path.join(root, dirent.name, `${sessionId}.jsonl`);
187
- if (!fs.existsSync(filePath)) {
188
- continue;
189
- }
190
- const preview = extractSessionPreviewFromJsonl(filePath);
191
- const cwd = preview.cwd?.trim();
192
- if (!cwd) {
193
- continue;
194
- }
195
- let mtimeMs = 0;
196
- try {
197
- mtimeMs = fs.statSync(filePath).mtimeMs;
198
- }
199
- catch {
200
- mtimeMs = 0;
201
- }
202
- if (!best || mtimeMs >= best.sort_ms) {
203
- best = {
204
- session_id: sessionId,
205
- cwd,
206
- file_path: filePath,
207
- sort_ms: mtimeMs,
208
- };
209
- }
210
- }
211
- return best;
212
- }
213
- function persistedMessageCandidates(record) {
214
- const candidates = [];
215
- const topLevel = asRecordOrNull(record.message);
13
+ const topLevel = asRecordOrNull(raw);
216
14
  if (topLevel) {
217
15
  candidates.push(topLevel);
218
- }
219
- const nested = asRecordOrNull(asRecordOrNull(asRecordOrNull(record.data)?.message)?.message);
220
- if (nested) {
221
- candidates.push(nested);
16
+ const nested = asRecordOrNull(topLevel.message);
17
+ if (nested) {
18
+ candidates.push(nested);
19
+ }
222
20
  }
223
21
  return candidates;
224
22
  }
@@ -278,39 +76,49 @@ function pushResumeUsageUpdate(updates, message, emittedUsageMessageIds) {
278
76
  emittedUsageMessageIds.add(messageId);
279
77
  }
280
78
  }
281
- export function extractSessionHistoryUpdatesFromJsonl(filePath) {
282
- let text;
283
- try {
284
- text = fs.readFileSync(filePath, "utf8");
285
- }
286
- catch {
287
- return [];
79
+ function summaryFromSession(info) {
80
+ return (nonEmptyTrimmed(info.summary) ??
81
+ nonEmptyTrimmed(info.customTitle) ??
82
+ nonEmptyTrimmed(info.firstPrompt) ??
83
+ info.sessionId);
84
+ }
85
+ export function mapSdkSessionInfo(info) {
86
+ return {
87
+ session_id: info.sessionId,
88
+ summary: summaryFromSession(info),
89
+ last_modified_ms: info.lastModified,
90
+ file_size_bytes: info.fileSize,
91
+ ...(nonEmptyTrimmed(info.cwd) ? { cwd: info.cwd?.trim() } : {}),
92
+ ...(nonEmptyTrimmed(info.gitBranch) ? { git_branch: info.gitBranch?.trim() } : {}),
93
+ ...(nonEmptyTrimmed(info.customTitle) ? { custom_title: info.customTitle?.trim() } : {}),
94
+ ...(nonEmptyTrimmed(info.firstPrompt) ? { first_prompt: info.firstPrompt?.trim() } : {}),
95
+ };
96
+ }
97
+ export function mapSdkSessions(infos, limit = 50) {
98
+ const sorted = [...infos].sort((a, b) => b.lastModified - a.lastModified);
99
+ const entries = [];
100
+ const seen = new Set();
101
+ for (const info of sorted) {
102
+ if (!info.sessionId || seen.has(info.sessionId)) {
103
+ continue;
104
+ }
105
+ seen.add(info.sessionId);
106
+ entries.push(mapSdkSessionInfo(info));
107
+ if (entries.length >= limit) {
108
+ break;
109
+ }
288
110
  }
111
+ return entries;
112
+ }
113
+ export function mapSessionMessagesToUpdates(messages) {
289
114
  const updates = [];
290
115
  const toolCalls = new Map();
291
116
  const emittedUsageMessageIds = new Set();
292
- const lines = text.split(/\r?\n/);
293
- for (const rawLine of lines) {
294
- const line = rawLine.trim();
295
- if (line.length === 0) {
296
- continue;
297
- }
298
- let parsed;
299
- try {
300
- parsed = JSON.parse(line);
301
- }
302
- catch {
303
- continue;
304
- }
305
- const record = asRecordOrNull(parsed);
306
- if (!record) {
307
- continue;
308
- }
309
- for (const message of persistedMessageCandidates(record)) {
310
- const role = message.role;
311
- if (role !== "user" && role !== "assistant") {
312
- continue;
313
- }
117
+ for (const entry of messages) {
118
+ const fallbackRole = entry.type === "assistant" ? "assistant" : "user";
119
+ for (const message of messageCandidates(entry.message)) {
120
+ const roleCandidate = message.role;
121
+ const role = roleCandidate === "assistant" || roleCandidate === "user" ? roleCandidate : fallbackRole;
314
122
  const content = Array.isArray(message.content) ? message.content : [];
315
123
  for (const item of content) {
316
124
  const block = asRecordOrNull(item);
@@ -34,6 +34,7 @@ export function normalizeToolKind(name) {
34
34
  case "TodoWrite":
35
35
  return "other";
36
36
  case "Task":
37
+ case "Agent":
37
38
  return "think";
38
39
  case "ExitPlanMode":
39
40
  return "switch_mode";
@@ -169,16 +170,37 @@ function persistedOutputFirstLine(text) {
169
170
  }
170
171
  return null;
171
172
  }
172
- export function normalizeToolResultText(value) {
173
+ /**
174
+ * Replace verbose SDK-internal tool rejection messages with short user-facing text.
175
+ * The SDK sends these as tool result content meant for Claude, not for the user.
176
+ */
177
+ const USER_REJECTED_TOOL_USE_EXACT = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
178
+ const USER_REJECTED_TOOL_USE_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:";
179
+ const PERMISSION_DENIED_TOOL_USE_EXACT = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task.";
180
+ const PERMISSION_DENIED_TOOL_USE_PREFIX = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:";
181
+ function sanitizeSdkRejectionText(text) {
182
+ const normalized = text.trim();
183
+ if (normalized === USER_REJECTED_TOOL_USE_EXACT ||
184
+ normalized.startsWith(USER_REJECTED_TOOL_USE_PREFIX)) {
185
+ return "Cancelled by user.";
186
+ }
187
+ if (normalized === PERMISSION_DENIED_TOOL_USE_EXACT ||
188
+ normalized.startsWith(PERMISSION_DENIED_TOOL_USE_PREFIX)) {
189
+ return "Permission denied.";
190
+ }
191
+ return text;
192
+ }
193
+ export function normalizeToolResultText(value, isError = false) {
173
194
  const text = extractText(value);
174
195
  if (!text) {
175
196
  return "";
176
197
  }
177
198
  const persistedLine = persistedOutputFirstLine(text);
178
- if (persistedLine) {
179
- return persistedLine;
199
+ const normalized = persistedLine || text;
200
+ if (!isError) {
201
+ return normalized;
180
202
  }
181
- return text;
203
+ return sanitizeSdkRejectionText(normalized);
182
204
  }
183
205
  function resolveToolName(toolCall) {
184
206
  const meta = asRecordOrNull(toolCall?.meta);
@@ -242,7 +264,7 @@ function writeDiffFromResult(rawContent) {
242
264
  return [];
243
265
  }
244
266
  export function buildToolResultFields(isError, rawContent, base) {
245
- const rawOutput = normalizeToolResultText(rawContent);
267
+ const rawOutput = normalizeToolResultText(rawContent, isError);
246
268
  const toolName = resolveToolName(base);
247
269
  const fields = {
248
270
  status: isError ? "failed" : "completed",
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import readline from "node:readline";
6
6
  import { pathToFileURL } from "node:url";
7
- import { query, } from "@anthropic-ai/claude-agent-sdk";
7
+ import { getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
8
8
  import { parseCommandEnvelope, toPermissionMode, buildModeState } from "./bridge/commands.js";
9
9
  import { asRecordOrNull } from "./bridge/shared.js";
10
10
  import { looksLikeAuthRequired } from "./bridge/auth.js";
@@ -12,10 +12,11 @@ import { TOOL_RESULT_TYPES, buildToolResultFields, createToolCall, normalizeTool
12
12
  import { CACHE_SPLIT_POLICY, previewKilobyteLabel } from "./bridge/cache_policy.js";
13
13
  import { buildUsageUpdateFromResult, buildUsageUpdateFromResultForSession } from "./bridge/usage.js";
14
14
  import { formatPermissionUpdates, permissionOptionsFromSuggestions, permissionResultFromOutcome, } from "./bridge/permissions.js";
15
- import { extractSessionHistoryUpdatesFromJsonl, listRecentPersistedSessions, resolvePersistedSessionEntry, } from "./bridge/history.js";
16
- export { CACHE_SPLIT_POLICY, buildToolResultFields, buildUsageUpdateFromResult, createToolCall, extractSessionHistoryUpdatesFromJsonl, looksLikeAuthRequired, normalizeToolKind, normalizeToolResultText, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, unwrapToolUseResult, };
15
+ import { mapSdkSessions, mapSessionMessagesToUpdates, } from "./bridge/history.js";
16
+ export { CACHE_SPLIT_POLICY, buildToolResultFields, buildUsageUpdateFromResult, createToolCall, mapSessionMessagesToUpdates, mapSdkSessions, looksLikeAuthRequired, normalizeToolKind, normalizeToolResultText, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, unwrapToolUseResult, };
17
17
  const sessions = new Map();
18
- const EXPECTED_AGENT_SDK_VERSION = "0.2.52";
18
+ const EXPECTED_AGENT_SDK_VERSION = "0.2.63";
19
+ const SESSION_LIST_LIMIT = 50;
19
20
  const require = createRequire(import.meta.url);
20
21
  const permissionDebugEnabled = process.env.CLAUDE_RS_SDK_PERMISSION_DEBUG === "1" || process.env.CLAUDE_RS_SDK_DEBUG === "1";
21
22
  export function resolveInstalledAgentSdkVersion() {
@@ -130,6 +131,7 @@ function emitConnectEvent(session) {
130
131
  const staleSessions = session.sessionsToCloseAfterConnect;
131
132
  session.sessionsToCloseAfterConnect = undefined;
132
133
  if (!staleSessions || staleSessions.length === 0) {
134
+ refreshSessionsList();
133
135
  return;
134
136
  }
135
137
  void (async () => {
@@ -142,8 +144,25 @@ function emitConnectEvent(session) {
142
144
  }
143
145
  await closeSession(stale);
144
146
  }
147
+ refreshSessionsList();
145
148
  })();
146
149
  }
150
+ async function emitSessionsList(requestId) {
151
+ try {
152
+ const sdkSessions = await listSessions({ limit: SESSION_LIST_LIMIT });
153
+ writeEvent({ event: "sessions_listed", sessions: mapSdkSessions(sdkSessions, SESSION_LIST_LIMIT) }, requestId);
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ console.error(`[sdk warn] listSessions failed: ${message}`);
158
+ writeEvent({ event: "sessions_listed", sessions: [] }, requestId);
159
+ }
160
+ }
161
+ function refreshSessionsList() {
162
+ void emitSessionsList().catch(() => {
163
+ // Defensive no-op.
164
+ });
165
+ }
147
166
  function textFromPrompt(command) {
148
167
  const chunks = command.chunks ?? [];
149
168
  return chunks
@@ -346,7 +365,7 @@ function handleTaskSystemMessage(session, subtype, msg) {
346
365
  if (!toolUseId) {
347
366
  return;
348
367
  }
349
- const toolCall = ensureToolCallVisible(session, toolUseId, "Task", {});
368
+ const toolCall = ensureToolCallVisible(session, toolUseId, "Agent", {});
350
369
  if (toolCall.status === "pending") {
351
370
  toolCall.status = "in_progress";
352
371
  emitSessionUpdate(session.sessionId, {
@@ -568,11 +587,150 @@ function numberField(record, ...keys) {
568
587
  }
569
588
  return undefined;
570
589
  }
590
+ export function parseFastModeState(value) {
591
+ if (value === "off" || value === "cooldown" || value === "on") {
592
+ return value;
593
+ }
594
+ return null;
595
+ }
596
+ export function parseRateLimitStatus(value) {
597
+ if (value === "allowed" || value === "allowed_warning" || value === "rejected") {
598
+ return value;
599
+ }
600
+ return null;
601
+ }
602
+ export function buildRateLimitUpdate(rateLimitInfo) {
603
+ const info = asRecordOrNull(rateLimitInfo);
604
+ if (!info) {
605
+ return null;
606
+ }
607
+ const status = parseRateLimitStatus(info.status);
608
+ if (!status) {
609
+ return null;
610
+ }
611
+ const update = {
612
+ type: "rate_limit_update",
613
+ status,
614
+ };
615
+ const resetsAt = numberField(info, "resetsAt");
616
+ if (resetsAt !== undefined) {
617
+ update.resets_at = resetsAt;
618
+ }
619
+ const utilization = numberField(info, "utilization");
620
+ if (utilization !== undefined) {
621
+ update.utilization = utilization;
622
+ }
623
+ if (typeof info.rateLimitType === "string" && info.rateLimitType.length > 0) {
624
+ update.rate_limit_type = info.rateLimitType;
625
+ }
626
+ const overageStatus = parseRateLimitStatus(info.overageStatus);
627
+ if (overageStatus) {
628
+ update.overage_status = overageStatus;
629
+ }
630
+ const overageResetsAt = numberField(info, "overageResetsAt");
631
+ if (overageResetsAt !== undefined) {
632
+ update.overage_resets_at = overageResetsAt;
633
+ }
634
+ if (typeof info.overageDisabledReason === "string" && info.overageDisabledReason.length > 0) {
635
+ update.overage_disabled_reason = info.overageDisabledReason;
636
+ }
637
+ if (typeof info.isUsingOverage === "boolean") {
638
+ update.is_using_overage = info.isUsingOverage;
639
+ }
640
+ const surpassedThreshold = numberField(info, "surpassedThreshold");
641
+ if (surpassedThreshold !== undefined) {
642
+ update.surpassed_threshold = surpassedThreshold;
643
+ }
644
+ return update;
645
+ }
646
+ function availableAgentsSignature(agents) {
647
+ return JSON.stringify(agents);
648
+ }
649
+ function normalizeAvailableAgentName(value) {
650
+ if (typeof value !== "string") {
651
+ return "";
652
+ }
653
+ return value.trim();
654
+ }
655
+ export function mapAvailableAgents(value) {
656
+ if (!Array.isArray(value)) {
657
+ return [];
658
+ }
659
+ const byName = new Map();
660
+ for (const entry of value) {
661
+ if (!entry || typeof entry !== "object") {
662
+ continue;
663
+ }
664
+ const record = entry;
665
+ const name = normalizeAvailableAgentName(record.name);
666
+ if (!name) {
667
+ continue;
668
+ }
669
+ const description = typeof record.description === "string" ? record.description : "";
670
+ const model = typeof record.model === "string" && record.model.trim().length > 0 ? record.model : undefined;
671
+ const existing = byName.get(name);
672
+ if (!existing) {
673
+ byName.set(name, { name, description, model });
674
+ continue;
675
+ }
676
+ if (existing.description.trim().length === 0 && description.trim().length > 0) {
677
+ existing.description = description;
678
+ }
679
+ if (!existing.model && model) {
680
+ existing.model = model;
681
+ }
682
+ }
683
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
684
+ }
685
+ function mapAvailableAgentsFromNames(value) {
686
+ if (!Array.isArray(value)) {
687
+ return [];
688
+ }
689
+ const byName = new Map();
690
+ for (const entry of value) {
691
+ const name = normalizeAvailableAgentName(entry);
692
+ if (!name || byName.has(name)) {
693
+ continue;
694
+ }
695
+ byName.set(name, { name, description: "" });
696
+ }
697
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
698
+ }
699
+ function emitAvailableAgentsIfChanged(session, agents) {
700
+ const signature = availableAgentsSignature(agents);
701
+ if (session.lastAvailableAgentsSignature === signature) {
702
+ return;
703
+ }
704
+ session.lastAvailableAgentsSignature = signature;
705
+ emitSessionUpdate(session.sessionId, { type: "available_agents_update", agents });
706
+ }
707
+ function refreshAvailableAgents(session) {
708
+ if (typeof session.query.supportedAgents !== "function") {
709
+ return;
710
+ }
711
+ void session.query
712
+ .supportedAgents()
713
+ .then((agents) => {
714
+ emitAvailableAgentsIfChanged(session, mapAvailableAgents(agents));
715
+ })
716
+ .catch(() => {
717
+ // Best-effort only.
718
+ });
719
+ }
720
+ function emitFastModeUpdateIfChanged(session, value) {
721
+ const next = parseFastModeState(value);
722
+ if (!next || next === session.fastModeState) {
723
+ return;
724
+ }
725
+ session.fastModeState = next;
726
+ emitSessionUpdate(session.sessionId, { type: "fast_mode_update", fast_mode_state: next });
727
+ }
571
728
  function handleResultMessage(session, message) {
572
729
  const usageUpdate = buildUsageUpdateFromResultForSession(session, message);
573
730
  if (usageUpdate) {
574
731
  emitSessionUpdate(session.sessionId, usageUpdate);
575
732
  }
733
+ emitFastModeUpdateIfChanged(session, message.fast_mode_state);
576
734
  const subtype = typeof message.subtype === "string" ? message.subtype : "";
577
735
  if (subtype === "success") {
578
736
  session.lastAssistantError = undefined;
@@ -619,6 +777,7 @@ function handleSdkMessage(session, message) {
619
777
  if (incomingMode) {
620
778
  session.mode = incomingMode;
621
779
  }
780
+ emitFastModeUpdateIfChanged(session, msg.fast_mode_state);
622
781
  if (!session.connected) {
623
782
  emitConnectEvent(session);
624
783
  }
@@ -635,6 +794,7 @@ function handleSdkMessage(session, message) {
635
794
  : {}),
636
795
  });
637
796
  session.resumeUpdates = undefined;
797
+ refreshSessionsList();
638
798
  }
639
799
  if (Array.isArray(msg.slash_commands)) {
640
800
  const commands = msg.slash_commands
@@ -644,6 +804,9 @@ function handleSdkMessage(session, message) {
644
804
  emitSessionUpdate(session.sessionId, { type: "available_commands_update", commands });
645
805
  }
646
806
  }
807
+ if (session.lastAvailableAgentsSignature === undefined && Array.isArray(msg.agents)) {
808
+ emitAvailableAgentsIfChanged(session, mapAvailableAgentsFromNames(msg.agents));
809
+ }
647
810
  void session.query
648
811
  .supportedCommands()
649
812
  .then((commands) => {
@@ -657,6 +820,7 @@ function handleSdkMessage(session, message) {
657
820
  .catch(() => {
658
821
  // Best-effort only; slash commands from init were already emitted.
659
822
  });
823
+ refreshAvailableAgents(session);
660
824
  return;
661
825
  }
662
826
  if (subtype === "status") {
@@ -671,6 +835,7 @@ function handleSdkMessage(session, message) {
671
835
  else if (msg.status === null) {
672
836
  emitSessionUpdate(session.sessionId, { type: "session_status_update", status: "idle" });
673
837
  }
838
+ emitFastModeUpdateIfChanged(session, msg.fast_mode_state);
674
839
  return;
675
840
  }
676
841
  if (subtype === "compact_boundary") {
@@ -689,6 +854,20 @@ function handleSdkMessage(session, message) {
689
854
  }
690
855
  return;
691
856
  }
857
+ if (subtype === "local_command_output") {
858
+ const content = typeof msg.content === "string" ? msg.content : "";
859
+ if (content.trim().length > 0) {
860
+ emitSessionUpdate(session.sessionId, {
861
+ type: "agent_message_chunk",
862
+ content: { type: "text", text: content },
863
+ });
864
+ }
865
+ return;
866
+ }
867
+ if (subtype === "elicitation_complete") {
868
+ // No-op: elicitation flow is auto-canceled in the onElicitation callback.
869
+ return;
870
+ }
692
871
  handleTaskSystemMessage(session, subtype, msg);
693
872
  return;
694
873
  }
@@ -729,6 +908,13 @@ function handleSdkMessage(session, message) {
729
908
  }
730
909
  return;
731
910
  }
911
+ if (type === "rate_limit_event") {
912
+ const update = buildRateLimitUpdate(msg.rate_limit_info);
913
+ if (update) {
914
+ emitSessionUpdate(session.sessionId, update);
915
+ }
916
+ return;
917
+ }
732
918
  if (type === "user") {
733
919
  handleUserToolResultBlocks(session, msg);
734
920
  const toolUseId = typeof msg.parent_tool_use_id === "string" ? msg.parent_tool_use_id : "";
@@ -751,6 +937,42 @@ function handleSdkMessage(session, message) {
751
937
  }
752
938
  const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion";
753
939
  const QUESTION_CHOICE_KIND = "question_choice";
940
+ const EXIT_PLAN_MODE_TOOL_NAME = "ExitPlanMode";
941
+ const PLAN_APPROVE_KIND = "plan_approve";
942
+ const PLAN_REJECT_KIND = "plan_reject";
943
+ async function requestExitPlanModeApproval(session, toolUseId, inputData, baseToolCall) {
944
+ const options = [
945
+ {
946
+ option_id: "approve",
947
+ name: "Approve",
948
+ description: "Approve the plan and continue",
949
+ kind: PLAN_APPROVE_KIND,
950
+ },
951
+ {
952
+ option_id: "reject",
953
+ name: "Reject",
954
+ description: "Reject the plan",
955
+ kind: PLAN_REJECT_KIND,
956
+ },
957
+ ];
958
+ const request = {
959
+ tool_call: baseToolCall,
960
+ options,
961
+ };
962
+ const outcome = await new Promise((resolve) => {
963
+ session.pendingPermissions.set(toolUseId, {
964
+ onOutcome: resolve,
965
+ toolName: EXIT_PLAN_MODE_TOOL_NAME,
966
+ inputData,
967
+ });
968
+ writeEvent({ event: "permission_request", session_id: session.sessionId, request });
969
+ });
970
+ if (outcome.outcome !== "selected" || outcome.option_id === "reject") {
971
+ setToolCallStatus(session, toolUseId, "failed", "Plan rejected");
972
+ return { behavior: "deny", message: "Plan rejected", toolUseID: toolUseId };
973
+ }
974
+ return { behavior: "allow", updatedInput: inputData, toolUseID: toolUseId };
975
+ }
754
976
  function parseAskUserQuestionPrompts(inputData) {
755
977
  const rawQuestions = Array.isArray(inputData.questions) ? inputData.questions : [];
756
978
  const prompts = [];
@@ -906,8 +1128,9 @@ async function createSession(params) {
906
1128
  let session;
907
1129
  const canUseTool = async (toolName, inputData, options) => {
908
1130
  const toolUseId = options.toolUseID;
909
- if (toolName === "ExitPlanMode") {
910
- return { behavior: "allow", toolUseID: toolUseId };
1131
+ if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
1132
+ const existing = ensureToolCallVisible(session, toolUseId, toolName, inputData);
1133
+ return await requestExitPlanModeApproval(session, toolUseId, inputData, existing);
911
1134
  }
912
1135
  logPermissionDebug(`request tool_use_id=${toolUseId} tool=${toolName} blocked_path=${options.blockedPath ?? "<none>"} ` +
913
1136
  `decision_reason=${options.decisionReason ?? "<none>"} suggestions=${formatPermissionUpdates(options.suggestions)}`);
@@ -981,6 +1204,19 @@ async function createSession(params) {
981
1204
  resume: params.resume,
982
1205
  model: params.model,
983
1206
  canUseTool,
1207
+ onElicitation: async (request) => {
1208
+ const requestMode = typeof request.mode === "string" ? request.mode : "unknown";
1209
+ const requestServer = typeof request.serverName === "string" && request.serverName.trim().length > 0
1210
+ ? request.serverName
1211
+ : "unknown";
1212
+ const requestMessage = typeof request.message === "string" && request.message.trim().length > 0
1213
+ ? request.message
1214
+ : "<no message>";
1215
+ console.error(`[sdk warn] elicitation unsupported without MCP settings UI; ` +
1216
+ `auto-canceling session_id=${session.sessionId} server=${requestServer} ` +
1217
+ `mode=${requestMode} message=${JSON.stringify(requestMessage)}`);
1218
+ return { action: "cancel" };
1219
+ },
984
1220
  },
985
1221
  });
986
1222
  }
@@ -995,6 +1231,7 @@ async function createSession(params) {
995
1231
  cwd: params.cwd,
996
1232
  model: params.model ?? "default",
997
1233
  mode: startMode,
1234
+ fastModeState: "off",
998
1235
  yolo: params.yolo,
999
1236
  query: queryHandle,
1000
1237
  input,
@@ -1022,6 +1259,7 @@ async function createSession(params) {
1022
1259
  if (!session.connected) {
1023
1260
  emitConnectEvent(session);
1024
1261
  }
1262
+ emitFastModeUpdateIfChanged(session, result.fast_mode_state);
1025
1263
  const commands = Array.isArray(result.commands)
1026
1264
  ? result.commands.map((command) => ({
1027
1265
  name: command.name,
@@ -1032,6 +1270,8 @@ async function createSession(params) {
1032
1270
  if (commands.length > 0) {
1033
1271
  emitSessionUpdate(session.sessionId, { type: "available_commands_update", commands });
1034
1272
  }
1273
+ emitAvailableAgentsIfChanged(session, mapAvailableAgents(result.agents));
1274
+ refreshAvailableAgents(session);
1035
1275
  })
1036
1276
  .catch((error) => {
1037
1277
  if (session.connected) {
@@ -1129,21 +1369,12 @@ async function handleCommand(command, requestId) {
1129
1369
  capabilities: {
1130
1370
  prompt_image: false,
1131
1371
  prompt_embedded_context: true,
1132
- load_session: true,
1133
- supports_list_sessions: true,
1134
- supports_resume: true,
1372
+ supports_session_listing: true,
1373
+ supports_resume_session: true,
1135
1374
  },
1136
1375
  },
1137
1376
  }, requestId);
1138
- writeEvent({
1139
- event: "sessions_listed",
1140
- sessions: listRecentPersistedSessions().map((entry) => ({
1141
- session_id: entry.session_id,
1142
- cwd: entry.cwd,
1143
- ...(entry.title ? { title: entry.title } : {}),
1144
- ...(entry.updated_at ? { updated_at: entry.updated_at } : {}),
1145
- })),
1146
- });
1377
+ await emitSessionsList(requestId);
1147
1378
  return;
1148
1379
  case "create_session":
1149
1380
  await createSession({
@@ -1155,18 +1386,20 @@ async function handleCommand(command, requestId) {
1155
1386
  requestId,
1156
1387
  });
1157
1388
  return;
1158
- case "load_session": {
1159
- const persisted = resolvePersistedSessionEntry(command.session_id);
1160
- if (!persisted) {
1161
- slashError(command.session_id, `unknown session: ${command.session_id}`, requestId);
1162
- return;
1163
- }
1164
- const resumeUpdates = extractSessionHistoryUpdatesFromJsonl(persisted.file_path);
1165
- const staleSessions = Array.from(sessions.values());
1166
- const hadActiveSession = staleSessions.length > 0;
1389
+ case "resume_session": {
1167
1390
  try {
1391
+ const sdkSessions = await listSessions();
1392
+ const matched = sdkSessions.find((entry) => entry.sessionId === command.session_id);
1393
+ if (!matched) {
1394
+ slashError(command.session_id, `unknown session: ${command.session_id}`, requestId);
1395
+ return;
1396
+ }
1397
+ const historyMessages = await getSessionMessages(command.session_id, matched.cwd ? { dir: matched.cwd } : undefined);
1398
+ const resumeUpdates = mapSessionMessagesToUpdates(historyMessages);
1399
+ const staleSessions = Array.from(sessions.values());
1400
+ const hadActiveSession = staleSessions.length > 0;
1168
1401
  await createSession({
1169
- cwd: persisted.cwd,
1402
+ cwd: matched.cwd ?? process.cwd(),
1170
1403
  yolo: false,
1171
1404
  resume: command.session_id,
1172
1405
  ...(resumeUpdates.length > 0 ? { resumeUpdates } : {}),
@@ -1,9 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { CACHE_SPLIT_POLICY, buildToolResultFields, buildUsageUpdateFromResult, createToolCall, extractSessionHistoryUpdatesFromJsonl, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
3
+ import { CACHE_SPLIT_POLICY, buildRateLimitUpdate, buildToolResultFields, buildUsageUpdateFromResult, createToolCall, mapAvailableAgents, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRateLimitStatus, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
7
4
  test("parseCommandEnvelope validates initialize command", () => {
8
5
  const parsed = parseCommandEnvelope(JSON.stringify({
9
6
  request_id: "req-1",
@@ -17,15 +14,15 @@ test("parseCommandEnvelope validates initialize command", () => {
17
14
  }
18
15
  assert.equal(parsed.command.cwd, "C:/work");
19
16
  });
20
- test("parseCommandEnvelope validates load_session command without cwd", () => {
17
+ test("parseCommandEnvelope validates resume_session command without cwd", () => {
21
18
  const parsed = parseCommandEnvelope(JSON.stringify({
22
19
  request_id: "req-2",
23
- command: "load_session",
20
+ command: "resume_session",
24
21
  session_id: "session-123",
25
22
  }));
26
23
  assert.equal(parsed.requestId, "req-2");
27
- assert.equal(parsed.command.command, "load_session");
28
- if (parsed.command.command !== "load_session") {
24
+ assert.equal(parsed.command.command, "resume_session");
25
+ if (parsed.command.command !== "resume_session") {
29
26
  throw new Error("unexpected command variant");
30
27
  }
31
28
  assert.equal(parsed.command.session_id, "session-123");
@@ -37,9 +34,76 @@ test("normalizeToolKind maps known tool names", () => {
37
34
  assert.equal(normalizeToolKind("Bash"), "execute");
38
35
  assert.equal(normalizeToolKind("Delete"), "delete");
39
36
  assert.equal(normalizeToolKind("Move"), "move");
37
+ assert.equal(normalizeToolKind("Task"), "think");
38
+ assert.equal(normalizeToolKind("Agent"), "think");
40
39
  assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode");
41
40
  assert.equal(normalizeToolKind("TodoWrite"), "other");
42
41
  });
42
+ test("parseFastModeState accepts known values and rejects unknown values", () => {
43
+ assert.equal(parseFastModeState("off"), "off");
44
+ assert.equal(parseFastModeState("cooldown"), "cooldown");
45
+ assert.equal(parseFastModeState("on"), "on");
46
+ assert.equal(parseFastModeState("CD"), null);
47
+ assert.equal(parseFastModeState(undefined), null);
48
+ });
49
+ test("parseRateLimitStatus accepts known values and rejects unknown values", () => {
50
+ assert.equal(parseRateLimitStatus("allowed"), "allowed");
51
+ assert.equal(parseRateLimitStatus("allowed_warning"), "allowed_warning");
52
+ assert.equal(parseRateLimitStatus("rejected"), "rejected");
53
+ assert.equal(parseRateLimitStatus("warn"), null);
54
+ assert.equal(parseRateLimitStatus(undefined), null);
55
+ });
56
+ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
57
+ const update = buildRateLimitUpdate({
58
+ status: "allowed_warning",
59
+ resetsAt: 1_741_280_000,
60
+ utilization: 0.92,
61
+ rateLimitType: "five_hour",
62
+ overageStatus: "rejected",
63
+ overageResetsAt: 1_741_280_600,
64
+ overageDisabledReason: "out_of_credits",
65
+ isUsingOverage: false,
66
+ surpassedThreshold: 0.9,
67
+ });
68
+ assert.deepEqual(update, {
69
+ type: "rate_limit_update",
70
+ status: "allowed_warning",
71
+ resets_at: 1_741_280_000,
72
+ utilization: 0.92,
73
+ rate_limit_type: "five_hour",
74
+ overage_status: "rejected",
75
+ overage_resets_at: 1_741_280_600,
76
+ overage_disabled_reason: "out_of_credits",
77
+ is_using_overage: false,
78
+ surpassed_threshold: 0.9,
79
+ });
80
+ });
81
+ test("buildRateLimitUpdate rejects invalid payloads", () => {
82
+ assert.equal(buildRateLimitUpdate(null), null);
83
+ assert.equal(buildRateLimitUpdate({}), null);
84
+ assert.equal(buildRateLimitUpdate({ status: "warning" }), null);
85
+ assert.deepEqual(buildRateLimitUpdate({
86
+ status: "rejected",
87
+ overageStatus: "bad_status",
88
+ }), { type: "rate_limit_update", status: "rejected" });
89
+ });
90
+ test("mapAvailableAgents normalizes and deduplicates agents", () => {
91
+ const agents = mapAvailableAgents([
92
+ { name: "reviewer", description: "", model: "" },
93
+ { name: "reviewer", description: "Reviews code", model: "haiku" },
94
+ { name: "explore", description: "Explore codebase", model: "sonnet" },
95
+ { name: " ", description: "ignored" },
96
+ {},
97
+ ]);
98
+ assert.deepEqual(agents, [
99
+ { name: "explore", description: "Explore codebase", model: "sonnet" },
100
+ { name: "reviewer", description: "Reviews code", model: "haiku" },
101
+ ]);
102
+ });
103
+ test("mapAvailableAgents rejects non-array payload", () => {
104
+ assert.deepEqual(mapAvailableAgents(null), []);
105
+ assert.deepEqual(mapAvailableAgents({}), []);
106
+ });
43
107
  test("createToolCall builds edit diff content", () => {
44
108
  const toolCall = createToolCall("tc-1", "Edit", {
45
109
  file_path: "src/main.rs",
@@ -100,6 +164,26 @@ test("normalizeToolResultText collapses persisted-output payload to first meanin
100
164
  `);
101
165
  assert.equal(normalized, "Output too large (132.5KB). Full output saved to: C:\\tmp\\tool-results\\bbf63b9.txt");
102
166
  });
167
+ test("normalizeToolResultText does not sanitize non-error output", () => {
168
+ const text = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
169
+ assert.equal(normalizeToolResultText(text), text);
170
+ });
171
+ test("normalizeToolResultText sanitizes exact SDK rejection payloads for errors", () => {
172
+ const cancelledText = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
173
+ assert.equal(normalizeToolResultText(cancelledText, true), "Cancelled by user.");
174
+ const deniedText = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task.";
175
+ assert.equal(normalizeToolResultText(deniedText, true), "Permission denied.");
176
+ });
177
+ test("normalizeToolResultText sanitizes SDK rejection prefixes with user follow-up", () => {
178
+ const cancelledWithUserMessage = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\nPlease skip this";
179
+ assert.equal(normalizeToolResultText(cancelledWithUserMessage, true), "Cancelled by user.");
180
+ const deniedWithUserMessage = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:\nNot now";
181
+ assert.equal(normalizeToolResultText(deniedWithUserMessage, true), "Permission denied.");
182
+ });
183
+ test("normalizeToolResultText does not sanitize substring matches in error output", () => {
184
+ const bashOutput = "grep output: doesn't want to proceed with this tool use";
185
+ assert.equal(normalizeToolResultText(bashOutput, true), bashOutput);
186
+ });
103
187
  test("cache split policy defaults stay aligned with UI thresholds", () => {
104
188
  assert.equal(CACHE_SPLIT_POLICY.softLimitBytes, 1536);
105
189
  assert.equal(CACHE_SPLIT_POLICY.hardLimitBytes, 4096);
@@ -124,6 +208,13 @@ test("buildToolResultFields uses normalized persisted-output text", () => {
124
208
  },
125
209
  ]);
126
210
  });
211
+ test("buildToolResultFields sanitizes SDK rejection text only for failed results", () => {
212
+ const sdkRejectionText = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
213
+ const successFields = buildToolResultFields(false, sdkRejectionText);
214
+ assert.equal(successFields.raw_output, sdkRejectionText);
215
+ const errorFields = buildToolResultFields(true, sdkRejectionText);
216
+ assert.equal(errorFields.raw_output, "Cancelled by user.");
217
+ });
127
218
  test("buildToolResultFields maps structured Write output to diff content", () => {
128
219
  const base = createToolCall("tc-w", "Write", {
129
220
  file_path: "src/main.ts",
@@ -334,122 +425,127 @@ test("looksLikeAuthRequired detects login hints", () => {
334
425
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
335
426
  });
336
427
  test("agent sdk version compatibility check matches pinned version", () => {
337
- assert.equal(resolveInstalledAgentSdkVersion(), "0.2.52");
428
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.2.63");
338
429
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
339
430
  });
340
- function withTempJsonl(lines, run) {
341
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-rs-resume-test-"));
342
- const filePath = path.join(dir, "session.jsonl");
343
- fs.writeFileSync(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
344
- try {
345
- run(filePath);
346
- }
347
- finally {
348
- fs.rmSync(dir, { recursive: true, force: true });
349
- }
350
- }
351
- test("extractSessionHistoryUpdatesFromJsonl parses nested progress message records", () => {
352
- const lines = [
431
+ test("mapSessionMessagesToUpdates maps message content blocks", () => {
432
+ const updates = mapSessionMessagesToUpdates([
353
433
  {
354
434
  type: "user",
435
+ uuid: "u1",
436
+ session_id: "s1",
437
+ parent_tool_use_id: null,
355
438
  message: {
356
439
  role: "user",
357
440
  content: [{ type: "text", text: "Top-level user prompt" }],
358
441
  },
359
442
  },
360
443
  {
361
- type: "progress",
362
- data: {
363
- message: {
364
- type: "assistant",
365
- message: {
366
- id: "msg-nested-1",
367
- role: "assistant",
368
- content: [
369
- {
370
- type: "tool_use",
371
- id: "tool-nested-1",
372
- name: "Bash",
373
- input: { command: "echo hello" },
374
- },
375
- ],
376
- usage: {
377
- input_tokens: 11,
378
- output_tokens: 7,
379
- cache_read_input_tokens: 5,
380
- cache_creation_input_tokens: 3,
381
- },
382
- },
444
+ type: "assistant",
445
+ uuid: "a1",
446
+ session_id: "s1",
447
+ parent_tool_use_id: null,
448
+ message: {
449
+ id: "msg-1",
450
+ role: "assistant",
451
+ content: [
452
+ { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "echo hello" } },
453
+ { type: "text", text: "Nested assistant final" },
454
+ ],
455
+ usage: {
456
+ input_tokens: 11,
457
+ output_tokens: 7,
458
+ cache_read_input_tokens: 5,
459
+ cache_creation_input_tokens: 3,
383
460
  },
384
461
  },
385
462
  },
386
463
  {
387
- type: "progress",
388
- data: {
389
- message: {
390
- type: "user",
391
- message: {
392
- role: "user",
393
- content: [
394
- {
395
- type: "tool_result",
396
- tool_use_id: "tool-nested-1",
397
- content: "ok",
398
- is_error: false,
399
- },
400
- ],
464
+ type: "user",
465
+ uuid: "u2",
466
+ session_id: "s1",
467
+ parent_tool_use_id: null,
468
+ message: {
469
+ role: "user",
470
+ content: [
471
+ {
472
+ type: "tool_result",
473
+ tool_use_id: "tool-1",
474
+ content: "ok",
475
+ is_error: false,
401
476
  },
402
- },
477
+ ],
403
478
  },
404
479
  },
480
+ ]);
481
+ const variantCounts = new Map();
482
+ for (const update of updates) {
483
+ variantCounts.set(update.type, (variantCounts.get(update.type) ?? 0) + 1);
484
+ }
485
+ assert.equal(variantCounts.get("user_message_chunk"), 1);
486
+ assert.equal(variantCounts.get("agent_message_chunk"), 1);
487
+ assert.equal(variantCounts.get("tool_call"), 1);
488
+ assert.equal(variantCounts.get("tool_call_update"), 1);
489
+ assert.equal(variantCounts.get("usage_update"), 1);
490
+ const usage = updates.find((update) => update.type === "usage_update");
491
+ assert.ok(usage && usage.type === "usage_update");
492
+ assert.deepEqual(usage.usage, {
493
+ input_tokens: 11,
494
+ output_tokens: 7,
495
+ cache_read_tokens: 5,
496
+ cache_write_tokens: 3,
497
+ });
498
+ });
499
+ test("mapSessionMessagesToUpdates ignores unsupported records", () => {
500
+ const updates = mapSessionMessagesToUpdates([
405
501
  {
406
- type: "progress",
407
- data: {
408
- message: {
409
- type: "assistant",
410
- message: {
411
- id: "msg-nested-1",
412
- role: "assistant",
413
- content: [{ type: "text", text: "Nested assistant final" }],
414
- usage: {
415
- input_tokens: 11,
416
- output_tokens: 7,
417
- cache_read_input_tokens: 5,
418
- cache_creation_input_tokens: 3,
419
- },
420
- },
421
- },
502
+ type: "user",
503
+ uuid: "u1",
504
+ session_id: "s1",
505
+ parent_tool_use_id: null,
506
+ message: {
507
+ role: "assistant",
508
+ content: [{ type: "thinking", thinking: "h" }],
422
509
  },
423
510
  },
424
- ];
425
- withTempJsonl(lines, (filePath) => {
426
- const updates = extractSessionHistoryUpdatesFromJsonl(filePath);
427
- const variantCounts = new Map();
428
- for (const update of updates) {
429
- variantCounts.set(update.type, (variantCounts.get(update.type) ?? 0) + 1);
430
- }
431
- assert.equal(variantCounts.get("user_message_chunk"), 1);
432
- assert.equal(variantCounts.get("agent_message_chunk"), 1);
433
- assert.equal(variantCounts.get("tool_call"), 1);
434
- assert.equal(variantCounts.get("tool_call_update"), 1);
435
- assert.equal(variantCounts.get("usage_update"), 1);
436
- const usage = updates.find((update) => update.type === "usage_update");
437
- assert.ok(usage && usage.type === "usage_update");
438
- assert.deepEqual(usage.usage, {
439
- input_tokens: 11,
440
- output_tokens: 7,
441
- cache_read_tokens: 5,
442
- cache_write_tokens: 3,
443
- });
444
- });
511
+ ]);
512
+ assert.equal(updates.length, 0);
445
513
  });
446
- test("extractSessionHistoryUpdatesFromJsonl ignores invalid records", () => {
447
- withTempJsonl([
448
- { type: "queue-operation", operation: "enqueue" },
449
- { type: "progress", data: { not_message: true } },
450
- { type: "user", message: { role: "assistant", content: [{ type: "thinking", thinking: "h" }] } },
451
- ], (filePath) => {
452
- const updates = extractSessionHistoryUpdatesFromJsonl(filePath);
453
- assert.equal(updates.length, 0);
454
- });
514
+ test("mapSdkSessions normalizes and sorts sessions", () => {
515
+ const mapped = mapSdkSessions([
516
+ {
517
+ sessionId: "older",
518
+ summary: " Older summary ",
519
+ lastModified: 100,
520
+ fileSize: 10,
521
+ cwd: "C:/work",
522
+ },
523
+ {
524
+ sessionId: "latest",
525
+ summary: "",
526
+ lastModified: 200,
527
+ fileSize: 20,
528
+ customTitle: "Custom title",
529
+ gitBranch: "main",
530
+ firstPrompt: "hello",
531
+ },
532
+ ]);
533
+ assert.deepEqual(mapped, [
534
+ {
535
+ session_id: "latest",
536
+ summary: "Custom title",
537
+ last_modified_ms: 200,
538
+ file_size_bytes: 20,
539
+ git_branch: "main",
540
+ custom_title: "Custom title",
541
+ first_prompt: "hello",
542
+ },
543
+ {
544
+ session_id: "older",
545
+ summary: "Older summary",
546
+ last_modified_ms: 100,
547
+ file_size_bytes: 10,
548
+ cwd: "C:/work",
549
+ },
550
+ ]);
455
551
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-rust",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code Rust - native Rust terminal interface for Claude Code",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,11 +29,11 @@
29
29
  "README.md"
30
30
  ],
31
31
  "dependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "0.2.52"
32
+ "@anthropic-ai/claude-agent-sdk": "0.2.63"
33
33
  },
34
34
  "scripts": {
35
35
  "postinstall": "node ./scripts/postinstall.js",
36
- "prepack": "npm --prefix agent-sdk run build"
36
+ "prepack": "pnpm -C agent-sdk run build"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18"