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
@@ -102,6 +102,17 @@ module Kward
102
102
  end
103
103
  end
104
104
 
105
+ def update_plugin_tab_slash_commands(tab)
106
+ return unless @prompt.respond_to?(:update_slash_commands)
107
+
108
+ entries = if tab&.driver&.respond_to?(:slash_command_entries)
109
+ tab.driver.slash_command_entries
110
+ else
111
+ []
112
+ end
113
+ @prompt.update_slash_commands(slash_command_entries + Array(entries))
114
+ end
115
+
105
116
  def slash_command_entries
106
117
  prompt_entries = prompt_templates.map do |template|
107
118
  {
@@ -192,6 +203,17 @@ module Kward
192
203
  plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
193
204
  end
194
205
 
206
+ def notify_plugin_tab_transcript_event(event, driver)
207
+ return if plugin_registry.transcript_event_handlers.empty?
208
+
209
+ context = PluginRegistry::Context.new(
210
+ conversation: driver,
211
+ workspace_root: current_workspace_root,
212
+ say_callback: lambda { |message| runtime_output(message) }
213
+ )
214
+ plugin_registry.notify_transcript_event(event, context)
215
+ end
216
+
195
217
  end
196
218
  end
197
219
  end
@@ -7,9 +7,15 @@ module Kward
7
7
  private
8
8
 
9
9
  def render_conversation_transcript(conversation)
10
+ render_transcript_messages(conversation.messages)
11
+ end
12
+
13
+ # Renders a transcript supplied by either a Kward conversation or a
14
+ # plugin-owned tab driver.
15
+ def render_transcript_messages(messages)
10
16
  tool_calls_by_id = {}
11
17
  @prompt.say("\n#{colored("Transcript", :gray, :bold)}\n")
12
- conversation.messages.each do |message|
18
+ Array(messages).each do |message|
13
19
  role = message_role(message)
14
20
  next if role == "system"
15
21
 
@@ -50,6 +50,7 @@ module Kward
50
50
  tool_registry = ToolRegistry.new(
51
51
  workspace: workspace,
52
52
  prompt: @prompt,
53
+ tool_approval: interactive_tool_approval_callback,
53
54
  hook_manager: hook_manager,
54
55
  hook_context: hook_context
55
56
  )
@@ -64,6 +65,19 @@ module Kward
64
65
  )
65
66
  end
66
67
 
68
+ def interactive_tool_approval_callback
69
+ return nil unless @prompt.respond_to?(:ask_tool_approval)
70
+ return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
71
+
72
+ lambda do |tool_call:, name:, args:, cancellation:|
73
+ @prompt.ask_tool_approval(
74
+ tool_name: name,
75
+ args: args,
76
+ reason: args["hook_message"] || args[:hook_message]
77
+ )
78
+ end
79
+ end
80
+
67
81
  def handle_interactive_shell_command(input, agent)
68
82
  command = input.to_s.sub(/\A!\s*/, "")
69
83
  if command.strip.empty?
@@ -1,3 +1,4 @@
1
+ require "json"
1
2
  require "thread"
2
3
  require_relative "../cancellation"
3
4
 
@@ -10,6 +11,7 @@ module Kward
10
11
  TabRuntime = Struct.new(
11
12
  :session,
12
13
  :agent,
14
+ :driver,
13
15
  :diff,
14
16
  :snapshot,
15
17
  :status,
@@ -58,6 +60,28 @@ module Kward
58
60
  def ask_user_question(questions, cancellation: nil)
59
61
  @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
60
62
  end
63
+
64
+ def ask_tool_approval(tool_name:, args:, reason: nil)
65
+ answers = ask_user_question([
66
+ {
67
+ header: "Approval required · #{tool_name.to_s.tr("_", " ").capitalize}",
68
+ question: (["The agent wants to use #{tool_name}.", "Arguments:\n#{JSON.pretty_generate(args.to_h)}", reason].compact).join("\n"),
69
+ options: [
70
+ { label: "Allow once", description: "Run this tool call." },
71
+ { label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
72
+ { label: "Deny", description: "Do not run this tool call." }
73
+ ]
74
+ }
75
+ ])
76
+ answer = answers&.first
77
+ return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
78
+
79
+ case answer&.fetch(:answer, nil)
80
+ when "Allow once" then true
81
+ when "Allow this tool for this session" then :allow_for_session
82
+ else false
83
+ end
84
+ end
61
85
  end
62
86
 
63
87
  private
@@ -72,8 +96,8 @@ module Kward
72
96
  return restored if restored
73
97
 
74
98
  if agent.nil? && (resumed_agent = resume_last_session(session_store))
75
- release_implementation_writer
76
- @tabs << build_tab(@active_session, build_tab_agent(resumed_agent.conversation, @active_session), label: default_tab_label(0))
99
+ restored_agent = build_tab_agent(resumed_agent.conversation, @active_session)
100
+ @tabs << build_tab(@active_session, restored_agent, driver: SessionTabDriver.new(session: @active_session, agent: restored_agent), label: default_tab_label(0))
77
101
  return activate_tab(0, render: false)
78
102
  end
79
103
 
@@ -87,19 +111,18 @@ module Kward
87
111
  @active_session.attach(conversation)
88
112
  tab_agent = build_tab_agent(conversation, @active_session)
89
113
  end
90
- @tabs << build_tab(@active_session, tab_agent, label: default_tab_label(0))
114
+ @tabs << build_tab(@active_session, tab_agent, driver: SessionTabDriver.new(session: @active_session, agent: tab_agent), label: default_tab_label(0))
91
115
  activate_tab(0, render: false)
92
116
  end
93
117
 
94
118
  def restore_tabs(session_store)
95
119
  data = @tab_store&.load || {}
96
- paths = Array(data["session_paths"]).map(&:to_s)
97
- return nil if paths.empty?
120
+ descriptors = Array(data["tabs"])
121
+ return nil if descriptors.empty?
98
122
 
99
- paths.each_with_index do |path, index|
100
- session, conversation = restore_tab_session(session_store, path)
101
- tab = build_tab(session, build_tab_agent(conversation, session), label: restored_tab_label(data, index))
102
- @tabs << tab
123
+ descriptors.each_with_index do |_descriptor, index|
124
+ tab = build_tab_from_descriptor(data.fetch("tabs")[index], session_store, label: restored_tab_label(data, index))
125
+ @tabs << tab if tab
103
126
  rescue StandardError
104
127
  next
105
128
  end
@@ -110,6 +133,47 @@ module Kward
110
133
  activate_tab(@active_tab_index)
111
134
  end
112
135
 
136
+ def build_tab_from_descriptor(descriptor, session_store, label: nil)
137
+ descriptor = descriptor.transform_keys(&:to_s)
138
+ if descriptor["kind"] == "session"
139
+ session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"))
140
+ agent = build_tab_agent(conversation, session)
141
+ return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: label)
142
+ end
143
+
144
+ tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
145
+ driver = if tab_type
146
+ host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
147
+ tab_type.handler.call(host, descriptor)
148
+ else
149
+ UnavailableTabDriver.new(descriptor: descriptor, message: "Plugin tab #{descriptor["plugin_tab_type"].inspect} is unavailable.")
150
+ end
151
+ raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless driver
152
+ build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || tab_type&.title)
153
+ end
154
+
155
+ def open_plugin_tab(name, session_store)
156
+ tab_type = plugin_registry.tab_type_for(name)
157
+ return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type
158
+
159
+ if tab_type.singleton == :global && (existing = @tabs.find { |tab| tab.driver.descriptor["plugin_tab_type"] == tab_type.id })
160
+ return switch_tab(@tabs.index(existing))
161
+ end
162
+
163
+ save_active_tab_state
164
+ stop_tab_live_view
165
+ descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title }
166
+ host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
167
+ driver = tab_type.handler.call(host, descriptor)
168
+ raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
169
+
170
+ @tabs << build_tab(nil, nil, driver: driver, label: tab_type.title)
171
+ @active_tab_index = @tabs.length - 1
172
+ activate_tab(@active_tab_index)
173
+ rescue StandardError => e
174
+ runtime_output("Could not open plugin tab #{name.inspect}: #{e.message}")
175
+ end
176
+
113
177
  def restore_tab_session(session_store, path)
114
178
  if File.file?(path)
115
179
  session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
@@ -131,6 +195,7 @@ module Kward
131
195
  tool_registry = ToolRegistry.new(
132
196
  workspace: workspace,
133
197
  prompt: prompt,
198
+ tool_approval: tab_tool_approval_callback(prompt),
134
199
  hook_manager: hook_manager,
135
200
  hook_context: hook_context
136
201
  )
@@ -147,10 +212,20 @@ module Kward
147
212
  agent
148
213
  end
149
214
 
150
- def build_tab(session, agent, label: nil)
215
+ def tab_tool_approval_callback(prompt)
216
+ return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
217
+
218
+ lambda do |tool_call:, name:, args:, cancellation:|
219
+ prompt.ask_tool_approval(tool_name: name, args: args, reason: args["hook_message"] || args[:hook_message])
220
+ end
221
+ end
222
+
223
+ def build_tab(session, agent, driver: nil, label: nil)
224
+ driver ||= SessionTabDriver.new(session: session, agent: agent)
151
225
  TabRuntime.new(
152
226
  session: session,
153
227
  agent: agent,
228
+ driver: driver,
154
229
  diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
155
230
  snapshot: nil,
156
231
  status: "idle",
@@ -162,7 +237,7 @@ module Kward
162
237
  steering: nil,
163
238
  error: nil,
164
239
  answer: nil,
165
- stream_state: new_tab_stream_state(agent),
240
+ stream_state: new_tab_stream_state(driver),
166
241
  markdown_chunks: [],
167
242
  label: label,
168
243
  unread: false,
@@ -177,6 +252,17 @@ module Kward
177
252
  @tabs && @tabs[@active_tab_index]
178
253
  end
179
254
 
255
+ def plugin_tab?(tab = active_tab)
256
+ tab && tab.session.nil?
257
+ end
258
+
259
+ def plugin_tab_command_allowed?(command)
260
+ name, = parse_slash_command(command)
261
+ return true if %w[tab exit quit].include?(name)
262
+
263
+ active_tab.driver.respond_to?(:handles_command?) && active_tab.driver.handles_command?(command)
264
+ end
265
+
180
266
  def assign_tab_question_prompt(agent, tab)
181
267
  prompt = agent.instance_variable_get(:@tab_question_prompt) if agent
182
268
  prompt.tab = tab if prompt.respond_to?(:tab=)
@@ -243,7 +329,8 @@ module Kward
243
329
  session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
244
330
  conversation = new_conversation(workspace_root: session_store.cwd)
245
331
  session.attach(conversation)
246
- @tabs << build_tab(session, build_tab_agent(conversation, session), label: default_tab_label(@tabs.length))
332
+ agent = build_tab_agent(conversation, session)
333
+ @tabs << build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: default_tab_label(@tabs.length))
247
334
  @active_tab_index = @tabs.length - 1
248
335
  activate_tab(@active_tab_index)
249
336
  end
@@ -263,6 +350,7 @@ module Kward
263
350
 
264
351
  stop_tab_live_view
265
352
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
353
+ tab.driver.close if tab.driver.respond_to?(:close)
266
354
  @tabs.delete_at(@active_tab_index)
267
355
  @active_tab_index = [@active_tab_index, @tabs.length - 1].min
268
356
  activate_tab(@active_tab_index)
@@ -285,6 +373,7 @@ module Kward
285
373
 
286
374
  tab.session = @active_session
287
375
  tab.agent = build_tab_agent(agent.conversation, tab.session)
376
+ tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
288
377
  assign_tab_question_prompt(tab.agent, tab)
289
378
  tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
290
379
  tab.snapshot = nil
@@ -298,7 +387,7 @@ module Kward
298
387
  tab.queued_inputs.clear
299
388
  tab.steering = nil
300
389
  tab.shell = nil
301
- tab.stream_state = new_tab_stream_state(tab.agent)
390
+ tab.stream_state = new_tab_stream_state(tab.driver)
302
391
  tab.markdown_chunks.clear
303
392
  update_prompt_tabs
304
393
  persist_tabs
@@ -323,11 +412,19 @@ module Kward
323
412
 
324
413
  @active_session = tab.session
325
414
  @session_diff = tab.diff || SessionDiff.new
326
- @footer_conversation = tab.agent.conversation
327
- @footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
328
- update_assistant_prompt(tab.agent.conversation)
415
+ if tab.agent
416
+ @footer_conversation = tab.agent.conversation
417
+ @footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
418
+ update_assistant_prompt(tab.agent.conversation)
419
+ else
420
+ @footer_conversation = nil
421
+ @footer_tool_registry = nil
422
+ @assistant_prompt = "#{tab.driver.assistant_label || "Plugin"}>"
423
+ @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
424
+ end
329
425
  tab.unread = false
330
426
  restore_tab_composer_snapshot(tab.snapshot)
427
+ update_plugin_tab_slash_commands(tab)
331
428
  update_prompt_tabs
332
429
  render_tab(tab) if render
333
430
  start_tab_live_view(tab) if tab.running?
@@ -344,10 +441,10 @@ module Kward
344
441
  end
345
442
 
346
443
  restore_prompt_transcript do
347
- if empty_tab_conversation?(tab.agent.conversation)
444
+ if tab.agent && empty_tab_conversation?(tab.agent.conversation)
348
445
  print_visual_banner
349
446
  else
350
- render_conversation_transcript(tab.agent.conversation)
447
+ render_transcript_messages(tab.driver.messages)
351
448
  end
352
449
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
353
450
  end
@@ -409,14 +506,14 @@ module Kward
409
506
  tab.status = "queued"
410
507
  tab.unread = false
411
508
  tab.cancellation = Cancellation.new
412
- tab.steering = steering_supported? ? Steering.new : nil
509
+ tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
413
510
  tab.error = nil
414
511
  tab.answer = nil
415
512
  tab.error_reported = false
416
513
  tab.event_history.clear
417
514
  tab.seen_events = 0
418
515
  tab.markdown_chunks.clear
419
- tab.stream_state = new_tab_stream_state(tab.agent)
516
+ tab.stream_state = new_tab_stream_state(tab.driver)
420
517
  update_prompt_tabs
421
518
  tab.thread = Thread.new { run_tab_turn(tab, input, display_input: display_input) }
422
519
  tab.thread.report_on_exception = false
@@ -424,12 +521,9 @@ module Kward
424
521
  end
425
522
 
426
523
  def run_tab_turn(tab, input, display_input: nil)
427
- options = agent_display_options(display_input)
428
- options[:cancellation] = tab.cancellation
429
- options[:steering] = tab.steering if tab.steering
430
524
  tab.status = "running"
431
525
  update_prompt_tabs
432
- tab.answer = tab.agent.ask(input, **options) do |event|
526
+ tab.answer = tab.driver.submit(input, display_input: display_input, cancellation: tab.cancellation, steering: tab.steering) do |event|
433
527
  tab.record_event(event)
434
528
  end
435
529
  tab.status = "ready"
@@ -574,10 +668,10 @@ module Kward
574
668
  renderer = tab_live_renderer(tab)
575
669
  until @tab_live_view_stop
576
670
  events = tab.event_history[tab.seen_events..] || []
577
- events.each { |event| renderer.call(event, tab.agent) }
671
+ events.each { |event| renderer.call(event, tab.driver) }
578
672
  tab.seen_events += events.length
579
673
  if tab.idle?
580
- renderer.call(:flush, tab.agent)
674
+ renderer.call(:flush, tab.driver)
581
675
  break
582
676
  end
583
677
  sleep 0.05
@@ -587,7 +681,7 @@ module Kward
587
681
  end
588
682
 
589
683
  def tab_live_renderer(tab)
590
- lambda do |event, agent|
684
+ lambda do |event, driver|
591
685
  if event == :flush
592
686
  flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state, force: true)
593
687
  render_tab_answer(tab)
@@ -595,7 +689,11 @@ module Kward
595
689
  next
596
690
  end
597
691
 
598
- notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
692
+ if driver.respond_to?(:conversation)
693
+ notify_plugin_transcript_event(event, driver.conversation)
694
+ elsif plugin_tab_type_for(driver)&.transcript_events
695
+ notify_plugin_tab_transcript_event(event, driver)
696
+ end
599
697
  handle_interactive_event(event, tab.markdown_chunks, tab.stream_state)
600
698
  flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state)
601
699
  rescue StandardError => e
@@ -603,6 +701,10 @@ module Kward
603
701
  end
604
702
  end
605
703
 
704
+ def plugin_tab_type_for(driver)
705
+ plugin_registry.tab_type_for_id(driver.descriptor["plugin_tab_type"])
706
+ end
707
+
606
708
  def render_tab_answer(tab)
607
709
  return unless tab.status == "ready"
608
710
  return if tab.stream_state[:streamed]
@@ -611,13 +713,13 @@ module Kward
611
713
  @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
612
714
  end
613
715
 
614
- def new_tab_stream_state(agent)
716
+ def new_tab_stream_state(driver)
615
717
  {
616
718
  streamed: false,
617
719
  last_flush: monotonic_now,
618
720
  stream_block_open: false,
619
721
  markdown_streams: {},
620
- defer_assistant_streaming: defer_assistant_streaming?(agent)
722
+ defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
621
723
  }
622
724
  end
623
725
 
@@ -650,7 +752,7 @@ module Kward
650
752
  action, value = argument.to_s.strip.split(/\s+/, 2)
651
753
  case action
652
754
  when nil, ""
653
- runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
755
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
654
756
  nil
655
757
  when /^\d+$/
656
758
  switch_tab_number(action)
@@ -664,11 +766,16 @@ module Kward
664
766
  when "new"
665
767
  open_new_tab(session_store)
666
768
  active_tab&.agent
769
+ when "open"
770
+ return runtime_output("Usage: /tab open <plugin-tab>") if value.to_s.strip.empty?
771
+
772
+ open_plugin_tab(value.strip, session_store)
773
+ active_tab&.agent
667
774
  when "name", "rename"
668
775
  rename_active_tab(value)
669
776
  active_tab&.agent
670
777
  else
671
- runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
778
+ runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
672
779
  nil
673
780
  end
674
781
  end
@@ -743,8 +850,7 @@ module Kward
743
850
  return unless @tab_store
744
851
 
745
852
  @tab_store.save(
746
- session_paths: @tabs.map { |tab| tab.session&.path }.compact,
747
- labels: @tabs.map { |tab| tab.label.to_s },
853
+ tabs: @tabs.map { |tab| tab.driver.descriptor.merge("label" => tab.label.to_s) },
748
854
  active_index: @active_tab_index
749
855
  )
750
856
  end
data/lib/kward/cli.rb CHANGED
@@ -35,6 +35,7 @@ require_relative "session_diff"
35
35
  require_relative "session_store"
36
36
  require_relative "session_naming"
37
37
  require_relative "tab_store"
38
+ require_relative "tab_driver"
38
39
  require_relative "session_trash"
39
40
  require_relative "session_tree_renderer"
40
41
  require_relative "starter_pack_installer"
@@ -438,13 +439,15 @@ module Kward
438
439
  if input.is_a?(Hash) && input[:tab_action]
439
440
  tab_result = handle_tab_action(input, session_store)
440
441
  break if tab_result == PromptInterface::EXIT_INPUT
441
- agent = active_tab.agent if active_tab
442
+ agent = active_tab.agent if active_tab&.agent
442
443
  next
443
444
  end
444
445
  if input.is_a?(Hash) && input[:reasoning_action]
446
+ next if active_tab && plugin_tab?
447
+
445
448
  conversation = active_tab ? active_tab.agent.conversation : agent.conversation
446
449
  cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
447
- agent = active_tab.agent if active_tab
450
+ agent = active_tab.agent if active_tab&.agent
448
451
  next
449
452
  end
450
453
  next if input == :tab_idle
@@ -464,22 +467,37 @@ module Kward
464
467
  display_input = input if display_input
465
468
  end
466
469
  break if ["/exit", "/quit"].include?(command)
470
+ if active_tab && plugin_tab? && command.start_with?("/")
471
+ unless plugin_tab_command_allowed?(command)
472
+ runtime_output("#{command.split.first} is unavailable in this plugin tab.")
473
+ next
474
+ end
475
+ if active_tab.driver.respond_to?(:handle_command) && active_tab.driver.handles_command?(command)
476
+ output = active_tab.driver.handle_command(command)
477
+ runtime_output(output) unless output.to_s.empty?
478
+ next
479
+ end
480
+ end
467
481
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
468
482
  if replacement_agent?(replacement_agent)
469
483
  agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
484
+ elsif active_tab&.agent
485
+ agent = active_tab.agent
470
486
  end
471
487
  end
472
488
  next if handled
473
489
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
474
490
 
475
- flush_pending_reasoning_config(conversation: agent.conversation)
491
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
492
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
476
493
  expanded_input = expand_prompt_template(input)
477
494
  display_input = display_input || input if expanded_input
478
495
  input = expanded_input || input
479
- @footer_conversation = agent.conversation
496
+ @footer_conversation = active_tab.agent.conversation if active_tab&.agent
497
+ @footer_conversation = agent.conversation unless active_tab
480
498
  begin
481
499
  @rewind_return_leaf_id = nil
482
- auto_name_active_session(display_input || input)
500
+ auto_name_active_session(display_input || input) unless active_tab && plugin_tab?
483
501
  if active_tab
484
502
  submit_tab_input(active_tab, input, display_input: display_input)
485
503
  pending_inputs = []
@@ -494,12 +512,14 @@ module Kward
494
512
  end
495
513
  end
496
514
 
497
- flush_pending_reasoning_config(conversation: agent.conversation)
498
- agent.conversation
515
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
516
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
517
+ active_tab&.agent&.conversation || agent.conversation
499
518
  rescue Interrupt
500
- flush_pending_reasoning_config(conversation: agent&.conversation)
519
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
520
+ flush_pending_reasoning_config(conversation: agent&.conversation) unless active_tab
501
521
  runtime_output("Goodbye.")
502
- agent&.conversation
522
+ active_tab&.agent&.conversation || agent&.conversation
503
523
  ensure
504
524
  begin
505
525
  stop_tabs if respond_to?(:stop_tabs, true)
@@ -5,6 +5,7 @@ require "yaml"
5
5
  require_relative "frontmatter"
6
6
  require_relative "private_file"
7
7
  require_relative "path_guard"
8
+ require_relative "permissions/policy"
8
9
  require_relative "ekwsh"
9
10
  require_relative "editor_mode"
10
11
  require_relative "diff_view_mode"
@@ -153,6 +154,10 @@ module Kward
153
154
  "mcpServers" => {},
154
155
  "tools" => {
155
156
  "workspace_guardrails" => true
157
+ },
158
+ "permissions" => {
159
+ "enabled" => false,
160
+ "mode" => "ask"
156
161
  }
157
162
  }
158
163
  end
@@ -488,6 +493,11 @@ module Kward
488
493
  tools["workspace_guardrails"] != false
489
494
  end
490
495
 
496
+ # Builds the opt-in model-tool permission policy from persisted configuration.
497
+ def permission_policy(config = read_config)
498
+ Permissions::Policy.from_config(config)
499
+ end
500
+
491
501
  # Returns whether project-level Agent Skills should be loaded from the workspace.
492
502
  def project_skills_trusted?(config = read_config)
493
503
  skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
@@ -3,6 +3,7 @@ require "net/http"
3
3
  require "uri"
4
4
  require_relative "catalog"
5
5
  require_relative "decision"
6
+ require_relative "../http"
6
7
 
7
8
  # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
@@ -33,7 +34,7 @@ module Kward
33
34
  private
34
35
 
35
36
  def post(body)
36
- request = Net::HTTP::Post.new(@uri)
37
+ request = Http.apply_user_agent(Net::HTTP::Post.new(@uri))
37
38
  request["Content-Type"] = "application/json"
38
39
  request["Accept"] = "application/json"
39
40
  @headers.each { |key, value| request[key] = value }
data/lib/kward/http.rb ADDED
@@ -0,0 +1,18 @@
1
+ require_relative "version"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Shared HTTP request headers for Kward-owned network traffic.
6
+ module Http
7
+ module_function
8
+
9
+ def user_agent
10
+ "Kward/#{VERSION}"
11
+ end
12
+
13
+ def apply_user_agent(request)
14
+ request["User-Agent"] = user_agent
15
+ request
16
+ end
17
+ end
18
+ end