kward 0.79.0 → 0.80.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 +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require_relative "../transport/runtime"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
class CLI
|
|
5
|
+
# Foreground transport lifecycle commands.
|
|
6
|
+
module Transports
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_transport_command(arguments)
|
|
10
|
+
command = arguments.first.to_s
|
|
11
|
+
case command
|
|
12
|
+
when "list"
|
|
13
|
+
raise ArgumentError, command_usage("transport") unless arguments.length == 1
|
|
14
|
+
|
|
15
|
+
with_transport_runtime { |runtime| print_transport_entries(runtime.manager.list) }
|
|
16
|
+
when "status"
|
|
17
|
+
raise ArgumentError, command_usage("transport") unless arguments.length <= 2
|
|
18
|
+
|
|
19
|
+
with_transport_runtime do |runtime|
|
|
20
|
+
entries = arguments.length == 2 ? [runtime.manager.status(arguments[1])] : runtime.manager.status
|
|
21
|
+
print_transport_entries(entries)
|
|
22
|
+
end
|
|
23
|
+
when "run"
|
|
24
|
+
raise ArgumentError, command_usage("transport") unless (2..3).cover?(arguments.length)
|
|
25
|
+
|
|
26
|
+
workspace_root = arguments[2] ? expanded_working_directory(arguments[2]) : @working_directory
|
|
27
|
+
run_transport(arguments[1], workspace_root: workspace_root)
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, command_usage("transport")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def print_transport_entries(entries)
|
|
34
|
+
entries.each do |entry|
|
|
35
|
+
@prompt.say("#{entry[:id]}\t#{entry[:state] || "stopped"}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_transport(name, workspace_root: nil)
|
|
40
|
+
runtime = Transport::Runtime.new(client: ensure_client!)
|
|
41
|
+
runtime.manager.start(name, workspace_root: workspace_root)
|
|
42
|
+
@prompt.say("Transport #{name} is running. Press Ctrl-C to stop.")
|
|
43
|
+
wait_for_transport_shutdown
|
|
44
|
+
ensure
|
|
45
|
+
runtime&.shutdown
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_transport_runtime
|
|
49
|
+
runtime = Transport::Runtime.new(client: ensure_client!)
|
|
50
|
+
yield runtime
|
|
51
|
+
ensure
|
|
52
|
+
runtime&.shutdown
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def wait_for_transport_shutdown
|
|
56
|
+
stopped = false
|
|
57
|
+
previous_handlers = {}
|
|
58
|
+
%w[INT TERM].each do |signal|
|
|
59
|
+
previous_handlers[signal] = Signal.trap(signal) { stopped = true }
|
|
60
|
+
end
|
|
61
|
+
sleep 0.1 until stopped
|
|
62
|
+
ensure
|
|
63
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Coordinates Git worktree bindings with session-backed tab runtimes.
|
|
6
|
+
class CLI
|
|
7
|
+
module Worktrees
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def git_worktree_manager
|
|
11
|
+
@git_worktree_manager ||= GitWorktreeManager.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def worktree_binding_for(tab)
|
|
15
|
+
driver = tab&.driver
|
|
16
|
+
driver.worktree if driver&.respond_to?(:worktree)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def worktree_root_for(binding, fallback: current_workspace_root)
|
|
20
|
+
return fallback unless binding
|
|
21
|
+
|
|
22
|
+
binding.active? ? binding.path : binding.origin_root
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reconcile_worktree_binding(tab, conversation)
|
|
26
|
+
binding = worktree_binding_for(tab)
|
|
27
|
+
return nil unless binding
|
|
28
|
+
return binding unless binding.active?
|
|
29
|
+
|
|
30
|
+
binding.active = false unless File.realpath(conversation.workspace_root) == File.realpath(binding.path)
|
|
31
|
+
binding
|
|
32
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
33
|
+
binding.active = false
|
|
34
|
+
binding
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_worktree_binding!(binding)
|
|
38
|
+
return unless binding
|
|
39
|
+
|
|
40
|
+
unless File.directory?(binding.origin_root)
|
|
41
|
+
raise GitWorktreeManager::Error, "Original workspace is unavailable: #{binding.origin_root}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return unless binding.active?
|
|
45
|
+
|
|
46
|
+
info = git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
|
|
47
|
+
return if info.branch == binding.branch
|
|
48
|
+
|
|
49
|
+
raise GitWorktreeManager::Error, "Worktree branch changed: expected #{binding.branch}, found #{info.branch || "detached HEAD"}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rebind_session_tab(tab, root:, worktree:)
|
|
53
|
+
raise GitWorktreeManager::Error, "A session tab is required." unless tab&.session
|
|
54
|
+
raise GitWorktreeManager::Error, "The session store is unavailable." unless @session_store
|
|
55
|
+
|
|
56
|
+
session, conversation = @session_store.load(
|
|
57
|
+
tab.session.path,
|
|
58
|
+
workspace: configured_workspace(root: root, strict: worktree&.active? == true),
|
|
59
|
+
provider: current_model_provider,
|
|
60
|
+
model: current_model_id,
|
|
61
|
+
reasoning_effort: current_reasoning_effort
|
|
62
|
+
)
|
|
63
|
+
agent = build_tab_agent(conversation, session, worktree: worktree)
|
|
64
|
+
tab.session = track_session(session)
|
|
65
|
+
tab.agent = agent
|
|
66
|
+
tab.driver = SessionTabDriver.new(session: tab.session, agent: agent, worktree: worktree)
|
|
67
|
+
tab.diff = SessionDiff.from_session_file(tab.session.path)
|
|
68
|
+
tab.status = "idle"
|
|
69
|
+
tab.thread = nil
|
|
70
|
+
tab.cancellation = nil
|
|
71
|
+
tab.steering = nil
|
|
72
|
+
tab.error = nil
|
|
73
|
+
tab.answer = nil
|
|
74
|
+
tab.pending_question = nil
|
|
75
|
+
tab.unread = false
|
|
76
|
+
tab.error_reported = false
|
|
77
|
+
tab.event_history.clear
|
|
78
|
+
tab.seen_events = 0
|
|
79
|
+
tab.queued_inputs.clear
|
|
80
|
+
tab.shell = nil
|
|
81
|
+
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
82
|
+
tab.markdown_chunks.clear
|
|
83
|
+
assign_tab_question_prompt(tab.agent, tab)
|
|
84
|
+
if tab == active_tab
|
|
85
|
+
@active_session = tab.session
|
|
86
|
+
@session_diff = tab.diff
|
|
87
|
+
update_assistant_prompt(tab.agent.conversation)
|
|
88
|
+
end
|
|
89
|
+
tab
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle_worktree_command(argument)
|
|
93
|
+
case argument.to_s.strip
|
|
94
|
+
when "", "activate"
|
|
95
|
+
activate_active_tab_worktree
|
|
96
|
+
when "detach"
|
|
97
|
+
detach_current_worktree
|
|
98
|
+
when "status"
|
|
99
|
+
print_active_worktree_status
|
|
100
|
+
when "remove"
|
|
101
|
+
remove_active_worktree
|
|
102
|
+
when "merge"
|
|
103
|
+
merge_active_worktree
|
|
104
|
+
when "merge abort"
|
|
105
|
+
abort_active_worktree_merge
|
|
106
|
+
else
|
|
107
|
+
runtime_output("Usage: /tab worktree [activate|detach|status|merge|merge abort|remove]")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def activate_active_tab_worktree
|
|
112
|
+
tab = active_tab
|
|
113
|
+
return runtime_output("There is no active tab.") unless tab
|
|
114
|
+
return runtime_output("Worktrees are available only for normal session tabs.") if plugin_tab?(tab)
|
|
115
|
+
return runtime_output("Tab #{active_tab_number} is running and cannot change workspaces yet.") if tab&.running? || tab&.local_busy? || tab&.shell
|
|
116
|
+
|
|
117
|
+
binding = worktree_binding_for(tab)
|
|
118
|
+
return runtime_output("Tab #{active_tab_number} is already using #{binding.branch} at #{binding.path}.") if binding&.active?
|
|
119
|
+
return activate_existing_worktree(tab, binding) if binding
|
|
120
|
+
|
|
121
|
+
attach_new_worktree(tab)
|
|
122
|
+
rescue GitWorktreeManager::Error, Sandbox::UnavailableError => e
|
|
123
|
+
runtime_output("Worktree error: #{e.message}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detach_current_worktree
|
|
127
|
+
tab = active_tab
|
|
128
|
+
binding = worktree_binding_for(tab)
|
|
129
|
+
return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
|
|
130
|
+
return runtime_output("Tab #{active_tab_number} is not using its linked worktree.") unless binding.active?
|
|
131
|
+
return runtime_output("Tab #{active_tab_number} is running and cannot change workspaces yet.") if tab.running? || tab.local_busy? || tab.shell
|
|
132
|
+
|
|
133
|
+
detach_active_worktree(tab, binding)
|
|
134
|
+
rescue GitWorktreeManager::Error, Sandbox::UnavailableError => e
|
|
135
|
+
runtime_output("Worktree error: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def attach_new_worktree(tab)
|
|
139
|
+
origin_root = tab.agent.conversation.workspace_root
|
|
140
|
+
repository_root = git_worktree_manager.repository_root(origin_root)
|
|
141
|
+
status = git_worktree_manager.status(origin_root)
|
|
142
|
+
if status.dirty? && !confirm_worktree_action(<<~MESSAGE)
|
|
143
|
+
#{origin_root} has #{status.entries.length} local change(s).
|
|
144
|
+
|
|
145
|
+
The new worktree will be created from HEAD. Existing changes will remain in the original workspace and will not be copied.
|
|
146
|
+
|
|
147
|
+
Create the worktree anyway?
|
|
148
|
+
MESSAGE
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
branch = generated_worktree_branch(tab)
|
|
153
|
+
path = default_worktree_path(repository_root, branch)
|
|
154
|
+
binding = git_worktree_manager.create(
|
|
155
|
+
repository_root: repository_root,
|
|
156
|
+
origin_root: origin_root,
|
|
157
|
+
path: path,
|
|
158
|
+
branch: branch
|
|
159
|
+
)
|
|
160
|
+
begin
|
|
161
|
+
rebind_active_tab(tab, root: binding.path, worktree: binding)
|
|
162
|
+
rescue StandardError
|
|
163
|
+
git_worktree_manager.remove(repository_root: binding.repository_root, path: binding.path)
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
runtime_output("Tab #{active_tab_number} is now using #{binding.branch} at #{binding.path}.")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def activate_existing_worktree(tab, binding)
|
|
170
|
+
binding.active = true
|
|
171
|
+
validate_worktree_binding!(binding)
|
|
172
|
+
rebind_active_tab(tab, root: binding.path, worktree: binding)
|
|
173
|
+
runtime_output("Tab #{active_tab_number} is now using #{binding.branch} at #{binding.path}.")
|
|
174
|
+
rescue StandardError
|
|
175
|
+
binding.active = false
|
|
176
|
+
raise
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def detach_active_worktree(tab, binding)
|
|
180
|
+
validate_worktree_binding!(binding)
|
|
181
|
+
status = git_worktree_manager.status(binding.path)
|
|
182
|
+
if status.dirty? && !confirm_worktree_action(<<~MESSAGE)
|
|
183
|
+
#{binding.path} has #{status.entries.length} local change(s).
|
|
184
|
+
|
|
185
|
+
They will remain in the worktree after this tab switches back to #{binding.origin_root}.
|
|
186
|
+
|
|
187
|
+
Detach and keep the worktree?
|
|
188
|
+
MESSAGE
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
binding.active = false
|
|
193
|
+
rebind_active_tab(tab, root: binding.origin_root, worktree: binding)
|
|
194
|
+
runtime_output("Tab #{active_tab_number} returned to #{binding.origin_root}; worktree kept at #{binding.path}.")
|
|
195
|
+
rescue StandardError
|
|
196
|
+
binding.active = true
|
|
197
|
+
raise
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def rebind_active_tab(tab, root:, worktree:)
|
|
201
|
+
save_active_tab_state
|
|
202
|
+
stop_tab_live_view
|
|
203
|
+
rebind_session_tab(tab, root: root, worktree: worktree)
|
|
204
|
+
activate_tab(@active_tab_index)
|
|
205
|
+
persist_tabs
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def print_active_worktree_status
|
|
209
|
+
tab = active_tab
|
|
210
|
+
binding = worktree_binding_for(tab)
|
|
211
|
+
return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
|
|
212
|
+
|
|
213
|
+
lines = [
|
|
214
|
+
"Worktree: #{binding.active? ? "active" : "detached"}",
|
|
215
|
+
"Origin: #{binding.origin_root}",
|
|
216
|
+
"Path: #{binding.path}",
|
|
217
|
+
"Branch: #{binding.branch}",
|
|
218
|
+
"Base: #{binding.base_revision}"
|
|
219
|
+
]
|
|
220
|
+
begin
|
|
221
|
+
info = git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
|
|
222
|
+
raise GitWorktreeManager::Error, "Worktree branch changed: expected #{binding.branch}, found #{info.branch || "detached HEAD"}" unless info.branch == binding.branch
|
|
223
|
+
status = git_worktree_manager.status(binding.path)
|
|
224
|
+
lines << "Changes: #{status.clean? ? "clean" : "#{status.entries.length} local change(s)"}"
|
|
225
|
+
rescue GitWorktreeManager::Error => e
|
|
226
|
+
lines << "State: unavailable (#{e.message})"
|
|
227
|
+
end
|
|
228
|
+
runtime_output(lines.join("\n"))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def merge_active_worktree
|
|
232
|
+
tab = active_tab
|
|
233
|
+
binding = worktree_binding_for(tab)
|
|
234
|
+
return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
|
|
235
|
+
return runtime_output("Tab #{active_tab_number} is running and cannot merge its worktree yet.") if tab.running? || tab.local_busy? || tab.shell
|
|
236
|
+
return runtime_output("Activate the worktree before merging it.") unless binding.active?
|
|
237
|
+
|
|
238
|
+
validate_worktree_binding!(binding)
|
|
239
|
+
source_status = git_worktree_manager.status(binding.path)
|
|
240
|
+
return runtime_output("Worktree has local changes; commit or clean it before merging.") if source_status.dirty?
|
|
241
|
+
|
|
242
|
+
target_status = git_worktree_manager.status(binding.origin_root)
|
|
243
|
+
return runtime_output("Original workspace has local changes; clean it before merging.") if target_status.dirty?
|
|
244
|
+
|
|
245
|
+
target_branch = git_worktree_manager.current_branch(binding.origin_root)
|
|
246
|
+
return runtime_output("Original workspace is in detached HEAD state; select a target branch before merging.") if target_branch.empty?
|
|
247
|
+
return runtime_output("Worktree already uses the target branch #{target_branch}.") if target_branch == binding.branch
|
|
248
|
+
|
|
249
|
+
target_revision = git_worktree_manager.current_revision(binding.origin_root)
|
|
250
|
+
source_revision = git_worktree_manager.current_revision(binding.path)
|
|
251
|
+
return unless confirm_worktree_action(<<~MESSAGE)
|
|
252
|
+
Merge #{binding.branch} into #{target_branch}?
|
|
253
|
+
|
|
254
|
+
Source: #{binding.branch} (#{source_revision})
|
|
255
|
+
Target: #{target_branch} (#{target_revision})
|
|
256
|
+
Original workspace: #{binding.origin_root}
|
|
257
|
+
|
|
258
|
+
The merge runs directly in the original workspace.
|
|
259
|
+
MESSAGE
|
|
260
|
+
|
|
261
|
+
unless git_worktree_manager.current_revision(binding.origin_root) == target_revision && git_worktree_manager.status(binding.origin_root).clean?
|
|
262
|
+
return runtime_output("Original workspace changed before the merge could begin; review it and try again.")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
result = git_worktree_manager.merge(
|
|
266
|
+
repository_root: binding.repository_root,
|
|
267
|
+
target_path: binding.origin_root,
|
|
268
|
+
source_branch: binding.branch
|
|
269
|
+
)
|
|
270
|
+
if result.merged?
|
|
271
|
+
runtime_output("Merged #{binding.branch} into #{target_branch}.")
|
|
272
|
+
else
|
|
273
|
+
runtime_output("Merge conflicts in #{result.conflicts.join(", ")}. Resolve them in #{binding.origin_root} or run /tab worktree merge abort.")
|
|
274
|
+
end
|
|
275
|
+
rescue GitWorktreeManager::Error => e
|
|
276
|
+
runtime_output("Worktree error: #{e.message}")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def abort_active_worktree_merge
|
|
280
|
+
tab = active_tab
|
|
281
|
+
binding = worktree_binding_for(tab)
|
|
282
|
+
return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
|
|
283
|
+
return runtime_output("Tab #{active_tab_number} is running and cannot abort its merge yet.") if tab.running? || tab.local_busy? || tab.shell
|
|
284
|
+
return runtime_output("No merge is in progress in the original workspace.") unless git_worktree_manager.merge_in_progress?(binding.origin_root)
|
|
285
|
+
return unless confirm_worktree_action("Abort the merge in #{binding.origin_root}?")
|
|
286
|
+
|
|
287
|
+
git_worktree_manager.abort_merge(binding.origin_root)
|
|
288
|
+
runtime_output("Aborted the merge in #{binding.origin_root}.")
|
|
289
|
+
rescue GitWorktreeManager::Error => e
|
|
290
|
+
runtime_output("Worktree error: #{e.message}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def remove_active_worktree
|
|
294
|
+
tab = active_tab
|
|
295
|
+
binding = worktree_binding_for(tab)
|
|
296
|
+
return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
|
|
297
|
+
return runtime_output("Tab #{active_tab_number} is running and cannot remove its worktree yet.") if tab.running? || tab.local_busy? || tab.shell
|
|
298
|
+
|
|
299
|
+
git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
|
|
300
|
+
status = git_worktree_manager.status(binding.path)
|
|
301
|
+
return runtime_output("Worktree has local changes; detach it or clean it before removing it.") if status.dirty?
|
|
302
|
+
|
|
303
|
+
was_active = binding.active?
|
|
304
|
+
detached = false
|
|
305
|
+
if was_active
|
|
306
|
+
binding.active = false
|
|
307
|
+
rebind_active_tab(tab, root: binding.origin_root, worktree: binding)
|
|
308
|
+
detached = true
|
|
309
|
+
end
|
|
310
|
+
git_worktree_manager.remove(repository_root: binding.repository_root, path: binding.path)
|
|
311
|
+
tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
|
|
312
|
+
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
313
|
+
persist_tabs
|
|
314
|
+
runtime_output("Removed worktree #{binding.path}. Branch #{binding.branch} was kept.")
|
|
315
|
+
rescue GitWorktreeManager::Error => e
|
|
316
|
+
binding.active = true if was_active && !detached
|
|
317
|
+
runtime_output("Worktree error: #{e.message}")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def confirm_worktree_action(message)
|
|
321
|
+
prompt = message.to_s.strip
|
|
322
|
+
if @prompt.respond_to?(:ask_user_question)
|
|
323
|
+
answer = @prompt.ask_user_question([{
|
|
324
|
+
header: "Confirm worktree action",
|
|
325
|
+
question: prompt,
|
|
326
|
+
options: [
|
|
327
|
+
{ label: "Continue", description: "Proceed with this Git operation." },
|
|
328
|
+
{ label: "Cancel", description: "Leave the workspace unchanged." }
|
|
329
|
+
]
|
|
330
|
+
}])
|
|
331
|
+
return answer&.first&.fetch(:answer, nil) == "Continue"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
return false unless @prompt.respond_to?(:yes?)
|
|
335
|
+
|
|
336
|
+
runtime_output(prompt) if prompt.include?("\n")
|
|
337
|
+
@prompt.yes?(prompt.include?("\n") ? "Continue?" : prompt, default: false)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def generated_worktree_branch(tab)
|
|
341
|
+
label = tab.label.to_s.downcase.gsub(/[^a-z0-9]+/, "-").sub(/\A-+|-+\z/, "")
|
|
342
|
+
label = "tab-#{active_tab_number}" if label.empty?
|
|
343
|
+
"kward/#{label}-#{SecureRandom.hex(3)}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def default_worktree_path(repository_root, branch)
|
|
347
|
+
slug = branch.to_s.delete_prefix("kward/").gsub(/[^a-zA-Z0-9._-]+/, "-")
|
|
348
|
+
File.join(File.dirname(repository_root), ".kward-worktrees", File.basename(repository_root), slug)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def unavailable_worktree_tab(descriptor, error, label: nil)
|
|
352
|
+
driver = UnavailableTabDriver.new(
|
|
353
|
+
descriptor: descriptor,
|
|
354
|
+
message: "Worktree unavailable: #{error.message}"
|
|
355
|
+
)
|
|
356
|
+
build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || "Worktree")
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
data/lib/kward/cli.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative "export_path"
|
|
|
17
17
|
require_relative "ekwsh"
|
|
18
18
|
require_relative "interactive_pty_runner"
|
|
19
19
|
require_relative "auth/anthropic_oauth"
|
|
20
|
+
require_relative "auth/api_key_store"
|
|
20
21
|
require_relative "auth/github_oauth"
|
|
21
22
|
require_relative "auth/openrouter_api_key"
|
|
22
23
|
require_relative "image_attachments"
|
|
@@ -25,12 +26,16 @@ require_relative "memory/turn_context"
|
|
|
25
26
|
require_relative "transcript_export"
|
|
26
27
|
require_relative "message_access"
|
|
27
28
|
require_relative "model/model_info"
|
|
29
|
+
require_relative "model/azure_openai_config"
|
|
30
|
+
require_relative "model/provider_catalog"
|
|
31
|
+
require_relative "model/catalog"
|
|
28
32
|
require_relative "auth/openai_oauth"
|
|
29
33
|
require_relative "pan/server"
|
|
30
34
|
require_relative "plugin_registry"
|
|
31
35
|
require_relative "prompts/commands"
|
|
32
36
|
require_relative "model/retry_message"
|
|
33
37
|
require_relative "rpc/server"
|
|
38
|
+
require_relative "transport/runtime"
|
|
34
39
|
require_relative "session_diff"
|
|
35
40
|
require_relative "session_store"
|
|
36
41
|
require_relative "session_naming"
|
|
@@ -46,6 +51,7 @@ require_relative "tools/tool_call"
|
|
|
46
51
|
require_relative "tools/registry"
|
|
47
52
|
require_relative "telemetry/stats"
|
|
48
53
|
require_relative "workspace_factory"
|
|
54
|
+
require_relative "git_worktree_manager"
|
|
49
55
|
require_relative "cli/commands"
|
|
50
56
|
require_relative "cli/auth_commands"
|
|
51
57
|
require_relative "cli/doctor"
|
|
@@ -59,6 +65,7 @@ require_relative "cli/hook_commands"
|
|
|
59
65
|
require_relative "cli/settings"
|
|
60
66
|
require_relative "cli/sessions"
|
|
61
67
|
require_relative "cli/tabs"
|
|
68
|
+
require_relative "cli/worktrees"
|
|
62
69
|
require_relative "cli/compaction"
|
|
63
70
|
require_relative "cli/rendering"
|
|
64
71
|
require_relative "cli/prompt_interface"
|
|
@@ -66,6 +73,7 @@ require_relative "cli/plugins"
|
|
|
66
73
|
require_relative "cli/git"
|
|
67
74
|
require_relative "cli/interactive_turn"
|
|
68
75
|
require_relative "cli/tool_summaries"
|
|
76
|
+
require_relative "cli/transports"
|
|
69
77
|
|
|
70
78
|
# Namespace for the Kward CLI agent runtime.
|
|
71
79
|
module Kward
|
|
@@ -92,6 +100,7 @@ module Kward
|
|
|
92
100
|
include CLI::Settings
|
|
93
101
|
include CLI::Sessions
|
|
94
102
|
include CLI::Tabs
|
|
103
|
+
include CLI::Worktrees
|
|
95
104
|
include CLI::CompactionCommands
|
|
96
105
|
include CLI::Rendering
|
|
97
106
|
include CLI::PromptInterfaceSupport
|
|
@@ -99,6 +108,7 @@ module Kward
|
|
|
99
108
|
include CLI::GitCommands
|
|
100
109
|
include CLI::InteractiveTurn
|
|
101
110
|
include CLI::ToolSummaries
|
|
111
|
+
include CLI::Transports
|
|
102
112
|
|
|
103
113
|
def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: nil, session_store: nil, context_usage: ContextUsage.new)
|
|
104
114
|
@argv = argv
|
|
@@ -244,6 +254,16 @@ module Kward
|
|
|
244
254
|
return
|
|
245
255
|
end
|
|
246
256
|
|
|
257
|
+
if @argv.first == "transport"
|
|
258
|
+
if help_option_arguments?(@argv[1..] || [])
|
|
259
|
+
print_command_help("transport")
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
handle_transport_command(@argv[1..] || [])
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
|
|
247
267
|
if @argv.first == "rpc"
|
|
248
268
|
if help_option_arguments?(@argv[1..] || [])
|
|
249
269
|
print_command_help("rpc")
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "fileutils"
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
5
|
require_relative "frontmatter"
|
|
6
|
+
require_relative "deep_copy"
|
|
6
7
|
require_relative "private_file"
|
|
7
8
|
require_relative "path_guard"
|
|
8
9
|
require_relative "permissions/policy"
|
|
@@ -156,6 +157,7 @@ module Kward
|
|
|
156
157
|
"include_principles" => true
|
|
157
158
|
},
|
|
158
159
|
"mcpServers" => {},
|
|
160
|
+
"transports" => {},
|
|
159
161
|
"tools" => {
|
|
160
162
|
"workspace_guardrails" => true
|
|
161
163
|
},
|
|
@@ -190,6 +192,13 @@ module Kward
|
|
|
190
192
|
File.join(cache_dir, "openrouter_models.json")
|
|
191
193
|
end
|
|
192
194
|
|
|
195
|
+
def model_catalog_cache_path(provider_id)
|
|
196
|
+
provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
|
|
197
|
+
raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
|
|
198
|
+
|
|
199
|
+
File.join(cache_dir, "models", "#{provider}.json")
|
|
200
|
+
end
|
|
201
|
+
|
|
193
202
|
def update_check_cache_path
|
|
194
203
|
File.join(cache_dir, "update_check.json")
|
|
195
204
|
end
|
|
@@ -236,7 +245,10 @@ module Kward
|
|
|
236
245
|
return {} if skip_config? && path == config_path
|
|
237
246
|
return {} unless File.exist?(path)
|
|
238
247
|
|
|
239
|
-
JSON.parse(File.read(path))
|
|
248
|
+
config = JSON.parse(File.read(path))
|
|
249
|
+
return config if config.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
|
|
240
252
|
rescue JSON::ParserError => e
|
|
241
253
|
raise ConfigError.new(path: path, format: "JSON", detail: e.message)
|
|
242
254
|
end
|
|
@@ -544,6 +556,15 @@ module Kward
|
|
|
544
556
|
value.is_a?(Hash) ? value : {}
|
|
545
557
|
end
|
|
546
558
|
|
|
559
|
+
# Returns the private configuration namespace for one transport.
|
|
560
|
+
def transport_config(transport_id, config = read_config)
|
|
561
|
+
transports = config["transports"]
|
|
562
|
+
return {} unless transports.is_a?(Hash)
|
|
563
|
+
|
|
564
|
+
values = transports[transport_id.to_s]
|
|
565
|
+
values.is_a?(Hash) ? DeepCopy.dup(values) : {}
|
|
566
|
+
end
|
|
567
|
+
|
|
547
568
|
# Returns configured MCP stdio servers, or an empty config when absent.
|
|
548
569
|
def mcp_servers(config = read_config)
|
|
549
570
|
value = config["mcpServers"] || config.dig("mcp", "servers")
|
|
@@ -896,7 +917,7 @@ module Kward
|
|
|
896
917
|
File.expand_path("~/.kward/plugins")
|
|
897
918
|
end
|
|
898
919
|
|
|
899
|
-
# Finds trusted
|
|
920
|
+
# Finds trusted plugin files and package entrypoints.
|
|
900
921
|
#
|
|
901
922
|
# Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
|
|
902
923
|
# workspace or custom `KWARD_CONFIG_PATH` directory.
|
|
@@ -906,7 +927,9 @@ module Kward
|
|
|
906
927
|
plugins_root = plugin_dir
|
|
907
928
|
return [] unless Dir.exist?(plugins_root)
|
|
908
929
|
|
|
909
|
-
Dir.glob(File.join(plugins_root, "*.rb"))
|
|
930
|
+
paths = Dir.glob(File.join(plugins_root, "*.rb"))
|
|
931
|
+
paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
|
|
932
|
+
paths.sort
|
|
910
933
|
rescue StandardError => e
|
|
911
934
|
warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
|
|
912
935
|
[]
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -58,32 +58,33 @@ module Kward
|
|
|
58
58
|
# @return [PluginRegistry, nil] registry used to collect plugin prompt context
|
|
59
59
|
attr_accessor :plugin_registry
|
|
60
60
|
# @return [String, nil] plugin prompt context used in the current system prompt
|
|
61
|
-
attr_reader :last_plugin_prompt_context
|
|
61
|
+
attr_reader :last_plugin_prompt_context, :execution_profile_context
|
|
62
62
|
# @return [Hash] original large tool outputs retained outside model context
|
|
63
63
|
attr_reader :tool_output_artifacts
|
|
64
64
|
# @return [ContextBudgetMeter] runtime context savings for this conversation
|
|
65
65
|
attr_reader :context_budget_meter
|
|
66
66
|
|
|
67
|
-
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
|
|
67
|
+
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
|
|
68
68
|
@workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
|
|
69
69
|
@provider = provider
|
|
70
70
|
@model = model
|
|
71
71
|
@reasoning_effort = reasoning_effort
|
|
72
72
|
@plugin_registry = plugin_registry
|
|
73
|
+
@execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
|
|
73
74
|
@messages = []
|
|
74
75
|
restored_system_message, transcript_messages = split_system_message(messages)
|
|
75
76
|
if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
76
|
-
if restored_system_message
|
|
77
|
+
if restored_system_message && @execution_profile_context.to_s.empty?
|
|
77
78
|
system_message = restored_system_message
|
|
78
79
|
else
|
|
79
80
|
@last_plugin_prompt_context = plugin_prompt_context
|
|
80
|
-
system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
|
|
81
|
+
system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
@system_message = system_message
|
|
84
85
|
@system_message_enabled = !@system_message.nil?
|
|
85
86
|
if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
86
|
-
compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
|
|
87
|
+
compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
|
|
87
88
|
end
|
|
88
89
|
@compaction_system_message = compaction_system_message
|
|
89
90
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
@@ -187,15 +188,20 @@ module Kward
|
|
|
187
188
|
return nil unless @system_message_enabled
|
|
188
189
|
|
|
189
190
|
@last_plugin_prompt_context = plugin_prompt_context
|
|
190
|
-
replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
|
|
191
|
+
replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
|
|
191
192
|
@system_message = replacement
|
|
192
193
|
@on_system_message_change&.call(replacement)
|
|
193
|
-
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
|
|
194
|
+
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
|
|
194
195
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
195
196
|
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
196
197
|
replacement
|
|
197
198
|
end
|
|
198
199
|
|
|
200
|
+
def update_execution_profile_context!(context)
|
|
201
|
+
@execution_profile_context = context.to_s.empty? ? nil : context.to_s.freeze
|
|
202
|
+
refresh_system_message!
|
|
203
|
+
end
|
|
204
|
+
|
|
199
205
|
def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
|
|
200
206
|
@provider = provider unless provider.to_s.empty?
|
|
201
207
|
@model = model
|