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
@@ -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,15 +35,17 @@ 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"
41
+ require_relative "skills/capture"
40
42
  require_relative "starter_pack_installer"
41
43
  require_relative "steering"
42
44
  require_relative "update_check"
43
45
  require_relative "tools/tool_call"
44
46
  require_relative "tools/registry"
45
47
  require_relative "telemetry/stats"
46
- require_relative "workspace"
48
+ require_relative "workspace_factory"
47
49
  require_relative "cli/commands"
48
50
  require_relative "cli/auth_commands"
49
51
  require_relative "cli/doctor"
@@ -438,13 +440,15 @@ module Kward
438
440
  if input.is_a?(Hash) && input[:tab_action]
439
441
  tab_result = handle_tab_action(input, session_store)
440
442
  break if tab_result == PromptInterface::EXIT_INPUT
441
- agent = active_tab.agent if active_tab
443
+ agent = active_tab.agent if active_tab&.agent
442
444
  next
443
445
  end
444
446
  if input.is_a?(Hash) && input[:reasoning_action]
447
+ next if active_tab && plugin_tab?
448
+
445
449
  conversation = active_tab ? active_tab.agent.conversation : agent.conversation
446
450
  cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
447
- agent = active_tab.agent if active_tab
451
+ agent = active_tab.agent if active_tab&.agent
448
452
  next
449
453
  end
450
454
  next if input == :tab_idle
@@ -464,22 +468,37 @@ module Kward
464
468
  display_input = input if display_input
465
469
  end
466
470
  break if ["/exit", "/quit"].include?(command)
471
+ if active_tab && plugin_tab? && command.start_with?("/")
472
+ unless plugin_tab_command_allowed?(command)
473
+ runtime_output("#{command.split.first} is unavailable in this plugin tab.")
474
+ next
475
+ end
476
+ if active_tab.driver.respond_to?(:handle_command) && active_tab.driver.handles_command?(command)
477
+ output = active_tab.driver.handle_command(command)
478
+ runtime_output(output) unless output.to_s.empty?
479
+ next
480
+ end
481
+ end
467
482
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
468
483
  if replacement_agent?(replacement_agent)
469
484
  agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
485
+ elsif active_tab&.agent
486
+ agent = active_tab.agent
470
487
  end
471
488
  end
472
489
  next if handled
473
490
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
474
491
 
475
- flush_pending_reasoning_config(conversation: agent.conversation)
492
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
493
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
476
494
  expanded_input = expand_prompt_template(input)
477
495
  display_input = display_input || input if expanded_input
478
496
  input = expanded_input || input
479
- @footer_conversation = agent.conversation
497
+ @footer_conversation = active_tab.agent.conversation if active_tab&.agent
498
+ @footer_conversation = agent.conversation unless active_tab
480
499
  begin
481
500
  @rewind_return_leaf_id = nil
482
- auto_name_active_session(display_input || input)
501
+ auto_name_active_session(display_input || input) unless active_tab && plugin_tab?
483
502
  if active_tab
484
503
  submit_tab_input(active_tab, input, display_input: display_input)
485
504
  pending_inputs = []
@@ -494,12 +513,14 @@ module Kward
494
513
  end
495
514
  end
496
515
 
497
- flush_pending_reasoning_config(conversation: agent.conversation)
498
- agent.conversation
516
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
517
+ flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
518
+ active_tab&.agent&.conversation || agent.conversation
499
519
  rescue Interrupt
500
- flush_pending_reasoning_config(conversation: agent&.conversation)
520
+ flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
521
+ flush_pending_reasoning_config(conversation: agent&.conversation) unless active_tab
501
522
  runtime_output("Goodbye.")
502
- agent&.conversation
523
+ active_tab&.agent&.conversation || agent&.conversation
503
524
  ensure
504
525
  begin
505
526
  stop_tabs if respond_to?(:stop_tabs, true)
@@ -5,6 +5,8 @@ require "yaml"
5
5
  require_relative "frontmatter"
6
6
  require_relative "private_file"
7
7
  require_relative "path_guard"
8
+ require_relative "permissions/policy"
9
+ require_relative "sandbox/policy"
8
10
  require_relative "ekwsh"
9
11
  require_relative "editor_mode"
10
12
  require_relative "diff_view_mode"
@@ -150,9 +152,22 @@ module Kward
150
152
  "trust_project" => false
151
153
  },
152
154
  "enforce_workspace_agents_file" => false,
155
+ "system_prompt" => {
156
+ "include_principles" => true
157
+ },
153
158
  "mcpServers" => {},
154
159
  "tools" => {
155
160
  "workspace_guardrails" => true
161
+ },
162
+ "permissions" => {
163
+ "enabled" => false,
164
+ "mode" => "ask"
165
+ },
166
+ "sandbox" => {
167
+ "mode" => "off",
168
+ "network" => "deny",
169
+ "writable_roots" => [],
170
+ "protect_git_metadata" => true
156
171
  }
157
172
  }
158
173
  end
@@ -488,6 +503,23 @@ module Kward
488
503
  tools["workspace_guardrails"] != false
489
504
  end
490
505
 
506
+ # Builds the opt-in model-tool permission policy from persisted configuration.
507
+ def permission_policy(config = read_config)
508
+ Permissions::Policy.from_config(config)
509
+ end
510
+
511
+ # Builds the user-controlled command sandbox policy for a workspace.
512
+ def sandbox_policy(workspace_root, config = read_config)
513
+ sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
514
+ Sandbox::Policy.new(
515
+ mode: sandbox.fetch("mode", "off"),
516
+ network: sandbox.fetch("network", "deny"),
517
+ workspace_root: workspace_root,
518
+ writable_roots: sandbox.fetch("writable_roots", []),
519
+ protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
520
+ )
521
+ end
522
+
491
523
  # Returns whether project-level Agent Skills should be loaded from the workspace.
492
524
  def project_skills_trusted?(config = read_config)
493
525
  skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
@@ -548,13 +580,61 @@ module Kward
548
580
  # alias for existing installations.
549
581
  #
550
582
  # @return [String, nil] prompt text, or nil when absent/too large
551
- def agents_prompt
583
+ def agents_prompt(config: read_config)
584
+ return nil unless include_config_principles?(config)
585
+
552
586
  path = config_principles_path
553
587
  return read_prompt_file(path, "Kward principles file") if File.exist?(path)
554
588
 
555
589
  read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
556
590
  end
557
591
 
592
+ # Returns whether config-directory principles are included in normal system
593
+ # prompt assembly. Replacement prompt files bypass all assembled sections.
594
+ def include_config_principles?(config = read_config)
595
+ settings = config["system_prompt"]
596
+ return true unless settings.is_a?(Hash)
597
+
598
+ settings["include_principles"] != false
599
+ end
600
+
601
+ # Returns the configured replacement system prompt path, or nil when normal
602
+ # prompt assembly should be used. Relative paths are anchored to the config
603
+ # directory so configuration remains portable with KWARD_CONFIG_PATH.
604
+ def system_prompt_file_path(config = read_config)
605
+ settings = config["system_prompt"]
606
+ value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
607
+ return nil if value.empty?
608
+
609
+ File.expand_path(value, config_dir)
610
+ end
611
+
612
+ # Returns the replacement system prompt text when configured. A configured
613
+ # but unavailable file intentionally yields nil: callers must not fall back
614
+ # to Kward's larger assembled prompt in replacement mode.
615
+ def system_prompt_file(config = read_config)
616
+ path = system_prompt_file_path(config)
617
+ return nil unless path
618
+
619
+ read_prompt_file(path, "custom system prompt file")
620
+ end
621
+
622
+ def replacement_system_prompt?(config = read_config)
623
+ !system_prompt_file_path(config).nil?
624
+ end
625
+
626
+ # Returns a lightweight fingerprint for config-owned prompt sources. It is
627
+ # used by active conversations to pick up prompt edits without a restart.
628
+ def system_prompt_sources_fingerprint
629
+ config = read_config
630
+ paths = [config_path, system_prompt_file_path(config)]
631
+ if include_config_principles?(config)
632
+ paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
633
+ end
634
+
635
+ paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
636
+ end
637
+
558
638
  def config_principles_path
559
639
  File.join(config_dir, "PRINCIPLES.md")
560
640
  end
@@ -676,6 +756,15 @@ module Kward
676
756
  read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
677
757
  end
678
758
 
759
+ def prompt_source_signature(path)
760
+ return "#{path}:missing" unless File.exist?(path)
761
+
762
+ stat = File.stat(path)
763
+ "#{path}:#{stat.size}:#{stat.mtime.to_f}"
764
+ rescue StandardError
765
+ "#{path}:unavailable"
766
+ end
767
+
679
768
  def read_prompt_file(path, label)
680
769
  return nil unless File.exist?(path)
681
770
 
@@ -87,6 +87,7 @@ module Kward
87
87
  end
88
88
  @compaction_system_message = compaction_system_message
89
89
  @workspace_agents_mtime = workspace_agents_mtime
90
+ @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
90
91
  @last_entry_compaction = false
91
92
  @memory_context = memory_context
92
93
  @session_memories = Array(session_memories)
@@ -191,6 +192,7 @@ module Kward
191
192
  @on_system_message_change&.call(replacement)
192
193
  @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
193
194
  @workspace_agents_mtime = workspace_agents_mtime
195
+ @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
194
196
  replacement
195
197
  end
196
198
 
@@ -206,7 +208,18 @@ module Kward
206
208
  end
207
209
 
208
210
  def refresh_system_message_if_workspace_agents_changed!
209
- refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
211
+ refresh_system_message_if_sources_changed!
212
+ end
213
+
214
+ # Refreshes runtime prompt state after workspace or config-owned prompt
215
+ # sources change. Keep the older public method above as a compatibility
216
+ # alias for existing callers.
217
+ def refresh_system_message_if_sources_changed!
218
+ return unless @system_message_enabled
219
+
220
+ changed = workspace_agents_mtime != @workspace_agents_mtime ||
221
+ ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint
222
+ refresh_system_message! if changed
210
223
  end
211
224
 
212
225
  def mark_read(path)
@@ -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