kward 0.77.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -0
  5. data/doc/code-search.md +9 -6
  6. data/doc/configuration.md +39 -0
  7. data/doc/permissions.md +179 -0
  8. data/doc/plugins.md +58 -0
  9. data/doc/rpc.md +61 -0
  10. data/doc/security.md +4 -0
  11. data/doc/tabs.md +4 -3
  12. data/doc/web-search.md +4 -2
  13. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  14. data/lib/kward/auth/github_oauth.rb +3 -3
  15. data/lib/kward/auth/oauth_helpers.rb +4 -2
  16. data/lib/kward/auth/openai_oauth.rb +2 -1
  17. data/lib/kward/cli/plugins.rb +22 -0
  18. data/lib/kward/cli/rendering.rb +7 -1
  19. data/lib/kward/cli/runtime_helpers.rb +14 -0
  20. data/lib/kward/cli/tabs.rb +140 -34
  21. data/lib/kward/cli.rb +29 -9
  22. data/lib/kward/config_files.rb +10 -0
  23. data/lib/kward/hooks/http_handler.rb +2 -1
  24. data/lib/kward/http.rb +18 -0
  25. data/lib/kward/model/client.rb +33 -11
  26. data/lib/kward/model/payloads.rb +6 -1
  27. data/lib/kward/openrouter_model_cache.rb +2 -1
  28. data/lib/kward/permissions/policy.rb +171 -0
  29. data/lib/kward/plugin_registry.rb +53 -1
  30. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  31. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  32. data/lib/kward/prompt_interface.rb +2 -0
  33. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  34. data/lib/kward/rpc/server.rb +35 -0
  35. data/lib/kward/rpc/session_manager.rb +1 -0
  36. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  37. data/lib/kward/starter_pack_installer.rb +3 -1
  38. data/lib/kward/tab_driver.rb +87 -0
  39. data/lib/kward/tab_store.rb +74 -12
  40. data/lib/kward/tools/code_search.rb +8 -2
  41. data/lib/kward/tools/fetch_content.rb +4 -2
  42. data/lib/kward/tools/fetch_raw.rb +3 -1
  43. data/lib/kward/tools/registry.rb +37 -4
  44. data/lib/kward/tools/search/code.rb +46 -12
  45. data/lib/kward/tools/search/web.rb +60 -17
  46. data/lib/kward/tools/search/web_fetch.rb +206 -38
  47. data/lib/kward/tools/web_search.rb +3 -1
  48. data/lib/kward/update_check.rb +2 -1
  49. data/lib/kward/version.rb +1 -1
  50. data/templates/default/kward_navigation.rb +2 -1
  51. data/templates/default/layout/html/layout.erb +2 -0
  52. data/templates/default/layout/html/setup.rb +1 -0
  53. metadata +7 -1
@@ -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
@@ -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
@@ -92,6 +94,7 @@ module Kward
92
94
  prompts: PROMPT_METHODS,
93
95
  sessions: SESSION_METHODS,
94
96
  turns: TURN_METHODS,
97
+ plugin_chats: PLUGIN_CHAT_METHODS,
95
98
  models: MODEL_METHODS,
96
99
  runtime: RUNTIME_METHODS,
97
100
  runtime_settings: RUNTIME_SETTING_METHODS,
@@ -119,6 +122,7 @@ module Kward
119
122
  @client = client
120
123
  @config_manager = ConfigManager.new
121
124
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
125
+ @plugin_chat_manager = PluginChatManager.new(server: self, client: client)
122
126
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
123
127
  @shutdown = false
124
128
  end
@@ -140,6 +144,7 @@ module Kward
140
144
  end
141
145
  end
142
146
  ensure
147
+ @plugin_chat_manager.shutdown
143
148
  @session_manager.shutdown_sessions
144
149
  end
145
150
 
@@ -349,6 +354,28 @@ module Kward
349
354
  @session_manager.list_turns(session_id: params["sessionId"])
350
355
  when TURN_METHODS[5]
351
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)
352
379
  when UI_METHODS[0]
353
380
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
354
381
  when TOOL_APPROVAL_METHODS[0]
@@ -392,6 +419,14 @@ module Kward
392
419
  tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
393
420
  updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
394
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
+ },
395
430
  turns: {
396
431
  mode: "async",
397
432
  perSessionConcurrency: 1,
@@ -968,6 +968,7 @@ module Kward
968
968
  prompt: prompt,
969
969
  allowed_tool_names: allowed_tool_names,
970
970
  tool_approval: tool_approval,
971
+ approval_for_allowed_tools: !tool_approval.nil?,
971
972
  hook_manager: hook_manager,
972
973
  hook_context: hook_context
973
974
  )
@@ -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
 
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require "uri"
7
7
  require "zlib"
8
8
  require_relative "config_files"
9
+ require_relative "http"
9
10
 
10
11
  # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
@@ -56,8 +57,9 @@ module Kward
56
57
 
57
58
  def download(url)
58
59
  uri = URI(url)
60
+ request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
59
61
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
60
- http.get(uri.request_uri)
62
+ http.request(request)
61
63
  end
62
64
  raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
63
65
 
@@ -0,0 +1,87 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Adapts a session-backed agent to the tab runtime interface. Plugin tab
4
+ # drivers implement the same small surface without becoming Kward sessions.
5
+ class SessionTabDriver
6
+ attr_reader :session, :agent
7
+
8
+ def initialize(session:, agent:)
9
+ @session = session
10
+ @agent = agent
11
+ end
12
+
13
+ def messages
14
+ agent.conversation.messages
15
+ end
16
+
17
+ def conversation
18
+ agent.conversation
19
+ end
20
+
21
+ def submit(input, display_input:, cancellation:, steering: nil, &block)
22
+ options = { cancellation: cancellation }
23
+ options[:display_input] = display_input unless display_input.nil?
24
+ options[:steering] = steering if steering
25
+ agent.ask(input, **options, &block)
26
+ end
27
+
28
+ def descriptor
29
+ { "kind" => "session", "session_path" => session.path }
30
+ end
31
+
32
+ def session?
33
+ true
34
+ end
35
+
36
+ def supports_steering?
37
+ true
38
+ end
39
+
40
+ def assistant_label
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Represents a persisted plugin tab whose provider plugin is unavailable.
46
+ # Its descriptor is retained so reinstalling the plugin restores the tab.
47
+ class UnavailableTabDriver
48
+ attr_reader :descriptor
49
+
50
+ def initialize(descriptor:, message:)
51
+ @descriptor = descriptor
52
+ @message = message
53
+ end
54
+
55
+ def messages
56
+ [{ role: "assistant", content: @message }]
57
+ end
58
+
59
+ def submit(*)
60
+ raise @message
61
+ end
62
+
63
+ def session?
64
+ false
65
+ end
66
+
67
+ def supports_steering?
68
+ false
69
+ end
70
+
71
+ def assistant_label
72
+ "Plugin"
73
+ end
74
+ end
75
+
76
+ # Dependencies made available to a plugin tab factory. The host deliberately
77
+ # exposes provider transport and frontend-neutral facts, not CLI internals or
78
+ # workspace session state.
79
+ class PluginTabHost
80
+ attr_reader :client, :workspace_root
81
+
82
+ def initialize(client:, workspace_root:)
83
+ @client = client
84
+ @workspace_root = workspace_root
85
+ end
86
+ end
87
+ end
@@ -5,32 +5,45 @@ require_relative "private_file"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
8
- # Persists the terminal UI's open session tabs per workspace.
8
+ # Persists the terminal UI's open tabs per workspace.
9
9
  class TabStore
10
+ VERSION = 2
11
+
10
12
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
11
13
  @config_dir = config_dir
12
14
  @cwd = File.expand_path(cwd)
13
15
  end
14
16
 
15
17
  def load
16
- return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
18
+ return empty_state unless File.file?(path)
17
19
 
18
20
  data = JSON.parse(File.read(path))
19
- paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
20
- labels = Array(data["labels"]).map(&:to_s)
21
- active_index = data["active_index"].to_i
22
- { "session_paths" => paths, "labels" => labels, "active_index" => active_index }
21
+ return typed_state(data) if data["tabs"].is_a?(Array)
22
+
23
+ legacy_state(data)
23
24
  rescue JSON::ParserError
24
- { "session_paths" => [], "active_index" => 0 }
25
+ empty_state
25
26
  end
26
27
 
27
- def save(session_paths:, active_index:, labels: [])
28
- paths = Array(session_paths).map(&:to_s).reject(&:empty?)
28
+ # `session_paths` remains supported for callers and layouts written before
29
+ # typed tab descriptors were introduced.
30
+ def save(session_paths: nil, tabs: nil, active_index:, labels: [])
31
+ tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
32
+ path = session_path.to_s
33
+ next if path.empty?
34
+
35
+ {
36
+ "kind" => "session",
37
+ "session_path" => path,
38
+ "label" => Array(labels)[index].to_s
39
+ }
40
+ end
41
+ tabs = normalize_tabs(tabs)
29
42
  PrivateFile.write_json(path, {
43
+ "version" => VERSION,
30
44
  "cwd" => @cwd,
31
- "session_paths" => paths,
32
- "labels" => Array(labels).map(&:to_s),
33
- "active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
45
+ "tabs" => tabs,
46
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
34
47
  })
35
48
  end
36
49
 
@@ -40,6 +53,55 @@ module Kward
40
53
 
41
54
  private
42
55
 
56
+ def empty_state
57
+ { "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
58
+ end
59
+
60
+ def typed_state(data)
61
+ tabs = normalize_tabs(data["tabs"])
62
+ state_for(tabs, data["active_index"])
63
+ end
64
+
65
+ def legacy_state(data)
66
+ paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
67
+ labels = Array(data["labels"]).map(&:to_s)
68
+ tabs = paths.each_with_index.map do |session_path, index|
69
+ { "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
70
+ end
71
+ state_for(tabs, data["active_index"])
72
+ end
73
+
74
+ def state_for(tabs, active_index)
75
+ {
76
+ "tabs" => tabs,
77
+ "session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
78
+ "labels" => tabs.map { |tab| tab["label"].to_s },
79
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
80
+ }
81
+ end
82
+
83
+ def normalize_tabs(tabs)
84
+ session_paths = {}
85
+
86
+ Array(tabs).filter_map do |tab|
87
+ next unless tab.is_a?(Hash)
88
+
89
+ normalized = tab.transform_keys(&:to_s)
90
+ kind = normalized["kind"].to_s
91
+ next if kind.empty?
92
+ next if kind == "session" && normalized["session_path"].to_s.empty?
93
+
94
+ if kind == "session"
95
+ session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
96
+ next if session_paths[session_path]
97
+
98
+ session_paths[session_path] = true
99
+ end
100
+
101
+ normalized
102
+ end
103
+ end
104
+
43
105
  def workspace_key
44
106
  Digest::SHA256.hexdigest(@cwd)[0, 24]
45
107
  end
@@ -37,7 +37,11 @@ module Kward
37
37
  },
38
38
  path: {
39
39
  type: "string",
40
- description: "Repo-relative path."
40
+ description: "Repo-relative path; inferred from GitHub blob URLs when omitted."
41
+ },
42
+ ref: {
43
+ type: "string",
44
+ description: "Git branch, tag, or commit; inferred from GitHub blob URLs when omitted."
41
45
  },
42
46
  start_line: {
43
47
  type: "integer",
@@ -63,7 +67,9 @@ module Kward
63
67
  # Executes the tool and returns model-facing output text.
64
68
  def call(args, _conversation, cancellation: nil)
65
69
  cancellation&.raise_if_cancelled!
66
- @code_search.call(args)
70
+ return @code_search.call(args) unless cancellation
71
+
72
+ @code_search.call(args, cancellation: cancellation)
67
73
  end
68
74
  end
69
75
  end