@@ -138,6 +138,40 @@ async function writeDesktopMetadata(
138138 await fs . writeFile ( path . join ( dir , `local_${ name } .json` ) , JSON . stringify ( metadata ) ) ;
139139}
140140
141+ async function writeBrokenClaudeNpmShim ( binDir : string ) : Promise < string > {
142+ await fs . mkdir ( binDir , { recursive : true } ) ;
143+ const executable = path . join ( binDir , process . platform === "win32" ? "claude.cmd" : "claude" ) ;
144+ const packageExecutable = path . join (
145+ binDir ,
146+ "node_modules" ,
147+ "@anthropic-ai" ,
148+ "claude-code" ,
149+ "bin" ,
150+ "claude.exe" ,
151+ ) ;
152+ await fs . mkdir ( path . dirname ( packageExecutable ) , { recursive : true } ) ;
153+ await fs . writeFile (
154+ packageExecutable ,
155+ [
156+ 'echo "Error: claude native binary not installed." >&2' ,
157+ 'echo "node node_modules/@anthropic-ai/claude-code/install.cjs" >&2' ,
158+ "exit 1" ,
159+ "" ,
160+ ] . join ( "\n" ) ,
161+ ) ;
162+ await fs . writeFile (
163+ executable ,
164+ process . platform === "win32"
165+ ? '@ECHO off\r\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n'
166+ : '#!/bin/sh\nexec "$basedir/node_modules/@anthropic-ai/claude-code/bin/claude.exe" "$@"\n' ,
167+ ) ;
168+ if ( process . platform !== "win32" ) {
169+ await fs . chmod ( executable , 0o755 ) ;
170+ await fs . chmod ( packageExecutable , 0o755 ) ;
171+ }
172+ return executable ;
173+ }
174+
141175function message (
142176 sessionId : string ,
143177 type : "user" | "assistant" ,
@@ -159,6 +193,7 @@ function message(
159193}
160194
161195afterEach ( async ( ) => {
196+ vi . restoreAllMocks ( ) ;
162197 nodeHostMocks . runNodePtyCommand . mockClear ( ) ;
163198 nodeHostMocks . userShellPaths . clear ( ) ;
164199 process . env . HOME = originalHome ;
@@ -1089,7 +1124,8 @@ describe("Claude session catalog", () => {
10891124 ) . rejects . toThrow ( "Claude session cannot be resumed in a terminal" ) ;
10901125 } ) ;
10911126
1092- it ( "prefers the login-shell Claude binary for local terminal capability and plans" , async ( ) => {
1127+ it ( "replaces a broken npm shim with Claude Desktop's newest native binary" , async ( ) => {
1128+ vi . spyOn ( process , "platform" , "get" ) . mockReturnValue ( "darwin" ) ;
10931129 const home = await createHome ( ) ;
10941130 process . env . HOME = home ;
10951131 const sessionId = "claude-session-1" ;
@@ -1135,17 +1171,43 @@ describe("Claude session catalog", () => {
11351171 } ,
11361172 } as unknown as OpenClawPluginApi ) ;
11371173
1138- await fs . writeFile ( path . join ( shellBinDir , "claude" ) , "#!/bin/sh\n" ) ;
1139- await fs . chmod ( path . join ( shellBinDir , "claude" ) , 0o755 ) ;
1174+ await writeBrokenClaudeNpmShim ( shellBinDir ) ;
11401175 nodeHostMocks . userShellPaths . set ( "claude" , shellBinDir ) ;
1176+ const desktopVersionRoot = path . join (
1177+ home ,
1178+ "Library" ,
1179+ "Application Support" ,
1180+ "Claude" ,
1181+ "claude-code" ,
1182+ ) ;
1183+ for ( const version of [ "2.1.9" , "2.1.10" ] ) {
1184+ const desktopBinDir = path . join (
1185+ desktopVersionRoot ,
1186+ version ,
1187+ "claude.app" ,
1188+ "Contents" ,
1189+ "MacOS" ,
1190+ ) ;
1191+ await fs . mkdir ( desktopBinDir , { recursive : true } ) ;
1192+ await fs . writeFile ( path . join ( desktopBinDir , "claude" ) , "#!/bin/sh\nexit 0\n" ) ;
1193+ await fs . chmod ( path . join ( desktopBinDir , "claude" ) , 0o755 ) ;
1194+ }
1195+ const desktopExecutable = path . join (
1196+ desktopVersionRoot ,
1197+ "2.1.10" ,
1198+ "claude.app" ,
1199+ "Contents" ,
1200+ "MacOS" ,
1201+ "claude" ,
1202+ ) ;
11411203 await expect ( provider ?. list ( { } ) ) . resolves . toMatchObject ( [
11421204 { sessions : [ { threadId : sessionId , canOpenTerminal : true } ] } ,
11431205 ] ) ;
11441206 await expect (
11451207 provider ?. openTerminal ?.( { hostId : "gateway:local" , threadId : sessionId } ) ,
11461208 ) . resolves . toMatchObject ( {
11471209 kind : "local" ,
1148- argv : [ path . join ( shellBinDir , "claude" ) , "--resume" , sessionId ] ,
1210+ argv : [ desktopExecutable , "--resume" , sessionId ] ,
11491211 cwd : home ,
11501212 pathEnv : shellBinDir ,
11511213 } ) ;
@@ -1154,6 +1216,40 @@ describe("Claude session catalog", () => {
11541216 ) . rejects . toThrow ( "Claude session is unavailable" ) ;
11551217 } ) ;
11561218
1219+ it ( "hides terminal capability when the failed npm shim has no native replacement" , async ( ) => {
1220+ vi . spyOn ( process , "platform" , "get" ) . mockReturnValue ( "win32" ) ;
1221+ const home = await createHome ( ) ;
1222+ process . env . HOME = home ;
1223+ const sessionId = "claude-session-broken-shim" ;
1224+ await writeProject ( {
1225+ home,
1226+ entries : [
1227+ {
1228+ sessionId,
1229+ fullPath : path . join ( home , ".claude" , "projects" , "-workspace" , `${ sessionId } .jsonl` ) ,
1230+ projectPath : home ,
1231+ summary : "Broken shim session" ,
1232+ } ,
1233+ ] ,
1234+ transcripts : { [ sessionId ] : [ message ( sessionId , "user" , "hello" , 1 ) ] } ,
1235+ } ) ;
1236+ const shellBinDir = path . join ( home , "shell-bin" ) ;
1237+ await writeBrokenClaudeNpmShim ( shellBinDir ) ;
1238+ process . env . PATH = shellBinDir ;
1239+ nodeHostMocks . userShellPaths . set ( "claude" , shellBinDir ) ;
1240+ const provider = captureCatalogProvider ( {
1241+ config : { current : ( ) => ( { } ) } ,
1242+ nodes : { list : async ( ) => ( { nodes : [ ] } ) } ,
1243+ } as unknown as PluginRuntime ) ;
1244+
1245+ await expect ( provider . list ( { } ) ) . resolves . toMatchObject ( [
1246+ { sessions : [ { threadId : sessionId , canOpenTerminal : false } ] } ,
1247+ ] ) ;
1248+ await expect (
1249+ provider . openTerminal ?.( { hostId : "gateway:local" , threadId : sessionId } ) ,
1250+ ) . rejects . toThrow ( "Claude CLI is unavailable" ) ;
1251+ } ) ;
1252+
11571253 it ( "keeps one failed node isolated from healthy hosts" , async ( ) => {
11581254 const runtime = {
11591255 nodes : {
0 commit comments