@@ -25,26 +25,102 @@ import { MessageType, type WeixinMessage } from './wechat/types.js';
2525
2626const MAX_MESSAGE_LENGTH = 2048 ;
2727
28+ /**
29+ * Split a long message into chunks, respecting code blocks.
30+ * - Never splits inside a fenced code block (``` ... ```)
31+ * - Adds continuation markers if split is unavoidable
32+ * - Prefers splitting at paragraph boundaries
33+ */
2834function splitMessage ( text : string , maxLen : number = MAX_MESSAGE_LENGTH ) : string [ ] {
2935 if ( text . length <= maxLen ) return [ text ] ;
36+
3037 const chunks : string [ ] = [ ] ;
3138 let remaining = text ;
39+
3240 while ( remaining . length > 0 ) {
3341 if ( remaining . length <= maxLen ) {
3442 chunks . push ( remaining ) ;
3543 break ;
3644 }
37- // Try to split at a newline near the limit
38- let splitIdx = remaining . lastIndexOf ( '\n' , maxLen ) ;
39- if ( splitIdx < maxLen * 0.3 ) {
40- splitIdx = maxLen ;
45+
46+ // Find the best split point
47+ const splitIdx = findBestSplitPoint ( remaining , maxLen ) ;
48+
49+ if ( splitIdx <= 0 ) {
50+ // No good split point found, force split with continuation marker
51+ const forcedChunk = remaining . slice ( 0 , maxLen - 15 ) + '\n... (续)' ;
52+ chunks . push ( forcedChunk ) ;
53+ remaining = '(续) ...\n' + remaining . slice ( maxLen - 15 ) ;
54+ continue ;
55+ }
56+
57+ const chunk = remaining . slice ( 0 , splitIdx ) ;
58+ const inCodeBlock = isInCodeBlock ( chunk ) ;
59+
60+ if ( inCodeBlock ) {
61+ // Close the code block and add continuation marker
62+ chunks . push ( chunk + '\n```\n... (续)' ) ;
63+ remaining = '```\n(续) ...\n' + remaining . slice ( splitIdx ) . replace ( / ^ \n + / , '' ) ;
64+ } else {
65+ chunks . push ( chunk ) ;
66+ remaining = remaining . slice ( splitIdx ) . replace ( / ^ \n + / , '' ) ;
4167 }
42- chunks . push ( remaining . slice ( 0 , splitIdx ) ) ;
43- remaining = remaining . slice ( splitIdx ) . replace ( / ^ \n + / , '' ) ;
4468 }
69+
4570 return chunks ;
4671}
4772
73+ /**
74+ * Find the best point to split text, avoiding code blocks.
75+ */
76+ function findBestSplitPoint ( text : string , maxLen : number ) : number {
77+ // First, try to find if there's a code block ending before maxLen
78+ const codeBlockPattern = / ` ` ` / g;
79+ let inBlock = false ;
80+ let lastCodeBlockEnd = - 1 ;
81+ let match ;
82+
83+ while ( ( match = codeBlockPattern . exec ( text . slice ( 0 , maxLen + 100 ) ) ) !== null ) {
84+ inBlock = ! inBlock ;
85+ if ( ! inBlock ) {
86+ lastCodeBlockEnd = match . index + 3 ;
87+ }
88+ }
89+
90+ // If we're in a code block at maxLen, split at the end of the last complete block
91+ if ( inBlock && lastCodeBlockEnd > maxLen * 0.3 ) {
92+ return lastCodeBlockEnd ;
93+ }
94+
95+ // Try to split at paragraph boundary (double newline)
96+ const paragraphIdx = text . lastIndexOf ( '\n\n' , maxLen ) ;
97+ if ( paragraphIdx > maxLen * 0.3 ) {
98+ return paragraphIdx ;
99+ }
100+
101+ // Try to split at single newline
102+ const newlineIdx = text . lastIndexOf ( '\n' , maxLen ) ;
103+ if ( newlineIdx > maxLen * 0.3 ) {
104+ return newlineIdx ;
105+ }
106+
107+ // No good split point
108+ return - 1 ;
109+ }
110+
111+ /**
112+ * Check if text ends inside an unclosed code block.
113+ */
114+ function isInCodeBlock ( text : string ) : boolean {
115+ const codeBlockPattern = / ` ` ` / g;
116+ let count = 0 ;
117+ let match ;
118+ while ( ( match = codeBlockPattern . exec ( text ) ) !== null ) {
119+ count ++ ;
120+ }
121+ return count % 2 === 1 ;
122+ }
123+
48124function promptUser ( question : string , defaultValue ?: string ) : Promise < string > {
49125 return new Promise ( ( resolve ) => {
50126 const rl = createInterface ( { input : process . stdin , output : process . stdout } ) ;
@@ -174,19 +250,31 @@ async function runDaemon(): Promise<void> {
174250 sessionStore . save ( account . accountId , session ) ;
175251 }
176252
177- // Fix: reset stale non-idle state on startup (e.g. after crash )
253+ // Fix: reset stuck session state after restart (processing/waiting_permission should not persist )
178254 if ( session . state !== 'idle' ) {
179- logger . warn ( 'Resetting stale session state on startup ' , { state : session . state } ) ;
255+ logger . warn ( 'Session state was stuck after restart, resetting to idle ' , { accountId : account . accountId , previousState : session . state } ) ;
180256 session . state = 'idle' ;
181- sessionStore . save ( account . accountId , session ) ;
182257 }
183258
259+ // Always clear sdkSessionId on restart since the SDK process is gone
260+ // The SDK session may still exist server-side but local state is inconsistent
261+ // Move to previousSdkSessionId for potential manual recovery
262+ if ( session . sdkSessionId ) {
263+ logger . info ( 'Clearing SDK session ID on restart' , { accountId : account . accountId , sessionId : session . sdkSessionId } ) ;
264+ session . previousSdkSessionId = session . sdkSessionId ;
265+ session . sdkSessionId = undefined ;
266+ }
267+
268+ sessionStore . save ( account . accountId , session ) ;
269+
184270 const sender = createSender ( api , account . accountId ) ;
271+ // Note: sharedCtx is kept for backward compatibility but permission timeout now uses stored context
185272 const sharedCtx = { lastContextToken : '' } ;
186273 const activeControllers = new Map < string , AbortController > ( ) ;
187- const permissionBroker = createPermissionBroker ( async ( ) => {
274+ // Permission broker callback now receives contextToken and fromUserId directly (fixes concurrency issue)
275+ const permissionBroker = createPermissionBroker ( async ( contextToken : string , fromUserId : string ) => {
188276 try {
189- await sender . sendText ( account . userId ?? '' , sharedCtx . lastContextToken , '⏰ 权限请求超时,已自动拒绝。' ) ;
277+ await sender . sendText ( fromUserId , contextToken , '⏰ 权限请求超时,已自动拒绝。' ) ;
190278 } catch {
191279 logger . warn ( 'Failed to send permission timeout message' ) ;
192280 }
@@ -244,6 +332,7 @@ async function handleMessage(
244332
245333 const contextToken = msg . context_token ?? '' ;
246334 const fromUserId = msg . from_user_id ;
335+ // Update sharedCtx for backward compatibility (though permission timeout now uses stored context)
247336 sharedCtx . lastContextToken = contextToken ;
248337
249338 // Extract text from items
@@ -411,6 +500,7 @@ async function sendToClaude(
411500 try {
412501 // Download image if present
413502 let images : QueryOptions [ 'images' ] ;
503+ let imageDownloadError : string | undefined ;
414504 if ( imageItem ) {
415505 const base64DataUri = await downloadImage ( imageItem ) ;
416506 if ( base64DataUri ) {
@@ -427,10 +517,21 @@ async function sendToClaude(
427517 } ,
428518 } ,
429519 ] ;
520+ } else {
521+ imageDownloadError = '图片格式解析失败' ;
522+ logger . error ( 'Failed to parse image data URI format' ) ;
430523 }
524+ } else {
525+ imageDownloadError = '图片下载失败' ;
526+ logger . error ( 'Failed to download image' , { imageItem } ) ;
431527 }
432528 }
433529
530+ // Notify user if image processing failed
531+ if ( imageDownloadError && ! images ) {
532+ await sender . sendText ( fromUserId , contextToken , `⚠️ ${ imageDownloadError } ,将以纯文字模式处理。` ) ;
533+ }
534+
434535 const effectivePermissionMode = session . permissionMode ?? config . permissionMode ;
435536 const isAutoPermission = effectivePermissionMode === 'auto' ;
436537
@@ -482,18 +583,36 @@ async function sendToClaude(
482583 session . state = 'waiting_permission' ;
483584 sessionStore . save ( account . accountId , session ) ;
484585
485- // Create pending permission
586+ // Create pending permission (includes context for timeout message - fixes concurrency)
486587 const permissionPromise = permissionBroker . createPending (
487588 account . accountId ,
488589 toolName ,
489590 toolInput ,
591+ contextToken ,
592+ fromUserId ,
490593 ) ;
491594
492595 // Send permission message to WeChat
493596 const perm = permissionBroker . getPending ( account . accountId ) ;
494597 if ( perm ) {
495- const permMsg = permissionBroker . formatPendingMessage ( perm ) ;
496- await sender . sendText ( fromUserId , contextToken , permMsg ) ;
598+ try {
599+ const permMsg = permissionBroker . formatPendingMessage ( perm ) ;
600+ await sender . sendText ( fromUserId , contextToken , permMsg ) ;
601+ } catch ( sendErr ) {
602+ // If we can't send the permission request, we must fail the permission
603+ // otherwise the SDK will hang indefinitely
604+ logger . error ( 'Failed to send permission request to WeChat' , { error : sendErr instanceof Error ? sendErr . message : String ( sendErr ) } ) ;
605+ permissionBroker . resolvePermission ( account . accountId , false ) ;
606+ session . state = 'processing' ;
607+ sessionStore . save ( account . accountId , session ) ;
608+ return false ;
609+ }
610+ } else {
611+ // Should not happen: pending permission not found after creation
612+ logger . error ( 'Pending permission not found after creation' ) ;
613+ session . state = 'processing' ;
614+ sessionStore . save ( account . accountId , session ) ;
615+ return false ;
497616 }
498617
499618 const allowed = await permissionPromise ;
@@ -536,7 +655,12 @@ async function sendToClaude(
536655 }
537656 } else if ( result . error ) {
538657 logger . error ( 'Claude query error' , { error : result . error } ) ;
539- await sender . sendText ( fromUserId , contextToken , '⚠️ Claude 处理请求时出错,请稍后重试。' ) ;
658+ // Check if it's a resume-related error that might be recoverable
659+ const isResumeError = result . error . includes ( 'session' ) || result . error . includes ( 'resume' ) ;
660+ const userMsg = isResumeError
661+ ? '⚠️ 会话状态异常,已自动重置。请重新发送你的请求。'
662+ : '⚠️ Claude 处理请求时出错,请稍后重试。' ;
663+ await sender . sendText ( fromUserId , contextToken , userMsg ) ;
540664 } else if ( ! anySent ) {
541665 await sender . sendText ( fromUserId , contextToken , 'ℹ️ Claude 无返回内容(可能因权限被拒而终止)' ) ;
542666 }
@@ -584,4 +708,4 @@ if (command === 'setup') {
584708 console . error ( '启动失败:' , err ) ;
585709 process . exit ( 1 ) ;
586710 } ) ;
587- }
711+ }
0 commit comments