kward 0.78.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -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
@@ -0,0 +1,179 @@
1
+ require "thread"
2
+ require "time"
3
+ require_relative "host"
4
+
5
+ module Kward
6
+ module Transport
7
+ # Starts and supervises registered transport adapters.
8
+ class Manager
9
+ Entry = Struct.new(:type, :host, :adapter, :state, :error, :started_at, :stopped_at, keyword_init: true)
10
+
11
+ def initialize(registry:, gateway: nil, plugin_chat_gateway: nil, config_root: nil, config_provider: nil, policy: nil, logger: nil)
12
+ @registry = registry
13
+ @gateway = gateway
14
+ @plugin_chat_gateway = plugin_chat_gateway
15
+ @config_root = config_root
16
+ @config_provider = config_provider
17
+ @policy = policy
18
+ @logger = logger || Logger.new($stderr)
19
+ @entries = {}
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def list
24
+ @registry.transports.map { |type| descriptor(type) }
25
+ end
26
+
27
+ def status(name_or_id = nil)
28
+ return status_for(resolve_type(name_or_id)) if name_or_id
29
+
30
+ list.map { |type| status_for(type) }
31
+ end
32
+
33
+ def start(name_or_id, config: nil, workspace_root: nil)
34
+ type = resolve_type(name_or_id)
35
+ @mutex.synchronize do
36
+ raise "Transport #{type.id} is already running" if running_entry?(type.id)
37
+ end
38
+
39
+ config ||= @config_provider&.call(type.id) || {}
40
+ config = config.merge("workspace" => workspace_root.to_s) if workspace_root
41
+ gateway = @gateway.respond_to?(:call) ? @gateway.call(type.id) : @gateway
42
+ plugin_chat_gateway = @plugin_chat_gateway.respond_to?(:call) ? @plugin_chat_gateway.call(type.id) : @plugin_chat_gateway
43
+ host = Host.new(
44
+ transport_id: type.id,
45
+ gateway: gateway,
46
+ plugin_chat_gateway: plugin_chat_gateway,
47
+ config: config,
48
+ storage: @config_root && Store.new(type.id, root: @config_root),
49
+ policy: @policy,
50
+ logger: @logger,
51
+ execution_profile: type.execution_profile
52
+ )
53
+ adapter = type.handler.call(host, host.config)
54
+ unless adapter.respond_to?(:start) && adapter.respond_to?(:stop)
55
+ raise "Transport #{type.id} must implement start and stop"
56
+ end
57
+
58
+ entry = Entry.new(type: type, host: host, adapter: adapter, state: "starting")
59
+ @mutex.synchronize { @entries[type.id] = entry }
60
+ begin
61
+ adapter.start
62
+ @mutex.synchronize do
63
+ entry.state = "running"
64
+ entry.started_at = Time.now.utc
65
+ entry.error = nil
66
+ end
67
+ adapter
68
+ rescue StandardError => e
69
+ @mutex.synchronize do
70
+ entry.state = "failed"
71
+ entry.error = e.message
72
+ entry.stopped_at = Time.now.utc
73
+ end
74
+ log_error(type, e)
75
+ raise
76
+ end
77
+ end
78
+
79
+ def stop(name_or_id)
80
+ type = resolve_type(name_or_id)
81
+ entry = @mutex.synchronize { @entries[type.id] }
82
+ return false unless entry
83
+
84
+ begin
85
+ entry.adapter.stop
86
+ ensure
87
+ entry.host.shutdown
88
+ @mutex.synchronize do
89
+ entry.state = "stopped"
90
+ entry.stopped_at = Time.now.utc
91
+ end
92
+ end
93
+ true
94
+ end
95
+
96
+ def restart(name_or_id, config: nil)
97
+ stop(name_or_id)
98
+ start(name_or_id, config: config)
99
+ end
100
+
101
+ def reload(registry)
102
+ shutdown
103
+ @mutex.synchronize { @registry = registry }
104
+ list
105
+ end
106
+
107
+ def shutdown
108
+ entries = @mutex.synchronize { @entries.values.dup }
109
+ entries.each { |entry| stop(entry.type.id) if entry.state == "running" || entry.state == "starting" }
110
+ nil
111
+ end
112
+
113
+ private
114
+
115
+ def resolve_type(name_or_id)
116
+ type = @registry.transport_for(name_or_id) || @registry.transport_for_id(name_or_id)
117
+ raise ArgumentError, "Unknown transport: #{name_or_id}" unless type
118
+
119
+ type
120
+ end
121
+
122
+ def running_entry?(id)
123
+ entry = @entries[id]
124
+ entry && %w[starting running].include?(entry.state)
125
+ end
126
+
127
+ def descriptor(type)
128
+ {
129
+ id: type.id,
130
+ name: type.name,
131
+ capabilities: type.capabilities.to_h,
132
+ execution_profile: profile_payload(type.execution_profile),
133
+ path: type.path
134
+ }
135
+ end
136
+
137
+ def status_for(type)
138
+ entry = @mutex.synchronize { @entries[type.id] }
139
+ descriptor(type).merge(
140
+ state: entry&.state || "stopped",
141
+ error: entry&.error,
142
+ health: health_for(entry),
143
+ started_at: entry&.started_at,
144
+ stopped_at: entry&.stopped_at
145
+ )
146
+ end
147
+
148
+ def profile_payload(profile)
149
+ return nil unless profile
150
+
151
+ {
152
+ id: profile.id,
153
+ tool_mode: profile.tool_mode,
154
+ allowed_tools: profile.allowed_tools,
155
+ disabled_tools: profile.disabled_tools,
156
+ plugin_commands: profile.plugin_commands,
157
+ approval_mode: profile.approval_mode,
158
+ memory: profile.memory,
159
+ attachments: profile.attachments,
160
+ workspace_mode: profile.workspace_mode
161
+ }
162
+ end
163
+
164
+ def health_for(entry)
165
+ return nil unless entry&.adapter&.respond_to?(:health)
166
+
167
+ entry.adapter.health
168
+ rescue StandardError => e
169
+ { state: "error", message: e.message }
170
+ end
171
+
172
+ def log_error(type, error)
173
+ @logger.error("Transport #{type.id} failed to start: #{error.class}: #{error.message}")
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,186 @@
1
+ require "base64"
2
+ require "thread"
3
+ require_relative "../plugin_chat_runtime"
4
+
5
+ module Kward
6
+ module Transport
7
+ # Adapts the shared plugin-chat runtime to the transport host contract.
8
+ class PluginChatGateway
9
+ POLL_INTERVAL = 0.05
10
+ TERMINAL_STATUSES = %w[completed canceled failed].freeze
11
+ IMAGE_MIME_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
12
+
13
+ def initialize(runtime:, transport_id:, poll_interval: POLL_INTERVAL)
14
+ @runtime = runtime
15
+ @transport_id = transport_id.to_s
16
+ @poll_interval = poll_interval
17
+ @subscriptions = []
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def resolve_transport_chat(transport_id:, type_id:, conversation:, actor:, scope_key:, descriptor: {}, workspace_root: nil)
22
+ validate_transport_id!(transport_id)
23
+ validate_conversation!(conversation)
24
+ validate_actor!(actor)
25
+ chat = @runtime.open(
26
+ type_id: type_id,
27
+ surface: :transport,
28
+ scope_key: scope_key,
29
+ descriptor: transport_descriptor(conversation, actor, descriptor),
30
+ workspace_root: workspace_root || Dir.pwd
31
+ )
32
+ Host::PluginChatHandle.new(
33
+ id: chat.id,
34
+ type_id: chat.type.id,
35
+ scope_key: chat.scope_key,
36
+ name: chat.type.title
37
+ )
38
+ end
39
+
40
+ def start_transport_plugin_chat_turn(chat_id:, actor:, input:, attachments: [], streaming_behavior: nil, execution_profile: nil)
41
+ if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
42
+ raise ArgumentError, "transport execution profile does not allow attachments"
43
+ end
44
+ unless streaming_behavior.nil? || %i[aggregate none].include?(streaming_behavior.to_sym)
45
+ raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
46
+ end
47
+
48
+ turn = @runtime.start_turn(
49
+ chat_id: chat_id,
50
+ input: @runtime.input_with_attachments(input, normalize_attachments(attachments)),
51
+ display_input: input.to_s,
52
+ context: { actor: actor }
53
+ )
54
+ Host::PluginChatTurnHandle.new(id: turn.id, chat_id: turn.chat_id)
55
+ end
56
+
57
+ def transport_plugin_chat_transcript(chat_id:)
58
+ chat = fetch_chat(chat_id)
59
+ { messages: chat.driver.messages }
60
+ end
61
+
62
+ def transport_plugin_chat_turn_events(turn_id:, after: nil)
63
+ @runtime.turn_events(turn_id: turn_id, after_sequence: after.to_i).map do |event|
64
+ Transport.turn_event(
65
+ type: event.fetch(:type),
66
+ session_id: event.fetch(:chatId),
67
+ turn_id: event.fetch(:turnId),
68
+ sequence: event.fetch(:sequence),
69
+ payload: event.fetch(:payload, {})
70
+ )
71
+ end
72
+ end
73
+
74
+ def transport_plugin_chat_turn_status(turn_id:)
75
+ turn_payload(@runtime.turn_status(turn_id: turn_id))
76
+ end
77
+
78
+ def cancel_transport_plugin_chat_turn(turn_id:)
79
+ @runtime.cancel_turn(turn_id: turn_id)
80
+ nil
81
+ end
82
+
83
+ def subscribe_transport_plugin_chat_turn(turn_id:, after: nil)
84
+ cursor = after.to_i
85
+ thread = Thread.new do
86
+ loop do
87
+ events = transport_plugin_chat_turn_events(turn_id: turn_id, after: cursor)
88
+ events.each do |event|
89
+ cursor = event.sequence
90
+ yield event
91
+ end
92
+ status = transport_plugin_chat_turn_status(turn_id: turn_id)
93
+ break if TERMINAL_STATUSES.include?(status[:status].to_s)
94
+
95
+ sleep @poll_interval
96
+ end
97
+ rescue StandardError
98
+ nil
99
+ end
100
+ @mutex.synchronize { @subscriptions << thread }
101
+ thread
102
+ end
103
+
104
+ def shutdown
105
+ subscriptions = @mutex.synchronize do
106
+ current = @subscriptions
107
+ @subscriptions = []
108
+ current
109
+ end
110
+ subscriptions.each(&:kill)
111
+ subscriptions.each(&:join)
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def validate_transport_id!(transport_id)
118
+ return if transport_id.to_s == @transport_id
119
+
120
+ raise ArgumentError, "transport id does not match plugin chat gateway"
121
+ end
122
+
123
+ def validate_conversation!(conversation)
124
+ unless conversation.is_a?(ConversationKey)
125
+ raise ArgumentError, "conversation must be a Transport::ConversationKey"
126
+ end
127
+ return if conversation.transport_id == @transport_id
128
+
129
+ raise ArgumentError, "conversation transport does not match plugin chat gateway"
130
+ end
131
+
132
+ def validate_actor!(actor)
133
+ return if actor.is_a?(Actor)
134
+
135
+ raise ArgumentError, "actor must be a Transport::Actor"
136
+ end
137
+
138
+ def fetch_chat(chat_id)
139
+ @runtime.chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
140
+ end
141
+
142
+ def transport_descriptor(conversation, actor, descriptor)
143
+ {
144
+ "kind" => "transport",
145
+ "transport_id" => @transport_id,
146
+ "conversation" => conversation.to_h,
147
+ "actor" => actor.to_h
148
+ }.merge(descriptor.transform_keys(&:to_s))
149
+ end
150
+
151
+ def normalize_attachments(attachments)
152
+ Array(attachments).map do |attachment|
153
+ unless attachment.is_a?(Attachment)
154
+ raise ArgumentError, "transport attachment must be a Transport::Attachment"
155
+ end
156
+ if attachment.url
157
+ raise ArgumentError, "plugin chat attachment URLs are not supported"
158
+ end
159
+ unless IMAGE_MIME_TYPES.include?(attachment.mime_type.to_s.downcase)
160
+ raise ArgumentError, "unsupported plugin chat attachment MIME type: #{attachment.mime_type}"
161
+ end
162
+
163
+ {
164
+ mimeType: attachment.mime_type,
165
+ data: Base64.strict_encode64(attachment.data),
166
+ alt: attachment.name
167
+ }.compact
168
+ end
169
+ end
170
+
171
+ def turn_payload(turn)
172
+ turn.mutex.synchronize do
173
+ {
174
+ id: turn.id,
175
+ chatId: turn.chat_id,
176
+ status: turn.status,
177
+ createdAt: turn.created_at,
178
+ startedAt: turn.started_at,
179
+ finishedAt: turn.finished_at,
180
+ error: turn.error
181
+ }.compact
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end