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,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
 
@@ -305,6 +327,8 @@ module Kward
305
327
  @interactive_commands = {}
306
328
  @tab_types = {}
307
329
  @tab_types_by_id = {}
330
+ @transports = {}
331
+ @transports_by_id = {}
308
332
  @footer = nil
309
333
  @footer_path = nil
310
334
  @transcript_event_handlers = []
@@ -347,6 +371,22 @@ module Kward
347
371
  @tab_types_by_id[id.to_s]
348
372
  end
349
373
 
374
+ def transport_tab_types
375
+ @tab_types.values.select(&:transport)
376
+ end
377
+
378
+ def transports
379
+ @transports.values
380
+ end
381
+
382
+ def transport_for(name)
383
+ @transports[name.to_s]
384
+ end
385
+
386
+ def transport_for_id(id)
387
+ @transports_by_id[id.to_s]
388
+ end
389
+
350
390
  def footer_renderer
351
391
  @footer
352
392
  end
@@ -464,7 +504,7 @@ module Kward
464
504
  )
465
505
  end
466
506
 
467
- def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
507
+ def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, path: nil, &handler)
468
508
  name = name.to_s
469
509
  id = id.to_s
470
510
  raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
@@ -476,11 +516,30 @@ module Kward
476
516
  return nil
477
517
  end
478
518
 
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)
519
+ 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
520
  @tab_types[name] = tab_type
481
521
  @tab_types_by_id[id] = tab_type
482
522
  end
483
523
 
524
+ def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
525
+ name = name.to_s
526
+ id = id.to_s
527
+ raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
528
+ raise "Plugin transport id is required" if id.empty?
529
+ raise "Plugin transport #{name} requires a handler" unless handler
530
+
531
+ if @transports.key?(name) || @transports_by_id.key?(id)
532
+ warn "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
533
+ return nil
534
+ end
535
+
536
+ capabilities = normalize_transport_capabilities(capabilities)
537
+ execution_profile = normalize_execution_profile(execution_profile)
538
+ transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
539
+ @transports[name] = transport
540
+ @transports_by_id[id] = transport
541
+ end
542
+
484
543
  def register_footer(path: nil, &renderer)
485
544
  raise "Plugin footer requires a renderer" unless renderer
486
545
 
@@ -520,6 +579,20 @@ module Kward
520
579
 
521
580
  private
522
581
 
582
+ def normalize_execution_profile(profile)
583
+ return nil if profile.nil?
584
+ return profile if profile.is_a?(Transport::ExecutionProfile)
585
+ raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
586
+ end
587
+
588
+ def normalize_transport_capabilities(capabilities)
589
+ return Transport.capabilities if capabilities.nil?
590
+ return capabilities if capabilities.is_a?(Transport::Capabilities)
591
+ raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
592
+
593
+ Transport.capabilities(**capabilities.transform_keys(&:to_sym))
594
+ end
595
+
523
596
  def transcript_event_for(event)
524
597
  case event.class.name
525
598
  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)
@@ -323,6 +323,8 @@ module Kward
323
323
  code.match?(/:\s*(?:[#].*)?\z/)
324
324
  when :html
325
325
  editor_html_line_opens_indent?(code)
326
+ when :erb
327
+ editor_erb_line_opens_indent?(code)
326
328
  when *C_LIKE_INDENT_LANGUAGES
327
329
  false
328
330
  else
@@ -364,6 +366,46 @@ module Kward
364
366
  true
365
367
  end
366
368
 
369
+ def editor_erb_line_opens_indent?(code)
370
+ segments, = editor_erb_segments(code.to_s, :template)
371
+ segments.any? do |kind, text|
372
+ case kind
373
+ when :ruby
374
+ editor_ruby_line_opens_indent?(text.strip)
375
+ when :template
376
+ editor_html_line_opens_indent?(text.strip)
377
+ else
378
+ false
379
+ end
380
+ end
381
+ end
382
+
383
+ def editor_erb_line_closer?(code)
384
+ segments, = editor_erb_segments(code.to_s, :template)
385
+ segments.any? do |kind, text|
386
+ case kind
387
+ when :ruby
388
+ text.strip.match?(/\Aend\z/)
389
+ when :template
390
+ text.strip.match?(/\A<\/[A-Za-z][\w:-]*>\z/)
391
+ else
392
+ false
393
+ end
394
+ end
395
+ end
396
+
397
+ def editor_matching_erb_indent(line_index = nil)
398
+ stack = []
399
+ editor_previous_code_lines(line_index).each do |line|
400
+ code = line[:code].strip
401
+ next if code.empty?
402
+
403
+ stack.pop if editor_erb_line_closer?(code)
404
+ stack << line[:indent] if editor_erb_line_opens_indent?(code)
405
+ end
406
+ stack.last || ""
407
+ end
408
+
367
409
  def editor_closing_punctuation?(text)
368
410
  PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
369
411
  end
@@ -424,6 +466,8 @@ module Kward
424
466
  code.match?(/\A[ \t]*(?:#{Regexp.union(SHELL_DEDENT_KEYWORDS)})\z/)
425
467
  when :html
426
468
  code.match?(/\A[ \t]*<\/[A-Za-z][\w:-]*>\z/)
469
+ when :erb
470
+ editor_erb_line_closer?(code)
427
471
  else
428
472
  false
429
473
  end
@@ -439,6 +483,8 @@ module Kward
439
483
  editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
440
484
  when :html
441
485
  editor_matching_html_indent(line_index)
486
+ when :erb
487
+ editor_matching_erb_indent(line_index)
442
488
  end
443
489
  end
444
490