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
@@ -1,40 +1,33 @@
1
- require "securerandom"
2
- require "thread"
3
1
  require_relative "../cancellation"
4
- require_relative "../events"
5
2
  require_relative "../image_attachments"
6
- require_relative "../message_access"
3
+ require_relative "../plugin_chat_runtime"
7
4
  require_relative "../plugin_registry"
8
- require_relative "../tools/tool_call"
9
- require_relative "../tab_driver"
10
5
  require_relative "attachment_normalizer"
11
6
  require_relative "transcript_normalizer"
12
7
 
13
8
  # Namespace for the Kward CLI agent runtime.
14
9
  module Kward
15
10
  module RPC
16
- # Coordinates optional RPC access to plugin-owned conversational runtimes.
17
- # Unlike SessionManager, this manager never creates workspace sessions,
18
- # agents, or tool registries: each plugin driver owns its chat and storage.
11
+ # Exposes the frontend-neutral plugin chat runtime over JSON-RPC.
19
12
  class PluginChatManager
20
- EVENT_LIMIT = 1_000
21
- WORKER_STOP = Object.new.freeze
22
-
23
- Chat = Struct.new(:id, :type, :driver, :queue, :worker, :running_turn_id, keyword_init: true)
24
- Turn = Struct.new(:id, :chat_id, :input, :display_input, :status, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :mutex, keyword_init: true)
13
+ EVENT_LIMIT = PluginChatRuntime::EVENT_LIMIT
25
14
 
26
15
  def initialize(server:, client: Client.new, plugin_registry_provider: nil)
27
16
  @server = server
28
- @client = client
29
- @plugin_registry_provider = plugin_registry_provider
30
- @chats = {}
31
- @turns = {}
17
+ @runtime = PluginChatRuntime.new(
18
+ client: client,
19
+ plugin_registry_provider: plugin_registry_provider,
20
+ message_normalizer: ->(message) { TranscriptNormalizer.new([message]).normalize.first }
21
+ )
32
22
  @subscriptions = {}
33
23
  @mutex = Mutex.new
24
+ @runtime.subscribe_events { |event| notify_event(event) }
34
25
  end
35
26
 
27
+ attr_reader :runtime
28
+
36
29
  def supported_types
37
- plugin_registry.tab_types.select(&:rpc)
30
+ @runtime.supported_types(surface: :rpc)
38
31
  end
39
32
 
40
33
  def list
@@ -42,11 +35,11 @@ module Kward
42
35
  end
43
36
 
44
37
  def open(type_id:)
45
- type = supported_types.find { |entry| entry.id == type_id.to_s } || raise(ArgumentError, "Unknown RPC plugin chat: #{type_id}")
46
- chat = chat_for(type)
47
- return chat_payload(chat) if chat.driver.respond_to?(:transcript_page)
38
+ chat = @runtime.open(type_id: type_id, surface: :rpc, scope_key: "owner")
39
+ payload = chat_payload(chat)
40
+ return payload if chat.driver.respond_to?(:transcript_page)
48
41
 
49
- chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
42
+ payload.merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
50
43
  end
51
44
 
52
45
  def transcript(chat_id:, limit: nil, before: nil)
@@ -75,190 +68,51 @@ module Kward
75
68
  def start_turn(chat_id:, input:, attachments: [])
76
69
  chat = fetch_chat(chat_id)
77
70
  normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
78
- turn = Turn.new(
79
- id: SecureRandom.uuid,
71
+ turn = @runtime.start_turn(
80
72
  chat_id: chat.id,
81
- input: input_with_attachments(input, normalized_attachments),
82
- display_input: input.to_s,
83
- status: "queued",
84
- cancellation: Cancellation.new,
85
- created_at: Time.now.utc.iso8601(3),
86
- events: [],
87
- next_sequence: 1,
88
- mutex: Mutex.new
73
+ input: @runtime.input_with_attachments(input, normalized_attachments),
74
+ display_input: input.to_s
89
75
  )
90
- @mutex.synchronize { @turns[turn.id] = turn }
91
- chat.queue << turn.id
92
- ensure_worker(chat)
93
- emit_event(turn, "turnQueued", { status: "queued" })
94
76
  turn_payload(turn)
95
77
  end
96
78
 
97
79
  def cancel_turn(turn_id:)
98
- turn = fetch_turn(turn_id)
99
- queued = turn.mutex.synchronize do
100
- turn.cancellation.cancel!
101
- turn.status == "queued"
102
- end
103
- emit_event(turn, "turnCancelRequested", {})
104
- finish_turn(turn, "canceled") if queued
105
- turn_payload(turn)
80
+ turn_payload(@runtime.cancel_turn(turn_id: turn_id))
106
81
  end
107
82
 
108
83
  def turn_status(turn_id:)
109
- turn_payload(fetch_turn(turn_id))
84
+ turn_payload(@runtime.turn_status(turn_id: turn_id))
110
85
  end
111
86
 
112
87
  def turn_events(turn_id:, after_sequence: 0)
113
- turn = fetch_turn(turn_id)
114
- events = turn.mutex.synchronize { turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i } }
115
- { turn: turn_payload(turn), events: events }
88
+ turn = @runtime.turn_status(turn_id: turn_id)
89
+ {
90
+ turn: turn_payload(turn),
91
+ events: @runtime.turn_events(turn_id: turn_id, after_sequence: after_sequence)
92
+ }
116
93
  end
117
94
 
118
95
  def list_turns(chat_id: nil, active: false)
119
- turns = @mutex.synchronize { @turns.values.dup }
120
- turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
121
- turns.select! { |turn| %w[queued running].include?(turn.status) } if active
122
- { turns: turns.map { |turn| turn_payload(turn) } }
96
+ { turns: @runtime.list_turns(chat_id: chat_id, active: active).map { |turn| turn_payload(turn) } }
123
97
  end
124
98
 
125
99
  def shutdown
126
- chats = @mutex.synchronize { @chats.values.dup }
127
- chats.each do |chat|
128
- chat.queue << WORKER_STOP if chat.worker&.alive?
129
- chat.worker&.join(0.2)
130
- end
100
+ @runtime.shutdown
131
101
  end
132
102
 
133
103
  private
134
104
 
135
- def plugin_registry
136
- return @plugin_registry_provider.call if @plugin_registry_provider
137
-
138
- @plugin_registry ||= PluginRegistry.load
139
- end
140
-
141
- def chat_for(type)
142
- @mutex.synchronize do
143
- @chats[type.id] ||= begin
144
- descriptor = { "kind" => "plugin", "plugin_tab_type" => type.id, "label" => type.title }
145
- driver = type.handler.call(PluginTabHost.new(client: @client, workspace_root: Dir.pwd), descriptor)
146
- raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
147
-
148
- Chat.new(id: type.id, type: type, driver: driver, queue: Queue.new)
149
- end
150
- end
151
- end
152
-
153
105
  def fetch_chat(chat_id)
154
- @mutex.synchronize { @chats[chat_id.to_s] } || open(type_id: chat_id).then { @mutex.synchronize { @chats[chat_id.to_s] } }
155
- end
156
-
157
- def fetch_turn(turn_id)
158
- @mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
159
- end
160
-
161
- def ensure_worker(chat)
162
- return if chat.worker&.alive?
163
-
164
- chat.worker = Thread.new do
165
- loop do
166
- turn_id = chat.queue.pop
167
- break if turn_id.equal?(WORKER_STOP)
168
-
169
- turn = fetch_turn(turn_id)
170
- run_turn(chat, turn) unless turn.cancellation.cancelled?
171
- end
172
- end
173
- chat.worker.report_on_exception = false
174
- end
175
-
176
- def run_turn(chat, turn)
177
- turn.mutex.synchronize do
178
- return if terminal?(turn)
179
-
180
- turn.status = "running"
181
- turn.started_at = Time.now.utc.iso8601(3)
182
- end
183
- chat.running_turn_id = turn.id
184
- emit_event(turn, "turnStarted", { status: "running" })
185
- chat.driver.submit(turn.input, display_input: turn.display_input, cancellation: turn.cancellation) do |event|
186
- handle_driver_event(chat, turn, event)
187
- end
188
- finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
189
- rescue Cancellation::CancelledError
190
- finish_turn(turn, "canceled")
191
- rescue StandardError => e
192
- turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
193
- finish_turn(turn, "failed")
194
- ensure
195
- chat.running_turn_id = nil if chat.running_turn_id == turn.id
196
- end
197
-
198
- def handle_driver_event(chat, turn, event)
199
- notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
200
-
201
- type, payload = case event
202
- when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
203
- when Events::ReasoningBoundary then ["reasoningBoundary", {}]
204
- when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
205
- when Events::AssistantMessage then ["assistantMessage", { message: TranscriptNormalizer.new([event.message]).normalize.first }]
206
- when Events::Retry then ["modelRetry", retry_event_payload(event)]
207
- when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
208
- when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
209
- when Events::Answer then ["answer", { content: event.content }]
210
- end
211
- emit_event(turn, type, payload) if type
212
- end
213
-
214
- def notify_plugin_tab_transcript_event(chat, event)
215
- return if plugin_registry.transcript_event_handlers.empty?
216
-
217
- context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: Dir.pwd)
218
- plugin_registry.notify_transcript_event(event, context)
219
- end
220
-
221
- def retry_event_payload(event)
222
- { provider: event.provider, model: event.model, attempt: event.attempt, maxAttempts: event.max_attempts, delaySeconds: event.delay_seconds, error: event.error, requestBytes: event.request_bytes }.compact
223
- end
224
-
225
- def tool_call_payload(tool_call)
226
- { toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)) }.compact
227
- end
228
-
229
- def tool_result_payload(tool_call, content)
230
- { toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), result: { content: content.to_s, isError: content.to_s.start_with?("Unknown", "Invalid", "Research tool failed") } }.compact
231
- end
106
+ return @runtime.chat(chat_id) if @runtime.chat(chat_id)
232
107
 
233
- def finish_turn(turn, status)
234
- event = turn.mutex.synchronize do
235
- next nil if terminal?(turn)
108
+ chat_id = chat_id.to_s
109
+ type = supported_types.find { |entry| chat_id == entry.id || chat_id.start_with?("#{entry.id}:") }
110
+ raise ArgumentError, "Unknown plugin chat: #{chat_id}" unless type
236
111
 
237
- turn.status = status
238
- turn.finished_at = Time.now.utc.iso8601(3)
239
- append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
240
- end
241
- notify_event(event) if event
242
- end
112
+ chat = @runtime.open(type_id: type.id, surface: :rpc, scope_key: "owner")
113
+ raise ArgumentError, "Unknown plugin chat: #{chat_id}" unless chat.id == chat_id
243
114
 
244
- def emit_event(turn, type, payload)
245
- event = turn.mutex.synchronize { append_event(turn, type, payload) }
246
- notify_event(event)
247
- end
248
-
249
- def append_event(turn, type, payload)
250
- event = {
251
- sequence: turn.next_sequence,
252
- timestamp: Time.now.utc.iso8601(3),
253
- chatId: turn.chat_id,
254
- turnId: turn.id,
255
- type: type,
256
- payload: payload
257
- }
258
- turn.next_sequence += 1
259
- turn.events << event
260
- turn.events.shift while turn.events.length > EVENT_LIMIT
261
- event
115
+ chat
262
116
  end
263
117
 
264
118
  def notify_event(event)
@@ -266,20 +120,14 @@ module Kward
266
120
  @server.notify("pluginChat/event", event) if subscribed
267
121
  end
268
122
 
269
- def input_with_attachments(input, attachments)
270
- return input.to_s if attachments.empty?
271
-
272
- [{ type: "text", text: input.to_s }] + attachments.map do |attachment|
273
- { type: "image", media_type: attachment[:mimeType], data: attachment[:data], alt: attachment[:alt] }
274
- end
275
- end
276
-
277
- def terminal?(turn)
278
- %w[completed failed canceled].include?(turn.status)
279
- end
280
-
281
123
  def type_payload(type)
282
- { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact
124
+ {
125
+ id: type.id,
126
+ name: type.name,
127
+ title: type.title,
128
+ singleton: type.singleton,
129
+ transport: type.transport == true ? true : nil
130
+ }.compact
283
131
  end
284
132
 
285
133
  def transcript_page(driver, limit:, before:)
@@ -289,12 +137,23 @@ module Kward
289
137
  end
290
138
 
291
139
  def chat_payload(chat)
292
- type_payload(chat.type).merge(id: chat.id, subscribed: @mutex.synchronize { @subscriptions[chat.id] == true })
140
+ type_payload(chat.type).merge(
141
+ id: chat.id,
142
+ subscribed: @mutex.synchronize { @subscriptions[chat.id] == true }
143
+ )
293
144
  end
294
145
 
295
146
  def turn_payload(turn)
296
147
  turn.mutex.synchronize do
297
- { id: turn.id, chatId: turn.chat_id, status: turn.status, createdAt: turn.created_at, startedAt: turn.started_at, finishedAt: turn.finished_at, error: turn.error }.compact
148
+ {
149
+ id: turn.id,
150
+ chatId: turn.chat_id,
151
+ status: turn.status,
152
+ createdAt: turn.created_at,
153
+ startedAt: turn.started_at,
154
+ finishedAt: turn.finished_at,
155
+ error: turn.error
156
+ }.compact
298
157
  end
299
158
  end
300
159
  end
@@ -8,8 +8,12 @@ module Kward
8
8
  module RPC
9
9
  # RPC prompt bridge for structured user questions.
10
10
  class PromptBridge
11
- def initialize(server:, session_id:)
12
- @server = server
11
+ def initialize(server: nil, session_id:, notify: nil)
12
+ @notify = notify || lambda do |method, payload|
13
+ raise ArgumentError, "PromptBridge requires a server or notify callback" unless server
14
+
15
+ server.notify(method, payload)
16
+ end
13
17
  @session_id = session_id
14
18
  @mutex = Mutex.new
15
19
  @condition = ConditionVariable.new
@@ -22,7 +26,7 @@ module Kward
22
26
  @mutex.synchronize { @pending_requests[request_id] = true }
23
27
  cancellation&.on_cancel { cancel_request(request_id) }
24
28
  unless cancellation&.cancelled?
25
- @server.notify("tool/approvalRequested", {
29
+ @notify.call("tool/approvalRequested", {
26
30
  sessionId: @session_id,
27
31
  approvalRequestId: request_id,
28
32
  toolCallId: tool_call_id,
@@ -49,7 +53,7 @@ module Kward
49
53
  @mutex.synchronize { @pending_requests[request_id] = true }
50
54
  cancellation&.on_cancel { cancel_request(request_id) }
51
55
  unless cancellation&.cancelled?
52
- @server.notify("ui/question", {
56
+ @notify.call("ui/question", {
53
57
  sessionId: @session_id,
54
58
  questionRequestId: request_id,
55
59
  questions: questions
@@ -37,7 +37,7 @@ module Kward
37
37
 
38
38
  def secret_key?(key)
39
39
  text = key.to_s
40
- return false if text == "apiKeyProviders"
40
+ return false if ["apiKeyProviders", "privateCredentialStorage", "storedCredentialType"].include?(text)
41
41
 
42
42
  text.match?(SECRET_KEYS)
43
43
  end
@@ -3,6 +3,7 @@ require_relative "../model/client"
3
3
  require_relative "../config_files"
4
4
  require_relative "../memory/manager"
5
5
  require_relative "../plugin_registry"
6
+ require_relative "../transport"
6
7
  require_relative "../prompts/commands"
7
8
  require_relative "../tools/registry"
8
9
  require_relative "../workspace_factory"
@@ -15,6 +16,7 @@ require_relative "redactor"
15
16
  require_relative "session_manager"
16
17
  require_relative "plugin_chat_manager"
17
18
  require_relative "transport"
19
+ require_relative "../transport/plugin_chat_gateway"
18
20
 
19
21
  # Namespace for the Kward CLI agent runtime.
20
22
  module Kward
@@ -59,7 +61,7 @@ module Kward
59
61
  ].freeze
60
62
  TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events", "turns/list", "turns/listActive"].freeze
61
63
  PLUGIN_CHAT_METHODS = ["pluginChats/list", "pluginChats/open", "pluginChats/transcript", "pluginChats/subscribe", "pluginChats/unsubscribe", "pluginChats/turns/start", "pluginChats/turns/cancel", "pluginChats/turns/status", "pluginChats/turns/events", "pluginChats/turns/list", "pluginChats/turns/listActive"].freeze
62
- MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
64
+ MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set", "models/refresh"].freeze
63
65
  RUNTIME_METHODS = ["runtime/state", "runtime/stats"].freeze
64
66
  RUNTIME_SETTING_METHODS = ["runtime/updateSetting", "runtime/reload"].freeze
65
67
  AUTH_METHODS = [
@@ -80,6 +82,7 @@ module Kward
80
82
  LIFECYCLE_HOOK_METHODS = ["hooks/logs"].freeze
81
83
  UI_METHODS = ["ui/answerQuestion"].freeze
82
84
  TOOL_APPROVAL_METHODS = ["tool/answerApproval"].freeze
85
+ TRANSPORT_METHODS = ["transports/list", "transports/status"].freeze
83
86
  SESSION_EVENT_NOTIFICATION = "session/event"
84
87
  SESSION_UPDATED_NOTIFICATION = "session/updated"
85
88
  TURN_EVENT_NOTIFICATION = "turn/event"
@@ -129,10 +132,23 @@ module Kward
129
132
  client: client,
130
133
  plugin_registry_provider: -> { @session_manager.plugin_registry }
131
134
  )
135
+ @transport_manager = Kward::Transport::Manager.new(
136
+ registry: @session_manager.plugin_registry,
137
+ gateway: lambda { |transport_id|
138
+ Kward::Transport::Gateway.new(session_manager: @session_manager, transport_id: transport_id)
139
+ },
140
+ plugin_chat_gateway: lambda { |transport_id|
141
+ Kward::Transport::PluginChatGateway.new(runtime: @plugin_chat_manager.runtime, transport_id: transport_id)
142
+ },
143
+ config_provider: ->(transport_id) { ConfigFiles.transport_config(transport_id) }
144
+ )
132
145
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
133
146
  @shutdown = false
147
+ @shutdown_complete = false
134
148
  end
135
149
 
150
+ attr_reader :session_manager, :plugin_chat_manager
151
+
136
152
  # Reads framed JSON-RPC messages until shutdown or EOF.
137
153
  #
138
154
  # @return [void]
@@ -150,8 +166,17 @@ module Kward
150
166
  end
151
167
  end
152
168
  ensure
169
+ shutdown
170
+ end
171
+
172
+ def shutdown
173
+ return if @shutdown_complete
174
+
175
+ @shutdown = true
176
+ @transport_manager.shutdown
153
177
  @plugin_chat_manager.shutdown
154
178
  @session_manager.shutdown_sessions
179
+ @shutdown_complete = true
155
180
  end
156
181
 
157
182
  # Sends a redacted JSON-RPC notification to the client.
@@ -229,6 +254,10 @@ module Kward
229
254
  @session_manager.tool_schemas(session_id: params["sessionId"])
230
255
  when MCP_METHODS[0]
231
256
  MCPStatus.new(config_manager: @config_manager).to_h
257
+ when TRANSPORT_METHODS[0]
258
+ { transports: @transport_manager.list }
259
+ when TRANSPORT_METHODS[1]
260
+ params["name"] ? @transport_manager.status(params["name"]) : { transports: @transport_manager.status }
232
261
  when PROMPT_METHODS[0]
233
262
  prompts_list
234
263
  when PROMPT_METHODS[1]
@@ -241,6 +270,8 @@ module Kward
241
270
  models_set(params)
242
271
  when MODEL_METHODS[3]
243
272
  reasoning_set(params)
273
+ when MODEL_METHODS[4]
274
+ models_refresh(params)
244
275
  when RUNTIME_METHODS[0]
245
276
  @session_manager.runtime_state(session_id: params.fetch("sessionId"))
246
277
  when RUNTIME_METHODS[1]
@@ -429,7 +460,14 @@ module Kward
429
460
  compact: { supported: true, method: SESSION_METHODS[5], notification: SESSION_EVENT_NOTIFICATION, events: ["compactionStart", "compactionEnd"] },
430
461
  import: { supported: false },
431
462
  tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
432
- updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
463
+ updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION },
464
+ worktrees: { supported: false, reason: "interactiveTuiOnly" }
465
+ },
466
+ transports: {
467
+ supported: true,
468
+ methods: TRANSPORT_METHODS,
469
+ startMode: "cliOnly",
470
+ entries: @transport_manager.list
433
471
  },
434
472
  pluginChats: {
435
473
  supported: @plugin_chat_manager.supported_types.any?,
@@ -437,7 +475,7 @@ module Kward
437
475
  notification: "pluginChat/event",
438
476
  subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
439
477
  attachments: { supported: true, method: PLUGIN_CHAT_METHODS[5], encoding: "base64", mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES, maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES },
440
- types: @plugin_chat_manager.supported_types.map { |type| { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact }
478
+ types: @plugin_chat_manager.supported_types.map { |type| { id: type.id, name: type.name, title: type.title, singleton: type.singleton, transport: type.transport == true ? true : nil }.compact }
441
479
  },
442
480
  turns: {
443
481
  mode: "async",
@@ -493,7 +531,7 @@ module Kward
493
531
  methods: MODEL_METHODS,
494
532
  fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
495
533
  scopedModels: false,
496
- openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
534
+ refresh: { supported: true, method: MODEL_METHODS[4], providerParameter: true }
497
535
  },
498
536
  runtime: {
499
537
  supported: true,
@@ -522,11 +560,19 @@ module Kward
522
560
  },
523
561
  auth: {
524
562
  supported: true,
525
- providerFormat: "kward-auth-v1",
563
+ providerFormat: "kward-auth-v2",
526
564
  methods: AUTH_METHODS,
527
- oauthProviders: ["openai", "anthropic", "github"],
528
- unsupportedOAuthProviders: { github: "CLI-only GitHub login for Copilot scaffolding; RPC login is not implemented yet." },
529
- apiKeyProviders: ["openrouter"],
565
+ providerCatalog: true,
566
+ authenticationMethods: ["api_key", "oauth"],
567
+ oauthProviders: ["openai", "anthropic"],
568
+ unsupportedOAuthProviders: {
569
+ copilot: "OAuth login is available only in the interactive CLI.",
570
+ openrouter: "Official PKCE is documented, but Kward has not implemented it.",
571
+ xai: "No official stable third-party OAuth flow is available."
572
+ },
573
+ apiKeyProviders: ProviderCatalog.api_key_providers.map(&:id),
574
+ privateCredentialStorage: true,
575
+ sanitizedStatus: true,
530
576
  logout: true
531
577
  },
532
578
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
@@ -775,17 +821,21 @@ module Kward
775
821
  end
776
822
 
777
823
  def auth_login_with_api_key(params)
778
- result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
824
+ result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"), configuration: params["configuration"])
779
825
  @session_manager.refresh_client_config
780
826
  result
781
827
  end
782
828
 
783
829
  def auth_logout_provider(params)
784
- result = @auth_manager.logout_provider(provider_id: params.fetch("providerId"))
830
+ result = @auth_manager.logout_provider(provider_id: params.fetch("providerId"), auth_method: params["authMethod"])
785
831
  @session_manager.refresh_client_config
786
832
  result
787
833
  end
788
834
 
835
+ def models_refresh(params)
836
+ { models: @session_manager.refresh_models(provider: params["provider"]) }
837
+ end
838
+
789
839
  def models_current
790
840
  @session_manager.current_model
791
841
  end