返回 DeepSeek-Reasonix
hub.go
1 // Package uihub implements the host side of the Extension Protocol v2
2 // structured UI surface (stage 8a). One Hub serves host/ui/publish and
3 // host/ui/request for every sidecar client of a runtime generation:
4 // publications are strict-decoded, credential-redacted, and emitted as
5 // frontend events; blocking prompts are translated onto the host's Ask
6 // machinery; and handshake-declared actions are registered for
7 // /<plugin>:<action> invocation and form submission routing.
8 //
9 // Stability contract: a publication or request whose generation or session
10 // does not match the hub's current binding is dropped with a debug log (late
11 // results after a reload must never overwrite the new generation's state);
12 // calls from unknown or crashed clients are rejected. All sidecar-sourced
13 // user-visible text passes secrets.RedactCredentials before surfacing.
14 package uihub
15
16 import (
17 "context"
18 "errors"
19 "fmt"
20 "log/slog"
21 "regexp"
22 "sort"
23 "strings"
24 "sync"
25
26 "reasonix/internal/event"
27 "reasonix/internal/extension"
28 "reasonix/internal/extension/protocol"
29 "reasonix/internal/extension/sidecar"
30 "reasonix/internal/secrets"
31 )
32
33 // UIHandler is the sidecar package's Extension → Host UI call surface,
34 // re-exported so hub bindings and the sidecar manager's UIBinder contract
35 // share ONE interface type (Go interface satisfaction is signature-exact —
36 // a same-shaped local interface would silently fail the manager's
37 // per-plugin binding path).
38 type UIHandler = sidecar.UIHandler
39
40 // ActionClient is the subset of a sidecar client the hub needs for
41 // host-initiated UI calls. *sidecar.Client satisfies it.
42 type ActionClient interface {
43 UIAction(ctx context.Context, p protocol.UIActionParams) (protocol.UIActionResult, error)
44 UISubmit(ctx context.Context, p protocol.UISubmitParams) (protocol.UISubmitResult, error)
45 }
46
47 // ClientResolver resolves a plugin ID to its live sidecar client, or nil when
48 // the plugin has no running sidecar in this generation.
49 type ClientResolver func(pluginID string) ActionClient
50
51 // HubRequest is one blocking prompt translated from a host/ui/request call,
52 // ready for the host's Ask machinery. Every user-visible string is already
53 // credential-redacted.
54 type HubRequest struct {
55 PluginID string
56 SurfaceID string
57 SessionID string
58 Kind protocol.UIRequestKind
59 Title string
60 Message string
61 Fields []protocol.UIFormField
62 }
63
64 // RequestFunc answers one blocking prompt. The returned values are keyed by
65 // form field key; cancelled reports a dismissal (distinct from an empty value
66 // set); err reports a channel failure.
67 type RequestFunc func(ctx context.Context, req HubRequest) (values map[string]any, cancelled bool, err error)
68
69 // Options configures a Hub. Emit and Request are the frontend seams: Emit
70 // receives the extension surface/status events, Request answers blocking
71 // prompts through the host's Ask machinery. Resolve is optional at
72 // construction because the sidecar manager only exists after StartPackages —
73 // bind it with SetResolver once clients are live.
74 type Options struct {
75 SessionID string
76 Generation uint64
77 Owner *extension.RuntimeOwner
78 Emit func(event.Event)
79 Request RequestFunc
80 Warn func(string)
81 Resolve ClientResolver
82 }
83
84 // ActionView is one registered extension action for frontend enumeration.
85 // Slash is the public invocation name, "/<plugin>:<action>".
86 type ActionView struct {
87 PluginID string
88 ActionID string
89 Label string
90 Slash string
91 }
92
93 // Hub is the per-generation host UI hub. It is constructed at build time,
94 // bound to the build's session ID and snapshot generation, and retired with
95 // its controller; BindGeneration re-binds it across a reload. The mutex makes
96 // every method safe for concurrent sidecar traffic.
97 type Hub struct {
98 mu sync.Mutex
99 sessionID string
100 generation uint64
101 owner *extension.RuntimeOwner
102 emit func(event.Event)
103 requestFn RequestFunc
104 warn func(string)
105 resolve ClientResolver
106 known map[string]bool
107 crashed map[string]bool
108 actions map[string]map[string]protocol.UIActionDecl
109 formSeq uint64
110 activeForms map[string]activeForm
111 }
112
113 // New builds a Hub bound to one session ID and generation.
114 func New(opts Options) *Hub {
115 owner := opts.Owner
116 if owner == nil {
117 owner = extension.RuntimeOwnerOrDefault(nil)
118 }
119 return &Hub{
120 sessionID: strings.TrimSpace(opts.SessionID), generation: opts.Generation,
121 owner: owner, emit: opts.Emit, requestFn: opts.Request, warn: opts.Warn, resolve: opts.Resolve,
122 known: map[string]bool{}, crashed: map[string]bool{},
123 actions: map[string]map[string]protocol.UIActionDecl{}, activeForms: map[string]activeForm{},
124 }
125 }
126
127 // SessionID returns the session the hub is bound to.
128 func (h *Hub) SessionID() string {
129 h.mu.Lock()
130 defer h.mu.Unlock()
131 return h.sessionID
132 }
133
134 // Generation returns the generation the hub is bound to.
135 func (h *Hub) Generation() uint64 {
136 h.mu.Lock()
137 defer h.mu.Unlock()
138 return h.generation
139 }
140
141 // BindGeneration re-binds session/generation after reload. Narrow rebuild
142 // stage reuses the hub without this call, so next-gen host/ui/* during
143 // handshake/ready is dropped until commit; do not rely on UI before publish.
144 func (h *Hub) BindGeneration(sessionID string, gen uint64) {
145 h.mu.Lock()
146 defer h.mu.Unlock()
147 h.sessionID = strings.TrimSpace(sessionID)
148 h.generation = gen
149 h.activeForms = map[string]activeForm{}
150 }
151
152 // SetResolver installs the client resolver once the sidecar manager exists.
153 func (h *Hub) SetResolver(r ClientResolver) {
154 h.mu.Lock()
155 defer h.mu.Unlock()
156 h.resolve = r
157 }
158
159 // HandlerFor returns the UIHandler binding the sidecar manager installs on
160 // one client's connection. Binding marks the plugin known and live: a publish
161 // or request is attributed to the plugin through the returned value.
162 func (h *Hub) HandlerFor(pluginID string) UIHandler {
163 pluginID = strings.TrimSpace(pluginID)
164 h.mu.Lock()
165 h.known[pluginID] = true
166 delete(h.crashed, pluginID)
167 h.mu.Unlock()
168 return binding{pluginID: pluginID, hub: h}
169 }
170
171 // ClientCrashed marks a bound plugin's sidecar dead. Its later UI calls are
172 // rejected until a fresh HandlerFor binding marks the replacement live.
173 func (h *Hub) ClientCrashed(pluginID string) {
174 h.mu.Lock()
175 defer h.mu.Unlock()
176 if h.known[pluginID] {
177 h.crashed[pluginID] = true
178 }
179 for key, form := range h.activeForms {
180 if form.pluginID == pluginID {
181 delete(h.activeForms, key)
182 }
183 }
184 }
185
186 // Publish implements the bare UIHandler. Without a per-plugin binding the hub
187 // cannot attribute the publication, so the unbound method rejects.
188 func (h *Hub) Publish(context.Context, protocol.UIPublishParams) (protocol.UIPublishResult, error) {
189 return protocol.UIPublishResult{}, unknownClientError("")
190 }
191
192 // Request implements the bare UIHandler; like Publish it requires a binding.
193 func (h *Hub) Request(context.Context, protocol.UIRequestParams) (protocol.UIRequestResult, error) {
194 return protocol.UIRequestResult{}, unknownClientError("")
195 }
196
197 // binding is the per-plugin UIHandler the sidecar manager installs.
198 type binding struct {
199 pluginID string
200 hub *Hub
201 }
202
203 func (b binding) Publish(ctx context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
204 return b.hub.publish(b.pluginID, ctx, p)
205 }
206
207 func (b binding) Request(ctx context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
208 return b.hub.request(b.pluginID, ctx, p)
209 }
210
211 // gate enforces the binding and staleness rules: unknown or crashed clients
212 // are rejected; a generation or session mismatch is a silent drop (stale) —
213 // late results after a reload must never overwrite the new generation's
214 // state, and a wedged old sidecar must not fail either.
215 func (h *Hub) gate(pluginID, sessionID string, generation uint64) (stale bool, err error) {
216 h.mu.Lock()
217 defer h.mu.Unlock()
218 return h.gateLocked(pluginID, sessionID, generation)
219 }
220
221 func (h *Hub) gateLocked(pluginID, sessionID string, generation uint64) (stale bool, err error) {
222 if !h.known[pluginID] {
223 return false, unknownClientError(pluginID)
224 }
225 if h.crashed[pluginID] {
226 return false, &protocol.ProtocolError{
227 Reason: protocol.ErrProviderInterrupted,
228 Message: "extension sidecar " + pluginID + " crashed",
229 }
230 }
231 if generation != h.generation {
232 slog.Debug("uihub: dropping stale-generation UI call", "plugin", pluginID, "got", generation, "current", h.generation)
233 return true, nil
234 }
235 // Also drop generations that have been superseded on the process gate
236 // (older than the published generation after a successful rebuild).
237 if h.owner.Gate.IsStale(generation) {
238 slog.Debug("uihub: dropping publish-gate-stale UI call", "plugin", pluginID, "got", generation)
239 return true, nil
240 }
241 if sessionID != h.sessionID {
242 slog.Debug("uihub: dropping UI call for an unbound session", "plugin", pluginID, "session", sessionID, "bound", h.sessionID)
243 return true, nil
244 }
245 return false, nil
246 }
247
248 // publish serves one host/ui/publish call: gate, strict-decode by kind,
249 // redact, emit the matching extension event, acknowledge.
250 func (h *Hub) publish(pluginID string, _ context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
251 stale, err := h.gate(pluginID, p.SessionID, p.Generation)
252 if err != nil {
253 return protocol.UIPublishResult{}, err
254 }
255 if stale {
256 return protocol.UIPublishResult{Accepted: false}, nil
257 }
258 payload, err := decodePublishEvent(pluginID, p)
259 if err != nil {
260 return protocol.UIPublishResult{}, err
261 }
262 h.mu.Lock()
263 stale, err = h.gateLocked(pluginID, p.SessionID, p.Generation)
264 if err != nil || stale {
265 h.mu.Unlock()
266 if err != nil {
267 return protocol.UIPublishResult{}, err
268 }
269 return protocol.UIPublishResult{Accepted: false}, nil
270 }
271 key := formKey(pluginID, p.SurfaceID)
272 if p.Kind == protocol.UISurfaceForm {
273 h.formSeq++
274 payload.FormInstanceID = fmt.Sprintf("form-%d", h.formSeq)
275 h.activeForms[key] = activeForm{pluginID: pluginID, surfaceID: p.SurfaceID, sessionID: p.SessionID,
276 generation: p.Generation, instanceID: payload.FormInstanceID}
277 } else {
278 delete(h.activeForms, key)
279 }
280 h.mu.Unlock()
281 kind := event.ExtensionSurface
282 if p.Kind == protocol.UISurfaceStatus {
283 kind = event.ExtensionStatus
284 }
285 h.emitEvent(event.Event{Kind: kind, Extension: payload})
286 return protocol.UIPublishResult{Accepted: true}, nil
287 }
288
289 // request serves one host/ui/request call: gate, strict-decode the form,
290 // redact, block on the host's Ask channel, and map the answers back to the
291 // protocol result. A stale request is answered cancelled immediately so the
292 // old sidecar never wedges on a prompt nobody will see.
293 func (h *Hub) request(pluginID string, ctx context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
294 stale, err := h.gate(pluginID, p.SessionID, p.Generation)
295 if err != nil {
296 return protocol.UIRequestResult{}, err
297 }
298 if stale {
299 return protocol.UIRequestResult{Cancelled: true}, nil
300 }
301 decoded, err := protocol.DecodeUIRequestPayload(p.Kind, p.Payload)
302 if err != nil {
303 return protocol.UIRequestResult{}, &protocol.ProtocolError{
304 Reason: protocol.ErrInvalidParams,
305 Message: "invalid " + string(p.Kind) + " request payload: " + err.Error(),
306 }
307 }
308 form := decoded.(protocol.UIFormPayload)
309 h.mu.Lock()
310 request := h.requestFn
311 h.mu.Unlock()
312 if request == nil {
313 return protocol.UIRequestResult{}, &protocol.ProtocolError{
314 Reason: protocol.ErrUnknownMethod,
315 Message: "extension UI is not available on this host",
316 }
317 }
318 values, cancelled, err := request(ctx, HubRequest{
319 PluginID: pluginID,
320 SurfaceID: p.SurfaceID,
321 SessionID: p.SessionID,
322 Kind: p.Kind,
323 Title: secrets.RedactCredentials(form.Title),
324 Message: secrets.RedactCredentials(form.Message),
325 Fields: redactFormFields(form.Fields),
326 })
327 if err != nil {
328 return protocol.UIRequestResult{}, err
329 }
330 return protocol.UIRequestResult{Cancelled: cancelled, Values: values}, nil
331 }
332
333 // RegisterActions records the UI actions one plugin declared in its
334 // handshake, replacing any previously registered set for that plugin. An
335 // invalid action ID rejects the whole batch: the registry is the allow-list
336 // for later invocations, so a malformed declaration must not slip through.
337 func (h *Hub) RegisterActions(pluginID string, actions []protocol.UIActionDecl) error {
338 pluginID = strings.TrimSpace(pluginID)
339 if pluginID == "" {
340 return errors.New("uihub: plugin id is required")
341 }
342 for _, action := range actions {
343 if !ValidActionID(action.ActionID) {
344 return fmt.Errorf("uihub: extension %s declared invalid action id %q", pluginID, action.ActionID)
345 }
346 }
347 h.mu.Lock()
348 defer h.mu.Unlock()
349 decls := make(map[string]protocol.UIActionDecl, len(actions))
350 for _, action := range actions {
351 if _, dup := decls[action.ActionID]; dup {
352 h.warnLocked("extension " + pluginID + " re-declared action " + action.ActionID + "; keeping the first declaration")
353 continue
354 }
355 decls[action.ActionID] = protocol.UIActionDecl{
356 ActionID: action.ActionID,
357 Label: secrets.RedactCredentials(action.Label),
358 }
359 }
360 h.actions[pluginID] = decls
361 h.known[pluginID] = true
362 return nil
363 }
364
365 // Actions returns every registered action ordered by its public slash name.
366 func (h *Hub) Actions() []ActionView {
367 h.mu.Lock()
368 defer h.mu.Unlock()
369 var out []ActionView
370 for pluginID, decls := range h.actions {
371 for actionID, decl := range decls {
372 out = append(out, ActionView{
373 PluginID: pluginID,
374 ActionID: actionID,
375 Label: decl.Label,
376 Slash: SlashName(pluginID, actionID),
377 })
378 }
379 }
380 sort.Slice(out, func(i, j int) bool { return out[i].Slash < out[j].Slash })
381 return out
382 }
383
384 // InvokeAction routes one /<plugin>:<action> invocation to the owning sidecar
385 // as extension/ui/action. The action must be handshake-declared, the plugin's
386 // sidecar live, and the session the hub's current one; the result message is
387 // credential-redacted before it travels back to the frontend.
388 func (h *Hub) InvokeAction(ctx context.Context, pluginID, actionID, sessionID string, args map[string]string) (protocol.UIActionResult, error) {
389 if !ValidActionID(actionID) {
390 return protocol.UIActionResult{}, &protocol.ProtocolError{
391 Reason: protocol.ErrInvalidParams,
392 Message: fmt.Sprintf("invalid action id %q", actionID),
393 }
394 }
395 client, generation, err := h.actionTarget(pluginID, sessionID)
396 if err != nil {
397 return protocol.UIActionResult{}, err
398 }
399 h.mu.Lock()
400 _, declared := h.actions[pluginID][actionID]
401 h.mu.Unlock()
402 if !declared {
403 return protocol.UIActionResult{}, &protocol.ProtocolError{
404 Reason: protocol.ErrInvalidParams,
405 Message: "extension " + pluginID + " declared no action " + actionID,
406 }
407 }
408 result, err := client.UIAction(ctx, protocol.UIActionParams{
409 ActionID: actionID,
410 SessionID: sessionID,
411 Generation: generation,
412 Args: args,
413 })
414 if err != nil {
415 return protocol.UIActionResult{}, err
416 }
417 result.Message = secrets.RedactCredentials(result.Message)
418 return result, nil
419 }
420
421 // Submit routes one form surface's values back to the owning sidecar as
422 // extension/ui/submit.
423 func (h *Hub) Submit(ctx context.Context, pluginID, surfaceID, sessionID string, values map[string]any) (protocol.UISubmitResult, error) {
424 if strings.TrimSpace(surfaceID) == "" {
425 return protocol.UISubmitResult{}, &protocol.ProtocolError{
426 Reason: protocol.ErrInvalidParams,
427 Message: "surface id is required",
428 }
429 }
430 client, generation, err := h.actionTarget(pluginID, sessionID)
431 if err != nil {
432 return protocol.UISubmitResult{}, err
433 }
434 return client.UISubmit(ctx, protocol.UISubmitParams{
435 SurfaceID: surfaceID,
436 SessionID: sessionID,
437 Generation: generation,
438 Values: values,
439 })
440 }
441
442 // actionTarget resolves the live client for one plugin after enforcing the
443 // binding and session rules shared by InvokeAction and Submit.
444 func (h *Hub) actionTarget(pluginID, sessionID string) (ActionClient, uint64, error) {
445 h.mu.Lock()
446 defer h.mu.Unlock()
447 if !h.known[pluginID] {
448 return nil, 0, unknownClientError(pluginID)
449 }
450 if h.crashed[pluginID] {
451 return nil, 0, &protocol.ProtocolError{
452 Reason: protocol.ErrProviderInterrupted,
453 Message: "extension sidecar " + pluginID + " crashed",
454 }
455 }
456 if sessionID != h.sessionID {
457 return nil, 0, &protocol.ProtocolError{
458 Reason: protocol.ErrInvalidParams,
459 Message: "extension UI call for a stale session",
460 }
461 }
462 if h.resolve == nil {
463 return nil, 0, &protocol.ProtocolError{
464 Reason: protocol.ErrUnknownMethod,
465 Message: "extension UI is not available on this host",
466 }
467 }
468 client := h.resolve(pluginID)
469 if client == nil {
470 return nil, 0, &protocol.ProtocolError{
471 Reason: protocol.ErrProviderInterrupted,
472 Message: "extension " + pluginID + " has no live sidecar",
473 }
474 }
475 return client, h.generation, nil
476 }
477
478 // emitEvent fans one extension event out to the frontend sink.
479 func (h *Hub) emitEvent(ev event.Event) {
480 h.mu.Lock()
481 emit := h.emit
482 h.mu.Unlock()
483 if emit == nil {
484 slog.Debug("uihub: dropping extension event (no emit sink)", "kind", ev.Kind)
485 return
486 }
487 emit(ev)
488 }
489
490 // warnLocked reports a non-fatal anomaly; the caller holds h.mu.
491 func (h *Hub) warnLocked(msg string) {
492 if h.warn != nil {
493 h.warn(msg)
494 return
495 }
496 slog.Warn("uihub: " + msg)
497 }
498
499 // decodePublishEvent strict-decodes one publish payload by kind, redacts
500 // every user-visible string, and builds the event payload.
501 func decodePublishEvent(pluginID string, p protocol.UIPublishParams) (*event.ExtensionSurfacePayload, error) {
502 decoded, err := protocol.DecodeUIPublishPayload(p.Kind, p.Payload)
503 if err != nil {
504 return nil, &protocol.ProtocolError{
505 Reason: protocol.ErrInvalidParams,
506 Message: "invalid " + string(p.Kind) + " surface payload: " + err.Error(),
507 }
508 }
509 out := &event.ExtensionSurfacePayload{
510 PluginID: pluginID,
511 SurfaceID: p.SurfaceID,
512 SessionID: p.SessionID,
513 Generation: p.Generation,
514 Kind: string(p.Kind),
515 }
516 switch payload := decoded.(type) {
517 case protocol.UIStatusPayload:
518 out.Status = &event.ExtensionStatusView{
519 Label: secrets.RedactCredentials(payload.Label),
520 Detail: secrets.RedactCredentials(payload.Detail),
521 Severity: string(payload.Severity),
522 Progress: payload.Progress,
523 }
524 case protocol.UICardPayload:
525 card := &event.ExtensionCardView{
526 Title: secrets.RedactCredentials(payload.Title),
527 Markdown: secrets.RedactCredentials(payload.Markdown),
528 Text: secrets.RedactCredentials(payload.Text),
529 Progress: payload.Progress,
530 }
531 for _, field := range payload.Fields {
532 card.Fields = append(card.Fields, event.ExtensionKeyValue{
533 Key: secrets.RedactCredentials(field.Key),
534 Value: secrets.RedactCredentials(field.Value),
535 })
536 }
537 for _, action := range payload.Actions {
538 card.Actions = append(card.Actions, event.ExtensionActionRef{
539 ActionID: action.ActionID,
540 Label: secrets.RedactCredentials(action.Label),
541 })
542 }
543 out.Card = card
544 case protocol.UIFormPayload:
545 out.Form = &event.ExtensionFormView{
546 Title: secrets.RedactCredentials(payload.Title),
547 Message: secrets.RedactCredentials(payload.Message),
548 Fields: redactEventFormFields(payload.Fields),
549 }
550 case protocol.UINotificationPayload:
551 out.Notification = &event.ExtensionNotificationView{
552 Title: secrets.RedactCredentials(payload.Title),
553 Body: secrets.RedactCredentials(payload.Body),
554 Severity: string(payload.Severity),
555 }
556 }
557 return out, nil
558 }
559
560 // redactFormFields redacts the user-visible strings of protocol form fields
561 // (labels and options; keys stay intact — they correlate answers back).
562 func redactFormFields(fields []protocol.UIFormField) []protocol.UIFormField {
563 out := make([]protocol.UIFormField, len(fields))
564 for i, field := range fields {
565 out[i] = protocol.UIFormField{
566 Key: field.Key,
567 Label: secrets.RedactCredentials(field.Label),
568 Kind: field.Kind,
569 Options: redactStrings(field.Options),
570 Default: redactDefault(field.Default),
571 Required: field.Required,
572 }
573 }
574 return out
575 }
576
577 // redactEventFormFields redacts protocol form fields into their event view.
578 func redactEventFormFields(fields []protocol.UIFormField) []event.ExtensionFormField {
579 out := make([]event.ExtensionFormField, 0, len(fields))
580 for _, field := range fields {
581 out = append(out, event.ExtensionFormField{
582 Key: field.Key,
583 Label: secrets.RedactCredentials(field.Label),
584 Kind: string(field.Kind),
585 Options: redactStrings(field.Options),
586 Default: redactDefault(field.Default),
587 Required: field.Required,
588 })
589 }
590 return out
591 }
592
593 func redactStrings(in []string) []string {
594 if len(in) == 0 {
595 return nil
596 }
597 out := make([]string, len(in))
598 for i, s := range in {
599 out[i] = secrets.RedactCredentials(s)
600 }
601 return out
602 }
603
604 // redactDefault redacts a string default value; non-string defaults carry no
605 // user-visible text.
606 func redactDefault(v any) any {
607 if s, ok := v.(string); ok {
608 return secrets.RedactCredentials(s)
609 }
610 return v
611 }
612
613 func unknownClientError(pluginID string) error {
614 return &protocol.ProtocolError{
615 Reason: protocol.ErrInternal,
616 Message: "host/ui call from an unknown extension client " + fmt.Sprintf("%q", pluginID),
617 }
618 }
619
620 // actionIDPattern freezes the public action ID contract: lowercase, digits,
621 // and dashes only, so actions compose cleanly into /<plugin>:<action> names.
622 var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
623
624 // ValidActionID reports whether id is a well-formed extension action ID.
625 func ValidActionID(id string) bool {
626 return actionIDPattern.MatchString(id)
627 }
628
629 // SlashName renders the public invocation name of one action,
630 // "/<plugin>:<action>".
631 func SlashName(pluginID, actionID string) string {
632 return "/" + pluginID + ":" + actionID
633 }
634
635 // ParseSlashName splits a public invocation name back into plugin and action
636 // IDs. ok is false when the name is not a well-formed "/<plugin>:<action>".
637 func ParseSlashName(name string) (pluginID, actionID string, ok bool) {
638 rest, found := strings.CutPrefix(strings.TrimSpace(name), "/")
639 if !found {
640 return "", "", false
641 }
642 plugin, action, found := strings.Cut(rest, ":")
643 if !found || strings.TrimSpace(plugin) == "" || !ValidActionID(action) {
644 return "", "", false
645 }
646 return plugin, action, true
647 }
648
649 // Confirmation option labels. They are display strings; the boolean answer
650 // maps back from the picked label.
651 const (
652 confirmYes = "Yes"
653 confirmNo = "No"
654 )
655
656 // AskRequestFunc adapts the host's Ask channel (agent.Asker-shaped, e.g.
657 // control.Controller.Ask) to the hub's RequestFunc. Form fields translate
658 // one-to-one into AskQuestions; answers map back to values keyed by field
659 // key — a string for input/select/confirm-free text, a bool for confirm, a
660 // string slice for multiselect. A prompt dismissed with no selections at all
661 // reports cancelled, mirroring the controller's own skip semantics.
662 func AskRequestFunc(ask func(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)) RequestFunc {
663 return func(ctx context.Context, req HubRequest) (map[string]any, bool, error) {
664 if ask == nil {
665 return nil, false, &protocol.ProtocolError{
666 Reason: protocol.ErrUnknownMethod,
667 Message: "extension UI is not available on this host",
668 }
669 }
670 fields := req.Fields
671 if len(fields) == 0 {
672 // A field-less request (the common confirm shape) asks one
673 // question of the request kind's matching field kind.
674 fields = []protocol.UIFormField{{
675 Key: "value",
676 Label: req.Message,
677 Kind: protocol.UIFieldKind(req.Kind),
678 }}
679 }
680 questions := make([]event.AskQuestion, 0, len(fields))
681 for _, field := range fields {
682 question := event.AskQuestion{ID: field.Key, Header: field.Label, Prompt: field.Label}
683 if question.Prompt == "" {
684 question.Prompt = req.Message
685 }
686 if question.Header == "" {
687 question.Header = req.Title
688 }
689 switch field.Kind {
690 case protocol.UIFieldConfirm:
691 question.Options = []event.AskOption{{Label: confirmYes}, {Label: confirmNo}}
692 case protocol.UIFieldSelect:
693 question.Options = askOptions(field.Options)
694 case protocol.UIFieldMultiselect:
695 question.Options = askOptions(field.Options)
696 question.Multi = true
697 default:
698 // input (and anything unrecognised) is a free-text question.
699 }
700 questions = append(questions, question)
701 }
702 answers, err := ask(ctx, questions)
703 if err != nil {
704 return nil, false, err
705 }
706 byID := make(map[string]event.AskAnswer, len(answers))
707 for _, answer := range answers {
708 byID[answer.QuestionID] = answer
709 }
710 values := map[string]any{}
711 selections := 0
712 for _, field := range fields {
713 answer, ok := byID[field.Key]
714 if !ok || len(answer.Selected) == 0 {
715 continue
716 }
717 selections += len(answer.Selected)
718 switch field.Kind {
719 case protocol.UIFieldConfirm:
720 values[field.Key] = strings.EqualFold(answer.Selected[0], confirmYes)
721 case protocol.UIFieldMultiselect:
722 values[field.Key] = append([]string(nil), answer.Selected...)
723 default:
724 values[field.Key] = answer.Selected[0]
725 }
726 }
727 if selections == 0 {
728 return nil, true, nil
729 }
730 return values, false, nil
731 }
732 }
733
734 func askOptions(options []string) []event.AskOption {
735 out := make([]event.AskOption, len(options))
736 for i, option := range options {
737 out[i] = event.AskOption{Label: option}
738 }
739 return out
740 }
741
741 lines GO