Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

306 lines
11 KiB
Go

package agentruntime
import (
"context"
"errors"
"strings"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
const claudeSDKCloseTimeout = 10 * time.Minute
func (a *ClaudeCodeSDKAdapter) Start(ctx context.Context, session Session) ([]activityshared.Event, error) {
unlock := a.lockClaudeSDKSessionLifecycle(session.AgentSessionID)
defer unlock()
if err := a.admitClaudeSDKReplacementLocked(session.AgentSessionID); err != nil {
return nil, err
}
return a.replaceClaudeSDKSessionLocked(ctx, session)
}
func (a *ClaudeCodeSDKAdapter) replaceClaudeSDKSessionLocked(
ctx context.Context,
session Session,
) ([]activityshared.Event, error) {
previous := a.getSession(session.AgentSessionID)
events, err := a.startClaudeSDKSession(ctx, session)
if err != nil && previous != nil {
a.restoreOwnedPreviousSession(session.AgentSessionID, previous)
}
if err == nil && previous != nil {
a.removeSession(session.AgentSessionID, previous)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, previous)
}
return events, err
}
func (a *ClaudeCodeSDKAdapter) startClaudeSDKSession(ctx context.Context, session Session) ([]activityshared.Event, error) {
if a == nil || a.transport == nil {
return nil, ErrSessionDisconnected
}
restore := strings.TrimSpace(session.ProviderSessionID) != ""
providerSessionID := firstNonEmpty(strings.TrimSpace(session.ProviderSessionID), newID())
session.ProviderSessionID = providerSessionID
spec, cleanup, err := prepareProviderLaunch(ctx, a.preparer, session, ProcessSpec{
Provider: ProviderClaudeCode,
AgentSessionID: session.AgentSessionID,
RootAgentSessionID: session.RootAgentSessionID,
RoomID: session.RoomID,
CWD: session.CWD,
Command: claudeSDKSidecarCommand(session.Env),
Env: claudeSDKSidecarEnv(session),
DirectStart: true,
})
if err != nil {
return nil, err
}
launchSession := session
launchSession.CWD = spec.CWD
launchSession.Env = append([]string(nil), spec.Env...)
conn, err := a.transport.Start(ctx, spec)
if err != nil {
cleanupPreparedLaunch(cleanup)
return nil, err
}
trackInputUnits := providerInputUnitsEnabled(conn)
conn = wrapProviderLaunchCleanup(conn, cleanup)
adapterSession := &claudeSDKAdapterSession{
conn: conn,
reader: newClaudeSDKLineReader(conn, trackInputUnits),
session: session,
providerSessionID: providerSessionID,
resumeCursor: claudeSDKResumeCursorFromSession(session),
assistantMessages: make(map[string]string),
thinkingMessages: make(map[string]string),
compactMessages: make(map[string]claudeSDKCompactMessage),
pendingRequests: make(map[string]*pendingInteractiveRequest),
pendingResponses: make(map[string]chan claudeSDKSidecarEvent),
turns: make(map[string]*claudeSDKTurnWaiter),
liveState: newClaudeSDKLiveState(),
}
a.storeSession(session.AgentSessionID, adapterSession)
a.emitCommandSnapshot(claudeSDKCommandSnapshot(session.AgentSessionID, adapterSession.liveState))
// Continue-session tapes attach after the provider connection is already
// initialized, so their first outbound is exec. Skip the cold start
// bootstrap on attached-live-connection replay exactly as Codex/ACP do.
if processCassetteCaptureOrigin(conn) ==
ProcessCassetteCaptureOriginAttachedLiveConnection {
if !restore {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return nil, errors.New(
"attached live Claude replay requires a restored provider session id",
)
}
return []activityshared.Event{newSessionActivityEvent(
session,
EventSessionStarted,
SessionStatusReady,
claudeSDKRuntimeContext(session, adapterSession),
)}, nil
}
startPayload := map[string]any{
"agentSessionId": session.AgentSessionID,
"providerSessionId": providerSessionID,
"cwd": launchSession.CWD,
"env": envListToMap(launchSession.Env),
"restore": restore,
"permissionModeId": session.PermissionModeID,
"settings": claudeSDKSessionSettingsPayload(session),
"resumeCursor": claudeSDKResumeCursorFromSession(session),
"mcpServers": claudeSDKMCPServers(session.MCPServers),
}
for key, value := range claudeCodeSDKStartOptions(session) {
startPayload[key] = value
}
if err := adapterSession.send(claudeSDKSidecarRequest{
ID: newID(),
Type: "start",
Payload: startPayload,
}); err != nil {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return nil, err
}
for {
event, err := adapterSession.reader.next(ctx)
if err != nil {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
if errors.Is(err, context.DeadlineExceeded) {
// The process is already launched and the start request has been
// sent. A deadline at this boundary means the provider never
// established its runtime Session, rather than preparation or
// delivery timing out before provider readiness was observable.
err = errors.Join(ErrProviderStartTimeout, err)
}
return nil, err
}
eventCtx := context.Background()
if event.inputUnit != nil {
eventCtx = contextWithProviderInputUnit(eventCtx, *event.inputUnit)
}
endInputUnit := a.inputUnits.begin(eventCtx, session.AgentSessionID)
next := a.applySidecarSessionEvent(adapterSession, session, event)
next = a.inputUnits.stamp(session.AgentSessionID, next)
endInputUnit()
if next != nil {
a.mu.Lock()
adapterSession.session = applySessionEvents(session, next)
a.mu.Unlock()
// Controller.Start applies and publishes the returned events. Complete
// asynchronously so an exact replay barrier can wait for that publish
// without deadlocking inside Adapter.Start.
go func() {
if completeErr := completeClaudeSDKProviderInputUnit(
context.Background(),
adapterSession.conn,
event,
); completeErr != nil {
a.failClaudeSDKReader(
session.AgentSessionID,
adapterSession,
completeErr,
)
}
}()
return next, nil
}
if err := completeClaudeSDKProviderInputUnit(
ctx,
adapterSession.conn,
event,
); err != nil {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return nil, err
}
if event.Type == "error" {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return nil, errors.New(payloadString(event.Payload, "error"))
}
}
}
func claudeSDKMCPServers(bindings []MCPServerBinding) map[string]any {
result := make(map[string]any)
for _, binding := range bindings {
name := strings.TrimSpace(binding.Name)
if name == "" || strings.TrimSpace(binding.Type) != "http" || strings.TrimSpace(binding.URL) == "" {
continue
}
headers := make(map[string]string, len(binding.Headers))
for key, value := range binding.Headers {
headers[key] = value
}
result[name] = map[string]any{"type": "http", "url": binding.URL, "headers": headers}
}
return result
}
func (a *ClaudeCodeSDKAdapter) Resume(ctx context.Context, session Session) error {
if strings.TrimSpace(session.ProviderSessionID) == "" {
return ErrSessionDisconnected
}
unlock := a.lockClaudeSDKSessionLifecycle(session.AgentSessionID)
defer unlock()
if err := a.admitClaudeSDKReplacementLocked(session.AgentSessionID); err != nil {
return err
}
_, err := a.replaceClaudeSDKSessionLocked(ctx, session)
return classifyClaudeSDKResumeError(session, err)
}
func (*ClaudeCodeSDKAdapter) CanResume(session Session) bool {
return strings.TrimSpace(session.ProviderSessionID) != ""
}
func (a *ClaudeCodeSDKAdapter) Close(ctx context.Context, session Session) error {
unlock := a.lockClaudeSDKSessionLifecycle(session.AgentSessionID)
defer unlock()
return a.closeClaudeSDKSession(ctx, session)
}
func (a *ClaudeCodeSDKAdapter) closeClaudeSDKSession(ctx context.Context, session Session) error {
adapterSession := a.getSession(session.AgentSessionID)
if adapterSession == nil {
return nil
}
a.mu.Lock()
readerStarted := adapterSession.readerStarted
a.mu.Unlock()
if !readerStarted {
if err := a.startClaudeSDKReader(session.AgentSessionID, adapterSession); err != nil {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return err
}
}
closeBaseCtx := context.WithoutCancel(ctx)
var closeCtx context.Context
var cancel context.CancelFunc
if deadline, ok := ctx.Deadline(); ok {
closeCtx, cancel = context.WithDeadline(closeBaseCtx, deadline)
} else {
closeCtx, cancel = context.WithTimeout(closeBaseCtx, claudeSDKCloseTimeout)
}
defer cancel()
if err := a.roundTripClaudeSDK(closeCtx, session.AgentSessionID, adapterSession, claudeSDKSidecarRequest{
ID: newID(),
Type: "close",
Payload: map[string]any{
"agentSessionId": session.AgentSessionID,
},
}); err != nil {
a.removeSession(session.AgentSessionID, adapterSession)
a.closeOrRetainClaudeSDKSession(session.AgentSessionID, adapterSession)
return err
}
a.removeSession(session.AgentSessionID, adapterSession)
if graceful, ok := adapterSession.conn.(GracefulProcessConnection); ok {
_ = graceful.CloseInput()
}
if err := adapterSession.conn.Close(); err != nil {
a.retainRetiredClaudeSDKSession(session.AgentSessionID, adapterSession)
return err
}
return nil
}
func (a *ClaudeCodeSDKAdapter) HasLiveSession(session Session) bool {
adapterSession := a.getSession(session.AgentSessionID)
return a.sessionIsUsable(session.AgentSessionID, adapterSession)
}
func (a *ClaudeCodeSDKAdapter) ReleaseLiveSession(ctx context.Context, session Session) error {
return a.Close(ctx, session)
}
// DisconnectLiveSession terminalizes provider-owned work through the same
// external-failure path as a broken sidecar connection, then closes only the
// transport. It deliberately does not send the sidecar "close" request,
// which is the graceful provider-session close contract.
func (a *ClaudeCodeSDKAdapter) DisconnectLiveSession(_ context.Context, session Session) error {
unlock := a.lockClaudeSDKSessionLifecycle(session.AgentSessionID)
defer unlock()
adapterSession := a.getSession(session.AgentSessionID)
if adapterSession == nil {
return nil
}
a.failClaudeSDKReaderWithOwnership(
session.AgentSessionID,
adapterSession,
ErrSessionDisconnected,
true,
)
if err := adapterSession.conn.Close(); err != nil {
return err
}
a.removeSession(session.AgentSessionID, adapterSession)
return nil
}