claude-code-rust 0.12.3 → 0.13.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
@@ -9,6 +9,10 @@ A native Rust terminal interface for Claude Code. Drop-in replacement for Anthro
9
9
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
10
10
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org/)
11
11
 
12
+ <p align="center">
13
+ <img src="assets/banner.png" alt="Claude Code Rust running a Read tool call with syntax-highlighted output" width="900">
14
+ </p>
15
+
12
16
  ## About
13
17
 
14
18
  Claude Code Rust replaces the stock Claude Code terminal interface with a native Rust binary built on [Ratatui](https://ratatui.rs/). It connects to the same Claude API through a local Agent SDK bridge. Core Claude Code functionality - tool calls, file editing, terminal commands, and permissions - works unchanged.
@@ -43,15 +47,17 @@ Full documentation is available at [srothgan.github.io/claude-code-rust](https:/
43
47
 
44
48
  ## Why
45
49
 
46
- The stock Claude Code TUI runs on Node.js with React Ink. This causes real problems:
50
+ The stock Claude Code TUI runs on Node.js with React Ink, which renders by redrawing full frames over raw ANSI escape codes. This causes real, widely-reported problems:
47
51
 
48
- - **Memory**: 200-400MB baseline vs ~20-50MB for a native binary
49
- - **Startup**: 2-5 seconds vs under 100ms
50
- - **Scrollback**: Broken virtual scrolling that loses history
51
- - **Input latency**: Event queue delays on keystroke handling
52
- - **Copy/paste**: Custom implementation instead of native terminal support
52
+ - **Flickering**: The whole view is redrawn on every status update, causing constant flicker — bad enough to crash editors' integrated terminals during long sessions
53
+ - **CPU**: Sustained high CPU even when idle, and runaway loops that spawn multiple background processes
54
+ - **Memory**: 200-400MB baseline (and climbing with conversation length) vs ~20-50MB for a native binary
55
+ - **Resize**: Window resizing leaves duplicated frames in scrollback, loses lines when shrinking, and can garble the display
56
+ - **Input latency**: Keystrokes echo with visible delay as context fills up, and noticeably worse on Windows
57
+ - **Scrollback**: Hijacks the terminal's native scrollback, erasing history you can no longer scroll back to
58
+ - **Paste**: Large pastes can flood stdout and freeze the terminal
53
59
 
54
- Claude Code Rust fixes all of these by compiling to a single native binary with direct terminal control via Crossterm.
60
+ Claude Code Rust addresses these by compiling to a single native binary with diffed, direct terminal control via Crossterm and Ratatui — no full-frame redraws, no Node runtime overhead.
55
61
 
56
62
  ## Documentation
57
63
 
@@ -95,6 +95,13 @@ function expectEffortLevel(record, key, context) {
95
95
  }
96
96
  return value;
97
97
  }
98
+ function expectRewindRestoreMode(record, key, context) {
99
+ const value = expectString(record, key, context);
100
+ if (value === "both" || value === "conversation" || value === "code") {
101
+ return value;
102
+ }
103
+ throw new Error(`${context}.${key} must be one of both, conversation, code`);
104
+ }
98
105
  function expectNonEmptyStringOrNull(record, key, context) {
99
106
  const value = record[key];
100
107
  if (value === null) {
@@ -281,6 +288,19 @@ export function parseCommandEnvelope(line) {
281
288
  command: "get_context_usage",
282
289
  session_id: expectString(raw, "session_id", "get_context_usage"),
283
290
  };
291
+ case "get_rewind_targets":
292
+ return {
293
+ command: "get_rewind_targets",
294
+ session_id: expectString(raw, "session_id", "get_rewind_targets"),
295
+ };
296
+ case "rewind":
297
+ return {
298
+ command: "rewind",
299
+ session_id: expectString(raw, "session_id", "rewind"),
300
+ target_user_message_id: expectString(raw, "target_user_message_id", "rewind"),
301
+ restore_mode: expectRewindRestoreMode(raw, "restore_mode", "rewind"),
302
+ launch_settings: optionalLaunchSettings(raw, "launch_settings", "rewind"),
303
+ };
284
304
  case "reload_plugins":
285
305
  return {
286
306
  command: "reload_plugins",
@@ -6,7 +6,9 @@ import { resolveCurrentModel } from "./session_lifecycle.js";
6
6
  const SESSION_LIST_LIMIT = 50;
7
7
  let sessionListingDir;
8
8
  export function buildSessionListOptions(dir, limit = SESSION_LIST_LIMIT) {
9
- return dir ? { dir, includeWorktrees: true, limit } : { limit };
9
+ return dir
10
+ ? { dir, includeProgrammatic: true, includeWorktrees: true, limit }
11
+ : { includeProgrammatic: true, limit };
10
12
  }
11
13
  export function setSessionListingDir(dir) {
12
14
  sessionListingDir = dir;
@@ -121,6 +123,7 @@ function buildConnectBridgeEvent(session, eventName) {
121
123
  available_models: session.availableModels,
122
124
  mode: session.mode ? buildModeState(session, session.mode) : null,
123
125
  ...(historyUpdates && historyUpdates.length > 0 ? { history_updates: historyUpdates } : {}),
126
+ ...(session.restoredInput !== undefined ? { restored_input: session.restoredInput } : {}),
124
127
  }
125
128
  : {
126
129
  event: "connected",
@@ -144,6 +147,7 @@ function logConnectEventEmission(session, eventName, requestId) {
144
147
  history_update_count: session.resumeUpdates?.length ?? 0,
145
148
  available_model_count: session.availableModels.length,
146
149
  stale_session_count: session.sessionsToCloseAfterConnect?.length ?? 0,
150
+ has_restored_input: session.restoredInput !== undefined,
147
151
  },
148
152
  });
149
153
  }
@@ -151,10 +155,15 @@ export function emitConnectEvent(session) {
151
155
  const bridgeEvent = buildConnectBridgeEvent(session, session.connectEvent);
152
156
  logConnectEventEmission(session, session.connectEvent, session.connectRequestId);
153
157
  writeEvent(bridgeEvent, session.connectRequestId);
158
+ if (session.pendingRewindResult) {
159
+ writeEvent({ ...session.pendingRewindResult, session_id: session.sessionId }, session.connectRequestId);
160
+ session.pendingRewindResult = undefined;
161
+ }
154
162
  session.connectRequestId = undefined;
155
163
  session.connected = true;
156
164
  session.authHintSent = false;
157
165
  session.resumeUpdates = undefined;
166
+ session.restoredInput = undefined;
158
167
  const staleSessions = session.sessionsToCloseAfterConnect;
159
168
  session.sessionsToCloseAfterConnect = undefined;
160
169
  if (!staleSessions || staleSessions.length === 0) {
@@ -180,7 +189,12 @@ export function emitSessionReplacedEvent(session, requestId) {
180
189
  const bridgeEvent = buildConnectBridgeEvent(session, "session_replaced");
181
190
  logConnectEventEmission(session, "session_replaced", requestId);
182
191
  writeEvent(bridgeEvent, requestId);
192
+ if (session.pendingRewindResult) {
193
+ writeEvent({ ...session.pendingRewindResult, session_id: session.sessionId }, requestId);
194
+ session.pendingRewindResult = undefined;
195
+ }
183
196
  session.resumeUpdates = undefined;
197
+ session.restoredInput = undefined;
184
198
  refreshSessionsList();
185
199
  }
186
200
  export async function emitSessionsList(requestId) {
@@ -71,6 +71,10 @@ function commandSessionId(command) {
71
71
  case "question_response":
72
72
  case "elicitation_response":
73
73
  case "get_status_snapshot":
74
+ case "get_context_usage":
75
+ case "get_rewind_targets":
76
+ case "rewind":
77
+ case "reload_plugins":
74
78
  case "mcp_status":
75
79
  case "mcp_reconnect":
76
80
  case "mcp_toggle":
@@ -105,6 +109,8 @@ function commandToolCallId(command) {
105
109
  case "elicitation_response":
106
110
  case "get_status_snapshot":
107
111
  case "get_context_usage":
112
+ case "get_rewind_targets":
113
+ case "rewind":
108
114
  case "reload_plugins":
109
115
  case "mcp_status":
110
116
  case "mcp_reconnect":
@@ -141,6 +147,8 @@ function eventToolCallId(event) {
141
147
  case "sessions_listed":
142
148
  case "status_snapshot":
143
149
  case "context_usage":
150
+ case "rewind_targets":
151
+ case "rewind_result":
144
152
  case "mcp_snapshot":
145
153
  return undefined;
146
154
  }
@@ -183,6 +191,8 @@ function protocolEventLevel(event) {
183
191
  case "sessions_listed":
184
192
  case "status_snapshot":
185
193
  case "context_usage":
194
+ case "rewind_targets":
195
+ case "rewind_result":
186
196
  case "runtime_reload_completed":
187
197
  case "mcp_set_servers_result":
188
198
  case "mcp_snapshot":
@@ -48,6 +48,16 @@ function optionalTimeout(record, context) {
48
48
  }
49
49
  return value;
50
50
  }
51
+ function optionalRequestTimeoutMs(record, context) {
52
+ const value = record.request_timeout_ms;
53
+ if (value === undefined) {
54
+ return undefined;
55
+ }
56
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1000) {
57
+ throw new Error(`${context}.request_timeout_ms must be an integer >= 1000`);
58
+ }
59
+ return value;
60
+ }
51
61
  function optionalAlwaysLoad(record, context) {
52
62
  const value = record.always_load;
53
63
  if (value === undefined) {
@@ -98,6 +108,7 @@ export function parseMcpServerConfig(value, context) {
98
108
  throw new Error(`${context}.type must be a string`);
99
109
  }
100
110
  const timeout = optionalTimeout(record, context);
111
+ const requestTimeoutMs = optionalRequestTimeoutMs(record, context);
101
112
  const alwaysLoad = optionalAlwaysLoad(record, context);
102
113
  switch (type) {
103
114
  case "stdio": {
@@ -114,6 +125,7 @@ export function parseMcpServerConfig(value, context) {
114
125
  ...(optionalStringArray(record, "args", context) ? { args: optionalStringArray(record, "args", context) } : {}),
115
126
  ...(optionalStringMap(record, "env", context) ? { env: optionalStringMap(record, "env", context) } : {}),
116
127
  ...(timeout === undefined ? {} : { timeout }),
128
+ ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }),
117
129
  ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }),
118
130
  };
119
131
  }
@@ -130,6 +142,7 @@ export function parseMcpServerConfig(value, context) {
130
142
  ...(optionalStringMap(record, "headers", context) ? { headers: optionalStringMap(record, "headers", context) } : {}),
131
143
  ...(tools === undefined ? {} : { tools }),
132
144
  ...(timeout === undefined ? {} : { timeout }),
145
+ ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }),
133
146
  ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }),
134
147
  };
135
148
  }
@@ -148,6 +161,9 @@ function toSdkToolPolicies(tools) {
148
161
  ...(tool.org_max_permission === undefined ? {} : { org_max_permission: tool.org_max_permission }),
149
162
  }));
150
163
  }
164
+ function sdkRequestTimeoutConfig(config) {
165
+ return config.request_timeout_ms === undefined ? {} : { requestTimeoutMs: config.request_timeout_ms };
166
+ }
151
167
  export function bridgeMcpConfigToSdk(config) {
152
168
  switch (config.type) {
153
169
  case "stdio":
@@ -157,6 +173,7 @@ export function bridgeMcpConfigToSdk(config) {
157
173
  ...(config.args ? { args: config.args } : {}),
158
174
  ...(config.env ? { env: config.env } : {}),
159
175
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
176
+ ...sdkRequestTimeoutConfig(config),
160
177
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
161
178
  };
162
179
  case "sse":
@@ -166,6 +183,7 @@ export function bridgeMcpConfigToSdk(config) {
166
183
  ...(config.headers ? { headers: config.headers } : {}),
167
184
  ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}),
168
185
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
186
+ ...sdkRequestTimeoutConfig(config),
169
187
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
170
188
  };
171
189
  case "http":
@@ -175,6 +193,7 @@ export function bridgeMcpConfigToSdk(config) {
175
193
  ...(config.headers ? { headers: config.headers } : {}),
176
194
  ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}),
177
195
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
196
+ ...sdkRequestTimeoutConfig(config),
178
197
  ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }),
179
198
  };
180
199
  }
@@ -250,6 +269,16 @@ export function mapMcpServerStatus(status) {
250
269
  : [],
251
270
  };
252
271
  }
272
+ function sdkRequestTimeoutMs(config) {
273
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
274
+ return undefined;
275
+ }
276
+ const raw = config;
277
+ const value = raw.requestTimeoutMs ?? raw.request_timeout_ms;
278
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)
279
+ ? value
280
+ : undefined;
281
+ }
253
282
  export function mapMcpServerStatusConfig(config) {
254
283
  switch (config.type) {
255
284
  case "stdio":
@@ -259,6 +288,7 @@ export function mapMcpServerStatusConfig(config) {
259
288
  ...(Array.isArray(config.args) && config.args.length > 0 ? { args: config.args } : {}),
260
289
  ...(config.env ? { env: config.env } : {}),
261
290
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
291
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
262
292
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
263
293
  };
264
294
  case "sse": {
@@ -269,6 +299,7 @@ export function mapMcpServerStatusConfig(config) {
269
299
  ...(config.headers ? { headers: config.headers } : {}),
270
300
  ...(tools === undefined ? {} : { tools }),
271
301
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
302
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
272
303
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
273
304
  };
274
305
  }
@@ -280,6 +311,7 @@ export function mapMcpServerStatusConfig(config) {
280
311
  ...(config.headers ? { headers: config.headers } : {}),
281
312
  ...(tools === undefined ? {} : { tools }),
282
313
  ...(config.timeout === undefined ? {} : { timeout: config.timeout }),
314
+ ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }),
283
315
  ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }),
284
316
  };
285
317
  }
@@ -321,6 +353,7 @@ function mcpStatusConfigDiagnostics(config) {
321
353
  return {
322
354
  config_type: "stdio",
323
355
  ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }),
356
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
324
357
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
325
358
  configured_tool_policy_count: 0,
326
359
  };
@@ -329,6 +362,7 @@ function mcpStatusConfigDiagnostics(config) {
329
362
  return {
330
363
  config_type: config.type,
331
364
  ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }),
365
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
332
366
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
333
367
  configured_tool_policy_count: config.tools?.length ?? 0,
334
368
  };
@@ -359,6 +393,7 @@ export function summarizeMcpServersForDiagnostics(servers) {
359
393
  config_type: config.config_type,
360
394
  ...(server.scope ? { scope: server.scope } : {}),
361
395
  ...(config.timeout_ms === undefined ? {} : { timeout_ms: config.timeout_ms }),
396
+ ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }),
362
397
  ...(config.always_load === undefined ? {} : { always_load: config.always_load }),
363
398
  tool_count: server.tools.length,
364
399
  configured_tool_policy_count: config.configured_tool_policy_count,
@@ -90,6 +90,162 @@ function emitSystemNoticeUpdate(session, severity, message) {
90
90
  }
91
91
  emitSessionUpdate(session.sessionId, { type: "system_notice_update", severity, message: trimmed });
92
92
  }
93
+ const MAX_INFORMATIONAL_DEDUP_KEYS = 256;
94
+ function shouldEmitInformationalMessage(session, level, content, toolUseId) {
95
+ if (!toolUseId) {
96
+ return true;
97
+ }
98
+ const key = `${toolUseId}\u0000${level}\u0000${content}`;
99
+ if (session.informationalDedupKeys.has(key)) {
100
+ return false;
101
+ }
102
+ session.informationalDedupKeys.add(key);
103
+ while (session.informationalDedupKeys.size > MAX_INFORMATIONAL_DEDUP_KEYS) {
104
+ const first = session.informationalDedupKeys.values().next().value;
105
+ if (typeof first !== "string") {
106
+ break;
107
+ }
108
+ session.informationalDedupKeys.delete(first);
109
+ }
110
+ return true;
111
+ }
112
+ function handleInformationalSystemMessage(session, msg) {
113
+ const content = typeof msg.content === "string" ? msg.content.trim() : "";
114
+ if (!content) {
115
+ return;
116
+ }
117
+ const level = typeof msg.level === "string" ? msg.level : "info";
118
+ const toolUseId = typeof msg.tool_use_id === "string" ? msg.tool_use_id : "";
119
+ if (!shouldEmitInformationalMessage(session, level, content, toolUseId)) {
120
+ return;
121
+ }
122
+ switch (level) {
123
+ case "notice":
124
+ emitSystemNoticeUpdate(session, "info", content);
125
+ return;
126
+ case "suggestion":
127
+ emitSystemNoticeUpdate(session, "info", `Suggestion: ${content}`);
128
+ return;
129
+ case "warning":
130
+ emitSystemNoticeUpdate(session, "warning", content);
131
+ return;
132
+ case "info":
133
+ if (msg.prevent_continuation === true) {
134
+ emitSystemNoticeUpdate(session, "warning", content);
135
+ }
136
+ return;
137
+ default:
138
+ bridgeLogger.debug({
139
+ target: LOG_TARGETS.BRIDGE_SDK,
140
+ eventName: "sdk_informational_level_unhandled",
141
+ message: "SDK informational message ignored for unknown level",
142
+ outcome: "ignored",
143
+ sessionId: session.sessionId,
144
+ toolCallId: toolUseId || undefined,
145
+ fields: {
146
+ informational_level: level,
147
+ },
148
+ });
149
+ }
150
+ }
151
+ function trimmedStringField(msg, field) {
152
+ const value = msg[field];
153
+ if (typeof value !== "string") {
154
+ return undefined;
155
+ }
156
+ const trimmed = value.trim();
157
+ return trimmed ? trimmed : undefined;
158
+ }
159
+ function ensureSentencePunctuation(value) {
160
+ const trimmed = value.trim();
161
+ if (!trimmed) {
162
+ return "";
163
+ }
164
+ return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
165
+ }
166
+ function modelRefusalNoFallbackMessage(msg) {
167
+ const model = trimmedStringField(msg, "original_model") ?? "the selected model";
168
+ const base = `Could not continue with ${model}: model refused the request and no fallback model is configured.`;
169
+ const explanation = trimmedStringField(msg, "api_refusal_explanation");
170
+ const category = trimmedStringField(msg, "api_refusal_category");
171
+ const content = trimmedStringField(msg, "content");
172
+ const detail = explanation
173
+ ? `Reason: ${explanation}`
174
+ : category
175
+ ? `Refusal category: ${category}`
176
+ : content;
177
+ const detailSentence = detail ? ensureSentencePunctuation(detail) : "";
178
+ return detailSentence ? `${base} ${detailSentence}` : base;
179
+ }
180
+ function handleModelRefusalNoFallbackMessage(session, msg) {
181
+ const message = modelRefusalNoFallbackMessage(msg);
182
+ emitSystemNoticeUpdate(session, "warning", message);
183
+ bridgeLogger.info({
184
+ target: LOG_TARGETS.APP_SESSION,
185
+ eventName: "sdk_model_refusal_no_fallback_received",
186
+ message: "SDK model refusal without fallback received",
187
+ outcome: "success",
188
+ sessionId: session.sessionId,
189
+ requestId: trimmedStringField(msg, "request_id"),
190
+ fields: {
191
+ original_model: trimmedStringField(msg, "original_model"),
192
+ api_refusal_category: trimmedStringField(msg, "api_refusal_category"),
193
+ refused_user_message_uuid: trimmedStringField(msg, "refused_user_message_uuid"),
194
+ sdk_message_uuid: trimmedStringField(msg, "uuid"),
195
+ sdk_message_session_id: trimmedStringField(msg, "session_id"),
196
+ has_api_refusal_explanation: trimmedStringField(msg, "api_refusal_explanation") !== undefined,
197
+ has_content: trimmedStringField(msg, "content") !== undefined,
198
+ },
199
+ });
200
+ }
201
+ function workerShutdownMessage(reason) {
202
+ const trimmed = reason.trim();
203
+ return trimmed ? `Claude worker is shutting down: ${trimmed}` : "Claude worker is shutting down.";
204
+ }
205
+ function handleWorkerShuttingDownSystemMessage(session, msg) {
206
+ const reason = typeof msg.reason === "string" ? msg.reason.trim() : "";
207
+ if (!session.connected) {
208
+ bridgeLogger.debug({
209
+ target: LOG_TARGETS.BRIDGE_SDK,
210
+ eventName: "sdk_worker_shutdown_preconnect_ignored",
211
+ message: "SDK worker shutdown ignored before session connect",
212
+ outcome: "ignored",
213
+ sessionId: session.sessionId,
214
+ fields: {
215
+ reason: reason || undefined,
216
+ },
217
+ });
218
+ return;
219
+ }
220
+ session.pendingWorkerShutdown = { reason };
221
+ }
222
+ function cancelPendingWorkerShutdown(session) {
223
+ if (!session.pendingWorkerShutdown) {
224
+ return;
225
+ }
226
+ bridgeLogger.debug({
227
+ target: LOG_TARGETS.BRIDGE_SDK,
228
+ eventName: "sdk_worker_shutdown_cancelled",
229
+ message: "SDK worker shutdown ignored after later stream activity",
230
+ outcome: "ignored",
231
+ sessionId: session.sessionId,
232
+ fields: {
233
+ reason: session.pendingWorkerShutdown.reason || undefined,
234
+ },
235
+ });
236
+ session.pendingWorkerShutdown = undefined;
237
+ }
238
+ export function flushPendingWorkerShutdown(session) {
239
+ const pending = session.pendingWorkerShutdown;
240
+ if (!pending) {
241
+ return;
242
+ }
243
+ session.pendingWorkerShutdown = undefined;
244
+ if (!session.connected) {
245
+ return;
246
+ }
247
+ emitSystemNoticeUpdate(session, "warning", workerShutdownMessage(pending.reason));
248
+ }
93
249
  function notificationSeverity(priority) {
94
250
  return priority === "high" || priority === "immediate" ? "warning" : "info";
95
251
  }
@@ -675,12 +831,19 @@ function terminalReasonFromValue(value) {
675
831
  export function handleSdkMessage(session, message) {
676
832
  const msg = message;
677
833
  const type = typeof msg.type === "string" ? msg.type : "";
834
+ const subtype = type === "system" && typeof msg.subtype === "string" ? msg.subtype : "";
835
+ if (subtype !== "worker_shutting_down") {
836
+ cancelPendingWorkerShutdown(session);
837
+ }
678
838
  logSdkMessageOrigin(session, msg);
679
839
  if (type === "system") {
680
- const subtype = typeof msg.subtype === "string" ? msg.subtype : "";
681
840
  if (handleFallbackRetractionMessage(session, subtype, msg)) {
682
841
  return;
683
842
  }
843
+ if (subtype === "model_refusal_no_fallback") {
844
+ handleModelRefusalNoFallbackMessage(session, msg);
845
+ return;
846
+ }
684
847
  if (subtype === "commands_changed") {
685
848
  updateAvailableCommands(session, "commands_changed", mapSdkSlashCommands(msg.commands));
686
849
  return;
@@ -690,6 +853,14 @@ export function handleSdkMessage(session, message) {
690
853
  emitSystemNoticeUpdate(session, notificationSeverity(msg.priority), text);
691
854
  return;
692
855
  }
856
+ if (subtype === "informational") {
857
+ handleInformationalSystemMessage(session, msg);
858
+ return;
859
+ }
860
+ if (subtype === "worker_shutting_down") {
861
+ handleWorkerShuttingDownSystemMessage(session, msg);
862
+ return;
863
+ }
693
864
  if (subtype === "mirror_error") {
694
865
  const error = typeof msg.error === "string" ? msg.error : "";
695
866
  const key = asRecordOrNull(msg.key);
@@ -1,11 +1,6 @@
1
- const OPUS_MODEL_ALIAS = "opus";
1
+ const DEFAULT_MODEL_ALIAS = "fable";
2
2
  const MAX_MODEL_VERSION_PARTS = 2;
3
3
  const RELEASE_BUILD_TOKEN = /^20\d{6}$/;
4
- function isUnavailableModelId(id) {
5
- const normalized = id.trim().toLowerCase();
6
- // TODO: Revisit only after a product decision if Anthropic restores Fable 5 access.
7
- return normalized === "fable" || normalized.startsWith("claude-fable-5");
8
- }
9
4
  function isEffortLevel(value) {
10
5
  return (value === "low" ||
11
6
  value === "medium" ||
@@ -19,7 +14,7 @@ function normalizeModelKey(id) {
19
14
  return { original, family: "unknown", versionParts: [], variantParts: [], buildParts: [] };
20
15
  }
21
16
  const lower = original.toLowerCase();
22
- const contextMatch = lower.match(/\[([^\]]+)\]$/);
17
+ const contextMatch = lower.match(/\[([^[\]]+)\]$/);
23
18
  const contextSuffix = contextMatch?.[1];
24
19
  const withoutContext = contextMatch ? lower.slice(0, contextMatch.index) : lower;
25
20
  const withoutPrefix = withoutContext.startsWith("claude-")
@@ -27,7 +22,10 @@ function normalizeModelKey(id) {
27
22
  : withoutContext;
28
23
  const parts = withoutPrefix.split("-").filter((part) => part.length > 0);
29
24
  const familyPart = parts[0] ?? "";
30
- const family = familyPart === "opus" || familyPart === "sonnet" || familyPart === "haiku"
25
+ const family = familyPart === "fable" ||
26
+ familyPart === "opus" ||
27
+ familyPart === "sonnet" ||
28
+ familyPart === "haiku"
31
29
  ? familyPart
32
30
  : "unknown";
33
31
  const versionParts = [];
@@ -36,6 +34,10 @@ function normalizeModelKey(id) {
36
34
  if (family !== "unknown") {
37
35
  for (const part of parts.slice(1)) {
38
36
  if (/^\d+$/.test(part)) {
37
+ if (versionParts.length > 0 && RELEASE_BUILD_TOKEN.test(part)) {
38
+ buildParts.push(part);
39
+ continue;
40
+ }
39
41
  if (versionParts.length < MAX_MODEL_VERSION_PARTS) {
40
42
  const parsed = Number.parseInt(part, 10);
41
43
  if (Number.isFinite(parsed)) {
@@ -120,11 +122,13 @@ function humanizeModelId(id) {
120
122
  if (normalized.family === "unknown") {
121
123
  return id;
122
124
  }
123
- const familyLabel = normalized.family === "opus"
124
- ? "Opus"
125
- : normalized.family === "sonnet"
126
- ? "Sonnet"
127
- : "Haiku";
125
+ const familyLabel = normalized.family === "fable"
126
+ ? "Fable"
127
+ : normalized.family === "opus"
128
+ ? "Opus"
129
+ : normalized.family === "sonnet"
130
+ ? "Sonnet"
131
+ : "Haiku";
128
132
  const versionLabel = normalized.versionParts.length > 0 ? ` ${normalized.versionParts.join(".")}` : "";
129
133
  const contextLabel = normalized.contextSuffix?.toLowerCase() === "1m"
130
134
  ? " [1M]"
@@ -144,19 +148,23 @@ function currentModelIsAuthoritative(resolvedId, requestedId) {
144
148
  return true;
145
149
  }
146
150
  function resolveCatalogModel(availableModels, resolvedId, requestedId) {
147
- const exactResolved = availableModels.find((entry) => entry.id === resolvedId);
151
+ const exactResolved = availableModels.find((entry) => entry.id === resolvedId || entry.resolved_model === resolvedId);
148
152
  if (exactResolved) {
149
153
  return exactResolved;
150
154
  }
151
155
  if (requestedId) {
152
- const exactRequested = availableModels.find((entry) => entry.id === requestedId);
156
+ const exactRequested = availableModels.find((entry) => entry.id === requestedId || entry.resolved_model === requestedId);
153
157
  if (exactRequested &&
154
- modelKeysAreCompatible(exactRequested.id, resolvedId) &&
158
+ (modelKeysAreCompatible(exactRequested.id, resolvedId) ||
159
+ (exactRequested.resolved_model !== undefined &&
160
+ modelKeysAreCompatible(exactRequested.resolved_model, resolvedId))) &&
155
161
  !hasVariantSiblingConflict(availableModels, exactRequested.id, resolvedId)) {
156
162
  return exactRequested;
157
163
  }
158
164
  }
159
- const compatible = availableModels.filter((entry) => modelKeysAreCompatible(entry.id, resolvedId) &&
165
+ const compatible = availableModels.filter((entry) => (modelKeysAreCompatible(entry.id, resolvedId) ||
166
+ (entry.resolved_model !== undefined &&
167
+ modelKeysAreCompatible(entry.resolved_model, resolvedId))) &&
160
168
  !hasVariantSiblingConflict(availableModels, entry.id, resolvedId));
161
169
  return compatible.length === 1 ? compatible[0] : undefined;
162
170
  }
@@ -168,12 +176,14 @@ export function mapAvailableModels(models) {
168
176
  .filter((entry) => {
169
177
  return (typeof entry?.value === "string" &&
170
178
  entry.value.trim().length > 0 &&
171
- !isUnavailableModelId(entry.value) &&
172
179
  typeof entry.displayName === "string" &&
173
180
  entry.displayName.trim().length > 0);
174
181
  })
175
182
  .map((entry) => ({
176
183
  id: entry.value,
184
+ ...(typeof entry.resolvedModel === "string" && entry.resolvedModel.trim().length > 0
185
+ ? { resolved_model: entry.resolvedModel.trim() }
186
+ : {}),
177
187
  display_name: entry.displayName,
178
188
  supports_effort: entry.supportsEffort === true,
179
189
  supported_effort_levels: Array.isArray(entry.supportedEffortLevels)
@@ -198,9 +208,9 @@ export function resolveCurrentModel(session) {
198
208
  const resolvedId = session.resolvedRuntimeModelId?.trim() ||
199
209
  session.model.trim() ||
200
210
  requestedId ||
201
- OPUS_MODEL_ALIAS;
211
+ DEFAULT_MODEL_ALIAS;
202
212
  const catalogModel = resolveCatalogModel(session.availableModels, resolvedId, requestedId);
203
- const runtimeDisplayId = resolvedId || requestedId || OPUS_MODEL_ALIAS;
213
+ const runtimeDisplayId = resolvedId || requestedId || DEFAULT_MODEL_ALIAS;
204
214
  const displayNameShort = shortDisplayNameForModelId(runtimeDisplayId);
205
215
  const displayNameLong = catalogModel?.display_name ?? humanizeModelId(runtimeDisplayId);
206
216
  return {