Merge branch 'main' into HEAD

This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 00:23:36 +08:00
commit df52bea0d0
56 changed files with 517 additions and 13 deletions

View File

@ -283,6 +283,20 @@ Thanks to the following open-source projects and community practices for referen
---
## ⭐ Star History
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## Disclaimer
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.

View File

@ -283,6 +283,20 @@ http://127.0.0.1:2024
---
## ⭐ Star 趋势图
如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## Disclaimer
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。

View File

@ -27,6 +27,7 @@
</linearGradient>
</defs>
<g transform="translate(512 512) scale(1.0612244898) translate(-512 -512)">
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
@ -39,4 +40,5 @@
<g filter="url(#sparkGlow)">
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,5 +1,6 @@
use std::{
collections::{HashMap, VecDeque},
fs,
io::{Error as IoError, ErrorKind, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
@ -14,11 +15,11 @@ use std::{
};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Emitter;
use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent};
use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
@ -28,6 +29,10 @@ const SERVER_STARTUP_LOG_LIMIT: usize = 80;
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_QUIT_ID: &str = "tray_quit";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 960;
const MIN_WINDOW_HEIGHT: u32 = 640;
const MIN_VISIBLE_PIXELS: i64 = 64;
#[derive(Default)]
struct ServerState(Mutex<ServerStatus>);
@ -48,6 +53,15 @@ struct AppExitState {
is_quitting: Mutex<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
struct StoredWindowState {
x: i32,
y: i32,
width: u32,
height: u32,
maximized: bool,
}
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecarclaude-sidecar adapters --feishu --telegram的生命周期
@ -157,10 +171,187 @@ fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool {
.unwrap_or(true)
}
fn is_persistable_window_state(state: &StoredWindowState) -> bool {
state.width >= MIN_WINDOW_WIDTH && state.height >= MIN_WINDOW_HEIGHT
}
fn has_meaningful_intersection(
state: &StoredWindowState,
monitor_x: i32,
monitor_y: i32,
monitor_width: u32,
monitor_height: u32,
) -> bool {
let left = state.x as i64;
let top = state.y as i64;
let right = left + state.width as i64;
let bottom = top + state.height as i64;
let monitor_left = monitor_x as i64;
let monitor_top = monitor_y as i64;
let monitor_right = monitor_left + monitor_width as i64;
let monitor_bottom = monitor_top + monitor_height as i64;
right > monitor_left + MIN_VISIBLE_PIXELS
&& bottom > monitor_top + MIN_VISIBLE_PIXELS
&& left < monitor_right - MIN_VISIBLE_PIXELS
&& top < monitor_bottom - MIN_VISIBLE_PIXELS
}
fn is_window_state_visible_on_any_monitor(
state: &StoredWindowState,
monitors: &[tauri::Monitor],
) -> bool {
if monitors.is_empty() {
return true;
}
monitors.iter().any(|monitor| {
let position = monitor.position();
let size = monitor.size();
has_meaningful_intersection(state, position.x, position.y, size.width, size.height)
})
}
fn window_state_path(app: &AppHandle) -> Option<PathBuf> {
match app.path().app_config_dir() {
Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)),
Err(err) => {
eprintln!("[desktop] failed to resolve app config dir: {err}");
None
}
}
}
fn read_stored_window_state(app: &AppHandle) -> Option<StoredWindowState> {
let path = window_state_path(app)?;
let data = match fs::read_to_string(&path) {
Ok(data) => data,
Err(err) if err.kind() == ErrorKind::NotFound => return None,
Err(err) => {
eprintln!(
"[desktop] failed to read window state {}: {err}",
path.display()
);
return None;
}
};
match serde_json::from_str::<StoredWindowState>(&data) {
Ok(state) if is_persistable_window_state(&state) => Some(state),
Ok(_) => None,
Err(err) => {
eprintln!(
"[desktop] failed to parse window state {}: {err}",
path.display()
);
None
}
}
}
fn write_stored_window_state(app: &AppHandle, state: &StoredWindowState) {
if !is_persistable_window_state(state) {
return;
}
let Some(path) = window_state_path(app) else {
return;
};
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
eprintln!(
"[desktop] failed to create window state directory {}: {err}",
parent.display()
);
return;
}
}
let data = match serde_json::to_string_pretty(state) {
Ok(data) => data,
Err(err) => {
eprintln!("[desktop] failed to serialize window state: {err}");
return;
}
};
if let Err(err) = fs::write(&path, data) {
eprintln!(
"[desktop] failed to write window state {}: {err}",
path.display()
);
}
}
fn capture_window_state(window: &tauri::WebviewWindow) -> Option<StoredWindowState> {
if window.is_minimized().unwrap_or(false) {
return None;
}
let position = match window.outer_position() {
Ok(position) => position,
Err(err) => {
eprintln!("[desktop] failed to read window position: {err}");
return None;
}
};
let size = match window.outer_size() {
Ok(size) => size,
Err(err) => {
eprintln!("[desktop] failed to read window size: {err}");
return None;
}
};
let state = StoredWindowState {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
maximized: window.is_maximized().unwrap_or(false),
};
is_persistable_window_state(&state).then_some(state)
}
fn save_main_window_state(app: &AppHandle) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
let Some(state) = capture_window_state(&window) else {
return;
};
write_stored_window_state(app, &state);
}
fn restore_main_window_state(app: &AppHandle) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
let Some(state) = read_stored_window_state(app) else {
return;
};
let monitors = window.available_monitors().unwrap_or_default();
if !is_window_state_visible_on_any_monitor(&state, &monitors) {
return;
}
let _ = window.unmaximize();
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
if state.maximized {
let _ = window.maximize();
}
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
@ -879,9 +1070,71 @@ fn kill_windows_sidecars() {
#[cfg(test)]
mod tests {
use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block};
use super::{
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, parse_env_block,
StoredWindowState,
};
use std::collections::HashMap;
#[test]
fn window_state_rejects_too_small_sizes() {
let valid = StoredWindowState {
x: 100,
y: 100,
width: 1200,
height: 800,
maximized: false,
};
let too_narrow = StoredWindowState {
width: 959,
..valid.clone()
};
let too_short = StoredWindowState {
height: 639,
..valid.clone()
};
assert!(is_persistable_window_state(&valid));
assert!(!is_persistable_window_state(&too_narrow));
assert!(!is_persistable_window_state(&too_short));
}
#[test]
fn window_state_requires_visible_monitor_intersection() {
let state = StoredWindowState {
x: 100,
y: 100,
width: 1200,
height: 800,
maximized: false,
};
assert!(has_meaningful_intersection(&state, 0, 0, 1920, 1080));
assert!(!has_meaningful_intersection(
&StoredWindowState {
x: -1200,
y: 100,
..state.clone()
},
0,
0,
1920,
1080,
));
assert!(!has_meaningful_intersection(
&StoredWindowState {
x: 1900,
y: 100,
..state
},
0,
0,
1920,
1080,
));
}
#[test]
fn terminal_output_decoder_preserves_split_chinese_characters() {
let mut pending = Vec::new();
@ -1043,6 +1296,7 @@ pub fn run() {
let app = builder
.setup(|app| {
setup_system_tray(app)?;
restore_main_window_state(&app.handle());
let state = app.state::<ServerState>();
let mut guard = state
@ -1080,10 +1334,18 @@ pub fn run() {
..
} if should_hide_to_tray(app_handle, &label) => {
api.prevent_close();
save_main_window_state(app_handle);
if let Some(window) = app_handle.get_webview_window(&label) {
let _ = window.hide();
}
}
RunEvent::WindowEvent {
label,
event: WindowEvent::Moved(_) | WindowEvent::Resized(_),
..
} if label == MAIN_WINDOW_LABEL => {
save_main_window_state(app_handle);
}
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
@ -1093,11 +1355,13 @@ pub fn run() {
}
RunEvent::ExitRequested { .. } => {
mark_app_quitting(app_handle);
save_main_window_state(app_handle);
stop_server_sidecar(app_handle);
stop_adapters_sidecar(app_handle);
}
RunEvent::Exit => {
mark_app_quitting(app_handle);
save_main_window_state(app_handle);
stop_server_sidecar(app_handle);
stop_adapters_sidecar(app_handle);
}

View File

@ -859,6 +859,32 @@ describe('WebSocket Chat Integration', () => {
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
})
it('should include desktop service diagnostics when CLI startup fails', async () => {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-'))
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
await fs.rm(workDir, { recursive: true, force: true })
const messages = await runTurn(sessionId, 'trigger startup diagnostics', true)
const error = messages.find((msg) => msg.type === 'error')
expect(error).toMatchObject({
code: 'WORKDIR_INVALID',
})
expect(error?.message).toContain('Desktop service diagnostics:')
expect(error?.message).toContain(`sessionId: ${sessionId}`)
expect(error?.message).toContain(`workDir: ${workDir}`)
expect(error?.message).toContain('runtimeOverride: (none)')
expect(error?.message).toContain('activeProviderId:')
expect(error?.message).toContain('configuredProviders:')
})
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
@ -1143,6 +1169,108 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => {
const providerService = new ProviderService()
const activeProvider = await providerService.addProvider({
presetId: 'custom',
name: 'Current Valid Provider',
apiKey: 'key-current-valid',
baseUrl: 'http://127.0.0.1:1/anthropic',
apiFormat: 'anthropic',
models: {
main: 'current-main',
haiku: 'current-haiku',
sonnet: 'current-sonnet',
opus: 'current-opus',
},
})
await providerService.activateProvider(activeProvider.id)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const staleProviderId = crypto.randomUUID()
const originalStartSession = conversationService.startSession.bind(conversationService)
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
}> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const messages: any[] = []
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error(`Timed out waiting for stale runtime resume for session ${sessionId}`))
}, 10_000)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({
type: 'set_runtime_config',
providerId: staleProviderId,
modelId: 'stale-model',
}))
ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
return
}
if (msg.type === 'error') {
clearTimeout(timeout)
ws.close()
reject(new Error(msg.message))
return
}
if (msg.type === 'message_complete') {
clearTimeout(timeout)
ws.close()
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error(`WebSocket error for stale runtime resume session ${sessionId}`))
}
})
expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
providerId: activeProvider.id,
},
})
expect(startCalls[0]?.options?.model).not.toBe('stale-model')
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
} finally {
ws.close()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
await providerService.activateOfficial()
}
}, 20_000)
it('should resume streaming to a reconnected client during an active turn', async () => {
await withMockStreamDelay(150, async () => {
const sessionId = `chat-reconnect-${crypto.randomUUID()}`

View File

@ -61,6 +61,7 @@ const runtimeOverrides = new Map<string, {
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
const lastResolvedStartupWorkDirs = new Map<string, string>()
const prewarmPendingSessions = new Set<string>()
const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
@ -265,7 +266,7 @@ async function handleUserMessage(
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: errMsg,
message: await buildSessionStartupDiagnosticMessage(sessionId, errMsg),
code,
retryable:
err instanceof ConversationStartupError ? err.retryable : false,
@ -511,7 +512,10 @@ async function restartSessionWithPermissionMode(
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: `Failed to restart session with new permission mode: ${errMsg}`,
message: await buildSessionStartupDiagnosticMessage(
sessionId,
`Failed to restart session with new permission mode: ${errMsg}`,
),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
@ -545,7 +549,10 @@ async function restartSessionWithRuntimeConfig(
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: `Failed to switch provider/model: ${errMsg}`,
message: await buildSessionStartupDiagnosticMessage(
sessionId,
`Failed to switch provider/model: ${errMsg}`,
),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
@ -661,6 +668,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
runtimeOverrides.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)
clearPrewarmState(sessionId)
}
@ -758,6 +766,7 @@ async function ensureCliSessionStarted(
const startup = (async () => {
const workDir = await resolveSessionWorkDir(sessionId)
lastResolvedStartupWorkDirs.set(sessionId, workDir)
const runtimeSettings = await getRuntimeSettings(sessionId)
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
@ -1223,14 +1232,28 @@ function rebindSessionOutput(
})
}
async function getRuntimeSettings(sessionId?: string): Promise<{
type RuntimeSettings = {
permissionMode?: string
model?: string
effort?: string
providerId?: string | null
}> {
}
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
if (runtimeOverride) {
if (typeof runtimeOverride.providerId === 'string') {
const { providers } = await providerService.listProviders()
const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId)
if (!providerExists) {
console.warn(
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
)
runtimeOverrides.delete(sessionId!)
return getDefaultRuntimeSettings()
}
}
const userSettings = await settingsService.getUserSettings()
const effort =
typeof userSettings.effort === 'string' && userSettings.effort.trim()
@ -1245,10 +1268,21 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
}
}
return getDefaultRuntimeSettings()
}
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
// Check if a custom provider is active
const { activeId } = await providerService.listProviders()
const { providers, activeId } = await providerService.listProviders()
let resolvedActiveId = activeId
if (activeId && !providers.some((provider) => provider.id === activeId)) {
console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
resolvedActiveId = null
await providerService.activateOfficial()
}
const userSettings = await settingsService.getUserSettings()
const providerSettings = activeId
const providerSettings = resolvedActiveId
? await providerService.getManagedSettings()
: undefined
const modelSettings = providerSettings ?? userSettings
@ -1262,7 +1296,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
: undefined
let model: string | undefined
if (activeId) {
if (resolvedActiveId) {
// Provider is active — only consult provider-managed cc-haha settings.
// Global ~/.claude/settings.json model values must not bleed into provider mode.
const baseModel =
@ -1286,10 +1320,58 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
model,
effort,
providerId: activeId,
providerId: resolvedActiveId,
}
}
async function buildSessionStartupDiagnosticMessage(
sessionId: string,
cause: string,
): Promise<string> {
const lines = [
cause,
'',
'Desktop service diagnostics:',
`- sessionId: ${sessionId}`,
]
try {
const recentWorkDir = lastResolvedStartupWorkDirs.get(sessionId)
const workDir =
recentWorkDir ||
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
lines.push(`- workDir: ${workDir ?? '(unknown)'}`)
} catch (err) {
lines.push(`- workDir: failed to resolve (${err instanceof Error ? err.message : String(err)})`)
}
const runtimeOverride = runtimeOverrides.get(sessionId)
if (runtimeOverride) {
lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`)
lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`)
} else {
lines.push('- runtimeOverride: (none)')
}
try {
const { providers, activeId } = await providerService.listProviders()
lines.push(`- activeProviderId: ${activeId ?? '(official)'}`)
lines.push(`- configuredProviders: ${providers.length}`)
if (providers.length > 0) {
lines.push(
`- providerIndex: ${providers
.map((provider) => `${provider.name} (${provider.id})`)
.join(', ')}`,
)
}
} catch (err) {
lines.push(`- providers: failed to read (${err instanceof Error ? err.message : String(err)})`)
}
return lines.join('\n')
}
function enqueueRuntimeTransition(
sessionId: string,
transition: () => Promise<void>,