kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -19,13 +19,11 @@ module Kward
19
19
  { name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
20
20
  { name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
21
21
  { name: "redraw", description: "Refresh the visible terminal.", argument_hint: "" },
22
- { name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
22
+ { name: "settings", description: "Configure Kward interactively.", argument_hint: "" },
23
23
  { name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
24
24
  { name: "model", description: "Select the default model.", argument_hint: "" },
25
25
  { name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
26
26
  { name: "reload", description: "Reload installed plugins.", argument_hint: "" },
27
- { name: "workers", description: "Open the worker pipeline.", argument_hint: "[new|do <task>]" },
28
- { name: "queue", description: "Manage the tab-backed worker queue.", argument_hint: "[add|list|open|run|suspend|resume]" },
29
27
  { name: "git", description: "Review uncommitted changes and commit them.", argument_hint: "" },
30
28
  { name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
31
29
  { name: "files", description: "Browse project files.", argument_hint: "" },
data/lib/kward/prompts.rb CHANGED
@@ -3,9 +3,25 @@ require_relative "config_files"
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
5
5
  # System prompt assembly from config, workspace instructions, memory, and plugins.
6
+ #
7
+ # Call {system_message} for the provider-ready message or {prompt_sections}
8
+ # when a frontend needs to inspect the labeled sources separately.
9
+ #
10
+ # @api public
6
11
  module Prompts
7
12
  module_function
8
13
 
14
+ # Builds the system message sent to a model provider.
15
+ #
16
+ # @param workspace_root [String] active workspace root
17
+ # @param include_workspace_personality [Boolean] include persona and plugin context
18
+ # @param model [String, nil] model used for persona matching
19
+ # @param reasoning_effort [String, nil] reasoning effort used for persona matching
20
+ # @param now [Time] time used for persona modifiers
21
+ # @param memory_context [String, nil] retrieved memory text
22
+ # @param plugin_context [String, nil] trusted plugin-provided instructions
23
+ # @return [Hash] role/content system message
24
+ # @api public
9
25
  def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
10
26
  {
11
27
  role: "system",
@@ -17,6 +33,10 @@ module Kward
17
33
  prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
18
34
  end
19
35
 
36
+ # Returns labeled prompt sections for inspection by CLI and RPC frontends.
37
+ #
38
+ # @return [Array<Hash>] section hashes with label, content, and optional source
39
+ # @api public
20
40
  def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
21
41
  sections = [prompt_section("Built-in system prompt", base_prompt)]
22
42
  sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
@@ -0,0 +1,299 @@
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)
27
+ @server = server
28
+ @client = client
29
+ @chats = {}
30
+ @turns = {}
31
+ @subscriptions = {}
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ def supported_types
36
+ plugin_registry.tab_types.select(&:rpc)
37
+ end
38
+
39
+ def list
40
+ { chats: supported_types.map { |type| type_payload(type) } }
41
+ end
42
+
43
+ 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)
47
+
48
+ chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
49
+ end
50
+
51
+ def transcript(chat_id:, limit: nil, before: nil)
52
+ chat = fetch_chat(chat_id)
53
+ page = transcript_page(chat.driver, limit: limit, before: before)
54
+ {
55
+ chat: chat_payload(chat),
56
+ messages: TranscriptNormalizer.new(page.fetch(:messages)).normalize,
57
+ hasMore: page.fetch(:has_more),
58
+ nextBefore: page[:next_before]
59
+ }.compact
60
+ end
61
+
62
+ def subscribe(chat_id:)
63
+ chat = fetch_chat(chat_id)
64
+ @mutex.synchronize { @subscriptions[chat.id] = true }
65
+ { chat: chat_payload(chat), subscribed: true }
66
+ end
67
+
68
+ def unsubscribe(chat_id:)
69
+ chat = fetch_chat(chat_id)
70
+ @mutex.synchronize { @subscriptions.delete(chat.id) }
71
+ { chat: chat_payload(chat), subscribed: false }
72
+ end
73
+
74
+ def start_turn(chat_id:, input:, attachments: [])
75
+ chat = fetch_chat(chat_id)
76
+ normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
77
+ turn = Turn.new(
78
+ id: SecureRandom.uuid,
79
+ 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
88
+ )
89
+ @mutex.synchronize { @turns[turn.id] = turn }
90
+ chat.queue << turn.id
91
+ ensure_worker(chat)
92
+ emit_event(turn, "turnQueued", { status: "queued" })
93
+ turn_payload(turn)
94
+ end
95
+
96
+ 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)
105
+ end
106
+
107
+ def turn_status(turn_id:)
108
+ turn_payload(fetch_turn(turn_id))
109
+ end
110
+
111
+ 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 }
115
+ end
116
+
117
+ 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) } }
122
+ end
123
+
124
+ 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
130
+ end
131
+
132
+ private
133
+
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
+ 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
172
+
173
+ def run_turn(chat, turn)
174
+ turn.mutex.synchronize do
175
+ return if terminal?(turn)
176
+
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
197
+
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
259
+ end
260
+
261
+ def notify_event(event)
262
+ subscribed = @mutex.synchronize { @subscriptions[event[:chatId]] }
263
+ @server.notify("pluginChat/event", event) if subscribed
264
+ end
265
+
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
+ def type_payload(type)
279
+ { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact
280
+ end
281
+
282
+ def transcript_page(driver, limit:, before:)
283
+ return { messages: driver.messages, has_more: false } unless limit && driver.respond_to?(:transcript_page)
284
+
285
+ driver.transcript_page(limit: limit, before: before)
286
+ end
287
+
288
+ def chat_payload(chat)
289
+ type_payload(chat.type).merge(id: chat.id, subscribed: @mutex.synchronize { @subscriptions[chat.id] == true })
290
+ end
291
+
292
+ def turn_payload(turn)
293
+ 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
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -5,7 +5,6 @@ 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 "../workers"
9
8
  require_relative "../workspace"
10
9
  require_relative "../telemetry/logger"
11
10
  require_relative "../telemetry/stats"
@@ -14,6 +13,7 @@ require_relative "config_manager"
14
13
  require_relative "mcp_status"
15
14
  require_relative "redactor"
16
15
  require_relative "session_manager"
16
+ require_relative "plugin_chat_manager"
17
17
  require_relative "transport"
18
18
 
19
19
  # Namespace for the Kward CLI agent runtime.
@@ -33,6 +33,7 @@ module Kward
33
33
  # `SessionManager`, `AuthManager`, and `ConfigManager`. When adding an RPC
34
34
  # feature, update dispatch, capabilities, docs, and tests together so clients
35
35
  # can trust `initialize` as the source of supported behavior.
36
+ # @api public
36
37
  class Server
37
38
  PROTOCOL_VERSION = 1
38
39
  JSONRPC_VERSION = "2.0"
@@ -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
@@ -70,7 +72,6 @@ module Kward
70
72
  "memory/forget", "memory/promote", "memory/relax", "memory/inspect",
71
73
  "memory/why", "memory/summarize"
72
74
  ].freeze
73
- WORKER_METHODS = ["workers/list", "workers/show"].freeze
74
75
  COMMAND_METHODS = ["commands/list", "commands/run"].freeze
75
76
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
76
77
  CONFIG_METHODS = ["config/read", "config/update"].freeze
@@ -93,12 +94,12 @@ module Kward
93
94
  prompts: PROMPT_METHODS,
94
95
  sessions: SESSION_METHODS,
95
96
  turns: TURN_METHODS,
97
+ plugin_chats: PLUGIN_CHAT_METHODS,
96
98
  models: MODEL_METHODS,
97
99
  runtime: RUNTIME_METHODS,
98
100
  runtime_settings: RUNTIME_SETTING_METHODS,
99
101
  auth: AUTH_METHODS,
100
102
  memory: MEMORY_METHODS,
101
- workers: WORKER_METHODS,
102
103
  commands: COMMAND_METHODS,
103
104
  startup_resources: STARTUP_RESOURCE_METHODS,
104
105
  config: CONFIG_METHODS,
@@ -110,15 +111,19 @@ module Kward
110
111
  RPC_METHODS = METHOD_GROUPS.values.flatten.freeze
111
112
 
112
113
  # Creates the RPC server and its stateful managers.
113
- def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new, experimental_workers: false)
114
+ #
115
+ # @param input [IO] framed JSON-RPC input stream
116
+ # @param output [IO] framed JSON-RPC output stream
117
+ # @param error_output [IO, nil] redacted diagnostic stream
118
+ # @param client [Client] model-provider client shared by sessions
119
+ def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
114
120
  @transport = Transport.new(input: input, output: output)
115
121
  @error_output = error_output
116
122
  @client = client
117
123
  @config_manager = ConfigManager.new
118
124
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
125
+ @plugin_chat_manager = PluginChatManager.new(server: self, client: client)
119
126
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
120
- @worker_store = Workers::Store.new
121
- @experimental_workers = experimental_workers
122
127
  @shutdown = false
123
128
  end
124
129
 
@@ -139,6 +144,7 @@ module Kward
139
144
  end
140
145
  end
141
146
  ensure
147
+ @plugin_chat_manager.shutdown
142
148
  @session_manager.shutdown_sessions
143
149
  end
144
150
 
@@ -162,6 +168,10 @@ module Kward
162
168
  })
163
169
  end
164
170
 
171
+ # Writes a redacted server diagnostic without exposing error payload data.
172
+ #
173
+ # @param error [Exception]
174
+ # @return [void]
165
175
  def log_error(error)
166
176
  @error_output.puts("Kward RPC error: #{Redactor.redact_string(error.message)}") if @error_output
167
177
  end
@@ -277,12 +287,6 @@ module Kward
277
287
  @session_manager.memory_why(session_id: params["sessionId"])
278
288
  when MEMORY_METHODS[13]
279
289
  @session_manager.memory_summarize(session_id: params.fetch("sessionId"))
280
- when WORKER_METHODS[0]
281
- require_experimental_workers!
282
- workers_list(params)
283
- when WORKER_METHODS[1]
284
- require_experimental_workers!
285
- workers_show(params)
286
290
  when AUTH_METHODS[0]
287
291
  @auth_manager.status
288
292
  when AUTH_METHODS[1]
@@ -350,6 +354,28 @@ module Kward
350
354
  @session_manager.list_turns(session_id: params["sessionId"])
351
355
  when TURN_METHODS[5]
352
356
  @session_manager.list_turns(session_id: params["sessionId"], active: true)
357
+ when PLUGIN_CHAT_METHODS[0]
358
+ @plugin_chat_manager.list
359
+ when PLUGIN_CHAT_METHODS[1]
360
+ @plugin_chat_manager.open(type_id: params.fetch("typeId"))
361
+ when PLUGIN_CHAT_METHODS[2]
362
+ @plugin_chat_manager.transcript(chat_id: params.fetch("chatId"), limit: params["limit"], before: params["before"])
363
+ when PLUGIN_CHAT_METHODS[3]
364
+ @plugin_chat_manager.subscribe(chat_id: params.fetch("chatId"))
365
+ when PLUGIN_CHAT_METHODS[4]
366
+ @plugin_chat_manager.unsubscribe(chat_id: params.fetch("chatId"))
367
+ when PLUGIN_CHAT_METHODS[5]
368
+ @plugin_chat_manager.start_turn(chat_id: params.fetch("chatId"), input: params.fetch("input"), attachments: params["attachments"] || [])
369
+ when PLUGIN_CHAT_METHODS[6]
370
+ @plugin_chat_manager.cancel_turn(turn_id: params.fetch("turnId"))
371
+ when PLUGIN_CHAT_METHODS[7]
372
+ @plugin_chat_manager.turn_status(turn_id: params.fetch("turnId"))
373
+ when PLUGIN_CHAT_METHODS[8]
374
+ @plugin_chat_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
375
+ when PLUGIN_CHAT_METHODS[9]
376
+ @plugin_chat_manager.list_turns(chat_id: params["chatId"])
377
+ when PLUGIN_CHAT_METHODS[10]
378
+ @plugin_chat_manager.list_turns(chat_id: params["chatId"], active: true)
353
379
  when UI_METHODS[0]
354
380
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
355
381
  when TOOL_APPROVAL_METHODS[0]
@@ -393,6 +419,14 @@ module Kward
393
419
  tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
394
420
  updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
395
421
  },
422
+ pluginChats: {
423
+ supported: @plugin_chat_manager.supported_types.any?,
424
+ methods: PLUGIN_CHAT_METHODS,
425
+ notification: "pluginChat/event",
426
+ subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
427
+ 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 }
429
+ },
396
430
  turns: {
397
431
  mode: "async",
398
432
  perSessionConcurrency: 1,
@@ -429,7 +463,7 @@ module Kward
429
463
  reasoning: { start: false, delta: true, boundary: true, end: false },
430
464
  modelRetry: { supported: true, event: "modelRetry" },
431
465
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
432
- tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
466
+ tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, firstChangedLine: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
433
467
  errors: true,
434
468
  sessionUpdates: false
435
469
  },
@@ -484,8 +518,7 @@ module Kward
484
518
  logout: true
485
519
  },
486
520
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
487
- workers: workers_capability,
488
- stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: ["workers"] },
521
+ stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
489
522
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
490
523
  mcp: {
491
524
  supported: true,
@@ -543,18 +576,6 @@ module Kward
543
576
  }
544
577
  end
545
578
 
546
- def workers_capability
547
- return { supported: false, reason: "experimentalWorkersFlagRequired", flag: "--experimental-workers" } unless @experimental_workers
548
-
549
- { supported: true, methods: WORKER_METHODS, roles: ["implementation", "request"], statuses: Workers::Worker::STATUSES, transcriptStorage: "sessions", metadataStorage: "json" }
550
- end
551
-
552
- def require_experimental_workers!
553
- return if @experimental_workers
554
-
555
- raise NoMethodError, "workers require --experimental-workers"
556
- end
557
-
558
579
  def workspace_info(root)
559
580
  root = @session_manager.validate_workspace_root(root)
560
581
  { root: root, basename: File.basename(root), writable: File.writable?(root) }
@@ -715,20 +736,6 @@ module Kward
715
736
  { sections: sections }
716
737
  end
717
738
 
718
- def workers_list(params)
719
- include_archived = params["includeArchived"] == true
720
- workers = @worker_store.list(include_archived: include_archived)
721
- { workers: workers }
722
- end
723
-
724
- def workers_show(params)
725
- id = params.fetch("id").to_s.delete_prefix("#")
726
- worker = @worker_store.find(id)
727
- return { worker: worker } if worker
728
-
729
- raise ArgumentError, "Unknown worker: #{id}"
730
- end
731
-
732
739
  def auth_login_with_api_key(params)
733
740
  result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
734
741
  @session_manager.refresh_client_config