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
737 lines
31 KiB
Go
737 lines
31 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
|
|
)
|
|
|
|
// Codex app-server JSON-RPC methods used by the adapter. The app-server
|
|
// protocol is the official first-party integration surface for Codex; it
|
|
// replaces the previous codex-acp (ACP) bridge for the "codex" provider.
|
|
const (
|
|
appServerMethodInitialize = "initialize"
|
|
appServerMethodInitialized = "initialized"
|
|
appServerMethodAccountRead = "account/read"
|
|
appServerMethodRateLimitsRead = "account/rateLimits/read"
|
|
appServerMethodModelList = "model/list"
|
|
appServerMethodPluginList = "plugin/list"
|
|
appServerMethodSkillsExtraRootsSet = "skills/extraRoots/set"
|
|
appServerMethodSkillsList = "skills/list"
|
|
// Experimental: collaboration mode presets (plan/pair/execute). Absence of
|
|
// the method on older binaries downgrades planMode capability gracefully.
|
|
appServerMethodCollaborationModeList = "collaborationMode/list"
|
|
appServerMethodThreadStart = "thread/start"
|
|
appServerMethodThreadResume = "thread/resume"
|
|
appServerMethodThreadFork = "thread/fork"
|
|
appServerMethodThreadInjectItems = "thread/inject_items"
|
|
appServerMethodThreadUnsubscribe = "thread/unsubscribe"
|
|
appServerMethodThreadRollback = "thread/rollback"
|
|
appServerMethodThreadRead = "thread/read"
|
|
appServerMethodThreadCompact = "thread/compact/start"
|
|
appServerMethodThreadGoalSet = "thread/goal/set"
|
|
appServerMethodThreadGoalGet = "thread/goal/get"
|
|
appServerMethodThreadGoalClear = "thread/goal/clear"
|
|
appServerMethodTurnStart = "turn/start"
|
|
appServerMethodTurnSteer = "turn/steer"
|
|
appServerMethodTurnInterrupt = "turn/interrupt"
|
|
appServerMethodReviewStart = "review/start"
|
|
appServerMethodFeedbackUpload = "feedback/upload"
|
|
appServerMethodAccountLoginStart = "account/login/start"
|
|
|
|
// Server -> client requests.
|
|
appServerMethodCommandApproval = "item/commandExecution/requestApproval"
|
|
appServerMethodFileChangeApproval = "item/fileChange/requestApproval"
|
|
appServerMethodPermissionsApproval = "item/permissions/requestApproval"
|
|
appServerMethodRequestUserInput = "item/tool/requestUserInput"
|
|
appServerMethodMCPElicitation = "mcpServer/elicitation/request"
|
|
appServerMethodExecApprovalV1 = "execCommandApproval"
|
|
appServerMethodPatchApprovalV1 = "applyPatchApproval"
|
|
|
|
// Server -> client notifications.
|
|
appServerNotifyThreadStarted = "thread/started"
|
|
appServerNotifyTurnStarted = "turn/started"
|
|
appServerNotifyTurnCompleted = "turn/completed"
|
|
appServerNotifyAgentMessageDelta = "item/agentMessage/delta"
|
|
appServerNotifyCommandOutputDelta = "item/commandExecution/outputDelta"
|
|
appServerNotifyReasoningDelta = "item/reasoning/textDelta"
|
|
appServerNotifyReasoningSummary = "item/reasoning/summaryTextDelta"
|
|
appServerNotifyReasoningSummaryPart = "item/reasoning/summaryPartAdded"
|
|
appServerNotifyThreadSettingsUpdated = "thread/settings/updated"
|
|
appServerNotifyItemStarted = "item/started"
|
|
appServerNotifyItemCompleted = "item/completed"
|
|
appServerNotifyFileChangePatchUpdated = "item/fileChange/patchUpdated"
|
|
appServerNotifyTokenUsage = "thread/tokenUsage/updated"
|
|
appServerNotifyPlanUpdated = "turn/plan/updated"
|
|
appServerNotifyThreadNameUpdated = "thread/name/updated"
|
|
appServerNotifyRateLimitsUpdated = "account/rateLimits/updated"
|
|
appServerNotifyAccountUpdated = "account/updated"
|
|
appServerNotifyError = "error"
|
|
appServerNotifyWarning = "warning"
|
|
appServerNotifyDeprecation = "deprecationNotice"
|
|
appServerNotifyModelRerouted = "model/rerouted"
|
|
appServerNotifyThreadCompacted = "thread/compacted"
|
|
appServerNotifyServerRequestResolved = "serverRequest/resolved"
|
|
appServerNotifyThreadGoalUpdated = "thread/goal/updated"
|
|
appServerNotifyThreadGoalCleared = "thread/goal/cleared"
|
|
appServerNotifyMCPServerStartupStatusUpdated = "mcpServer/startupStatus/updated"
|
|
appServerNotifyMCPToolCallProgress = "item/mcpToolCall/progress"
|
|
appServerNotifyMCPOAuthLoginCompleted = "mcpServer/oauthLogin/completed"
|
|
)
|
|
|
|
const (
|
|
appServerSlashCompact = "/compact"
|
|
appServerSlashGoal = "/goal"
|
|
appServerSlashReview = "/review"
|
|
appServerSlashUndo = "/undo"
|
|
)
|
|
|
|
// appServerAdapterConfig captures the provider-specific identity of an
|
|
// app-server CLI so a single adapter implementation can serve Codex and
|
|
// Codex-compatible forks (Tutti Agent) without sharing brand, command, or
|
|
// auth assumptions.
|
|
type appServerAdapterConfig struct {
|
|
provider string
|
|
runtimeName string
|
|
displayName string
|
|
command []string
|
|
clientInfoName string
|
|
authRequiredMessage string
|
|
skillRootsStrategy providerregistry.AppServerSkillRootsStrategy
|
|
commandNetworkAccess bool
|
|
rateLimits bool
|
|
nativeSessionFork bool
|
|
sessionForkUserAgentBrand string
|
|
sessionForkThroughTurnMinVersion string
|
|
}
|
|
|
|
// CodexAppServerAdapterOptions controls host-owned app-server execution policy
|
|
// without changing the provider-native permission-mode mapping.
|
|
type CodexAppServerAdapterOptions struct {
|
|
// CommandNetworkAccess enables network access for commands and subprocesses
|
|
// in the read-only and workspace-write sandboxes. Filesystem access,
|
|
// approval policy, and approval reviewer remain owned by the selected
|
|
// permission mode. Network proxy configuration remains a separate concern.
|
|
CommandNetworkAccess bool
|
|
// StartupSpanObserver receives Codex app-server startup span boundaries.
|
|
// It is best-effort observability only and must not influence provider
|
|
// startup or command correctness.
|
|
StartupSpanObserver CodexAppServerSpanObserver
|
|
// StartupObserver receives one bounded summary when a Codex app-server
|
|
// session start or resume finishes. It is best-effort observability only
|
|
// and must not influence provider startup or command correctness.
|
|
StartupObserver CodexAppServerStartupObserver
|
|
// StartupResourceObserver receives a best-effort snapshot of the resources
|
|
// exposed by the app-server. The snapshot is collected asynchronously and
|
|
// must not influence provider startup or command correctness.
|
|
StartupResourceObserver CodexAppServerResourceObserver
|
|
}
|
|
|
|
// CodexAppServerSpanObservation is one allowlisted startup span boundary
|
|
// emitted by the Codex app-server. A new observation is emitted when the span
|
|
// starts and a close observation uses the same SpanInstanceID. It intentionally
|
|
// contains only bounded timing and session-scope facts; raw prompts, commands,
|
|
// paths, and payloads are not part of this contract.
|
|
type CodexAppServerSpanObservation struct {
|
|
Provider string
|
|
RoomID string
|
|
AgentSessionID string
|
|
SpanName string
|
|
SpanPhase string
|
|
SpanInstanceID string
|
|
SpanTarget string
|
|
CodexTimestamp string
|
|
DurationMS int64
|
|
SpanBusy string
|
|
SpanIdle string
|
|
}
|
|
|
|
// CodexAppServerSpanObserver consumes startup span boundary observations.
|
|
// Implementations must be best-effort and must not affect provider behavior.
|
|
type CodexAppServerSpanObserver func(CodexAppServerSpanObservation)
|
|
|
|
// CodexAppServerStartupObservation is one bounded summary of a Codex
|
|
// app-server session start or resume. Counts describe resources bound to the
|
|
// Tutti session and completed allowlisted Codex spans.
|
|
type CodexAppServerStartupObservation struct {
|
|
Provider string
|
|
RoomID string
|
|
AgentSessionID string
|
|
StartedAt string
|
|
Outcome string
|
|
DurationMS int64
|
|
MCPServerCount int
|
|
CompletedSpanCount int
|
|
}
|
|
|
|
// CodexAppServerStartupObserver consumes one completed startup summary.
|
|
// Implementations must be best-effort and must not affect provider behavior.
|
|
type CodexAppServerStartupObserver func(CodexAppServerStartupObservation)
|
|
|
|
// CodexAppServerResourceObservation is a bounded snapshot of the resources
|
|
// visible to one app-server startup attempt. MCPServerCount is the number of
|
|
// bindings supplied by the Tutti session. PluginCount and SkillCount are
|
|
// Codex-native counts; -1 means that the corresponding list request did not
|
|
// produce a trustworthy response.
|
|
type CodexAppServerResourceObservation struct {
|
|
Provider string
|
|
RoomID string
|
|
AgentSessionID string
|
|
StartedAt string
|
|
Outcome string
|
|
DurationMS int64
|
|
MCPServerCount int
|
|
PluginCount int
|
|
SkillCount int
|
|
PluginQueryOutcome string
|
|
SkillQueryOutcome string
|
|
}
|
|
|
|
// CodexAppServerResourceObserver consumes one best-effort resource snapshot.
|
|
// Implementations must be best-effort and must not affect provider behavior.
|
|
type CodexAppServerResourceObserver func(CodexAppServerResourceObservation)
|
|
|
|
// defaultCodexAppServerCancelGraceWindow is how long Cancel waits for codex to
|
|
// honor turn/interrupt gracefully before force-closing the app-server process.
|
|
const defaultCodexAppServerCancelGraceWindow = 3 * time.Second
|
|
|
|
// defaultCodexAppServerTurnStartAckTimeout only bounds the immediate
|
|
// turn/start acknowledgement. Turn output continues through notifications
|
|
// without this deadline after the acknowledgement arrives.
|
|
const defaultCodexAppServerTurnStartAckTimeout = acpStartCallTimeout
|
|
|
|
// defaultCodexAppServerTurnSteerTimeout bounds guidance delivery to a running
|
|
// turn so a missing app-server response cannot block the caller forever.
|
|
const defaultCodexAppServerTurnSteerTimeout = 10 * time.Second
|
|
|
|
// startupModelSteadyRetryCount is how many 30s-spaced model/list retries follow
|
|
// the initial fast ramp before the background refresh gives up (~18 minutes
|
|
// total), bounding the goroutine while covering realistic transient outages.
|
|
const startupModelSteadyRetryCount = 36
|
|
|
|
// defaultCodexAppServerGoalContinuationGraceWindow is how long the adapter
|
|
// waits after a goal turn settles for codex to auto-start the next turn
|
|
// before nudging it with a thread/goal/set re-send.
|
|
const defaultCodexAppServerGoalContinuationGraceWindow = 1500 * time.Millisecond
|
|
|
|
// defaultCodexAppServerGoalProvenanceGraceWindow bounds how long an
|
|
// unowned provider turn may wait for exact turn-scoped goal evidence or an
|
|
// operation-scoped continuation claim. turn/started alone carries no Goal
|
|
// generation and must never inherit the session's latest desired Goal identity.
|
|
const defaultCodexAppServerGoalProvenanceGraceWindow = 250 * time.Millisecond
|
|
|
|
type CodexAppServerAdapter struct {
|
|
transport ProcessTransport
|
|
host HostMetadata
|
|
config appServerAdapterConfig
|
|
startupSpanObserver CodexAppServerSpanObserver
|
|
startupObserver CodexAppServerStartupObserver
|
|
startupResourceObserver CodexAppServerResourceObserver
|
|
preparer ProviderLaunchPreparer
|
|
commandResolver ProviderCommandResolver
|
|
mu sync.Mutex
|
|
sessions map[string]*codexAppServerSession
|
|
pendingSideRoutes map[*codexAppServerClient]*codexPendingSideRoute
|
|
retiredSessions map[string][]*codexAppServerSession
|
|
terminalInteractions terminalInteractiveDispositionStore
|
|
interactiveDispositionSink InteractiveDispositionSink
|
|
commandSink CommandSnapshotSink
|
|
eventSink SessionEventSink
|
|
inputUnits *providerInputUnitTracker
|
|
goalReconcileSink GoalReconcileDurableSink
|
|
goalProvenanceSink GoalProvenanceDurableSink
|
|
providerGoalAdoptionSink ProviderGoalAdoptionSink
|
|
promptImageMaterializer providerPromptImageMaterializer
|
|
goalReconcileAckTimeout time.Duration
|
|
configSink ConfigOptionsUpdateSink
|
|
// lifecycleMu guards lifecycleLocks; the per-session locks serialize
|
|
// Start/Resume/Close/ReleaseLiveSession per agent session so concurrent
|
|
// lifecycle calls can never leave two live app-server processes for the
|
|
// same session. Different sessions never contend.
|
|
lifecycleMu sync.Mutex
|
|
lifecycleLocks map[string]*codexAppServerSessionLock
|
|
// cancelGraceWindow bounds the graceful-interrupt wait in Cancel before the
|
|
// process is force-closed. Zero falls back to the default.
|
|
cancelGraceWindow time.Duration
|
|
// turnStartAckTimeout bounds only the immediate turn/start RPC response.
|
|
// Zero falls back to the default.
|
|
turnStartAckTimeout time.Duration
|
|
// turnSteerTimeout bounds turn/steer guidance delivery. Zero falls back to
|
|
// the default.
|
|
turnSteerTimeout time.Duration
|
|
// cliVersionMu/cliVersionCached memoize the served CLI's --version result
|
|
// per adapter instance (each instance owns one command).
|
|
cliVersionMu sync.Mutex
|
|
cliVersionCached string
|
|
// startupModelRetryBackoffs is the wait schedule between background model/list
|
|
// refetches when the initial probe came back empty; the slice length bounds
|
|
// the number of retries. Nil falls back to defaultStartupModelRetryBackoffs.
|
|
// Overridable in tests to drive the loop without real delays.
|
|
startupModelRetryBackoffs []time.Duration
|
|
// goalContinuationGraceWindow is how long a settled goal turn waits for
|
|
// codex to auto-start the next turn before the adapter nudges it. Zero
|
|
// falls back to the default.
|
|
goalContinuationGraceWindow time.Duration
|
|
// goalProvenanceGraceWindow gives a turn-scoped goal update (or the
|
|
// matching goal/set response) a bounded window to establish immutable
|
|
// provider-turn provenance. Zero falls back to the default.
|
|
goalProvenanceGraceWindow time.Duration
|
|
// goalHandoffCommittedHook is test-only synchronization injected after the
|
|
// atomic pending->adopting commit and before TurnStarted/drain.
|
|
goalHandoffCommittedHook func()
|
|
goalBeforeAdoptHook func()
|
|
goalHandoffDrainHook func()
|
|
}
|
|
|
|
type codexAppServerSessionLock struct {
|
|
mu sync.Mutex
|
|
refs int
|
|
}
|
|
|
|
type codexAppServerSession struct {
|
|
client *codexAppServerClient
|
|
// runtimeSession is the routing identity for connection-scoped clients
|
|
// that host multiple app-server threads. It is never persisted.
|
|
runtimeSession Session
|
|
// releaseFailed preserves ownership after a physical Close error while
|
|
// making the client unavailable to Exec. A successful replacement moves
|
|
// this handle to retiredSessions until bounded cleanup confirms closure.
|
|
releasing bool
|
|
releaseFailed bool
|
|
threadID string
|
|
serverInfo map[string]any
|
|
// resumeRuntimeContext preserves the historical adapter projection only
|
|
// when replay attaches at an already-initialized connection checkpoint.
|
|
resumeRuntimeContext map[string]any
|
|
account map[string]any
|
|
rateLimits map[string]any
|
|
goal map[string]any
|
|
// goalOperationID/revision identify the latest durable desired-goal write.
|
|
// They gate future scheduling; accepted Turns retain their own identity.
|
|
goalOperationID string
|
|
goalRevision int64
|
|
goalRepairEpoch int64
|
|
// Goal provenance is deliberately separate from the mutable desired Goal
|
|
// identity above. A provider turn may only consume an immutable association
|
|
// established by matching a provider Goal generation observed in both a
|
|
// successful goal/set response and a turn-scoped goal/updated notification,
|
|
// or by consuming the bounded continuation claim for versions that omit
|
|
// notification.turnId.
|
|
goalGenerationBindings map[string]codexGoalGenerationBinding
|
|
goalGenerationOrder []string
|
|
currentGoalGenerationFingerprint string
|
|
// currentGoalGenerationLineage identifies the provider Goal independently
|
|
// of mutable status/progress timestamps. Its owner lets late updates from
|
|
// an older set/pause/resume/clear revision fail closed without entering
|
|
// provider-authored Goal adoption.
|
|
currentGoalGenerationLineage string
|
|
currentGoalGenerationIdentity goalOperationIdentity
|
|
// providerGoalAdoptionsInFlight keeps provider-authored generation
|
|
// persistence off the app-server read loop while preventing a continuation
|
|
// turn from exhausting its provenance grace window before the durable
|
|
// identity is available.
|
|
providerGoalAdoptionsInFlight map[string]struct{}
|
|
goalTurnEvidence map[string]*codexGoalTurnEvidence
|
|
pendingGoalTurns map[string]*codexPendingGoalTurn
|
|
// goalContinuationClaim is an in-process, single-use compatibility fence
|
|
// for Codex versions whose thread/goal/updated notification omits turnId.
|
|
// A successful Goal RPC seeds the first claim; each adopted Goal turn may
|
|
// seed the next one after settlement. The claim is usable only while its
|
|
// immutable operation identity is still current, so a newer set/clear
|
|
// invalidates delayed work without rebinding it to the latest Goal.
|
|
goalContinuationClaim *codexGoalContinuationClaim
|
|
// fencedGoalIdentities contains durable Host revocations restored whenever
|
|
// the runtime session resumes. Exact identity matching preserves later
|
|
// Owner-authored Goal generations and ordinary user turns.
|
|
fencedGoalIdentities map[goalOperationIdentity]struct{}
|
|
// provenanceDegraded is fail-closed for the lifetime of this provider
|
|
// session. Once bounded evidence can no longer preserve ambiguity, no later
|
|
// notification may rebuild a partial cache and become adoptable.
|
|
provenanceDegraded bool
|
|
// goalMutationMu is the provider-side half of the Host goal mutation lane. It serializes
|
|
// direct control, reconcile and delayed continuation nudges for this thread.
|
|
goalMutationMu sync.Mutex
|
|
// goalStateVersion fences asynchronous reads that started before a newer
|
|
// local Goal observation or control result. Guarded by the adapter mutex.
|
|
goalStateVersion uint64
|
|
models []map[string]any
|
|
startupModelsReady bool
|
|
startupRateLimitsReady bool
|
|
// lifecycleSeq numbers the adapter's TurnLifecycle snapshots (ADR 0008):
|
|
// monotonically increasing per session so consumers receiving snapshots
|
|
// over different channels can drop stale ones. Guarded by the adapter
|
|
// mutex.
|
|
lifecycleSeq uint64
|
|
// Collaboration mode masks come from collaborationMode/list. The app-server
|
|
// expects the active mode settings, including developer_instructions, on
|
|
// every turn/start request.
|
|
planModeMask map[string]any
|
|
defaultModeMask map[string]any
|
|
defaultModel string
|
|
// tuttiModeHostContext is the latest Tutti-owned developer context applied
|
|
// to this thread. A Side fork preserves it alongside the provider mode so
|
|
// the fork cannot silently lose session and workspace context.
|
|
tuttiModeHostContext string
|
|
authState string
|
|
authMessage string
|
|
activeTurnID string
|
|
// activeTurnStartConfirmed reports whether a turn/started notification
|
|
// confirmed activeTurnID. A turn/start issued while another turn is
|
|
// already running responds with a stub turn id that codex never starts
|
|
// (live-verified: TestLiveProtocolTurnStartDuringActiveTurn) — the input
|
|
// is steered into the running turn instead. An unconfirmed id therefore
|
|
// must not veto the running turn's terminal in settleActiveTurn. Guarded
|
|
// by the adapter mutex.
|
|
activeTurnStartConfirmed bool
|
|
// lastCanonicalTurnID survives provider-turn settlement so provider-native
|
|
// child registrations can still resolve the canonical root turn while they
|
|
// drain. It must never contain an app-server provider turn id.
|
|
lastCanonicalTurnID string
|
|
// canceledRootTurnID is an adapter-local execution boundary. Once the user
|
|
// cancels a canonical root turn, server-initiated root turns and child
|
|
// threads discovered after the durable cancel target snapshot are stopped
|
|
// instead of being adopted or projected as new canonical work. A later
|
|
// explicit canonical turn clears the boundary in beginActiveTurn.
|
|
canceledRootTurnID string
|
|
// canceledProviderThreads remembers late child provider threads that were
|
|
// discovered behind canceledRootTurnID. They never receive canonical child
|
|
// session identities; the set only lets later provider notifications be
|
|
// interrupted and dropped consistently.
|
|
canceledProviderThreads map[string]struct{}
|
|
activeTurn *codexAppServerActiveTurn
|
|
childThreads map[string]*codexAppServerThreadContext
|
|
// recentForeignDrops remembers recently observed unknown thread ids so a
|
|
// late registration can report the ordering gap; terminal notifications are
|
|
// retained separately for replay while ordinary progress remains dropped.
|
|
recentForeignDrops map[string]int
|
|
// pendingForeignTerminalNotifications retains one terminal notification per
|
|
// unknown child thread until receiverThreadIds registers that child. This
|
|
// closes the provider announce/stream ordering gap without admitting
|
|
// ordinary foreign-thread progress into the parent session.
|
|
pendingForeignTerminalNotifications map[string]appServerBufferedNotification
|
|
acpLiveState
|
|
pendingRequests map[string]*pendingInteractiveRequest
|
|
}
|
|
|
|
type codexAppServerThreadContext struct {
|
|
agentSessionID string
|
|
turnID string
|
|
rootAgentSessionID string
|
|
rootTurnID string
|
|
parentAgentSessionID string
|
|
parentTurnID string
|
|
parentThreadID string
|
|
parentItemID string
|
|
normalizer *acpTurnNormalizer
|
|
// droppedBeforeRegistration counts events for this thread that arrived
|
|
// before its receiverThreadIds registration - permanent telemetry for ADR
|
|
// 0003's ordering question. Terminal events are replayed after registration;
|
|
// ordinary progress remains dropped.
|
|
droppedBeforeRegistration int
|
|
}
|
|
|
|
type appServerBufferedNotification struct {
|
|
method string
|
|
params map[string]any
|
|
}
|
|
|
|
// codexAppServerActiveTurn carries the streaming context of an in-flight
|
|
// turn. The app-server `turn/start` RPC responds immediately with the
|
|
// inProgress turn; all output arrives as notifications afterwards, so the
|
|
// session-level message handler resolves this context to keep translating
|
|
// notifications into activity events after the RPC has returned. The turn
|
|
// finishes when the `turn/completed` notification delivers the final turn
|
|
// payload through the reducer-owned terminal projection.
|
|
type codexAppServerActiveTurn struct {
|
|
processMu sync.Mutex
|
|
turnID string
|
|
providerTurnID string
|
|
session Session
|
|
ctx context.Context
|
|
normalizer *acpTurnNormalizer
|
|
diagnostics *codexAppServerTurnDiagnostics
|
|
emit func([]activityshared.Event)
|
|
emitCommands CommandSnapshotSink
|
|
kind codexAppServerTurnKind
|
|
phase codexAppServerTurnPhase
|
|
goalIdentity goalOperationIdentity
|
|
goalProvenance string
|
|
terminal chan codexAppServerTurnTerminal
|
|
// terminated is closed exactly once when the Exec goroutine for this turn
|
|
// returns (turn fully finalized). Cancel waits on it so it only responds
|
|
// after the turn has actually stopped.
|
|
terminated chan struct{}
|
|
// terminatedOnce closes terminated exactly once regardless of which path
|
|
// finalizes the turn (settle path or the blocking shell).
|
|
terminatedOnce sync.Once
|
|
// emitTerminal delivers the turn's final events through the turn's own
|
|
// single-shot emission chain (the shell's turnClosed guard dedupes).
|
|
emitTerminal func([]activityshared.Event)
|
|
// settleEmits marks turns whose terminal events are produced by the
|
|
// settle path (notification loop) instead of a parked goroutine
|
|
// (ADR 0005 C inversion). Guarded by the adapter mutex.
|
|
settleEmits bool
|
|
// settleFinalized records that finalizeSettledTurn produced the terminal
|
|
// events; the blocking shell logs a shadow miss if it ever has to.
|
|
settleFinalized atomic.Bool
|
|
|
|
cancelRequested bool
|
|
cancelInterruptSent bool
|
|
// forceCanceled is set (under the adapter mutex) when Cancel force-closed
|
|
// the app-server process because codex did not honor turn/interrupt. It
|
|
// makes the turn's terminal classification surface as canceled, not failed.
|
|
forceCanceled bool
|
|
}
|
|
|
|
func NewCodexAppServerAdapter(transport ProcessTransport) *CodexAppServerAdapter {
|
|
return NewCodexAppServerAdapterWithHostMetadata(transport, LegacyHostMetadata())
|
|
}
|
|
|
|
func NewCodexAppServerAdapterWithHostMetadata(transport ProcessTransport, host HostMetadata) *CodexAppServerAdapter {
|
|
return NewCodexAppServerAdapterWithHostMetadataAndCommandResolver(transport, host, nil)
|
|
}
|
|
|
|
func NewCodexAppServerAdapterWithHostMetadataAndOptions(
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
options CodexAppServerAdapterOptions,
|
|
) *CodexAppServerAdapter {
|
|
adapter := NewCodexAppServerAdapterWithHostMetadataAndCommandResolver(transport, host, nil)
|
|
adapter.config.commandNetworkAccess = options.CommandNetworkAccess
|
|
adapter.startupSpanObserver = options.StartupSpanObserver
|
|
adapter.startupObserver = options.StartupObserver
|
|
adapter.startupResourceObserver = options.StartupResourceObserver
|
|
return adapter
|
|
}
|
|
|
|
func NewCodexAppServerAdapterWithHostMetadataAndCommandResolver(
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
commandResolver ProviderCommandResolver,
|
|
) *CodexAppServerAdapter {
|
|
descriptor, ok := providerregistry.Find(providerregistry.CodexProviderID)
|
|
if !ok {
|
|
panic("migrated Codex provider descriptor is missing")
|
|
}
|
|
if err := providerregistry.Validate(descriptor); err != nil {
|
|
panic(fmt.Sprintf("invalid migrated Codex provider descriptor: %v", err))
|
|
}
|
|
adapter := newAdapterFromProviderDescriptor(
|
|
descriptor,
|
|
transport,
|
|
host,
|
|
commandResolver,
|
|
providerAdapterOptions{},
|
|
)
|
|
codexAdapter, ok := adapter.(*CodexAppServerAdapter)
|
|
if !ok {
|
|
panic(fmt.Sprintf("Codex provider descriptor constructed %T", adapter))
|
|
}
|
|
return codexAdapter
|
|
}
|
|
|
|
// NewTuttiAgentAppServerAdapterWithHostMetadata serves the tutti-agent
|
|
// provider through the shared app-server adapter with Tutti-branded command,
|
|
// client identity, and auth messaging.
|
|
func NewTuttiAgentAppServerAdapterWithHostMetadata(transport ProcessTransport, host HostMetadata) *CodexAppServerAdapter {
|
|
return newTuttiAgentAppServerAdapterWithHostMetadata(transport, host)
|
|
}
|
|
|
|
// NewTuttiAgentAppServerAdapterWithHostMetadataAndOptions serves the
|
|
// tutti-agent provider through the shared app-server adapter while applying
|
|
// host-owned command execution policy.
|
|
func NewTuttiAgentAppServerAdapterWithHostMetadataAndOptions(
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
options CodexAppServerAdapterOptions,
|
|
) *CodexAppServerAdapter {
|
|
adapter := newTuttiAgentAppServerAdapterWithHostMetadata(transport, host)
|
|
adapter.config.commandNetworkAccess = options.CommandNetworkAccess
|
|
adapter.startupSpanObserver = options.StartupSpanObserver
|
|
adapter.startupObserver = options.StartupObserver
|
|
adapter.startupResourceObserver = options.StartupResourceObserver
|
|
return adapter
|
|
}
|
|
|
|
func newTuttiAgentAppServerAdapterWithHostMetadata(
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
) *CodexAppServerAdapter {
|
|
descriptor, ok := providerregistry.Find(ProviderTuttiAgent)
|
|
if !ok {
|
|
panic("tutti-agent provider descriptor is missing")
|
|
}
|
|
adapter := newAdapterFromProviderDescriptor(
|
|
descriptor,
|
|
transport,
|
|
host,
|
|
nil,
|
|
providerAdapterOptions{},
|
|
)
|
|
appServerAdapter, ok := adapter.(*CodexAppServerAdapter)
|
|
if !ok {
|
|
panic(fmt.Sprintf("Tutti Agent provider descriptor constructed %T", adapter))
|
|
}
|
|
return appServerAdapter
|
|
}
|
|
|
|
func newAppServerAdapter(
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
config appServerAdapterConfig,
|
|
commandResolver ProviderCommandResolver,
|
|
) *CodexAppServerAdapter {
|
|
return &CodexAppServerAdapter{
|
|
transport: transport,
|
|
host: host,
|
|
config: config,
|
|
commandResolver: commandResolver,
|
|
sessions: make(map[string]*codexAppServerSession),
|
|
retiredSessions: make(map[string][]*codexAppServerSession),
|
|
lifecycleLocks: make(map[string]*codexAppServerSessionLock),
|
|
cancelGraceWindow: defaultCodexAppServerCancelGraceWindow,
|
|
turnStartAckTimeout: defaultCodexAppServerTurnStartAckTimeout,
|
|
turnSteerTimeout: defaultCodexAppServerTurnSteerTimeout,
|
|
inputUnits: providerInputUnitTrackerForTransport(transport),
|
|
}
|
|
}
|
|
|
|
// resolveCLIVersion returns the version of the binary that serves the
|
|
// app-server (e.g. "0.142.1"), resolved with the same env (PATH) the
|
|
// app-server is spawned with so the two agree. The result is cached per
|
|
// adapter after the first successful lookup; an empty string signals
|
|
// "unknown" so callers can fall back.
|
|
func (a *CodexAppServerAdapter) resolveCLIVersion(env []string) string {
|
|
a.cliVersionMu.Lock()
|
|
defer a.cliVersionMu.Unlock()
|
|
if a.cliVersionCached != "" {
|
|
return a.cliVersionCached
|
|
}
|
|
cmd := exec.Command(a.config.command[0], "--version")
|
|
if len(env) > 0 {
|
|
cmd.Env = env
|
|
}
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// Output looks like "codex-cli 0.142.1"; the version is the last field.
|
|
fields := strings.Fields(string(out))
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
a.cliVersionCached = strings.TrimSpace(fields[len(fields)-1])
|
|
return a.cliVersionCached
|
|
}
|
|
|
|
// clientInfoParams builds the app-server initialize clientInfo. The served
|
|
// CLI derives its outbound originator/User-Agent from clientInfo.name, so the
|
|
// name comes from the adapter config: the official Codex originator for the
|
|
// codex provider, the Tutti identity for tutti-agent.
|
|
func (a *CodexAppServerAdapter) clientInfoParams(env []string) map[string]any {
|
|
return clientInfoParamsForVersion(a.host, a.config.clientInfoName, a.resolveCLIVersion(env))
|
|
}
|
|
|
|
func clientInfoParamsForVersion(host HostMetadata, name string, version string) map[string]any {
|
|
if strings.TrimSpace(version) == "" {
|
|
version = strings.TrimSpace(host.ClientInfo.Version)
|
|
}
|
|
return map[string]any{
|
|
"name": name,
|
|
"title": host.ClientInfo.Title,
|
|
"version": version,
|
|
}
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) Provider() string {
|
|
return a.config.provider
|
|
}
|
|
|
|
func (*CodexAppServerAdapter) ConnectorCapabilities(
|
|
context.Context,
|
|
Session,
|
|
) (ConnectorCapabilities, error) {
|
|
return ConnectorCapabilities{HTTPMCP: true}, nil
|
|
}
|
|
|
|
func (*CodexAppServerAdapter) sessionCWD(session Session) string {
|
|
return projectCodexWorkspaceCWD(strings.TrimSpace(session.CWD), session.RoomID)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetCommandSnapshotSink(sink CommandSnapshotSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.commandSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetSessionEventSink(sink SessionEventSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.eventSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetGoalReconcileDurableSink(sink GoalReconcileDurableSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.goalReconcileSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetGoalProvenanceDurableSink(sink GoalProvenanceDurableSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.goalProvenanceSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetProviderGoalAdoptionSink(sink ProviderGoalAdoptionSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.providerGoalAdoptionSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetConfigOptionsUpdateSink(sink ConfigOptionsUpdateSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.configSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SetProviderLaunchPreparer(preparer ProviderLaunchPreparer) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.preparer = preparer
|
|
}
|
|
|
|
func (*CodexAppServerAdapter) ValidatePromptContent(_ Session, content []PromptContentBlock) error {
|
|
// Codex app-server accepts text, image, and localImage user input items.
|
|
return validatePromptContentImagesForPreflight(content)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) commandString() string {
|
|
return strings.Join(a.config.command, " ")
|
|
}
|