host

package
v0.0.0-...-692c541 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package host implements the embedded assistant-host protocol.

The transport is stdio NDJSON — one JSON object per line: stdin carries the inbound command stream (first line = SessionDescriptor, every subsequent line = a HostCommand); stdout carries the outbound HostEvent stream; stderr carries human-readable diagnostics. Protocol JSON is NEVER written to stderr (and diagnostics are NEVER written to stdout) — Daintree Zod-validates stdout line-by-line and rejects unknown shapes.

This file is the pure protocol layer: the version constant, the verbatim string vocabularies, the wire event/command types with their JSON shapes, the severity map, and the descriptor/command parsers. No I/O.

The wire contract — event names, command names, field names, vocabulary values, PROTOCOL_VERSION — is the one hard external contract and must stay byte-for-byte with Daintree.

Index

Constants

View Source
const (
	// DefaultApprovalTimeoutMs is the unanswered-confirm auto-timeout (5 min).
	DefaultApprovalTimeoutMs = 5 * 60_000 // 300000

)

Approval/redaction constants.

View Source
const ProtocolVersion = 2

PROTOCOL_VERSION is the wire-format version. MUST equal Daintree's ASSISTANT_HOST_PROTOCOL_VERSION; Daintree Zod-rejects an unrecognized version.

It is 2: the transport is stdio NDJSON line frames. The framing is a breaking change for any consumer of an older format, so the version moves in lockstep.

Variables

View Source
var ErrAppFactoryUnset = errors.New("host: App factory not yet wired (cockpit/cli wave)")

ErrAppFactoryUnset is returned by the placeholder factory until the concrete App is wired. It lets `host --stdio` build green now and boot-fail cleanly (host:error bootstrap-error) rather than panic.

Functions

func Run

func Run(ctx context.Context, factory AppFactory) int

Run is the os-wired entry the CLI's `host --stdio` subcommand calls. It checks the stdio precondition (stdin must be a usable command stream), then Serves over os.Stdin/os.Stdout/os.Stderr. factory is the App builder.

Returns an exit code only when the precondition fails or Serve unwinds without exiting; on the normal path teardown calls os.Exit directly.

func Serve

func Serve(ctx context.Context, factory AppFactory, in io.Reader, out, errw io.Writer)

Serve runs the embedded host over the given streams until teardown exits the process. It is the headless protocol core: in = command stdin (NDJSON, first line = descriptor), out = event stdout (NDJSON), errw = diagnostics stderr. factory builds the App for the booted session (the cockpit/cli wave provides the concrete factory). Serve normally never returns — teardown calls os.Exit after flushing — but it returns if Run unwinds without exiting (e.g. tests injecting a non-exiting exit func).

Types

type App

type App interface {
	// SetHooks installs the agent event sink (bridge.sink) and the tool-confirm
	// hook (bridge.Confirm). Must be called before ConnectMCP/StartScheduler so a
	// wake or early tool call is bridged.
	SetHooks(hooks AppHooks)

	// ConnectMCP attempts the MCP connection. Best-effort: a degraded MCP is NOT a
	// boot failure (it surfaces in prompt context + tool results), so the returned
	// error is informational only — the host logs it to stderr and proceeds.
	ConnectMCP(ctx context.Context) error

	// StartScheduler starts the daemon (watchers/timers tick in-host). onAttention
	// receives each surfaced attention burst; the host filters for actionable wakes.
	StartScheduler(onAttention func(events []domain.QueueEvent))

	// RearmAttention durably re-arms delivered-but-unhandled attention events
	// (nulls their notifiedAt in the project store) so the NEXT owner's notify
	// pass re-digests and re-delivers them. Teardown calls it with whatever is
	// left in pendingWake — a burst a shutdown/hibernate-cancelled wake turn
	// requeued, or one that was queued but never started: those events were
	// already marked notified when they were handed to this process, and the
	// in-memory queue dies with it — without the durable re-arm the wake would
	// be silently lost across the restart. Best-effort (the host only logs a
	// failure).
	RearmAttention(ids []string) error

	// Session is the turn engine driven by prompt/wake.
	Session() *agent.Session

	// RiskOf looks up a tool's risk class for the danger hint (false if unknown).
	RiskOf(toolName string) (domain.RiskClass, bool)

	// Config is the resolved runtime config (used for debug-log start).
	Config() config.AppConfig

	// Shutdown tears down the runtime (best-effort; the host exits regardless).
	Shutdown(ctx context.Context) error
}

App is the SEAM to the full assistant runtime. The host depends on this interface and the cockpit/cli wave fills it with the concrete App. The surface it needs: wire the agent event sink + confirm hook, connect MCP best-effort, start the daemon, drive the session, and shut down.

The host NEVER touches the DB, models, or tools directly — everything flows through this seam, so this package compiles in isolation against the providers that already exist (agent/config/domain).

type AppFactory

type AppFactory func(ctx context.Context, params AppParams) (App, error)

AppFactory builds the App for a booted session. MCP url/token/tier/projectId come from env via loadConfig, NOT the descriptor. The cockpit/cli wave provides the concrete factory; the host stores it so it can be tested with a fake.

type AppHooks

type AppHooks struct {
	// AgentEvents is the bridge sink (agent.EventSink). The session emits through it.
	AgentEvents agent.EventSink
	// Confirm is the tool-confirm hook: a mutating tool calls it and blocks until
	// the approval is decided (true) / rejected / times out (false).
	Confirm func(ctx context.Context, req ConfirmRequest) bool
}

AppHooks bundles the hooks the host installs on the App.

type AppParams

type AppParams struct {
	SessionID           string // appSessionId: resume id when resuming, else session id
	ProjectPath         string // descriptor.cwd → overrides.projectPath
	ProjectInstructions string // loaded DAINTREE.md content → overrides
}

AppParams is the descriptor-derived input to AppFactory. appSessionId is resumeSessionId ?? sessionId (so resumed conversation state replays).

type AuditResult

type AuditResult string

AuditResult is the settled-tool result classification (7 values).

const (
	AuditSuccess             AuditResult = "success"
	AuditError               AuditResult = "error"
	AuditConfirmationPending AuditResult = "confirmation-pending"
	AuditUnauthorized        AuditResult = "unauthorized"
	AuditDedup               AuditResult = "dedup"
	AuditCollision           AuditResult = "collision"
	AuditRateLimited         AuditResult = "rate_limited"
)

type AuditSeverity

type AuditSeverity string

AuditSeverity is the severity bucket for an audited result (5 values). Note "critical" is in the set for Daintree parity but is never produced by SeverityForResult.

const (
	SeverityInfo     AuditSeverity = "info"
	SeverityNotice   AuditSeverity = "notice"
	SeverityWarning  AuditSeverity = "warning"
	SeverityErrorSev AuditSeverity = "error"
	SeverityCritical AuditSeverity = "critical"
)

func SeverityForResult

func SeverityForResult(r AuditResult) AuditSeverity

SeverityForResult maps an AuditResult to its AuditSeverity (unknown → error).

type Bridge

type Bridge struct {
	// contains filtered or unexported fields
}

Bridge adapts the in-process agent EventSink + tool-confirm hook into wire HostEvents. It owns the single-turn lifecycle, approvals, redaction, and audit mapping. The agent loop runs Send() on another goroutine and calls the sink methods concurrently, so all mutable state is guarded by mu. No transport dependency — events go through the injected Post.

func NewBridge

func NewBridge(opts BridgeOptions) *Bridge

NewBridge builds a Bridge with defaults filled.

func (*Bridge) AssistantCancelled

func (b *Bridge) AssistantCancelled(string)

func (*Bridge) AssistantEnd

func (b *Bridge) AssistantEnd(content, _ string)

AssistantEnd closes the turn: "answered" if content is non-blank else "unknown". (reasoning is not forwarded over the host protocol.)

func (*Bridge) AssistantStart

func (b *Bridge) AssistantStart()

func (*Bridge) AssistantToken

func (b *Bridge) AssistantToken(chunk string)

func (*Bridge) Confirm

func (b *Bridge) Confirm(ctx context.Context, req ConfirmRequest) bool

Confirm is the tool-confirm hook: mint an approval, emit approval:requested, and block until decided / timed out / drained. Returns true iff approved. Runs on the agent's dispatch goroutine; the command loop resolves via decide/drain.

The emitted approval:requested carries the request's display context — risk class (passed through verbatim, so a per-confirm override survives), the human-readable consequence, and a redacted args summary (redactArgs, the same helper tool:started uses) — so Daintree's timeline matches a local cockpit approval. Empty fields are omitted by the event encoder.

func (*Bridge) Error

func (b *Bridge) Error(message string)

func (*Bridge) Info

func (b *Bridge) Info(string)

Info has no protocol channel — intentionally dropped.

func (*Bridge) Interjection

func (b *Bridge) Interjection(string)

Interjection has no host-protocol channel: the Daintree parent already holds the text it sent as the mid-turn prompt (handlePrompt routes it to InjectPrompt while busy), so echoing it back would be redundant. Dropped, like Phase.

func (*Bridge) Interrupt

func (b *Bridge) Interrupt()

Interrupt is the display side of an interrupt: latch interrupted, stop forwarding the in-flight turn, close it "agent-stuck". No-op without an active turn.

func (*Bridge) ModelRateLimited

func (b *Bridge) ModelRateLimited()

ModelRateLimited is a live cockpit health cue with no host-protocol channel — dropped. The "Model rate-limited" reply still flows through the normal turn text.

func (*Bridge) Phase

func (b *Bridge) Phase(domain.RunPhase)

Phase is live-only UI vocabulary with no host-protocol channel — dropped.

func (*Bridge) ResolveApproval

func (b *Bridge) ResolveApproval(approvalID string, decision ConfirmationDecision)

ResolveApproval settles an outstanding approval (decide / timeout / drain). No-op if not pending. Emits approval:decided and unblocks the Confirm caller.

func (*Bridge) SettlePendingApprovals

func (b *Bridge) SettlePendingApprovals(decision ConfirmationDecision)

SettlePendingApprovals rejects (default) every outstanding approval. Used on interrupt + teardown drain so a parked dispatch never strands busy.

func (*Bridge) SettleTurn

func (b *Bridge) SettleTurn(outcome TurnOutcomeClass)

SettleTurn closes any dangling assistant turn (no-op if already closed). Called in the prompt/wake finally.

func (*Bridge) SkillLoaded

func (b *Bridge) SkillLoaded([]string)

SkillLoaded has no host-protocol channel (the parent doesn't surface the assistant's internal skill loads); dropped, like Interjection.

func (*Bridge) StartExchange

func (b *Bridge) StartExchange()

StartExchange resets per-turn state and emits a zero-duration user turn (start+end at the same ts). Prompt text is NOT carried — Daintree originated it.

func (*Bridge) ToolBatch

func (b *Bridge) ToolBatch([]agent.BatchedToolCall)

ToolBatch/ToolState/ToolProgress are live-footer-only in the loop; the host protocol keys off the concrete tool:started/tool:settled events, so the in-tool substep stream has no host channel and is dropped.

func (*Bridge) ToolCall

func (b *Bridge) ToolCall(ev agent.ToolCallEvent)

func (*Bridge) ToolProgress

func (b *Bridge) ToolProgress(string, string)

func (*Bridge) ToolResult

func (b *Bridge) ToolResult(ev agent.ToolResultEvent)

func (*Bridge) ToolState

func (b *Bridge) ToolState(string, agent.ToolState)

func (*Bridge) TurnPrompt

func (b *Bridge) TurnPrompt(string)

TurnPrompt has no host-protocol channel — Daintree originated the prompt, so the bridge drops it (it's persisted for /explain by the run-event sink).

func (*Bridge) Usage

func (b *Bridge) Usage(agent.UsageEvent)

Usage is not forwarded over the host protocol (token/cost stays in-process).

func (*Bridge) Warn

func (b *Bridge) Warn(string)

Warn has no protocol channel — intentionally dropped (like Info).

type BridgeOptions

type BridgeOptions struct {
	SessionID         string
	Post              PostFunc
	RiskOf            RiskOfFunc   // default: always unknown
	Now               func() int64 // default: domain.NowMS
	ApprovalTimeoutMs int          // default: DefaultApprovalTimeoutMs (0 disables the timer)
}

BridgeOptions configures a Bridge.

type ConfirmRequest

type ConfirmRequest struct {
	ToolName    string
	Summary     string
	RiskClass   domain.RiskClass
	Consequence string
	// RawArgs is the raw JSON args string the model emitted. The bridge redacts it
	// (redactArgs) before emitting the wire event — it never crosses verbatim.
	RawArgs string
}

ConfirmRequest is the confirm payload the host bridges to an approval. The App adapts its tool-context ConfirmRequest (tools.ConfirmRequest) into this when calling the installed hook. Beyond the tool name + summary, it carries the display context the approval:requested wire event surfaces so Daintree's timeline matches a local cockpit approval: the risk class, the human-readable consequence, and the raw args (redacted by the bridge before they cross the wire). RiskClass is passed through (not re-derived from the registry) so a tool's explicit per-confirm override — e.g. grant.create electing RiskSystem — reaches the UI verbatim.

Intentional scope boundary: this bridge deliberately drops NeedsTypedConfirm. The embedded host does not render its own approval sheet — it delegates the decision to its external caller (Daintree's orchestration UI), which owns the approval UX. The typed-confirm friction (issue #210) is enforced on the surfaces that DO render the sheet — the cockpit and the classic REPL — not here. The risk-class label travels on RiskClass above so the external timeline can still display it.

type ConfirmationDecision

type ConfirmationDecision string

ConfirmationDecision is the outcome of an approval (3 values).

const (
	DecisionApproved ConfirmationDecision = "approved"
	DecisionRejected ConfirmationDecision = "rejected"
	DecisionTimeout  ConfirmationDecision = "timeout"
)

type EvApprovalDecided

type EvApprovalDecided struct {
	ApprovalID string
	Decision   ConfirmationDecision
	DecidedAt  int64
}

EvApprovalDecided — approval:decided.

type EvApprovalRequested

type EvApprovalRequested struct {
	ApprovalID  string
	ToolID      string
	Summary     string
	RequestedAt int64
	TurnID      string
	RiskClass   domain.RiskClass
	Consequence string
	ArgsSummary string
}

EvApprovalRequested — approval:requested. turnId optional. riskClass, consequence, and argsSummary are optional display context (parity with a local cockpit approval); each is omitted from the wire object when empty.

type EvError

type EvError struct {
	Code    string
	Message string
}

EvError — host:error.

type EvReady

type EvReady struct {
	ProtocolVersion  int
	ResumedSessionID string
}

EvReady — host:ready. resumedSessionId only set when the descriptor carried a resumeSessionId.

type EvShutdown

type EvShutdown struct {
	Reason          HostShutdownReason
	ResumeSessionID string
}

EvShutdown — host:shutdown. resumeSessionId optional. Emitted FIRST in teardown.

type EvToolSettled

type EvToolSettled struct {
	ToolCallID string
	ToolID     string
	DurationMs int64
	Result     AuditResult
	Severity   AuditSeverity
	ErrorCode  string
	TurnID     string
	// AsyncID marks an ACCEPTED-but-still-running async operation (asy_…): the
	// call settled but the work continues in the background, so a host must NOT
	// render it as a finished success (the cockpit shows it as a distinct yellow
	// pending state). Empty for every ordinary synchronous result.
	AsyncID string
}

EvToolSettled — tool:settled. errorCode + turnId + asyncId optional.

type EvToolStarted

type EvToolStarted struct {
	ToolCallID  string
	ToolID      string
	ArgsSummary string
	StartedAt   int64
	TurnID      string
	Danger      bool
}

EvToolStarted — tool:started. turnId optional.

type EvTurnEnd

type EvTurnEnd struct {
	TurnID  string
	EndedAt int64
	Outcome TurnOutcomeClass
}

EvTurnEnd — turn:end. Outcome optional.

type EvTurnStart

type EvTurnStart struct {
	TurnID    string
	Role      TurnRole
	StartedAt int64
}

EvTurnStart — turn:start.

type EvTurnToken

type EvTurnToken struct {
	TurnID string
	Chunk  string
}

EvTurnToken — turn:token.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host owns the transport, the descriptor handshake, the command loop, the wake reactor, boot, and teardown. It is driven by Run(ctx). Process-level state (busy/ready/pendingWake/…) is owned by the single command-loop goroutine, EXCEPT the Bridge's own state (the agent loop calls the sink concurrently and uses its own mutex). The command loop MUST NOT block on a running Send — send runs on a worker goroutine while the loop keeps servicing interrupt/approval:decide (the key structural property of the event loop).

func NewHost

func NewHost(factory AppFactory, in io.Reader, out, errw io.Writer) *Host

NewHost builds a Host over the given App factory and stdio streams (in = command stdin, out = NDJSON stdout, errw = diagnostics stderr).

func (*Host) Run

func (h *Host) Run(parent context.Context)

Run is the entry point: install the stdout-fail hook, run the command loop, and (on a terminal inbound) tear down. It blocks until teardown calls exit. The caller wires os.Stdin/os.Stdout/os.Stderr.

type HostCommand

type HostCommand struct {
	Type      HostCommandType
	SessionID string
	// prompt
	Text string
	// approval:decide
	ApprovalID string
	Decision   string
}

HostCommand is a decoded inbound command. Only the fields relevant to the arm are populated; the rest are zero. SessionID is always required.

func ParseCommand

func ParseCommand(line []byte) (HostCommand, error)

ParseCommand decodes + validates an inbound command line: object with a string sessionId, then a per-type field check. An unknown type or a missing required field → errNotCommand (drop).

type HostCommandType

type HostCommandType string

HostCommandType is the inbound command discriminator.

const (
	CmdPrompt         HostCommandType = "prompt"
	CmdApprovalDecide HostCommandType = "approval:decide"
	CmdInterrupt      HostCommandType = "interrupt"
	CmdHibernate      HostCommandType = "hibernate"
	CmdShutdown       HostCommandType = "shutdown"
)

type HostEvent

type HostEvent interface {
	// contains filtered or unexported methods
}

HostEvent is the outbound (host → Daintree) wire union. Every event carries a "type" discriminator + "sessionId"; the Go encoder writes each as one NDJSON line on stdout. Concrete event structs implement encode() which injects "type" and "sessionId" so callers never repeat them. Field names are verbatim wire.

type HostShutdownReason

type HostShutdownReason string

HostShutdownReason is why teardown ran (4 values). "revoke" is kept for Daintree parity even though index/teardown never emits it.

const (
	ShutdownHibernate HostShutdownReason = "hibernate"
	ShutdownRevoke    HostShutdownReason = "revoke"
	ShutdownError     HostShutdownReason = "error"
	ShutdownExit      HostShutdownReason = "exit"
)

type PostFunc

type PostFunc func(HostEvent)

PostFunc is the transport sink the bridge writes events through. Injected so the bridge has no transport dependency (tests + the NDJSON owner share it).

type RiskOfFunc

type RiskOfFunc func(toolName string) (domain.RiskClass, bool)

RiskOfFunc looks up a tool's risk class for the danger hint. The bool is false when the tool is unknown.

type SessionDescriptor

type SessionDescriptor struct {
	SessionID       string `json:"sessionId"`
	WindowID        int64  `json:"windowId"`
	ProjectID       string `json:"projectId"`
	Cwd             string `json:"cwd"`
	Tier            string `json:"tier"`
	ProtocolVersion int    `json:"protocolVersion"`
	ResumeSessionID string `json:"resumeSessionId,omitempty"`
}

SessionDescriptor is the non-secret handshake Daintree sends as the very first inbound line. windowId/projectId/tier are validated here but the live binding values come from env via loadConfig — a leaked descriptor cannot re-bind.

func ParseDescriptor

func ParseDescriptor(line []byte) (SessionDescriptor, error)

ParseDescriptor decodes + validates a descriptor line: the six required fields must be present with the right JSON types; resumeSessionId is optional and NOT type-checked. A failing line yields an error (caller emits host:error code bad-descriptor + teardown).

type TurnOutcomeClass

type TurnOutcomeClass string

TurnOutcomeClass classifies how a turn ended (12 values).

const (
	OutcomeAnswered             TurnOutcomeClass = "answered"
	OutcomeHedged               TurnOutcomeClass = "hedged"
	OutcomeRefused              TurnOutcomeClass = "refused"
	OutcomeDocsEmpty            TurnOutcomeClass = "docs-empty"
	OutcomeTierRejected         TurnOutcomeClass = "tier-rejected"
	OutcomeMcpNotReady          TurnOutcomeClass = "mcp-not-ready"
	OutcomeAgentStuck           TurnOutcomeClass = "agent-stuck"
	OutcomeToolError            TurnOutcomeClass = "tool-error"
	OutcomeReasoningLoop        TurnOutcomeClass = "reasoning-loop"
	OutcomeHibernateResumeStale TurnOutcomeClass = "hibernate-resume-stale"
	OutcomeCancelled            TurnOutcomeClass = "cancelled"
	OutcomeUnknown              TurnOutcomeClass = "unknown"
)

type TurnRole

type TurnRole string

TurnRole distinguishes the user prompt turn from the assistant reply turn.

const (
	RoleUser      TurnRole = "user"
	RoleAssistant TurnRole = "assistant"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL