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
@@ -1,39 +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
13
+ EVENT_LIMIT = PluginChatRuntime::EVENT_LIMIT
22
14
 
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)
25
-
26
- def initialize(server:, client: Client.new)
15
+ def initialize(server:, client: Client.new, plugin_registry_provider: nil)
27
16
  @server = server
28
- @client = client
29
- @chats = {}
30
- @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
+ )
31
22
  @subscriptions = {}
32
23
  @mutex = Mutex.new
24
+ @runtime.subscribe_events { |event| notify_event(event) }
33
25
  end
34
26
 
27
+ attr_reader :runtime
28
+
35
29
  def supported_types
36
- plugin_registry.tab_types.select(&:rpc)
30
+ @runtime.supported_types(surface: :rpc)
37
31
  end
38
32
 
39
33
  def list
@@ -41,11 +35,11 @@ module Kward
41
35
  end
42
36
 
43
37
  def open(type_id:)
44
- type = supported_types.find { |entry| entry.id == type_id.to_s } || raise(ArgumentError, "Unknown RPC plugin chat: #{type_id}")
45
- chat = chat_for(type)
46
- 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)
47
41
 
48
- chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
42
+ payload.merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
49
43
  end
50
44
 
51
45
  def transcript(chat_id:, limit: nil, before: nil)
@@ -74,188 +68,51 @@ module Kward
74
68
  def start_turn(chat_id:, input:, attachments: [])
75
69
  chat = fetch_chat(chat_id)
76
70
  normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
77
- turn = Turn.new(
78
- id: SecureRandom.uuid,
71
+ turn = @runtime.start_turn(
79
72
  chat_id: chat.id,
80
- input: input_with_attachments(input, normalized_attachments),
81
- display_input: input.to_s,
82
- status: "queued",
83
- cancellation: Cancellation.new,
84
- created_at: Time.now.utc.iso8601(3),
85
- events: [],
86
- next_sequence: 1,
87
- mutex: Mutex.new
73
+ input: @runtime.input_with_attachments(input, normalized_attachments),
74
+ display_input: input.to_s
88
75
  )
89
- @mutex.synchronize { @turns[turn.id] = turn }
90
- chat.queue << turn.id
91
- ensure_worker(chat)
92
- emit_event(turn, "turnQueued", { status: "queued" })
93
76
  turn_payload(turn)
94
77
  end
95
78
 
96
79
  def cancel_turn(turn_id:)
97
- turn = fetch_turn(turn_id)
98
- queued = turn.mutex.synchronize do
99
- turn.cancellation.cancel!
100
- turn.status == "queued"
101
- end
102
- emit_event(turn, "turnCancelRequested", {})
103
- finish_turn(turn, "canceled") if queued
104
- turn_payload(turn)
80
+ turn_payload(@runtime.cancel_turn(turn_id: turn_id))
105
81
  end
106
82
 
107
83
  def turn_status(turn_id:)
108
- turn_payload(fetch_turn(turn_id))
84
+ turn_payload(@runtime.turn_status(turn_id: turn_id))
109
85
  end
110
86
 
111
87
  def turn_events(turn_id:, after_sequence: 0)
112
- turn = fetch_turn(turn_id)
113
- events = turn.mutex.synchronize { turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i } }
114
- { 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
+ }
115
93
  end
116
94
 
117
95
  def list_turns(chat_id: nil, active: false)
118
- turns = @mutex.synchronize { @turns.values.dup }
119
- turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
120
- turns.select! { |turn| %w[queued running].include?(turn.status) } if active
121
- { turns: turns.map { |turn| turn_payload(turn) } }
96
+ { turns: @runtime.list_turns(chat_id: chat_id, active: active).map { |turn| turn_payload(turn) } }
122
97
  end
123
98
 
124
99
  def shutdown
125
- chats = @mutex.synchronize { @chats.values.dup }
126
- chats.each do |chat|
127
- chat.queue << WORKER_STOP if chat.worker&.alive?
128
- chat.worker&.join(0.2)
129
- end
100
+ @runtime.shutdown
130
101
  end
131
102
 
132
103
  private
133
104
 
134
- def plugin_registry
135
- @plugin_registry ||= PluginRegistry.load
136
- end
137
-
138
- def chat_for(type)
139
- @mutex.synchronize do
140
- @chats[type.id] ||= begin
141
- descriptor = { "kind" => "plugin", "plugin_tab_type" => type.id, "label" => type.title }
142
- driver = type.handler.call(PluginTabHost.new(client: @client, workspace_root: Dir.pwd), descriptor)
143
- raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
144
-
145
- Chat.new(id: type.id, type: type, driver: driver, queue: Queue.new)
146
- end
147
- end
148
- end
149
-
150
105
  def fetch_chat(chat_id)
151
- @mutex.synchronize { @chats[chat_id.to_s] } || open(type_id: chat_id).then { @mutex.synchronize { @chats[chat_id.to_s] } }
152
- end
153
-
154
- def fetch_turn(turn_id)
155
- @mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
156
- end
157
-
158
- def ensure_worker(chat)
159
- return if chat.worker&.alive?
160
-
161
- chat.worker = Thread.new do
162
- loop do
163
- turn_id = chat.queue.pop
164
- break if turn_id.equal?(WORKER_STOP)
165
-
166
- turn = fetch_turn(turn_id)
167
- run_turn(chat, turn) unless turn.cancellation.cancelled?
168
- end
169
- end
170
- chat.worker.report_on_exception = false
171
- end
106
+ return @runtime.chat(chat_id) if @runtime.chat(chat_id)
172
107
 
173
- def run_turn(chat, turn)
174
- turn.mutex.synchronize do
175
- return 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
176
111
 
177
- turn.status = "running"
178
- turn.started_at = Time.now.utc.iso8601(3)
179
- end
180
- chat.running_turn_id = turn.id
181
- emit_event(turn, "turnStarted", { status: "running" })
182
- chat.driver.submit(turn.input, display_input: turn.display_input, cancellation: turn.cancellation) do |event|
183
- handle_driver_event(chat, turn, event)
184
- end
185
- finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
186
- rescue Cancellation::CancelledError
187
- finish_turn(turn, "canceled")
188
- rescue StandardError => e
189
- turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
190
- finish_turn(turn, "failed")
191
- ensure
192
- chat.running_turn_id = nil if chat.running_turn_id == turn.id
193
- end
194
-
195
- def handle_driver_event(chat, turn, event)
196
- notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
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
197
114
 
198
- type, payload = case event
199
- when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
200
- when Events::ReasoningBoundary then ["reasoningBoundary", {}]
201
- when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
202
- when Events::AssistantMessage then ["assistantMessage", { message: TranscriptNormalizer.new([event.message]).normalize.first }]
203
- when Events::Retry then ["modelRetry", retry_event_payload(event)]
204
- when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
205
- when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
206
- when Events::Answer then ["answer", { content: event.content }]
207
- end
208
- emit_event(turn, type, payload) if type
209
- end
210
-
211
- def notify_plugin_tab_transcript_event(chat, event)
212
- return if plugin_registry.transcript_event_handlers.empty?
213
-
214
- context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: Dir.pwd)
215
- plugin_registry.notify_transcript_event(event, context)
216
- end
217
-
218
- def retry_event_payload(event)
219
- { 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
220
- end
221
-
222
- def tool_call_payload(tool_call)
223
- { toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)) }.compact
224
- end
225
-
226
- def tool_result_payload(tool_call, content)
227
- { 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
228
- end
229
-
230
- def finish_turn(turn, status)
231
- event = turn.mutex.synchronize do
232
- next nil if terminal?(turn)
233
-
234
- turn.status = status
235
- turn.finished_at = Time.now.utc.iso8601(3)
236
- append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
237
- end
238
- notify_event(event) if event
239
- end
240
-
241
- def emit_event(turn, type, payload)
242
- event = turn.mutex.synchronize { append_event(turn, type, payload) }
243
- notify_event(event)
244
- end
245
-
246
- def append_event(turn, type, payload)
247
- event = {
248
- sequence: turn.next_sequence,
249
- timestamp: Time.now.utc.iso8601(3),
250
- chatId: turn.chat_id,
251
- turnId: turn.id,
252
- type: type,
253
- payload: payload
254
- }
255
- turn.next_sequence += 1
256
- turn.events << event
257
- turn.events.shift while turn.events.length > EVENT_LIMIT
258
- event
115
+ chat
259
116
  end
260
117
 
261
118
  def notify_event(event)
@@ -263,20 +120,14 @@ module Kward
263
120
  @server.notify("pluginChat/event", event) if subscribed
264
121
  end
265
122
 
266
- def input_with_attachments(input, attachments)
267
- return input.to_s if attachments.empty?
268
-
269
- [{ type: "text", text: input.to_s }] + attachments.map do |attachment|
270
- { type: "image", media_type: attachment[:mimeType], data: attachment[:data], alt: attachment[:alt] }
271
- end
272
- end
273
-
274
- def terminal?(turn)
275
- %w[completed failed canceled].include?(turn.status)
276
- end
277
-
278
123
  def type_payload(type)
279
- { 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
280
131
  end
281
132
 
282
133
  def transcript_page(driver, limit:, before:)
@@ -286,12 +137,23 @@ module Kward
286
137
  end
287
138
 
288
139
  def chat_payload(chat)
289
- 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
+ )
290
144
  end
291
145
 
292
146
  def turn_payload(turn)
293
147
  turn.mutex.synchronize do
294
- { 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
295
157
  end
296
158
  end
297
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,9 +3,10 @@ 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
- require_relative "../workspace"
9
+ require_relative "../workspace_factory"
9
10
  require_relative "../telemetry/logger"
10
11
  require_relative "../telemetry/stats"
11
12
  require_relative "auth_manager"
@@ -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 = [
@@ -73,12 +75,14 @@ module Kward
73
75
  "memory/why", "memory/summarize"
74
76
  ].freeze
75
77
  COMMAND_METHODS = ["commands/list", "commands/run"].freeze
78
+ SKILL_CAPTURE_METHODS = ["skills/captureSessions", "skills/captureDraft", "skills/saveCapturedDraft"].freeze
76
79
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
77
80
  CONFIG_METHODS = ["config/read", "config/update"].freeze
78
81
  LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
79
82
  LIFECYCLE_HOOK_METHODS = ["hooks/logs"].freeze
80
83
  UI_METHODS = ["ui/answerQuestion"].freeze
81
84
  TOOL_APPROVAL_METHODS = ["tool/answerApproval"].freeze
85
+ TRANSPORT_METHODS = ["transports/list", "transports/status"].freeze
82
86
  SESSION_EVENT_NOTIFICATION = "session/event"
83
87
  SESSION_UPDATED_NOTIFICATION = "session/updated"
84
88
  TURN_EVENT_NOTIFICATION = "turn/event"
@@ -101,6 +105,7 @@ module Kward
101
105
  auth: AUTH_METHODS,
102
106
  memory: MEMORY_METHODS,
103
107
  commands: COMMAND_METHODS,
108
+ skill_capture: SKILL_CAPTURE_METHODS,
104
109
  startup_resources: STARTUP_RESOURCE_METHODS,
105
110
  config: CONFIG_METHODS,
106
111
  logging: LOGGING_METHODS,
@@ -122,11 +127,28 @@ module Kward
122
127
  @client = client
123
128
  @config_manager = ConfigManager.new
124
129
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
125
- @plugin_chat_manager = PluginChatManager.new(server: self, client: client)
130
+ @plugin_chat_manager = PluginChatManager.new(
131
+ server: self,
132
+ client: client,
133
+ plugin_registry_provider: -> { @session_manager.plugin_registry }
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
+ )
126
145
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
127
146
  @shutdown = false
147
+ @shutdown_complete = false
128
148
  end
129
149
 
150
+ attr_reader :session_manager, :plugin_chat_manager
151
+
130
152
  # Reads framed JSON-RPC messages until shutdown or EOF.
131
153
  #
132
154
  # @return [void]
@@ -144,8 +166,17 @@ module Kward
144
166
  end
145
167
  end
146
168
  ensure
169
+ shutdown
170
+ end
171
+
172
+ def shutdown
173
+ return if @shutdown_complete
174
+
175
+ @shutdown = true
176
+ @transport_manager.shutdown
147
177
  @plugin_chat_manager.shutdown
148
178
  @session_manager.shutdown_sessions
179
+ @shutdown_complete = true
149
180
  end
150
181
 
151
182
  # Sends a redacted JSON-RPC notification to the client.
@@ -223,6 +254,10 @@ module Kward
223
254
  @session_manager.tool_schemas(session_id: params["sessionId"])
224
255
  when MCP_METHODS[0]
225
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 }
226
261
  when PROMPT_METHODS[0]
227
262
  prompts_list
228
263
  when PROMPT_METHODS[1]
@@ -235,6 +270,8 @@ module Kward
235
270
  models_set(params)
236
271
  when MODEL_METHODS[3]
237
272
  reasoning_set(params)
273
+ when MODEL_METHODS[4]
274
+ models_refresh(params)
238
275
  when RUNTIME_METHODS[0]
239
276
  @session_manager.runtime_state(session_id: params.fetch("sessionId"))
240
277
  when RUNTIME_METHODS[1]
@@ -247,6 +284,12 @@ module Kward
247
284
  commands_list(params)
248
285
  when COMMAND_METHODS[1]
249
286
  commands_run(params)
287
+ when SKILL_CAPTURE_METHODS[0]
288
+ { sessions: @session_manager.skill_capture_sessions }
289
+ when SKILL_CAPTURE_METHODS[1]
290
+ @session_manager.capture_skill(session_path: params.fetch("sessionPath"))
291
+ when SKILL_CAPTURE_METHODS[2]
292
+ @session_manager.save_captured_skill(content: params.fetch("content"), overwrite: params["overwrite"] == true)
250
293
  when STARTUP_RESOURCE_METHODS[0]
251
294
  startup_resources(params)
252
295
  when CONFIG_METHODS[0]
@@ -417,7 +460,14 @@ module Kward
417
460
  compact: { supported: true, method: SESSION_METHODS[5], notification: SESSION_EVENT_NOTIFICATION, events: ["compactionStart", "compactionEnd"] },
418
461
  import: { supported: false },
419
462
  tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
420
- 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
421
471
  },
422
472
  pluginChats: {
423
473
  supported: @plugin_chat_manager.supported_types.any?,
@@ -425,7 +475,7 @@ module Kward
425
475
  notification: "pluginChat/event",
426
476
  subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
427
477
  attachments: { supported: true, method: PLUGIN_CHAT_METHODS[5], encoding: "base64", mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES, maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES },
428
- 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 }
429
479
  },
430
480
  turns: {
431
481
  mode: "async",
@@ -481,7 +531,7 @@ module Kward
481
531
  methods: MODEL_METHODS,
482
532
  fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
483
533
  scopedModels: false,
484
- openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
534
+ refresh: { supported: true, method: MODEL_METHODS[4], providerParameter: true }
485
535
  },
486
536
  runtime: {
487
537
  supported: true,
@@ -510,16 +560,25 @@ module Kward
510
560
  },
511
561
  auth: {
512
562
  supported: true,
513
- providerFormat: "kward-auth-v1",
563
+ providerFormat: "kward-auth-v2",
514
564
  methods: AUTH_METHODS,
515
- oauthProviders: ["openai", "anthropic", "github"],
516
- unsupportedOAuthProviders: { github: "CLI-only GitHub login for Copilot scaffolding; RPC login is not implemented yet." },
517
- 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,
518
576
  logout: true
519
577
  },
520
578
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
521
579
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
522
580
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
581
+ skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
523
582
  mcp: {
524
583
  supported: true,
525
584
  transport: "stdio",
@@ -556,7 +615,8 @@ module Kward
556
615
  workspaceMutationGuard: "none",
557
616
  toolApproval: { supported: true, defaultMode: "none", modes: ["none", "ask"], requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
558
617
  canRunShell: true,
559
- canWriteFiles: true
618
+ canWriteFiles: true,
619
+ sandbox: sandbox_capabilities
560
620
  },
561
621
  export: { supported: true, formats: ["markdown", "html"], defaultFormat: "markdown" },
562
622
  logging: {
@@ -576,6 +636,30 @@ module Kward
576
636
  }
577
637
  end
578
638
 
639
+ def sandbox_capabilities
640
+ config = @config_manager.read(redacted: false)
641
+ policy = ConfigFiles.sandbox_policy(Dir.pwd, config)
642
+ runner = Sandbox::RunnerFactory.build(policy)
643
+ runner.capabilities.to_h.merge(
644
+ mode: policy.mode,
645
+ network: policy.network,
646
+ scope: "shellCommandsOnly",
647
+ sessionPinning: false,
648
+ oneTimeElevation: false
649
+ )
650
+ rescue ArgumentError => error
651
+ {
652
+ available: false,
653
+ filesystemEnforced: false,
654
+ childNetworkEnforced: false,
655
+ backend: "invalidConfiguration",
656
+ reason: error.message,
657
+ scope: "shellCommandsOnly",
658
+ sessionPinning: false,
659
+ oneTimeElevation: false
660
+ }
661
+ end
662
+
579
663
  def workspace_info(root)
580
664
  root = @session_manager.validate_workspace_root(root)
581
665
  { root: root, basename: File.basename(root), writable: File.writable?(root) }
@@ -737,17 +821,21 @@ module Kward
737
821
  end
738
822
 
739
823
  def auth_login_with_api_key(params)
740
- 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"])
741
825
  @session_manager.refresh_client_config
742
826
  result
743
827
  end
744
828
 
745
829
  def auth_logout_provider(params)
746
- 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"])
747
831
  @session_manager.refresh_client_config
748
832
  result
749
833
  end
750
834
 
835
+ def models_refresh(params)
836
+ { models: @session_manager.refresh_models(provider: params["provider"]) }
837
+ end
838
+
751
839
  def models_current
752
840
  @session_manager.current_model
753
841
  end
@@ -797,7 +885,7 @@ module Kward
797
885
  end
798
886
 
799
887
  def configured_workspace(root = Dir.pwd)
800
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
888
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
801
889
  end
802
890
 
803
891
  def workspace_guardrails_enabled?