diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go index 1749889..36f7c6c 100644 --- a/agent/claudecode/session.go +++ b/agent/claudecode/session.go @@ -16,6 +16,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/chenhg5/cc-connect/core" @@ -41,6 +42,13 @@ type claudeSession struct { cancel context.CancelFunc done chan struct{} alive atomic.Bool + + // gracefulStopTimeout is how long Close() waits for a clean exit + // (stdin close → Stop hooks → process exit) before escalating to + // SIGTERM and then SIGKILL. Default: 120s to match claude-mem's + // Stop hook timeout. The wait ends as soon as the process exits, + // so typical shutdowns take seconds, not the full timeout. + gracefulStopTimeout time.Duration } func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode string, allowedTools, disallowedTools []string, extraEnv []string, platformPrompt string, disableVerbose bool, maxContextTokens int) (*claudeSession, error) { @@ -125,13 +133,14 @@ func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode strin } cs := &claudeSession{ - cmd: cmd, - stdin: stdin, - events: make(chan core.Event, 64), - workDir: workDir, - ctx: sessionCtx, - cancel: cancel, - done: make(chan struct{}), + cmd: cmd, + stdin: stdin, + events: make(chan core.Event, 64), + workDir: workDir, + ctx: sessionCtx, + cancel: cancel, + done: make(chan struct{}), + gracefulStopTimeout: 120 * time.Second, } cs.setPermissionMode(mode) cs.sessionID.Store(sessionID) @@ -568,19 +577,47 @@ func (cs *claudeSession) Alive() bool { } func (cs *claudeSession) Close() error { - cs.cancel() + // Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on + // stdin close, running Stop hooks (e.g. claude-mem session summary). + cs.stdinMu.Lock() + _ = cs.stdin.Close() + cs.stdinMu.Unlock() + + graceful := cs.gracefulStopTimeout + if graceful <= 0 { + graceful = 8 * time.Second // legacy fallback + } select { case <-cs.done: + slog.Info("claudeSession: exited cleanly after stdin close") return nil - case <-time.After(8 * time.Second): - slog.Warn("claudeSession: graceful close timed out, killing process") - if cs.cmd != nil && cs.cmd.Process != nil { - _ = cs.cmd.Process.Kill() - } - <-cs.done - return nil + case <-time.After(graceful): + slog.Warn("claudeSession: graceful stop timed out, sending SIGTERM", + "timeout", graceful) } + + // Phase 2: SIGTERM — gives the process a second chance to run + // cleanup handlers that respond to signals but not stdin EOF. + if cs.cmd != nil && cs.cmd.Process != nil { + _ = cs.cmd.Process.Signal(syscall.SIGTERM) + } + + select { + case <-cs.done: + slog.Info("claudeSession: exited after SIGTERM") + return nil + case <-time.After(5 * time.Second): + slog.Warn("claudeSession: SIGTERM timed out, sending SIGKILL") + } + + // Phase 3: SIGKILL — last resort. + cs.cancel() + if cs.cmd != nil && cs.cmd.Process != nil { + _ = cs.cmd.Process.Kill() + } + <-cs.done + return nil } // filterEnv returns a copy of env with entries matching the given key removed. diff --git a/core/engine.go b/core/engine.go index d4215f4..1bb7e45 100644 --- a/core/engine.go +++ b/core/engine.go @@ -1500,6 +1500,20 @@ func (e *Engine) maybeAutoResetSessionOnIdle(p Platform, msg *Message, sessions "threshold", e.resetOnIdle, ) + // Check if the old session has an agent process that needs graceful + // shutdown. If so, tell the user we're wrapping up before blocking. + e.interactiveMu.Lock() + state, hasState := e.interactiveStates[interactiveKey] + hasAgent := hasState && state != nil && state.agentSession != nil && state.agentSession.Alive() + e.interactiveMu.Unlock() + + if hasAgent { + // Notify the user before the potentially long close. The close + // returns as soon as the process exits (usually seconds), but + // Stop hooks can take up to 120s. + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSessionClosingGraceful)) + } + e.cleanupInteractiveState(interactiveKey) session.UnlockWithoutUpdate() @@ -2188,6 +2202,14 @@ func (e *Engine) closeAgentSessionWithTimeout(sessionKey string, agentSession Ag return } + // Allow enough time for the agent's own graceful shutdown sequence: + // stdin close → Stop hooks (claude-mem summary etc.) → SIGTERM → SIGKILL. + // Claude Code's Stop hooks can take up to 120s (claude-mem uses a + // sonnet summarizer). The 130s budget covers the default 120s graceful + // phase + 5s SIGTERM + 5s buffer. The wait ends early if the process + // exits sooner — this is the ceiling, not the typical duration. + const closeTimeout = 130 * time.Second + slog.Debug("cleanupInteractiveState: closing agent session", "session", sessionKey) closeStart := time.Now() @@ -2202,8 +2224,9 @@ func (e *Engine) closeAgentSessionWithTimeout(sessionKey string, agentSession Ag if elapsed := time.Since(closeStart); elapsed >= slowAgentClose { slog.Warn("slow agent session close", "elapsed", elapsed, "session", sessionKey) } - case <-time.After(10 * time.Second): - slog.Error("agent session close timed out (10s), abandoning", "session", sessionKey) + case <-time.After(closeTimeout): + slog.Error("agent session close timed out, abandoning", + "timeout", closeTimeout, "session", sessionKey) } } diff --git a/core/i18n.go b/core/i18n.go index f0b8aff..22e6263 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -394,7 +394,8 @@ const ( MsgNewSessionCreated MsgKey = "new_session_created" MsgNewSessionCreatedName MsgKey = "new_session_created_name" - MsgSessionAutoResetIdle MsgKey = "session_auto_reset_idle" + MsgSessionAutoResetIdle MsgKey = "session_auto_reset_idle" + MsgSessionClosingGraceful MsgKey = "session_closing_graceful" MsgDeleteUsage MsgKey = "delete_usage" MsgDeleteSuccess MsgKey = "delete_success" @@ -2708,6 +2709,13 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "⏰ %d 分以上操作がなかったため、新しいセッションに自動切り替えました。", LangSpanish: "⏰ La sesión se reinició automáticamente tras %d minuto(s) de inactividad.", }, + MsgSessionClosingGraceful: { + LangEnglish: "⏳ Wrapping up your previous session (usually a few seconds, up to 2 minutes). Your new session will start automatically.", + LangChinese: "⏳ 正在结束上一个会话(通常几秒钟,最多2分钟)。新会话将自动启动。", + LangTraditionalChinese: "⏳ 正在結束上一個會話(通常幾秒鐘,最多2分鐘)。新會話將自動啟動。", + LangJapanese: "⏳ 前のセッションを終了中です(通常は数秒、最大2分)。新しいセッションは自動的に開始されます。", + LangSpanish: "⏳ Cerrando la sesión anterior (normalmente unos segundos, hasta 2 minutos). La nueva sesión se iniciará automáticamente.", + }, MsgDeleteUsage: { LangEnglish: "Usage: `/delete ` or `/delete 1,2,3` or `/delete 3-7` or `/delete 1,3-5,8`.\nUse `/list` to see session numbers.", LangChinese: "用法:`/delete <序号>`,或 `/delete 1,2,3`,或 `/delete 3-7`,或 `/delete 1,3-5,8`。\n使用 `/list` 查看会话序号。",