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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -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
|
-
|
|
76
|
-
@tabs << build_tab(@active_session,
|
|
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
|
-
|
|
97
|
-
return nil if
|
|
120
|
+
descriptors = Array(data["tabs"])
|
|
121
|
+
return nil if descriptors.empty?
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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)
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -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
|
-
|
|
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
|