Documentation
¶
Overview ¶
Package askuser holds the daemon-side coordinator for the ask_user MCP tool. The agent (claude / codex / gemini) calls `ask_user` over MCP; the handler registers a pending question + broadcasts SSE; the user answers in the web UI; the answer resolves the pending channel, and the MCP tool returns to the agent.
Lifecycle parallels gate.ApprovalManager but the trigger is an MCP RPC instead of a unix socket dial — there is no subprocess hook, just a blocking goroutine inside the wick process.
Index ¶
Constants ¶
const DefaultTimeout = 5 * time.Minute
DefaultTimeout is how long Ask blocks before returning an error to the calling agent. Five minutes is long enough for "the user stepped away to grab coffee" but short enough that a forgotten session doesn't pin a goroutine forever. Configurable per Ask via Question.Timeout.
Variables ¶
This section is empty.
Functions ¶
func SocketPath ¶ added in v0.17.0
SocketPath returns the unix socket the server's ask listener binds and sibling processes dial. Lives next to agentctl.sock / the gate dir; security comes from the 0700 parent dir, like gate.sock.
Layout: ~/.<app>/agents/askuser.sock
Types ¶
type Answer ¶
type Answer struct {
Value string `json:"value,omitempty"`
Text string `json:"text,omitempty"`
Values map[string]string `json:"values,omitempty"`
}
Answer is what the user posts via /sessions/{id}/answer. One of Value / Text / Values is set; if Value and Text are both present, Value wins (it's the label of a clicked option, more authoritative than free-typed text on the same form). Values carries the field map of a structured form ask.
type AskRequest ¶
type AskRequest struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
AgentName string `json:"agent_name,omitempty"`
Question string `json:"question"`
Options []Option `json:"options,omitempty"`
AllowFreeform bool `json:"allow_freeform,omitempty"`
Fields []Field `json:"fields,omitempty"`
}
AskRequest is the broadcast payload — Question + a server-minted id used by both UI (in POST /answer) and the manager's pending map (to route the answer back).
type Asker ¶ added in v0.17.0
Asker is the transport-agnostic ask contract. The in-process Manager implements it for the HTTP server; SocketAsker implements it for sibling processes (stdio MCP) by dialing the server's unix socket — same pattern as the gate binary dialing gate.sock, so an ask works from any local process without HTTP auth.
type Field ¶ added in v0.17.0
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Value string `json:"value,omitempty"` // prefill; empty for secrets
Required bool `json:"required,omitempty"`
Help string `json:"help,omitempty"`
Options []Option `json:"options,omitempty"` // choice/multi/dropdown
AllowFreeform bool `json:"allow_freeform,omitempty"` // add an "Other…" text box
}
Field is one question/input in a structured form ask. When Question.Fields is non-empty the UI renders a form modal instead of the plain option/freeform card, and the answer comes back as Answer.Values keyed by Field.Key.
Type drives the input widget:
- "choice" — Options as single-select rows (pick one)
- "multi" — Options as multi-select rows (pick many; the answer is a JSON-encoded array of the selected values)
- "rank" — Options drag-reordered; the answer is a JSON-encoded array of values in the user's chosen order
- "dropdown" — Options as a <select> (pick one, compact)
- "text" — free text input (default when no Options)
- "secret" — password input; plaintext handled server-side, never echoed to the agent
- "number" — numeric input
In a multi-question ask (Question.Fields), the UI renders each field as one step in a wizard the user pages through and can skip.
AllowFreeform on a choice/dropdown field adds an "Other…" text box so the user can answer outside the preset Options.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the per-process pending-asks map. Concurrent-safe; caller is expected to wire OnRequest/OnResolved into the SSE broadcaster so the UI sees state changes.
func (*Manager) Ask ¶
Ask registers a pending question, fires onRequest, and blocks until the user answers, the timeout fires, or done is closed.
Returns Answer + error. error is non-nil only on timeout / cancel — agent-side handlers convert that into a tool error so the LLM can decide to retry or give up.
func (*Manager) PendingFor ¶
func (m *Manager) PendingFor(sessionID string) []AskRequest
PendingFor returns a snapshot of in-flight asks for one session. Used by the UI for reconnect rehydrate.
type Option ¶
type Option struct {
Label string `json:"label"`
Value string `json:"value"`
Description string `json:"description,omitempty"`
}
Option is one choice presented to the user. Label = what they see, Value = what gets returned to the agent, Description = optional secondary line shown under the label in the wizard.
type Options ¶
type Options struct {
DefaultTimeout time.Duration
OnRequest func(AskRequest)
OnResolved func(sessionID, requestID string)
}
Options wires callbacks. Both are optional; nil = no broadcast.
type Question ¶
type Question struct {
SessionID string `json:"session_id"`
AgentName string `json:"agent_name,omitempty"`
Question string `json:"question"`
Options []Option `json:"options,omitempty"`
AllowFreeform bool `json:"allow_freeform,omitempty"`
Fields []Field `json:"fields,omitempty"`
Timeout time.Duration `json:"-"`
}
Question is the input to Manager.Ask. Mirrors the MCP tool's input schema 1:1 so the handler can pass it through unchanged.
type SocketAsker ¶ added in v0.17.0
type SocketAsker struct {
Path string
}
SocketAsker dials the server's askuser socket. Implements Asker so the MCP handlers can use it interchangeably with the in-process Manager. Construction is cheap and dial happens per Ask — the server may start or restart at any time relative to this process.
func (*SocketAsker) Ask ¶ added in v0.17.0
func (a *SocketAsker) Ask(q Question, done <-chan struct{}) (Answer, error)
Ask sends one Question over the socket and blocks until the answer comes back, the server-side timeout fires, or done closes. A dial failure means no wick server (or one too old to serve asks) is running on this machine.
type SocketServer ¶ added in v0.17.0
type SocketServer struct {
// contains filtered or unexported fields
}
SocketServer accepts ask requests from sibling processes and resolves them through the wrapped Manager (pending map + SSE + web-UI answer — identical path to an in-process ask).
func ServeSocket ¶ added in v0.17.0
func ServeSocket(path string, mgr *Manager) (*SocketServer, error)
ServeSocket binds path and serves asks against mgr until Close. Stale socket files from a crashed previous run are removed first, mirroring gate.NewListener.
func (*SocketServer) Close ¶ added in v0.17.0
func (s *SocketServer) Close() error
Close stops the accept loop and removes the socket file. In-flight asks resolve through their own conn-scoped done channels.
func (*SocketServer) SocketPath ¶ added in v0.17.0
func (s *SocketServer) SocketPath() string
SocketPath returns the bound socket path.