// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // Package tentacle implements the experimental command, presentation and interaction SDK. // It has no reader, input, or output backend dependencies. package tentacle import ( "bufio" "context" "encoding/json" "errors" "io" "strconv" "sync" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type Binding struct { Layout string `json:"layout"` Scope string `json:"scope"` Key string `json:"key"` } // Presentation returns independent plain-text speech and braille messages. // Declare Command.Presentation and obtain the local host grant before using it. // Uninterruptible protects accepted speech through ordinary input/focus changes; // session revocation and core lifecycle/output policy still take precedence. type Presentation = tentaclewire.Presentation // Interaction requires the explicit interaction grant as well as presentation. type Interaction = tentaclewire.Interaction type Submission = tentaclewire.Submission type Item = tentaclewire.Item type Form = interaction.Form type Element = interaction.Element type ListItem = interaction.Item type Value = interaction.Value const ( List = interaction.List Entry = interaction.Entry CheckBox = interaction.CheckBox Button = interaction.Button ) // Handlers may run concurrently. Cancellation applies to the entire session; // check ctx before side effects. Returning an error produces a generic RPC error, // never the error text (which may contain private data). type Handler func(ctx context.Context, arguments json.RawMessage) (any, error) type Command struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Bindings []Binding `json:"bindings,omitempty"` Presentation bool `json:"presentation,omitempty"` Interaction bool `json:"interaction,omitempty"` // Settings exposes this interactive command through global Preferences. // Register at most one; its handler opens a form without requiring arguments. Settings bool `json:"settings,omitempty"` Handle Handler `json:"-"` } var ErrProtocol = tentaclewire.ErrProtocol type request struct { JSONRPC string `json:"jsonrpc"` ID string `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params"` } // Serve owns the streams and closes them on return. Use stdin/stdout in a // dedicated child process and exit when Serve returns. Shutdown/EOF cancels all // handlers and waits for them; the host kills a child that ignores cancellation. // Never print to stdout. Command declarations must remain unchanged during Serve. func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error { ctx, cancel := context.WithCancel(context.Background()) var workers sync.WaitGroup var writeMutex sync.Mutex defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }() handlers := make(map[string]Handler) if len(commands) == 0 || len(commands) > 32 { return ErrProtocol } settings := false for _, command := range commands { if command.Settings && (!command.Interaction || !command.Presentation || settings) || command.ID == "" || command.Handle == nil || handlers[command.ID] != nil { return ErrProtocol } settings = settings || command.Settings handlers[command.ID] = command.Handle } writeReply := func(id string, value any, failed bool, release func()) error { reply := map[string]any{"jsonrpc": "2.0", "id": id, "result": value} if failed { delete(reply, "result") reply["error"] = map[string]any{"code": -32603, "message": "Command failed"} } frame, err := json.Marshal(reply) if err != nil || len(frame) > tentaclewire.MaxFrameBytes || tentaclewire.ValidateJSON(frame) != nil { frame, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32603, "message": "Invalid or oversized result"}}) } writeMutex.Lock() defer writeMutex.Unlock() // A completed reply may reach the host before Write returns. Release // its handler slot first, with at most one additional serialized writer. if release != nil { release() } if ctx.Err() != nil { return ctx.Err() } _, err = output.Write(append(frame, '\n')) return err } reader := bufio.NewReader(input) var sequence uint64 session := "" active := false slots := make(chan struct{}, 8) for { frame, err := tentaclewire.ReadFrame(reader) if errors.Is(err, io.EOF) { return nil } if err != nil { return err } var message request if tentaclewire.DecodeStrict(frame, &message) != nil || message.JSONRPC != "2.0" { return ErrProtocol } var fields map[string]json.RawMessage _ = json.Unmarshal(frame, &fields) for key := range fields { if key != "jsonrpc" && key != "id" && key != "method" && key != "params" { return ErrProtocol } } if _, present := fields["id"]; present && message.ID == "" { return ErrProtocol } if message.ID != "" { number, err := strconv.ParseUint(message.ID, 10, 64) if err != nil || number <= sequence || strconv.FormatUint(number, 10) != message.ID { return ErrProtocol } sequence = number } switch message.Method { case "initialize": var params struct { Protocol struct { Major *int `json:"major"` Minor *int `json:"minor"` } `json:"protocol"` Session string `json:"session"` APIs []string `json:"apis"` Capabilities []string `json:"capabilities"` } if session != "" || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || *params.Protocol.Minor != 0 || params.Session == "" || len(params.Session) > 128 || !tentaclewire.ValidAPIs(params.APIs, params.Capabilities) { return ErrProtocol } for _, command := range commands { if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) != 3) { return ErrProtocol } } session = params.Session if err := writeReply(message.ID, map[string]any{"protocol": params.Protocol, "session": session, "commands": commands}, false, nil); err != nil { return err } case "initialized": var params struct { Session string `json:"session"` } if session == "" || active || message.ID != "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || params.Session != session { return ErrProtocol } active = true case "command.invoke": var params struct { Command string `json:"command"` Arguments json.RawMessage `json:"arguments"` } if !active || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || len(params.Arguments) == 0 || len(params.Arguments) > 16*1024 || handlers[params.Command] == nil { return ErrProtocol } select { case slots <- struct{}{}: default: return tentaclewire.ErrLimit } workers.Add(1) go func(id string, handler Handler, arguments json.RawMessage) { defer workers.Done() released := false release := func() { if !released { <-slots released = true } } defer release() value, err := call_handler(ctx, handler, arguments) if writeReply(id, value, err != nil, release) != nil { cancel() input.Close() } }(message.ID, handlers[params.Command], params.Arguments) case "shutdown": if message.ID != "" || (len(message.Params) != 0 && string(message.Params) != "null") { return ErrProtocol } return nil default: return ErrProtocol } } } func call_handler(ctx context.Context, handler Handler, arguments json.RawMessage) (value any, err error) { defer func() { if recover() != nil { value = nil err = errors.New("command panicked") } }() return handler(ctx, arguments) }