diff --git a/agent/sandbox/v2/claude/parse.go b/agent/sandbox/v2/claude/parse.go index 473167ac..5714a368 100644 --- a/agent/sandbox/v2/claude/parse.go +++ b/agent/sandbox/v2/claude/parse.go @@ -326,6 +326,12 @@ func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool) if text == "" { return false } + // If there is no active text message and this delta is only + // whitespace, buffer it instead of opening a brand-new message + // group just for spaces/indentation between tool calls. + if !p.textActive && strings.TrimSpace(text) == "" { + return false + } if p.ensureTextMessage() { return true } @@ -448,6 +454,12 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) { continue } + // Close any open text message before opening an execute message, + // otherwise textActive stays true while currentGroupID gets + // overwritten by the execute message lifecycle, causing subsequent + // text chunks to be emitted without a message_id. + p.closeTextMessage() + toolUseID, _ := ci["tool_use_id"].(string) content := ci["content"] isError, _ := ci["is_error"].(bool) diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index 44c745b7..d08a2c12 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" "github.com/yaoapp/gou/connector" @@ -52,7 +51,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e src := "local:///" + req.SkillsDir dst := prefix + "/skills" if _, err := ws.Copy(src, dst); err != nil { - fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err) + r.logger.Warn("copy skills %s -> %s: %v", src, dst, err) } } } @@ -115,6 +114,10 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han completed, err := sess.runStream(handler) r.lastCompleted = completed + r.logger.Debug("Stream: runStream returned completed=%v err=%v", completed, err) + if completed { + sess.shutdown() + } return err } diff --git a/agent/sandbox/v2/claude/session.go b/agent/sandbox/v2/claude/session.go index 05333653..1f0450dc 100644 --- a/agent/sandbox/v2/claude/session.go +++ b/agent/sandbox/v2/claude/session.go @@ -59,6 +59,8 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err parser := newStreamParser(handler) parseErr := parser.parse(s.ctx, s.exec.Stdout) + s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr) + if parser.completed { s.logger.Info("claude stream completed normally") return true, nil @@ -127,6 +129,26 @@ func (s *session) watchCancel() func() { return func() { close(done) } } +// shutdown terminates the claude process after a normal stream completion. +// +// Claude CLI's stream-json mode has a known bug where the process hangs +// indefinitely after emitting the "result" event (anthropics/claude-code#25629). +// There is no graceful exit mechanism, so we must kill the process externally. +// +// We send SIGKILL (-9) to processes named exactly "claude". SIGKILL cannot be +// caught, so Claude CLI has no opportunity to run its SIGTERM handler which +// would actively terminate child processes (web servers, etc.). Those children +// survive because they run in separate process groups/sessions. +func (s *session) shutdown() { + s.logger.Info("shutting down completed claude exec session") + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, err := s.computer.Exec(killCtx, []string{"sh", "-c", "pkill -9 -x claude || true"}) + s.logger.Debug("shutdown: pkill -9 -x claude exitCode=%d err=%v", result.ExitCode, err) + s.exec.Cancel() +} + // waitForExit waits for the Claude process to exit with timeout protection. // This fixes the old code's issue where Wait() could block forever. func (s *session) waitForExit(parseErr error) error {