kward 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -0,0 +1,39 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for model-callable tool wrappers.
4
+ module Kward
5
+ module Tools
6
+ # Tool wrapper for the trusted host-side Git commit operation.
7
+ class GitCommit < Base
8
+ def initialize(committer:)
9
+ @committer = committer
10
+ super(
11
+ "git_commit",
12
+ "Stage and commit changes in the active worktree. Omit paths to include all current changes.",
13
+ properties: {
14
+ message: { type: "string", description: "Commit message." },
15
+ paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
16
+ },
17
+ required: ["message"]
18
+ )
19
+ end
20
+
21
+ def call(args, _conversation, cancellation: nil)
22
+ cancellation&.raise_if_cancelled!
23
+ message = argument(args, :message, "").to_s
24
+ paths = argument(args, :paths, nil)
25
+ return "Error: commit message is required" if message.strip.empty?
26
+ return "Error: paths must be an array" unless paths.nil? || paths.is_a?(Array)
27
+ return "Error: paths must contain strings" if paths && paths.any? { |path| !path.is_a?(String) }
28
+
29
+ paths = nil if paths&.empty?
30
+ result = @committer.call(message: message, paths: paths)
31
+ output = result[:output].to_s.strip
32
+ return "Git commit succeeded\n#{output}" if result[:success]
33
+
34
+ failure = output.empty? ? "Git commit failed." : "Git commit failed.\n#{output}"
35
+ "Error: #{failure}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -8,6 +8,7 @@ require_relative "context_for_task"
8
8
  require_relative "edit_file"
9
9
  require_relative "fetch_content"
10
10
  require_relative "fetch_raw"
11
+ require_relative "git_commit"
11
12
  require_relative "list_directory"
12
13
  require_relative "mcp_tool"
13
14
  require_relative "read_file"
@@ -78,7 +79,7 @@ module Kward
78
79
  # @param web_search_enabled [Boolean, nil] override for web search exposure
79
80
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
80
81
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
81
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
82
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil, git_committer: nil)
82
83
  @workspace = workspace
83
84
  @prompt = prompt
84
85
  @web_search = web_search
@@ -96,6 +97,7 @@ module Kward
96
97
  @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
97
98
  @hook_manager = hook_manager
98
99
  @hook_context = hook_context
100
+ @git_committer = git_committer
99
101
  @mcp_clients = if mcp_clients
100
102
  mcp_clients
101
103
  elsif @allowed_tool_names
@@ -484,6 +486,7 @@ module Kward
484
486
 
485
487
  def build_schema_tools
486
488
  tools = @tools.values_at(*CORE_TOOL_NAMES)
489
+ tools << @tools["git_commit"] if @tools["git_commit"]
487
490
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
488
491
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
489
492
  tools << @tools["read_skill"] if skills_available?
@@ -492,7 +495,9 @@ module Kward
492
495
  end
493
496
 
494
497
  def all_tools
495
- core_tools + [
498
+ tools = core_tools
499
+ tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
500
+ tools + [
496
501
  Tools::WebSearch.new(web_search: @web_search),
497
502
  Tools::FetchContent.new(web_fetch: @web_fetch),
498
503
  Tools::FetchRaw.new(web_fetch: @web_fetch),
@@ -24,6 +24,7 @@ module Kward
24
24
  "web_search" => "web_search",
25
25
  "fetch_content" => "fetch_content",
26
26
  "fetch_raw" => "fetch_raw",
27
+ "git_commit" => "git_commit",
27
28
  "read_skill" => "read_skill",
28
29
  "ask_user_question" => "ask_user_question"
29
30
  }.freeze
@@ -0,0 +1,253 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "thread"
4
+ require_relative "../deep_copy"
5
+ require_relative "store"
6
+
7
+ module Kward
8
+ module Transport
9
+ # Adapts the existing session manager to the transport host contract.
10
+ #
11
+ # The adapter deliberately uses the manager's public session and turn
12
+ # methods. It can be replaced by a frontend-neutral runtime gateway later
13
+ # without changing transport plugins.
14
+ class Gateway
15
+ POLL_INTERVAL = 0.05
16
+ TERMINAL_STATUSES = %w[completed canceled failed].freeze
17
+
18
+ def initialize(session_manager:, transport_id:, storage: nil, poll_interval: POLL_INTERVAL)
19
+ @session_manager = session_manager
20
+ @transport_id = transport_id.to_s
21
+ @storage = storage || Store.new(@transport_id)
22
+ @poll_interval = poll_interval
23
+ @subscriptions = []
24
+ @interaction_subscribers = []
25
+ @mutex = Mutex.new
26
+ @session_manager.subscribe_events { |method, payload| handle_runtime_event(method, payload) } if @session_manager.respond_to?(:subscribe_events)
27
+ end
28
+
29
+ def resolve_transport_session(transport_id:, conversation:, actor:, workspace_root: nil, name: nil, execution_profile: nil)
30
+ raise ArgumentError, "transport id does not match gateway" unless transport_id.to_s == @transport_id
31
+ if conversation.respond_to?(:transport_id) && conversation.transport_id.to_s != @transport_id
32
+ raise ArgumentError, "conversation transport does not match gateway"
33
+ end
34
+
35
+ binding_key = binding_key_for(conversation)
36
+ binding = @storage.get(binding_key)
37
+ session = if binding
38
+ resume_bound_session(binding, execution_profile: execution_profile)
39
+ else
40
+ @session_manager.create_session(workspace_root: workspace_root || Dir.pwd, name: name, **execution_profile_arguments(execution_profile))
41
+ end
42
+ persist_binding(binding_key, session)
43
+ session_handle(session)
44
+ end
45
+
46
+ def start_transport_turn(session_id:, input:, attachments: [], options: {}, streaming_behavior: nil, execution_profile: nil)
47
+ payload = @session_manager.start_turn(
48
+ session_id: session_id,
49
+ input: input,
50
+ attachments: normalize_attachments(attachments, execution_profile: execution_profile),
51
+ options: profile_options(options, execution_profile),
52
+ streaming_behavior: streaming_behavior,
53
+ execution_profile: execution_profile
54
+ )
55
+ { id: payload.fetch(:id), session_id: payload.fetch(:sessionId) }
56
+ end
57
+
58
+ def transport_transcript(session_id:)
59
+ @session_manager.transcript(session_id: session_id)
60
+ end
61
+
62
+ def transport_turn_events(turn_id:, after: nil)
63
+ payload = @session_manager.turn_events(turn_id: turn_id, after_sequence: after.to_i)
64
+ Array(payload[:events]).map { |event| normalize_event(event) }
65
+ end
66
+
67
+ def transport_turn_status(turn_id:)
68
+ @session_manager.turn_status(turn_id: turn_id)
69
+ end
70
+
71
+ def cancel_transport_turn(turn_id:)
72
+ @session_manager.cancel_turn(turn_id: turn_id)
73
+ end
74
+
75
+ def answer_transport_interaction(session_id:, request_id:, answer:)
76
+ if answer == true || answer == false
77
+ @session_manager.answer_tool_approval(
78
+ session_id: session_id,
79
+ approval_request_id: request_id,
80
+ approved: answer
81
+ )
82
+ else
83
+ answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
84
+ @session_manager.answer_question(
85
+ session_id: session_id,
86
+ question_request_id: request_id,
87
+ answers: answers
88
+ )
89
+ end
90
+ end
91
+
92
+ def subscribe_transport_interactions(&block)
93
+ raise ArgumentError, "interaction subscription requires a block" unless block
94
+
95
+ @mutex.synchronize { @interaction_subscribers << block }
96
+ block
97
+ end
98
+
99
+ def subscribe_transport_turn(turn_id:, after: nil)
100
+ cursor = after.to_i
101
+ thread = Thread.new do
102
+ loop do
103
+ events = transport_turn_events(turn_id: turn_id, after: cursor)
104
+ events.each do |event|
105
+ cursor = event.sequence
106
+ yield event
107
+ end
108
+ status = transport_turn_status(turn_id: turn_id)
109
+ break if TERMINAL_STATUSES.include?(status[:status].to_s)
110
+
111
+ sleep @poll_interval
112
+ end
113
+ rescue StandardError
114
+ nil
115
+ end
116
+ @mutex.synchronize { @subscriptions << thread }
117
+ thread
118
+ end
119
+
120
+ def shutdown
121
+ subscriptions = @mutex.synchronize do
122
+ current = @subscriptions
123
+ @subscriptions = []
124
+ @interaction_subscribers = []
125
+ current
126
+ end
127
+ subscriptions.each(&:kill)
128
+ subscriptions.each(&:join)
129
+ nil
130
+ end
131
+
132
+ private
133
+
134
+ def resume_bound_session(binding, execution_profile: nil)
135
+ @session_manager.resume_session(
136
+ path: binding.fetch("path"),
137
+ workspace_root: binding.fetch("workspace_root"),
138
+ include_transcript: false,
139
+ **execution_profile_arguments(execution_profile)
140
+ )
141
+ rescue StandardError
142
+ @session_manager.create_session(workspace_root: binding.fetch("workspace_root"), **execution_profile_arguments(execution_profile))
143
+ end
144
+
145
+ def execution_profile_arguments(profile)
146
+ profile ? { execution_profile: profile } : {}
147
+ end
148
+
149
+ def persist_binding(key, session)
150
+ @storage.put(key, {
151
+ "path" => session.fetch(:path),
152
+ "workspace_root" => session.fetch(:workspaceRoot)
153
+ })
154
+ end
155
+
156
+ def session_handle(session)
157
+ Host::SessionHandle.new(
158
+ id: session.fetch(:id),
159
+ workspace_root: session.fetch(:workspaceRoot),
160
+ name: session[:name]
161
+ )
162
+ end
163
+
164
+ def binding_key_for(conversation)
165
+ external_id = conversation.respond_to?(:external_id) ? conversation.external_id : conversation.to_s
166
+ digest = Digest::SHA256.hexdigest("#{@transport_id}\0#{external_id}")
167
+ "binding:#{digest}"
168
+ end
169
+
170
+ def handle_runtime_event(method, payload)
171
+ request = case method
172
+ when "ui/question"
173
+ questions = Array(payload[:questions] || payload["questions"])
174
+ Transport.interaction_request(
175
+ id: payload[:questionRequestId] || payload["questionRequestId"],
176
+ session_id: payload[:sessionId] || payload["sessionId"],
177
+ turn_id: payload[:turnId] || payload["turnId"] || "unknown",
178
+ kind: :question,
179
+ prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
180
+ choices: questions
181
+ )
182
+ when "tool/approvalRequested"
183
+ Transport.interaction_request(
184
+ id: payload[:approvalRequestId] || payload["approvalRequestId"],
185
+ session_id: payload[:sessionId] || payload["sessionId"],
186
+ turn_id: payload[:turnId] || payload["turnId"] || "unknown",
187
+ kind: :tool_approval,
188
+ prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
189
+ choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
190
+ metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
191
+ )
192
+ end
193
+ return unless request
194
+
195
+ subscribers = @mutex.synchronize { @interaction_subscribers.dup }
196
+ subscribers.each do |subscriber|
197
+ begin
198
+ subscriber.call(request)
199
+ rescue StandardError
200
+ nil
201
+ end
202
+ end
203
+ end
204
+
205
+ def profile_options(options, profile)
206
+ return options unless profile
207
+
208
+ options = options.dup
209
+ case profile.tool_mode
210
+ when :none
211
+ options.delete("disabledTools")
212
+ options.delete(:disabled_tools)
213
+ options["allowedTools"] = []
214
+ when :allowlist
215
+ options.delete("disabledTools")
216
+ options.delete(:disabled_tools)
217
+ options["allowedTools"] = profile.allowed_tools
218
+ end
219
+ options["approvalMode"] = "none" if profile.approval_mode == :deny
220
+ options["approvalMode"] = "ask" if profile.approval_mode == :ask
221
+ options
222
+ end
223
+
224
+ def normalize_attachments(attachments, execution_profile: nil)
225
+ if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
226
+ raise ArgumentError, "transport execution profile does not allow attachments"
227
+ end
228
+
229
+ Array(attachments).map do |attachment|
230
+ raise ArgumentError, "transport attachment must be a Transport::Attachment" unless attachment.is_a?(Attachment)
231
+ raise ArgumentError, "transport attachment URLs are not supported by the session gateway" if attachment.url
232
+
233
+ {
234
+ type: "image",
235
+ data: Base64.strict_encode64(attachment.data),
236
+ mimeType: attachment.mime_type,
237
+ name: attachment.name
238
+ }.compact
239
+ end
240
+ end
241
+
242
+ def normalize_event(event)
243
+ Transport.turn_event(
244
+ type: event.fetch(:type),
245
+ session_id: event.fetch(:sessionId),
246
+ turn_id: event.fetch(:turnId),
247
+ sequence: event.fetch(:sequence),
248
+ payload: event.fetch(:payload, {})
249
+ )
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,343 @@
1
+ require_relative "../deep_copy"
2
+ require_relative "store"
3
+
4
+ module Kward
5
+ module Transport
6
+ # Public dependencies made available to a running transport adapter.
7
+ class Host
8
+ class UnavailableGateway < StandardError; end
9
+ class PolicyDenied < StandardError; end
10
+
11
+ SessionHandle = Data.define(:id, :workspace_root, :name)
12
+ TurnHandle = Data.define(:id, :session_id)
13
+ PluginChatHandle = Data.define(:id, :type_id, :scope_key, :name)
14
+ PluginChatTurnHandle = Data.define(:id, :chat_id)
15
+
16
+ # Creates a host for one registered transport instance.
17
+ def initialize(transport_id:, gateway: nil, plugin_chat_gateway: nil, config: {}, storage: nil, policy: nil, logger: nil, execution_profile: nil)
18
+ unless execution_profile.nil? || execution_profile.is_a?(ExecutionProfile)
19
+ raise ArgumentError, "execution_profile must be a Transport::ExecutionProfile"
20
+ end
21
+
22
+ @transport_id = transport_id.to_s.freeze
23
+ @gateway = gateway
24
+ @plugin_chat_gateway = plugin_chat_gateway
25
+ @config = freeze_copy(config)
26
+ @storage = storage || Store.new(@transport_id)
27
+ @policy = policy || AllowAllPolicy.new
28
+ @execution_profile = execution_profile
29
+ @logger = logger || Logger.new($stderr)
30
+ @sessions = Sessions.new(self)
31
+ @plugin_chats = PluginChats.new(self)
32
+ @interactions = Interactions.new(self)
33
+ end
34
+
35
+ attr_reader :transport_id, :config, :storage, :policy, :execution_profile, :logger
36
+
37
+ def sessions
38
+ @sessions
39
+ end
40
+
41
+ def interactions
42
+ @interactions
43
+ end
44
+
45
+ def plugin_chats
46
+ @plugin_chats
47
+ end
48
+
49
+ def gateway
50
+ return @gateway if @gateway
51
+
52
+ raise UnavailableGateway, "transport host has no session gateway"
53
+ end
54
+
55
+ def authorize!(action, **attributes)
56
+ return true if policy.authorize(action: action, transport_id: @transport_id, **attributes) == true
57
+
58
+ raise PolicyDenied, "Transport policy denied #{action}"
59
+ end
60
+
61
+ def plugin_chat_gateway
62
+ return @plugin_chat_gateway if @plugin_chat_gateway
63
+
64
+ raise UnavailableGateway, "transport host has no plugin chat gateway"
65
+ end
66
+
67
+ # Reads a transport secret from private config or an explicit environment
68
+ # variable without exposing it through status or logging helpers.
69
+ def secret(key, env: nil)
70
+ key = key.to_s
71
+ raise ArgumentError, "secret key is required" if key.empty?
72
+
73
+ value = config[key]
74
+ value = ENV.fetch(env.to_s) if value.nil? && env
75
+ value = ENV[default_secret_env_name(key)] if value.nil?
76
+ value.to_s unless value.nil?
77
+ end
78
+
79
+ def shutdown
80
+ @gateway&.shutdown
81
+ @plugin_chat_gateway&.shutdown
82
+ nil
83
+ end
84
+
85
+ # Resolves an external conversation to a normal Kward session.
86
+ class Sessions
87
+ def initialize(host)
88
+ @host = host
89
+ end
90
+
91
+ def resolve(conversation:, actor:, workspace_root: nil, name: nil)
92
+ workspace_root = fixed_workspace_root(workspace_root)
93
+ @host.authorize!(:resolve_session, conversation: conversation, actor: actor, workspace_root: workspace_root)
94
+ result = @host.gateway.resolve_transport_session(
95
+ transport_id: @host.transport_id,
96
+ conversation: conversation,
97
+ actor: actor,
98
+ workspace_root: workspace_root,
99
+ name: name,
100
+ execution_profile: @host.execution_profile
101
+ )
102
+ Session.new(@host, normalize_handle(result), actor)
103
+ end
104
+
105
+ private
106
+
107
+ def fixed_workspace_root(workspace_root)
108
+ return workspace_root unless @host.execution_profile&.workspace_mode == :fixed
109
+
110
+ configured = @host.config["workspace"] || @host.config[:workspace]
111
+ raise ArgumentError, "fixed execution profile requires a configured workspace" if configured.to_s.empty?
112
+
113
+ configured.to_s
114
+ end
115
+
116
+ def normalize_handle(result)
117
+ return result if result.is_a?(SessionHandle)
118
+ return SessionHandle.new(**result.transform_keys(&:to_sym)) if result.is_a?(Hash)
119
+
120
+ raise TypeError, "session gateway returned an invalid session handle"
121
+ end
122
+ end
123
+
124
+ # Narrow session operations exposed to a transport.
125
+ class Session
126
+ attr_reader :id, :workspace_root, :name
127
+
128
+ def initialize(host, handle, actor)
129
+ @host = host
130
+ @actor = actor
131
+ @id = handle.id.to_s.freeze
132
+ @workspace_root = handle.workspace_root.to_s.freeze
133
+ @name = handle.name&.to_s&.freeze
134
+ end
135
+
136
+ def start_turn(input, attachments: [], options: {}, streaming_behavior: nil)
137
+ @host.authorize!(:start_turn, actor: @actor, session_id: @id, input: input.to_s)
138
+ result = @host.gateway.start_transport_turn(
139
+ session_id: @id,
140
+ input: input.to_s,
141
+ attachments: attachments,
142
+ options: options,
143
+ streaming_behavior: streaming_behavior,
144
+ execution_profile: @host.execution_profile
145
+ )
146
+ handle = result.is_a?(TurnHandle) ? result : TurnHandle.new(**result.transform_keys(&:to_sym))
147
+ Turn.new(@host, handle, @actor)
148
+ end
149
+
150
+ def transcript
151
+ @host.authorize!(:read_transcript, actor: @actor, session_id: @id)
152
+ @host.gateway.transport_transcript(session_id: @id)
153
+ end
154
+
155
+ def answer_interaction(request_id:, answer:)
156
+ if @host.execution_profile&.approval_mode == :deny
157
+ raise PolicyDenied, "Transport execution profile does not allow interactions"
158
+ end
159
+
160
+ @host.authorize!(:answer_interaction, actor: @actor, session_id: @id, request_id: request_id)
161
+ @host.gateway.answer_transport_interaction(session_id: @id, request_id: request_id, answer: answer)
162
+ end
163
+ end
164
+
165
+ # Resolves an external conversation to a plugin-owned chat.
166
+ class PluginChats
167
+ def initialize(host)
168
+ @host = host
169
+ end
170
+
171
+ def resolve(type_id:, conversation:, actor:, scope_key: nil, descriptor: {})
172
+ scope_key ||= conversation.external_id
173
+ @host.authorize!(:resolve_plugin_chat, type_id: type_id.to_s, conversation: conversation, actor: actor, scope_key: scope_key)
174
+ result = @host.plugin_chat_gateway.resolve_transport_chat(
175
+ transport_id: @host.transport_id,
176
+ type_id: type_id,
177
+ conversation: conversation,
178
+ actor: actor,
179
+ scope_key: scope_key,
180
+ descriptor: descriptor,
181
+ workspace_root: workspace_root
182
+ )
183
+ PluginChat.new(@host, normalize_handle(result), actor)
184
+ end
185
+
186
+ private
187
+
188
+ def workspace_root
189
+ if @host.execution_profile&.workspace_mode == :fixed
190
+ configured = @host.config["workspace"] || @host.config[:workspace]
191
+ raise ArgumentError, "fixed execution profile requires a configured workspace" if configured.to_s.empty?
192
+
193
+ return configured.to_s
194
+ end
195
+
196
+ @host.config["workspace"] || @host.config[:workspace] || Dir.pwd
197
+ end
198
+
199
+ def normalize_handle(result)
200
+ return result if result.is_a?(PluginChatHandle)
201
+ return PluginChatHandle.new(**result.transform_keys(&:to_sym)) if result.is_a?(Hash)
202
+
203
+ raise TypeError, "plugin chat gateway returned an invalid chat handle"
204
+ end
205
+ end
206
+
207
+ # Narrow plugin-chat operations exposed to a transport.
208
+ class PluginChat
209
+ attr_reader :id, :type_id, :scope_key, :name
210
+
211
+ def initialize(host, handle, actor)
212
+ @host = host
213
+ @actor = actor
214
+ @id = handle.id.to_s.freeze
215
+ @type_id = handle.type_id.to_s.freeze
216
+ @scope_key = handle.scope_key.to_s.freeze
217
+ @name = handle.name&.to_s&.freeze
218
+ end
219
+
220
+ def start_turn(input, attachments: [], options: {}, streaming_behavior: nil)
221
+ @host.authorize!(:start_plugin_chat_turn, actor: @actor, chat_id: @id, type_id: @type_id, input: input.to_s)
222
+ if @host.execution_profile && !@host.execution_profile.attachments && !Array(attachments).empty?
223
+ raise PolicyDenied, "Transport execution profile does not allow attachments"
224
+ end
225
+ unless options.nil? || options.empty?
226
+ raise ArgumentError, "plugin chat turns do not support options"
227
+ end
228
+ unless streaming_behavior.nil? || %i[aggregate none].include?(streaming_behavior.to_sym)
229
+ raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
230
+ end
231
+
232
+ result = @host.plugin_chat_gateway.start_transport_plugin_chat_turn(
233
+ chat_id: @id,
234
+ actor: @actor,
235
+ input: input,
236
+ attachments: attachments,
237
+ streaming_behavior: streaming_behavior,
238
+ execution_profile: @host.execution_profile
239
+ )
240
+ handle = result.is_a?(PluginChatTurnHandle) ? result : PluginChatTurnHandle.new(**result.transform_keys(&:to_sym))
241
+ PluginChatTurn.new(@host, handle, @actor)
242
+ end
243
+
244
+ def transcript
245
+ @host.authorize!(:read_plugin_chat_transcript, actor: @actor, chat_id: @id, type_id: @type_id)
246
+ @host.plugin_chat_gateway.transport_plugin_chat_transcript(chat_id: @id)
247
+ end
248
+ end
249
+
250
+ # Handle for an asynchronously running plugin-chat turn.
251
+ class PluginChatTurn
252
+ attr_reader :id, :chat_id
253
+
254
+ def initialize(host, handle, actor)
255
+ @host = host
256
+ @actor = actor
257
+ @id = handle.id.to_s.freeze
258
+ @chat_id = handle.chat_id.to_s.freeze
259
+ end
260
+
261
+ def subscribe(after: nil, &block)
262
+ raise ArgumentError, "plugin chat turn subscription requires a block" unless block
263
+
264
+ @host.plugin_chat_gateway.subscribe_transport_plugin_chat_turn(turn_id: @id, after: after, &block)
265
+ end
266
+
267
+ def events(after: nil)
268
+ @host.plugin_chat_gateway.transport_plugin_chat_turn_events(turn_id: @id, after: after)
269
+ end
270
+
271
+ def status
272
+ @host.plugin_chat_gateway.transport_plugin_chat_turn_status(turn_id: @id)
273
+ end
274
+
275
+ def cancel
276
+ @host.authorize!(:cancel_plugin_chat_turn, actor: @actor, chat_id: @chat_id, turn_id: @id)
277
+ @host.plugin_chat_gateway.cancel_transport_plugin_chat_turn(turn_id: @id)
278
+ end
279
+ end
280
+
281
+ # Subscribes to questions and tool approval requests for this transport.
282
+ class Interactions
283
+ def initialize(host)
284
+ @host = host
285
+ end
286
+
287
+ def subscribe(&block)
288
+ raise ArgumentError, "interaction subscription requires a block" unless block
289
+
290
+ @host.gateway.subscribe_transport_interactions(&block)
291
+ end
292
+ end
293
+
294
+ # Handle for an asynchronously running Kward turn.
295
+ class Turn
296
+ attr_reader :id, :session_id
297
+
298
+ def initialize(host, handle, actor)
299
+ @host = host
300
+ @actor = actor
301
+ @id = handle.id.to_s.freeze
302
+ @session_id = handle.session_id.to_s.freeze
303
+ end
304
+
305
+ def subscribe(after: nil, &block)
306
+ raise ArgumentError, "turn subscription requires a block" unless block
307
+
308
+ @host.gateway.subscribe_transport_turn(turn_id: @id, after: after, &block)
309
+ end
310
+
311
+ def events(after: nil)
312
+ @host.gateway.transport_turn_events(turn_id: @id, after: after)
313
+ end
314
+
315
+ def status
316
+ @host.gateway.transport_turn_status(turn_id: @id)
317
+ end
318
+
319
+ def cancel
320
+ @host.authorize!(:cancel_turn, actor: @actor, session_id: @session_id, turn_id: @id)
321
+ @host.gateway.cancel_transport_turn(turn_id: @id)
322
+ end
323
+ end
324
+
325
+ # Default policy used by the host until a configured policy is supplied.
326
+ class AllowAllPolicy
327
+ def authorize(**)
328
+ true
329
+ end
330
+ end
331
+
332
+ private
333
+
334
+ def default_secret_env_name(key)
335
+ "KWARD_TRANSPORT_#{@transport_id.gsub(/[^A-Za-z0-9]/, "_").upcase}_#{key.gsub(/[^A-Za-z0-9]/, "_").upcase}"
336
+ end
337
+
338
+ def freeze_copy(value)
339
+ DeepCopy.freeze(DeepCopy.dup(value))
340
+ end
341
+ end
342
+ end
343
+ end