kward 0.77.0 → 0.79.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -0,0 +1,302 @@
1
+ require "securerandom"
2
+ require "thread"
3
+ require_relative "../cancellation"
4
+ require_relative "../events"
5
+ require_relative "../image_attachments"
6
+ require_relative "../message_access"
7
+ require_relative "../plugin_registry"
8
+ require_relative "../tools/tool_call"
9
+ require_relative "../tab_driver"
10
+ require_relative "attachment_normalizer"
11
+ require_relative "transcript_normalizer"
12
+
13
+ # Namespace for the Kward CLI agent runtime.
14
+ module Kward
15
+ 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.
19
+ 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)
25
+
26
+ def initialize(server:, client: Client.new, plugin_registry_provider: nil)
27
+ @server = server
28
+ @client = client
29
+ @plugin_registry_provider = plugin_registry_provider
30
+ @chats = {}
31
+ @turns = {}
32
+ @subscriptions = {}
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ def supported_types
37
+ plugin_registry.tab_types.select(&:rpc)
38
+ end
39
+
40
+ def list
41
+ { chats: supported_types.map { |type| type_payload(type) } }
42
+ end
43
+
44
+ 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)
48
+
49
+ chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
50
+ end
51
+
52
+ def transcript(chat_id:, limit: nil, before: nil)
53
+ chat = fetch_chat(chat_id)
54
+ page = transcript_page(chat.driver, limit: limit, before: before)
55
+ {
56
+ chat: chat_payload(chat),
57
+ messages: TranscriptNormalizer.new(page.fetch(:messages)).normalize,
58
+ hasMore: page.fetch(:has_more),
59
+ nextBefore: page[:next_before]
60
+ }.compact
61
+ end
62
+
63
+ def subscribe(chat_id:)
64
+ chat = fetch_chat(chat_id)
65
+ @mutex.synchronize { @subscriptions[chat.id] = true }
66
+ { chat: chat_payload(chat), subscribed: true }
67
+ end
68
+
69
+ def unsubscribe(chat_id:)
70
+ chat = fetch_chat(chat_id)
71
+ @mutex.synchronize { @subscriptions.delete(chat.id) }
72
+ { chat: chat_payload(chat), subscribed: false }
73
+ end
74
+
75
+ def start_turn(chat_id:, input:, attachments: [])
76
+ chat = fetch_chat(chat_id)
77
+ normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
78
+ turn = Turn.new(
79
+ id: SecureRandom.uuid,
80
+ 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
89
+ )
90
+ @mutex.synchronize { @turns[turn.id] = turn }
91
+ chat.queue << turn.id
92
+ ensure_worker(chat)
93
+ emit_event(turn, "turnQueued", { status: "queued" })
94
+ turn_payload(turn)
95
+ end
96
+
97
+ 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)
106
+ end
107
+
108
+ def turn_status(turn_id:)
109
+ turn_payload(fetch_turn(turn_id))
110
+ end
111
+
112
+ 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 }
116
+ end
117
+
118
+ 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) } }
123
+ end
124
+
125
+ 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
131
+ end
132
+
133
+ private
134
+
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
+ 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
232
+
233
+ def finish_turn(turn, status)
234
+ event = turn.mutex.synchronize do
235
+ next nil if terminal?(turn)
236
+
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
243
+
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
262
+ end
263
+
264
+ def notify_event(event)
265
+ subscribed = @mutex.synchronize { @subscriptions[event[:chatId]] }
266
+ @server.notify("pluginChat/event", event) if subscribed
267
+ end
268
+
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
+ def type_payload(type)
282
+ { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact
283
+ end
284
+
285
+ def transcript_page(driver, limit:, before:)
286
+ return { messages: driver.messages, has_more: false } unless limit && driver.respond_to?(:transcript_page)
287
+
288
+ driver.transcript_page(limit: limit, before: before)
289
+ end
290
+
291
+ def chat_payload(chat)
292
+ type_payload(chat.type).merge(id: chat.id, subscribed: @mutex.synchronize { @subscriptions[chat.id] == true })
293
+ end
294
+
295
+ def turn_payload(turn)
296
+ 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
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -5,7 +5,7 @@ require_relative "../memory/manager"
5
5
  require_relative "../plugin_registry"
6
6
  require_relative "../prompts/commands"
7
7
  require_relative "../tools/registry"
8
- require_relative "../workspace"
8
+ require_relative "../workspace_factory"
9
9
  require_relative "../telemetry/logger"
10
10
  require_relative "../telemetry/stats"
11
11
  require_relative "auth_manager"
@@ -13,6 +13,7 @@ require_relative "config_manager"
13
13
  require_relative "mcp_status"
14
14
  require_relative "redactor"
15
15
  require_relative "session_manager"
16
+ require_relative "plugin_chat_manager"
16
17
  require_relative "transport"
17
18
 
18
19
  # Namespace for the Kward CLI agent runtime.
@@ -57,6 +58,7 @@ module Kward
57
58
  "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript", "sessions/active"
58
59
  ].freeze
59
60
  TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events", "turns/list", "turns/listActive"].freeze
61
+ 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
60
62
  MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
61
63
  RUNTIME_METHODS = ["runtime/state", "runtime/stats"].freeze
62
64
  RUNTIME_SETTING_METHODS = ["runtime/updateSetting", "runtime/reload"].freeze
@@ -71,6 +73,7 @@ module Kward
71
73
  "memory/why", "memory/summarize"
72
74
  ].freeze
73
75
  COMMAND_METHODS = ["commands/list", "commands/run"].freeze
76
+ SKILL_CAPTURE_METHODS = ["skills/captureSessions", "skills/captureDraft", "skills/saveCapturedDraft"].freeze
74
77
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
75
78
  CONFIG_METHODS = ["config/read", "config/update"].freeze
76
79
  LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
@@ -92,12 +95,14 @@ module Kward
92
95
  prompts: PROMPT_METHODS,
93
96
  sessions: SESSION_METHODS,
94
97
  turns: TURN_METHODS,
98
+ plugin_chats: PLUGIN_CHAT_METHODS,
95
99
  models: MODEL_METHODS,
96
100
  runtime: RUNTIME_METHODS,
97
101
  runtime_settings: RUNTIME_SETTING_METHODS,
98
102
  auth: AUTH_METHODS,
99
103
  memory: MEMORY_METHODS,
100
104
  commands: COMMAND_METHODS,
105
+ skill_capture: SKILL_CAPTURE_METHODS,
101
106
  startup_resources: STARTUP_RESOURCE_METHODS,
102
107
  config: CONFIG_METHODS,
103
108
  logging: LOGGING_METHODS,
@@ -119,6 +124,11 @@ module Kward
119
124
  @client = client
120
125
  @config_manager = ConfigManager.new
121
126
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
127
+ @plugin_chat_manager = PluginChatManager.new(
128
+ server: self,
129
+ client: client,
130
+ plugin_registry_provider: -> { @session_manager.plugin_registry }
131
+ )
122
132
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
123
133
  @shutdown = false
124
134
  end
@@ -140,6 +150,7 @@ module Kward
140
150
  end
141
151
  end
142
152
  ensure
153
+ @plugin_chat_manager.shutdown
143
154
  @session_manager.shutdown_sessions
144
155
  end
145
156
 
@@ -242,6 +253,12 @@ module Kward
242
253
  commands_list(params)
243
254
  when COMMAND_METHODS[1]
244
255
  commands_run(params)
256
+ when SKILL_CAPTURE_METHODS[0]
257
+ { sessions: @session_manager.skill_capture_sessions }
258
+ when SKILL_CAPTURE_METHODS[1]
259
+ @session_manager.capture_skill(session_path: params.fetch("sessionPath"))
260
+ when SKILL_CAPTURE_METHODS[2]
261
+ @session_manager.save_captured_skill(content: params.fetch("content"), overwrite: params["overwrite"] == true)
245
262
  when STARTUP_RESOURCE_METHODS[0]
246
263
  startup_resources(params)
247
264
  when CONFIG_METHODS[0]
@@ -349,6 +366,28 @@ module Kward
349
366
  @session_manager.list_turns(session_id: params["sessionId"])
350
367
  when TURN_METHODS[5]
351
368
  @session_manager.list_turns(session_id: params["sessionId"], active: true)
369
+ when PLUGIN_CHAT_METHODS[0]
370
+ @plugin_chat_manager.list
371
+ when PLUGIN_CHAT_METHODS[1]
372
+ @plugin_chat_manager.open(type_id: params.fetch("typeId"))
373
+ when PLUGIN_CHAT_METHODS[2]
374
+ @plugin_chat_manager.transcript(chat_id: params.fetch("chatId"), limit: params["limit"], before: params["before"])
375
+ when PLUGIN_CHAT_METHODS[3]
376
+ @plugin_chat_manager.subscribe(chat_id: params.fetch("chatId"))
377
+ when PLUGIN_CHAT_METHODS[4]
378
+ @plugin_chat_manager.unsubscribe(chat_id: params.fetch("chatId"))
379
+ when PLUGIN_CHAT_METHODS[5]
380
+ @plugin_chat_manager.start_turn(chat_id: params.fetch("chatId"), input: params.fetch("input"), attachments: params["attachments"] || [])
381
+ when PLUGIN_CHAT_METHODS[6]
382
+ @plugin_chat_manager.cancel_turn(turn_id: params.fetch("turnId"))
383
+ when PLUGIN_CHAT_METHODS[7]
384
+ @plugin_chat_manager.turn_status(turn_id: params.fetch("turnId"))
385
+ when PLUGIN_CHAT_METHODS[8]
386
+ @plugin_chat_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
387
+ when PLUGIN_CHAT_METHODS[9]
388
+ @plugin_chat_manager.list_turns(chat_id: params["chatId"])
389
+ when PLUGIN_CHAT_METHODS[10]
390
+ @plugin_chat_manager.list_turns(chat_id: params["chatId"], active: true)
352
391
  when UI_METHODS[0]
353
392
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
354
393
  when TOOL_APPROVAL_METHODS[0]
@@ -392,6 +431,14 @@ module Kward
392
431
  tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
393
432
  updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
394
433
  },
434
+ pluginChats: {
435
+ supported: @plugin_chat_manager.supported_types.any?,
436
+ methods: PLUGIN_CHAT_METHODS,
437
+ notification: "pluginChat/event",
438
+ subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
439
+ 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 }
441
+ },
395
442
  turns: {
396
443
  mode: "async",
397
444
  perSessionConcurrency: 1,
@@ -485,6 +532,7 @@ module Kward
485
532
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
486
533
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
487
534
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
535
+ skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
488
536
  mcp: {
489
537
  supported: true,
490
538
  transport: "stdio",
@@ -521,7 +569,8 @@ module Kward
521
569
  workspaceMutationGuard: "none",
522
570
  toolApproval: { supported: true, defaultMode: "none", modes: ["none", "ask"], requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
523
571
  canRunShell: true,
524
- canWriteFiles: true
572
+ canWriteFiles: true,
573
+ sandbox: sandbox_capabilities
525
574
  },
526
575
  export: { supported: true, formats: ["markdown", "html"], defaultFormat: "markdown" },
527
576
  logging: {
@@ -541,6 +590,30 @@ module Kward
541
590
  }
542
591
  end
543
592
 
593
+ def sandbox_capabilities
594
+ config = @config_manager.read(redacted: false)
595
+ policy = ConfigFiles.sandbox_policy(Dir.pwd, config)
596
+ runner = Sandbox::RunnerFactory.build(policy)
597
+ runner.capabilities.to_h.merge(
598
+ mode: policy.mode,
599
+ network: policy.network,
600
+ scope: "shellCommandsOnly",
601
+ sessionPinning: false,
602
+ oneTimeElevation: false
603
+ )
604
+ rescue ArgumentError => error
605
+ {
606
+ available: false,
607
+ filesystemEnforced: false,
608
+ childNetworkEnforced: false,
609
+ backend: "invalidConfiguration",
610
+ reason: error.message,
611
+ scope: "shellCommandsOnly",
612
+ sessionPinning: false,
613
+ oneTimeElevation: false
614
+ }
615
+ end
616
+
544
617
  def workspace_info(root)
545
618
  root = @session_manager.validate_workspace_root(root)
546
619
  { root: root, basename: File.basename(root), writable: File.writable?(root) }
@@ -762,7 +835,7 @@ module Kward
762
835
  end
763
836
 
764
837
  def configured_workspace(root = Dir.pwd)
765
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
838
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
766
839
  end
767
840
 
768
841
  def workspace_guardrails_enabled?
@@ -15,6 +15,7 @@ require_relative "../memory/turn_context"
15
15
  require_relative "../message_access"
16
16
  require_relative "../message_text"
17
17
  require_relative "../session_tree_tool_display"
18
+ require_relative "../skills/capture"
18
19
  require_relative "../model/model_info"
19
20
  require_relative "../plugin_registry"
20
21
  require_relative "../prompts/commands"
@@ -25,7 +26,7 @@ require_relative "../steering"
25
26
  require_relative "../tools/tool_call"
26
27
  require_relative "../tools/registry"
27
28
  require_relative "../transcript_export"
28
- require_relative "../workspace"
29
+ require_relative "../workspace_factory"
29
30
  require_relative "attachment_normalizer"
30
31
  require_relative "config_manager"
31
32
  require_relative "memory_methods"
@@ -155,6 +156,38 @@ module Kward
155
156
  { sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
156
157
  end
157
158
 
159
+ def skill_capture_sessions
160
+ SessionStore.new(config_dir: @config_dir).capture_candidates.map do |session|
161
+ {
162
+ path: session.path,
163
+ workspaceRoot: session.cwd,
164
+ name: session.name,
165
+ firstMessage: session.first_message,
166
+ modifiedAt: session.modified_at.utc.iso8601(3)
167
+ }
168
+ end
169
+ end
170
+
171
+ def capture_skill(session_path:)
172
+ capture = Skills::Capture.new(
173
+ session_store: SessionStore.new(config_dir: @config_dir),
174
+ client: @client,
175
+ config_dir: @config_dir
176
+ )
177
+ draft = capture.generate(session_path)
178
+ { content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description }
179
+ end
180
+
181
+ def save_captured_skill(content:, overwrite: false)
182
+ capture = Skills::Capture.new(
183
+ session_store: SessionStore.new(config_dir: @config_dir),
184
+ client: @client,
185
+ config_dir: @config_dir
186
+ )
187
+ draft = capture.save(content, overwrite: overwrite)
188
+ { path: capture.skill_path(draft.name), name: draft.name, description: draft.description }
189
+ end
190
+
158
191
  def tool_schemas(session_id: nil)
159
192
  registry = if session_id
160
193
  fetch_session(session_id).tool_registry
@@ -164,6 +197,11 @@ module Kward
164
197
  { tools: registry.schemas }
165
198
  end
166
199
 
200
+ # Returns the plugin registry shared by RPC sessions and plugin chats.
201
+ def plugin_registry
202
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
203
+ end
204
+
167
205
  # Renames the persisted session attached to an RPC session id.
168
206
  def rename_session(session_id:, name:)
169
207
  rpc_session = fetch_session(session_id)
@@ -919,10 +957,6 @@ module Kward
919
957
  end
920
958
  end
921
959
 
922
- def plugin_registry
923
- @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
924
- end
925
-
926
960
  def reserved_plugin_command_names
927
961
  PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
928
962
  end
@@ -968,6 +1002,7 @@ module Kward
968
1002
  prompt: prompt,
969
1003
  allowed_tool_names: allowed_tool_names,
970
1004
  tool_approval: tool_approval,
1005
+ approval_for_allowed_tools: !tool_approval.nil?,
971
1006
  hook_manager: hook_manager,
972
1007
  hook_context: hook_context
973
1008
  )
@@ -1018,7 +1053,7 @@ module Kward
1018
1053
  end
1019
1054
 
1020
1055
  def configured_workspace(root)
1021
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
1056
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
1022
1057
  end
1023
1058
 
1024
1059
  def workspace_guardrails_enabled?
@@ -1160,6 +1195,8 @@ module Kward
1160
1195
  started, event = turn.mutex.synchronize do
1161
1196
  next [false, nil] if turn.status == "canceled"
1162
1197
 
1198
+ rpc_session.running_turn_id = turn.id
1199
+ turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
1163
1200
  turn.status = "running"
1164
1201
  turn.started_at = now
1165
1202
  [true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
@@ -1167,8 +1204,6 @@ module Kward
1167
1204
  return unless started
1168
1205
 
1169
1206
  @server.notify("turn/event", event)
1170
- rpc_session.running_turn_id = turn.id
1171
- turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
1172
1207
 
1173
1208
  if turn.cancel_requested
1174
1209
  finish_turn(turn, "canceled")
@@ -54,10 +54,13 @@ module Kward
54
54
  end
55
55
 
56
56
  def normalize_user_message(message)
57
- {
58
- role: "user",
59
- content: normalize_content(MessageAccess.content(message))
60
- }
57
+ with_timestamp(
58
+ {
59
+ role: "user",
60
+ content: normalize_content(MessageAccess.content(message))
61
+ },
62
+ message
63
+ )
61
64
  end
62
65
 
63
66
  def normalize_assistant_message(message)
@@ -76,7 +79,7 @@ module Kward
76
79
  result = { role: "assistant", content: content }
77
80
  error_message = ToolCall.value(message, :errorMessage) || ToolCall.value(message, :error_message)
78
81
  result[:errorMessage] = error_message unless error_message.to_s.empty?
79
- result
82
+ with_timestamp(result, message)
80
83
  end
81
84
 
82
85
  def normalize_tool_result_message(message)
@@ -96,6 +99,12 @@ module Kward
96
99
 
97
100
  details = tool_result_details(message, matching_call, content)
98
101
  result[:details] = details unless details.empty?
102
+ with_timestamp(result, message)
103
+ end
104
+
105
+ def with_timestamp(result, message)
106
+ timestamp = ToolCall.value(message, :timestamp)
107
+ result[:timestamp] = timestamp if timestamp
99
108
  result
100
109
  end
101
110
 
@@ -0,0 +1,39 @@
1
+ # Namespace for operating-system command sandboxing.
2
+ module Kward
3
+ module Sandbox
4
+ # Describes the enforcement a platform runner can provide.
5
+ class Capabilities
6
+ attr_reader :backend, :reason
7
+
8
+ def initialize(available:, filesystem_enforced:, child_network_enforced:, backend:, reason: nil)
9
+ @available = available == true
10
+ @filesystem_enforced = filesystem_enforced == true
11
+ @child_network_enforced = child_network_enforced == true
12
+ @backend = backend.to_s
13
+ @reason = reason&.to_s
14
+ end
15
+
16
+ def available?
17
+ @available
18
+ end
19
+
20
+ def filesystem_enforced?
21
+ @filesystem_enforced
22
+ end
23
+
24
+ def child_network_enforced?
25
+ @child_network_enforced
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ available: available?,
31
+ filesystemEnforced: filesystem_enforced?,
32
+ childNetworkEnforced: child_network_enforced?,
33
+ backend: backend,
34
+ reason: reason
35
+ }.compact
36
+ end
37
+ end
38
+ end
39
+ end