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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +39 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -0
- data/doc/security.md +4 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- 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/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +14 -0
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +29 -9
- data/lib/kward/config_files.rb +10 -0
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +53 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +2 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +35 -0
- data/lib/kward/rpc/session_manager.rb +1 -0
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- 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 +37 -4
- 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/templates/default/kward_navigation.rb +2 -1
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +7 -1
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -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
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
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,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
|
|
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)
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|