kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -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/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -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 +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -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 +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -0,0 +1,374 @@
1
+ require "digest"
2
+ require "securerandom"
3
+ require "thread"
4
+ require_relative "cancellation"
5
+ require_relative "events"
6
+ require_relative "plugin_registry"
7
+ require_relative "tab_driver"
8
+ require_relative "tools/tool_call"
9
+
10
+ # Frontend-neutral runtime for trusted plugin-owned conversational drivers.
11
+ module Kward
12
+ # Coordinates plugin chat drivers without depending on RPC or the terminal UI.
13
+ # Frontends use this runtime for queuing, cancellation, event replay, and
14
+ # transcript access while the plugin remains responsible for its own storage,
15
+ # model settings, and domain behavior.
16
+ class PluginChatRuntime
17
+ EVENT_LIMIT = 1_000
18
+ WORKER_STOP = Object.new.freeze
19
+
20
+ Chat = Struct.new(
21
+ :id,
22
+ :type,
23
+ :driver,
24
+ :queue,
25
+ :worker,
26
+ :running_turn_id,
27
+ :scope_key,
28
+ :descriptor,
29
+ :workspace_root,
30
+ keyword_init: true
31
+ )
32
+ Turn = Struct.new(
33
+ :id,
34
+ :chat_id,
35
+ :input,
36
+ :display_input,
37
+ :context,
38
+ :status,
39
+ :cancellation,
40
+ :created_at,
41
+ :started_at,
42
+ :finished_at,
43
+ :events,
44
+ :next_sequence,
45
+ :error,
46
+ :mutex,
47
+ keyword_init: true
48
+ )
49
+
50
+ def initialize(client:, plugin_registry_provider: nil, message_normalizer: nil)
51
+ @client = client
52
+ @plugin_registry_provider = plugin_registry_provider
53
+ @message_normalizer = message_normalizer
54
+ @chats = {}
55
+ @turns = {}
56
+ @event_listeners = []
57
+ @mutex = Mutex.new
58
+ end
59
+
60
+ def supported_types(surface: :rpc)
61
+ case surface.to_sym
62
+ when :rpc
63
+ plugin_registry.tab_types.select(&:rpc)
64
+ when :transport
65
+ plugin_registry.transport_tab_types
66
+ else
67
+ raise ArgumentError, "Unknown plugin chat surface: #{surface}"
68
+ end
69
+ end
70
+
71
+ def subscribe_events(&block)
72
+ raise ArgumentError, "plugin chat event subscription requires a block" unless block
73
+
74
+ @mutex.synchronize { @event_listeners << block }
75
+ block
76
+ end
77
+
78
+ def open(type_id:, surface: :rpc, scope_key: nil, descriptor: {}, workspace_root: Dir.pwd)
79
+ type = supported_types(surface: surface).find { |entry| entry.id == type_id.to_s }
80
+ raise ArgumentError, "Unknown #{surface} plugin chat: #{type_id}" unless type
81
+
82
+ chat_for(type, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
83
+ end
84
+
85
+ def chat(chat_id)
86
+ @mutex.synchronize { @chats[chat_id.to_s] }
87
+ end
88
+
89
+ def start_turn(chat_id:, input:, display_input: nil, context: nil)
90
+ chat = fetch_chat(chat_id)
91
+ turn = Turn.new(
92
+ id: SecureRandom.uuid,
93
+ chat_id: chat.id,
94
+ input: input,
95
+ display_input: display_input.nil? ? input.to_s : display_input.to_s,
96
+ context: context || {},
97
+ status: "queued",
98
+ cancellation: Cancellation.new,
99
+ created_at: Time.now.utc.iso8601(3),
100
+ events: [],
101
+ next_sequence: 1,
102
+ mutex: Mutex.new
103
+ )
104
+ @mutex.synchronize { @turns[turn.id] = turn }
105
+ chat.queue << turn.id
106
+ ensure_worker(chat)
107
+ emit_event(turn, "turnQueued", { status: "queued" })
108
+ turn
109
+ end
110
+
111
+ def cancel_turn(turn_id:)
112
+ turn = fetch_turn(turn_id)
113
+ queued = turn.mutex.synchronize do
114
+ turn.cancellation.cancel!
115
+ turn.status == "queued"
116
+ end
117
+ emit_event(turn, "turnCancelRequested", {})
118
+ finish_turn(turn, "canceled") if queued
119
+ turn
120
+ end
121
+
122
+ def turn_status(turn_id:)
123
+ fetch_turn(turn_id)
124
+ end
125
+
126
+ def turn_events(turn_id:, after_sequence: 0)
127
+ turn = fetch_turn(turn_id)
128
+ turn.mutex.synchronize do
129
+ turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i }
130
+ end
131
+ end
132
+
133
+ def list_turns(chat_id: nil, active: false)
134
+ turns = @mutex.synchronize { @turns.values.dup }
135
+ turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
136
+ turns.select! { |turn| %w[queued running].include?(turn.status) } if active
137
+ turns
138
+ end
139
+
140
+ def shutdown
141
+ chats = @mutex.synchronize { @chats.values.dup }
142
+ chats.each do |chat|
143
+ chat.queue << WORKER_STOP if chat.worker&.alive?
144
+ chat.worker&.join(0.2)
145
+ end
146
+ nil
147
+ end
148
+
149
+ # Converts normalized image attachment hashes into the input shape accepted
150
+ # by plugin chat drivers. RPC and transport frontends can normalize their
151
+ # own boundary formats before calling this helper.
152
+ def input_with_attachments(input, attachments)
153
+ attachments = Array(attachments)
154
+ return input.to_s if attachments.empty?
155
+
156
+ [{ type: "text", text: input.to_s }] + attachments.map do |attachment|
157
+ {
158
+ type: "image",
159
+ media_type: attachment.fetch(:mimeType) { attachment.fetch("mimeType") },
160
+ data: attachment.fetch(:data) { attachment.fetch("data") },
161
+ alt: attachment[:alt] || attachment["alt"]
162
+ }.compact
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def plugin_registry
169
+ return @plugin_registry_provider.call if @plugin_registry_provider
170
+
171
+ @plugin_registry ||= PluginRegistry.load
172
+ end
173
+
174
+ def chat_for(type, scope_key:, descriptor:, workspace_root:)
175
+ scope_key = normalize_scope_key(scope_key)
176
+ chat_id = chat_id_for(type, scope_key)
177
+ @mutex.synchronize do
178
+ @chats[chat_id] ||= begin
179
+ descriptor = {
180
+ "kind" => "plugin",
181
+ "plugin_tab_type" => type.id,
182
+ "label" => type.title,
183
+ "scope_key" => scope_key
184
+ }.merge(descriptor.transform_keys(&:to_s))
185
+ host = PluginTabHost.new(client: @client, workspace_root: workspace_root)
186
+ driver = type.handler.call(host, descriptor)
187
+ raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
188
+
189
+ Chat.new(
190
+ id: chat_id,
191
+ type: type,
192
+ driver: driver,
193
+ queue: Queue.new,
194
+ scope_key: scope_key,
195
+ descriptor: descriptor,
196
+ workspace_root: workspace_root
197
+ )
198
+ end
199
+ end
200
+ end
201
+
202
+ def normalize_scope_key(scope_key)
203
+ value = scope_key.to_s
204
+ value.empty? ? "default" : value
205
+ end
206
+
207
+ def chat_id_for(type, scope_key)
208
+ return type.id if type.singleton == :global
209
+ return type.id if scope_key == "default"
210
+
211
+ "#{type.id}:#{Digest::SHA256.hexdigest(scope_key)[0, 16]}"
212
+ end
213
+
214
+ def fetch_chat(chat_id)
215
+ chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
216
+ end
217
+
218
+ def fetch_turn(turn_id)
219
+ @mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
220
+ end
221
+
222
+ def ensure_worker(chat)
223
+ return if chat.worker&.alive?
224
+
225
+ chat.worker = Thread.new do
226
+ loop do
227
+ turn_id = chat.queue.pop
228
+ break if turn_id.equal?(WORKER_STOP)
229
+
230
+ turn = fetch_turn(turn_id)
231
+ run_turn(chat, turn) unless turn.cancellation.cancelled?
232
+ end
233
+ end
234
+ chat.worker.report_on_exception = false
235
+ end
236
+
237
+ def run_turn(chat, turn)
238
+ turn.mutex.synchronize do
239
+ return if terminal?(turn)
240
+
241
+ turn.status = "running"
242
+ turn.started_at = Time.now.utc.iso8601(3)
243
+ end
244
+ chat.running_turn_id = turn.id
245
+ emit_event(turn, "turnStarted", { status: "running" })
246
+ submit_options = {
247
+ display_input: turn.display_input,
248
+ cancellation: turn.cancellation
249
+ }
250
+ if !turn.context.empty? && accepts_context?(chat.driver)
251
+ submit_options[:context] = turn.context
252
+ end
253
+ chat.driver.submit(turn.input, **submit_options) do |event|
254
+ handle_driver_event(chat, turn, event)
255
+ end
256
+ finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
257
+ rescue Cancellation::CancelledError
258
+ finish_turn(turn, "canceled")
259
+ rescue StandardError => e
260
+ turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
261
+ finish_turn(turn, "failed")
262
+ ensure
263
+ chat.running_turn_id = nil if chat.running_turn_id == turn.id
264
+ end
265
+
266
+ def accepts_context?(driver)
267
+ driver.method(:submit).parameters.any? do |kind, name|
268
+ kind == :keyrest || ((kind == :key || kind == :keyreq) && name == :context)
269
+ end
270
+ end
271
+
272
+ def handle_driver_event(chat, turn, event)
273
+ notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
274
+
275
+ type, payload = case event
276
+ when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
277
+ when Events::ReasoningBoundary then ["reasoningBoundary", {}]
278
+ when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
279
+ when Events::AssistantMessage then ["assistantMessage", { message: normalized_assistant_message(event.message) }]
280
+ when Events::Retry then ["modelRetry", retry_event_payload(event)]
281
+ when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
282
+ when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
283
+ when Events::Answer then ["answer", { content: event.content }]
284
+ end
285
+ emit_event(turn, type, payload) if type
286
+ end
287
+
288
+ def notify_plugin_tab_transcript_event(chat, event)
289
+ return if plugin_registry.transcript_event_handlers.empty?
290
+
291
+ context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: chat.workspace_root)
292
+ plugin_registry.notify_transcript_event(event, context)
293
+ end
294
+
295
+ def normalized_assistant_message(message)
296
+ @message_normalizer ? @message_normalizer.call(message) : message
297
+ end
298
+
299
+ def retry_event_payload(event)
300
+ {
301
+ provider: event.provider,
302
+ model: event.model,
303
+ attempt: event.attempt,
304
+ maxAttempts: event.max_attempts,
305
+ delaySeconds: event.delay_seconds,
306
+ error: event.error,
307
+ requestBytes: event.request_bytes
308
+ }.compact
309
+ end
310
+
311
+ def tool_call_payload(tool_call)
312
+ {
313
+ toolCallId: ToolCall.id(tool_call),
314
+ toolName: ToolCall.name(tool_call),
315
+ args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call))
316
+ }.compact
317
+ end
318
+
319
+ def tool_result_payload(tool_call, content)
320
+ {
321
+ toolCallId: ToolCall.id(tool_call),
322
+ toolName: ToolCall.name(tool_call),
323
+ result: {
324
+ content: content.to_s,
325
+ isError: content.to_s.start_with?("Unknown", "Invalid", "Research tool failed")
326
+ }
327
+ }.compact
328
+ end
329
+
330
+ def finish_turn(turn, status)
331
+ event = turn.mutex.synchronize do
332
+ next nil if terminal?(turn)
333
+
334
+ turn.status = status
335
+ turn.finished_at = Time.now.utc.iso8601(3)
336
+ append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
337
+ end
338
+ notify_event(event) if event
339
+ end
340
+
341
+ def emit_event(turn, type, payload)
342
+ event = turn.mutex.synchronize { append_event(turn, type, payload) }
343
+ notify_event(event)
344
+ end
345
+
346
+ def append_event(turn, type, payload)
347
+ event = {
348
+ sequence: turn.next_sequence,
349
+ timestamp: Time.now.utc.iso8601(3),
350
+ chatId: turn.chat_id,
351
+ turnId: turn.id,
352
+ type: type,
353
+ payload: payload
354
+ }
355
+ turn.next_sequence += 1
356
+ turn.events << event
357
+ turn.events.shift while turn.events.length > EVENT_LIMIT
358
+ event
359
+ end
360
+
361
+ def notify_event(event)
362
+ listeners = @mutex.synchronize { @event_listeners.dup }
363
+ listeners.each do |listener|
364
+ listener.call(event)
365
+ rescue StandardError
366
+ nil
367
+ end
368
+ end
369
+
370
+ def terminal?(turn)
371
+ %w[completed failed canceled].include?(turn.status)
372
+ end
373
+ end
374
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative "config_files"
2
2
  require_relative "deep_copy"
3
3
  require_relative "hooks"
4
+ require_relative "transport"
4
5
 
5
6
  # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
@@ -36,7 +37,11 @@ module Kward
36
37
 
37
38
  # Registered plugin-owned tab runtime. Its factory receives a
38
39
  # `PluginTabHost` and its persisted descriptor, then returns a driver.
39
- TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
40
+ TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transport, :local, :transcript_events, :path, :handler, keyword_init: true)
41
+
42
+ # Registered external transport. The factory receives a transport host and
43
+ # configuration when the transport is started, not while plugins load.
44
+ TransportType = Struct.new(:id, :name, :capabilities, :execution_profile, :path, :handler, keyword_init: true)
40
45
 
41
46
  # Read-only event passed to plugin transcript observers.
42
47
  TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
@@ -269,21 +274,38 @@ module Kward
269
274
  @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
270
275
  end
271
276
 
272
- # Registers a plugin-owned tab type for the interactive CLI. `id` is a
273
- # durable identifier used in persisted tab layouts and must not change.
277
+ # Registers a plugin-owned chat type. `id` is a durable identifier used
278
+ # in persisted tab layouts and transport chat handles and must not change.
274
279
  # The factory receives a `PluginTabHost` and a descriptor hash.
275
280
  #
276
281
  # @param name [String] command name used by `/tab open <name>`
277
282
  # @param id [String] stable persisted tab type identifier
278
283
  # @param title [String] default tab label
279
284
  # @param singleton [Symbol] `:global` for one shared plugin runtime
285
+ # @param rpc [Boolean] expose this chat through trusted local RPC
286
+ # @param transport [Boolean] allow external transport adapters to target this chat
287
+ # @param local [Boolean] expose this chat as an interactive local tab
280
288
  # @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
281
289
  # @yieldparam host [PluginTabHost] supported host dependencies
282
290
  # @yieldparam descriptor [Hash] persisted tab descriptor
283
291
  # @return [void]
284
292
  # @api public
285
- def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
286
- @registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
293
+ def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, &block)
294
+ @registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transport: transport, local: local, transcript_events: transcript_events, path: @path, &block)
295
+ end
296
+
297
+ # Registers an external messaging or event transport. The factory is
298
+ # called when the transport runtime starts.
299
+ #
300
+ # @param name [String] human-readable transport name
301
+ # @param id [String] stable transport identifier
302
+ # @param capabilities [Hash, Transport::Capabilities] supported features
303
+ # @yieldparam host [Object] transport host
304
+ # @yieldparam config [Object] transport configuration
305
+ # @return [void]
306
+ # @api public
307
+ def transport(name, id:, capabilities: nil, execution_profile: nil, &block)
308
+ @registry.register_transport(name, id: id, capabilities: capabilities, execution_profile: execution_profile, path: @path, &block)
287
309
  end
288
310
  end
289
311
 
@@ -291,20 +313,25 @@ module Kward
291
313
  class << self
292
314
  attr_accessor :loading_registry, :loading_path
293
315
 
294
- def load(paths: ConfigFiles.plugin_paths, reserved_commands: [])
295
- registry = new(reserved_commands: reserved_commands)
316
+ def load(paths: nil, reserved_commands: [], warning_sink: nil)
317
+ warning_sink ||= ConfigFiles.warning_sink
318
+ paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
319
+ registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
296
320
  paths.each { |path| registry.load_file(path) }
297
321
  registry
298
322
  end
299
323
  end
300
324
 
301
325
  # Creates an object for trusted plugin loading and dispatch.
302
- def initialize(reserved_commands: [])
326
+ def initialize(reserved_commands: [], warning_sink: nil)
303
327
  @reserved_commands = reserved_commands.map(&:to_s)
328
+ @warning_sink = warning_sink
304
329
  @commands = {}
305
330
  @interactive_commands = {}
306
331
  @tab_types = {}
307
332
  @tab_types_by_id = {}
333
+ @transports = {}
334
+ @transports_by_id = {}
308
335
  @footer = nil
309
336
  @footer_path = nil
310
337
  @transcript_event_handlers = []
@@ -347,6 +374,22 @@ module Kward
347
374
  @tab_types_by_id[id.to_s]
348
375
  end
349
376
 
377
+ def transport_tab_types
378
+ @tab_types.values.select(&:transport)
379
+ end
380
+
381
+ def transports
382
+ @transports.values
383
+ end
384
+
385
+ def transport_for(name)
386
+ @transports[name.to_s]
387
+ end
388
+
389
+ def transport_for_id(id)
390
+ @transports_by_id[id.to_s]
391
+ end
392
+
350
393
  def footer_renderer
351
394
  @footer
352
395
  end
@@ -379,7 +422,7 @@ module Kward
379
422
  rendered = entry[:renderer].call(context)
380
423
  parts << rendered.to_s unless rendered.to_s.empty?
381
424
  rescue StandardError => e
382
- warn "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
425
+ emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
383
426
  end
384
427
  parts.empty? ? nil : parts.join("\n\n")
385
428
  end
@@ -391,7 +434,7 @@ module Kward
391
434
  @transcript_event_handlers.each do |entry|
392
435
  entry[:handler].call(transcript_event, context)
393
436
  rescue StandardError => e
394
- warn "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
437
+ emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
395
438
  end
396
439
  nil
397
440
  end
@@ -404,7 +447,7 @@ module Kward
404
447
  Kernel.load(path, true)
405
448
  @paths << path
406
449
  rescue StandardError => e
407
- warn "Warning: skipping Kward plugin #{path}: #{e.message}"
450
+ emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
408
451
  ensure
409
452
  self.class.loading_registry = previous_registry
410
453
  self.class.loading_path = previous_path
@@ -422,11 +465,11 @@ module Kward
422
465
  raise "Plugin command /#{name} requires a handler" unless handler
423
466
 
424
467
  if @reserved_commands.include?(name)
425
- warn "Warning: skipping Kward plugin command /#{name}: reserved command"
468
+ emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
426
469
  return nil
427
470
  end
428
471
  if @commands.key?(name)
429
- warn "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
472
+ emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
430
473
  return nil
431
474
  end
432
475
 
@@ -445,11 +488,11 @@ module Kward
445
488
  raise "Interactive command /#{name} requires a handler" unless handler
446
489
 
447
490
  if @reserved_commands.include?(name) || @commands.key?(name)
448
- warn "Warning: skipping Kward interactive command /#{name}: reserved command"
491
+ emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
449
492
  return nil
450
493
  end
451
494
  if @interactive_commands.key?(name)
452
- warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
495
+ emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
453
496
  return nil
454
497
  end
455
498
 
@@ -464,7 +507,7 @@ module Kward
464
507
  )
465
508
  end
466
509
 
467
- def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
510
+ def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, path: nil, &handler)
468
511
  name = name.to_s
469
512
  id = id.to_s
470
513
  raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
@@ -472,23 +515,46 @@ module Kward
472
515
  raise "Plugin tab type #{name} requires a handler" unless handler
473
516
 
474
517
  if @tab_types.key?(name) || @tab_types_by_id.key?(id)
475
- warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
518
+ emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
476
519
  return nil
477
520
  end
478
521
 
479
- tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
522
+ tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transport: transport == true, local: local == true, transcript_events: transcript_events == true, path: path, handler: handler)
480
523
  @tab_types[name] = tab_type
481
524
  @tab_types_by_id[id] = tab_type
482
525
  end
483
526
 
527
+ def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
528
+ name = name.to_s
529
+ id = id.to_s
530
+ raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
531
+ raise "Plugin transport id is required" if id.empty?
532
+ raise "Plugin transport #{name} requires a handler" unless handler
533
+
534
+ if @transports.key?(name) || @transports_by_id.key?(id)
535
+ emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
536
+ return nil
537
+ end
538
+
539
+ capabilities = normalize_transport_capabilities(capabilities)
540
+ execution_profile = normalize_execution_profile(execution_profile)
541
+ transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
542
+ @transports[name] = transport
543
+ @transports_by_id[id] = transport
544
+ end
545
+
484
546
  def register_footer(path: nil, &renderer)
485
547
  raise "Plugin footer requires a renderer" unless renderer
486
548
 
487
- warn "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
549
+ emit_warning "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
488
550
  @footer = renderer
489
551
  @footer_path = path
490
552
  end
491
553
 
554
+ def emit_warning(message)
555
+ @warning_sink ? @warning_sink.call(message) : warn(message)
556
+ end
557
+
492
558
  def register_transcript_event(path: nil, &handler)
493
559
  raise "Plugin transcript event requires a handler" unless handler
494
560
 
@@ -520,6 +586,20 @@ module Kward
520
586
 
521
587
  private
522
588
 
589
+ def normalize_execution_profile(profile)
590
+ return nil if profile.nil?
591
+ return profile if profile.is_a?(Transport::ExecutionProfile)
592
+ raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
593
+ end
594
+
595
+ def normalize_transport_capabilities(capabilities)
596
+ return Transport.capabilities if capabilities.nil?
597
+ return capabilities if capabilities.is_a?(Transport::Capabilities)
598
+ raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
599
+
600
+ Transport.capabilities(**capabilities.transform_keys(&:to_sym))
601
+ end
602
+
523
603
  def transcript_event_for(event)
524
604
  case event.class.name
525
605
  when "Kward::Events::ReasoningDelta"
@@ -1,5 +1,6 @@
1
1
  require "fileutils"
2
2
  require "json"
3
+ require "tempfile"
3
4
 
4
5
  # Namespace for the Kward CLI agent runtime.
5
6
  module Kward
@@ -9,12 +10,17 @@ module Kward
9
10
 
10
11
  def write_json(path, data)
11
12
  path = File.expand_path(path)
12
- FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
13
- File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
13
+ directory = File.dirname(path)
14
+ FileUtils.mkdir_p(directory, mode: 0o700)
15
+
16
+ Tempfile.create([".#{File.basename(path)}.", ".tmp"], directory, mode: 0o600) do |file|
14
17
  file.write(JSON.pretty_generate(data))
15
18
  file.write("\n")
19
+ file.flush
20
+ file.fsync
21
+ file.close
22
+ File.rename(file.path, path)
16
23
  end
17
- File.chmod(0o600, path)
18
24
  end
19
25
  end
20
26
  end
@@ -37,6 +37,7 @@ module Kward
37
37
  def tool_approval_title(tool_name)
38
38
  case tool_name.to_s
39
39
  when "run_shell_command" then "Shell command"
40
+ when "git_commit" then "Git commit"
40
41
  when "write_file" then "Write file"
41
42
  when "edit_file" then "Edit file"
42
43
  when "fetch_content", "fetch_raw" then "Network request"
@@ -48,6 +49,7 @@ module Kward
48
49
  def tool_approval_details(tool_name, args)
49
50
  action = case tool_name.to_s
50
51
  when "run_shell_command" then "run this shell command"
52
+ when "git_commit" then "create this Git commit"
51
53
  when "write_file" then "write this file"
52
54
  when "edit_file" then "edit this file"
53
55
  when "read_file", "read_skill" then "read these resources"
@@ -230,11 +230,7 @@ module Kward
230
230
  end
231
231
 
232
232
  def footer_text
233
- return "" unless @footer
234
-
235
- @footer.call.to_s.gsub(/\s+/, " ").strip
236
- rescue StandardError
237
- ""
233
+ cached_footer_text
238
234
  end
239
235
 
240
236
  def attachment_badge_rows(content_width)