kward 0.78.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 +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- 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 +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- 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 +4 -4
- 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/kward.gemspec +1 -1
- 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/doctor.rb +21 -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 +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- 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 +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -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 +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- 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 +43 -10
- 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 +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- 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 +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- 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 +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- 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.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- metadata +41 -2
|
@@ -15,6 +15,7 @@ require_relative "../memory/turn_context"
|
|
|
15
15
|
require_relative "../message_access"
|
|
16
16
|
require_relative "../message_text"
|
|
17
17
|
require_relative "../session_tree_tool_display"
|
|
18
|
+
require_relative "../skills/capture"
|
|
18
19
|
require_relative "../model/model_info"
|
|
19
20
|
require_relative "../plugin_registry"
|
|
20
21
|
require_relative "../prompts/commands"
|
|
@@ -25,7 +26,7 @@ require_relative "../steering"
|
|
|
25
26
|
require_relative "../tools/tool_call"
|
|
26
27
|
require_relative "../tools/registry"
|
|
27
28
|
require_relative "../transcript_export"
|
|
28
|
-
require_relative "../
|
|
29
|
+
require_relative "../workspace_factory"
|
|
29
30
|
require_relative "attachment_normalizer"
|
|
30
31
|
require_relative "config_manager"
|
|
31
32
|
require_relative "memory_methods"
|
|
@@ -65,8 +66,8 @@ module Kward
|
|
|
65
66
|
WORKER_STOP_TIMEOUT = 2.0
|
|
66
67
|
WORKER_STOP = Object.new.freeze
|
|
67
68
|
|
|
68
|
-
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
69
|
-
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, :mutex, keyword_init: true)
|
|
69
|
+
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :execution_profile, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
70
|
+
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, :execution_profile, :mutex, keyword_init: true)
|
|
70
71
|
|
|
71
72
|
# Creates an object for RPC session lifecycle and turn coordination.
|
|
72
73
|
def initialize(
|
|
@@ -76,7 +77,8 @@ module Kward
|
|
|
76
77
|
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
77
78
|
context_usage: ContextUsage.new,
|
|
78
79
|
session_trash: SessionTrash.new,
|
|
79
|
-
worker_stop_timeout: WORKER_STOP_TIMEOUT
|
|
80
|
+
worker_stop_timeout: WORKER_STOP_TIMEOUT,
|
|
81
|
+
event_listener: nil
|
|
80
82
|
)
|
|
81
83
|
@server = server
|
|
82
84
|
@client = client
|
|
@@ -86,6 +88,7 @@ module Kward
|
|
|
86
88
|
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
87
89
|
@session_trash = session_trash
|
|
88
90
|
@worker_stop_timeout = worker_stop_timeout
|
|
91
|
+
@event_listeners = event_listener ? [event_listener] : []
|
|
89
92
|
@sessions = {}
|
|
90
93
|
@turns = {}
|
|
91
94
|
@mutex = Mutex.new
|
|
@@ -96,26 +99,28 @@ module Kward
|
|
|
96
99
|
# Returns the normalized session payload expected by RPC clients. The RPC
|
|
97
100
|
# session id is separate from the persisted session id so one persisted file
|
|
98
101
|
# can be closed and reopened by different client connections.
|
|
99
|
-
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
|
|
102
|
+
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false, execution_profile: nil)
|
|
103
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
100
104
|
workspace_root = validate_workspace_root(workspace_root)
|
|
101
105
|
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
102
106
|
if resume_last && session_auto_resume_enabled? && name.to_s.strip.empty?
|
|
103
107
|
path = store.remembered_last_session_path
|
|
104
|
-
return resume_session(path: path, workspace_root: workspace_root, include_transcript: true) if path
|
|
108
|
+
return resume_session(path: path, workspace_root: workspace_root, include_transcript: true, execution_profile: execution_profile) if path
|
|
105
109
|
end
|
|
106
110
|
|
|
107
|
-
conversation = new_conversation(workspace_root: workspace_root)
|
|
111
|
+
conversation = new_conversation(workspace_root: workspace_root, execution_profile: execution_profile)
|
|
108
112
|
session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
109
113
|
session.rename(name) unless name.to_s.strip.empty?
|
|
110
114
|
session.attach(conversation)
|
|
111
|
-
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
115
|
+
rpc_session = build_rpc_session(store, session, conversation, workspace_root, execution_profile: execution_profile)
|
|
112
116
|
remember_session(rpc_session)
|
|
113
117
|
cleanup_other_unused_sessions(rpc_session)
|
|
114
118
|
emit_footer_update(rpc_session)
|
|
115
119
|
session_payload(rpc_session)
|
|
116
120
|
end
|
|
117
121
|
|
|
118
|
-
def resume_session(path:, workspace_root: nil, include_transcript: false)
|
|
122
|
+
def resume_session(path:, workspace_root: nil, include_transcript: false, execution_profile: nil)
|
|
123
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
119
124
|
root = validate_workspace_root(workspace_root || Dir.pwd)
|
|
120
125
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
121
126
|
location = store.session_location(path)
|
|
@@ -128,7 +133,8 @@ module Kward
|
|
|
128
133
|
model: current_model_id,
|
|
129
134
|
reasoning_effort: current_reasoning_effort
|
|
130
135
|
)
|
|
131
|
-
|
|
136
|
+
conversation.update_execution_profile_context!(execution_profile.prompt_context) if execution_profile
|
|
137
|
+
rpc_session = build_rpc_session(store, session, conversation, root, execution_profile: execution_profile)
|
|
132
138
|
remember_session(rpc_session)
|
|
133
139
|
cleanup_other_unused_sessions(rpc_session)
|
|
134
140
|
emit_footer_update(rpc_session)
|
|
@@ -155,6 +161,38 @@ module Kward
|
|
|
155
161
|
{ sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
def skill_capture_sessions
|
|
165
|
+
SessionStore.new(config_dir: @config_dir).capture_candidates.map do |session|
|
|
166
|
+
{
|
|
167
|
+
path: session.path,
|
|
168
|
+
workspaceRoot: session.cwd,
|
|
169
|
+
name: session.name,
|
|
170
|
+
firstMessage: session.first_message,
|
|
171
|
+
modifiedAt: session.modified_at.utc.iso8601(3)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def capture_skill(session_path:)
|
|
177
|
+
capture = Skills::Capture.new(
|
|
178
|
+
session_store: SessionStore.new(config_dir: @config_dir),
|
|
179
|
+
client: @client,
|
|
180
|
+
config_dir: @config_dir
|
|
181
|
+
)
|
|
182
|
+
draft = capture.generate(session_path)
|
|
183
|
+
{ content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def save_captured_skill(content:, overwrite: false)
|
|
187
|
+
capture = Skills::Capture.new(
|
|
188
|
+
session_store: SessionStore.new(config_dir: @config_dir),
|
|
189
|
+
client: @client,
|
|
190
|
+
config_dir: @config_dir
|
|
191
|
+
)
|
|
192
|
+
draft = capture.save(content, overwrite: overwrite)
|
|
193
|
+
{ path: capture.skill_path(draft.name), name: draft.name, description: draft.description }
|
|
194
|
+
end
|
|
195
|
+
|
|
158
196
|
def tool_schemas(session_id: nil)
|
|
159
197
|
registry = if session_id
|
|
160
198
|
fetch_session(session_id).tool_registry
|
|
@@ -164,6 +202,22 @@ module Kward
|
|
|
164
202
|
{ tools: registry.schemas }
|
|
165
203
|
end
|
|
166
204
|
|
|
205
|
+
# Subscribes to frontend-neutral runtime notifications.
|
|
206
|
+
#
|
|
207
|
+
# Subscribers are isolated from session execution: an exception is logged
|
|
208
|
+
# and does not interrupt the RPC notification or active turn.
|
|
209
|
+
def subscribe_events(&listener)
|
|
210
|
+
raise ArgumentError, "event subscription requires a block" unless listener
|
|
211
|
+
|
|
212
|
+
@mutex.synchronize { @event_listeners << listener }
|
|
213
|
+
listener
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Returns the plugin registry shared by RPC sessions and plugin chats.
|
|
217
|
+
def plugin_registry
|
|
218
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
219
|
+
end
|
|
220
|
+
|
|
167
221
|
# Renames the persisted session attached to an RPC session id.
|
|
168
222
|
def rename_session(session_id:, name:)
|
|
169
223
|
rpc_session = fetch_session(session_id)
|
|
@@ -175,7 +229,8 @@ module Kward
|
|
|
175
229
|
def clone_session(session_id:)
|
|
176
230
|
source = fetch_session(session_id)
|
|
177
231
|
session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
|
|
178
|
-
|
|
232
|
+
conversation.update_execution_profile_context!(source.execution_profile.prompt_context) if source.execution_profile
|
|
233
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root, execution_profile: source.execution_profile)
|
|
179
234
|
remember_session(rpc_session)
|
|
180
235
|
cleanup_other_unused_sessions(rpc_session)
|
|
181
236
|
emit_footer_update(rpc_session)
|
|
@@ -234,7 +289,8 @@ module Kward
|
|
|
234
289
|
parent_session: source.session
|
|
235
290
|
)
|
|
236
291
|
|
|
237
|
-
|
|
292
|
+
conversation.update_execution_profile_context!(source.execution_profile.prompt_context) if source.execution_profile
|
|
293
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root, execution_profile: source.execution_profile)
|
|
238
294
|
remember_session(rpc_session)
|
|
239
295
|
cleanup_other_unused_sessions(rpc_session)
|
|
240
296
|
{
|
|
@@ -348,12 +404,20 @@ module Kward
|
|
|
348
404
|
# queue a follow-up, or steer the running turn when the active provider
|
|
349
405
|
# supports native steering. The returned turn id is used for status,
|
|
350
406
|
# cancellation, and event replay.
|
|
351
|
-
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil)
|
|
407
|
+
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil, execution_profile: nil)
|
|
352
408
|
rpc_session = fetch_session(session_id)
|
|
353
|
-
|
|
409
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
410
|
+
normalized_options = enforce_execution_profile_options(normalize_turn_options(options), execution_profile)
|
|
354
411
|
normalized_context = TurnContext.normalize(context)
|
|
355
412
|
normalized_attachments = normalize_attachments(attachments)
|
|
356
|
-
|
|
413
|
+
if execution_profile && !execution_profile.attachments && !normalized_attachments.empty?
|
|
414
|
+
raise ArgumentError, "transport execution profile does not allow attachments"
|
|
415
|
+
end
|
|
416
|
+
plugin_command, plugin_arguments = if execution_profile && !execution_profile.plugin_commands
|
|
417
|
+
[nil, ""]
|
|
418
|
+
else
|
|
419
|
+
plugin_command_turn(input, normalized_attachments)
|
|
420
|
+
end
|
|
357
421
|
display_input = input.to_s if input.is_a?(String)
|
|
358
422
|
content = plugin_command ? input.to_s : user_turn_content(turn_input_with_context(expand_prompt_input(input), normalized_context), normalized_attachments)
|
|
359
423
|
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
@@ -376,6 +440,7 @@ module Kward
|
|
|
376
440
|
plugin_arguments: plugin_arguments,
|
|
377
441
|
options: normalized_options,
|
|
378
442
|
tool_registry: scoped_tool_registry(rpc_session, normalized_options),
|
|
443
|
+
execution_profile: execution_profile,
|
|
379
444
|
mutex: Mutex.new
|
|
380
445
|
)
|
|
381
446
|
@mutex.synchronize { @turns[turn.id] = turn }
|
|
@@ -391,7 +456,7 @@ module Kward
|
|
|
391
456
|
turn.cancel_requested = true
|
|
392
457
|
[turn.status == "queued", append_turn_event_locked(turn, "turnCancelRequested", {})]
|
|
393
458
|
end
|
|
394
|
-
|
|
459
|
+
notify("turn/event", event)
|
|
395
460
|
turn.cancellation&.cancel!
|
|
396
461
|
finish_turn(turn, "canceled") if queued
|
|
397
462
|
turn_payload(turn)
|
|
@@ -480,6 +545,11 @@ module Kward
|
|
|
480
545
|
normalized
|
|
481
546
|
end
|
|
482
547
|
|
|
548
|
+
def refresh_models(provider: nil)
|
|
549
|
+
@client.refresh_available_models(provider: provider) if @client.respond_to?(:refresh_available_models)
|
|
550
|
+
available_models
|
|
551
|
+
end
|
|
552
|
+
|
|
483
553
|
def current_model
|
|
484
554
|
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
485
555
|
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
@@ -585,7 +655,9 @@ module Kward
|
|
|
585
655
|
end
|
|
586
656
|
|
|
587
657
|
def session_modified_at(session)
|
|
588
|
-
File.exist?(session.path)
|
|
658
|
+
return nil unless File.exist?(session.path)
|
|
659
|
+
|
|
660
|
+
session.respond_to?(:modified_at) ? session.modified_at : nil
|
|
589
661
|
end
|
|
590
662
|
|
|
591
663
|
def validate_workspace_root(root)
|
|
@@ -597,9 +669,22 @@ module Kward
|
|
|
597
669
|
|
|
598
670
|
private
|
|
599
671
|
|
|
600
|
-
def
|
|
672
|
+
def notify(method, payload)
|
|
673
|
+
@server.notify(method, payload)
|
|
674
|
+
listeners = @mutex.synchronize { @event_listeners.dup }
|
|
675
|
+
listeners.each do |listener|
|
|
676
|
+
begin
|
|
677
|
+
listener.call(method, payload)
|
|
678
|
+
rescue StandardError => e
|
|
679
|
+
@server.log_error(e) if @server.respond_to?(:log_error)
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def new_conversation(workspace_root: Dir.pwd, execution_profile: nil)
|
|
601
685
|
Conversation.new(
|
|
602
686
|
workspace_root: workspace_root,
|
|
687
|
+
execution_profile_context: execution_profile&.prompt_context,
|
|
603
688
|
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
604
689
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
605
690
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
|
|
@@ -742,6 +827,7 @@ module Kward
|
|
|
742
827
|
reasoning_effort: current_reasoning_effort
|
|
743
828
|
)
|
|
744
829
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
830
|
+
conversation.update_execution_profile_context!(rpc_session.execution_profile.prompt_context) if rpc_session.execution_profile
|
|
745
831
|
rpc_session.session = session
|
|
746
832
|
rpc_session.conversation = conversation
|
|
747
833
|
rebuild_session_tools(rpc_session)
|
|
@@ -818,6 +904,30 @@ module Kward
|
|
|
818
904
|
PromptCommands.expand(input) || input
|
|
819
905
|
end
|
|
820
906
|
|
|
907
|
+
def validate_execution_profile(profile)
|
|
908
|
+
return nil if profile.nil?
|
|
909
|
+
return profile if profile.is_a?(Kward::Transport::ExecutionProfile)
|
|
910
|
+
|
|
911
|
+
raise ArgumentError, "execution_profile must be a Transport::ExecutionProfile"
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def enforce_execution_profile_options(options, profile)
|
|
915
|
+
return options unless profile
|
|
916
|
+
|
|
917
|
+
options = options.dup
|
|
918
|
+
case profile.tool_mode
|
|
919
|
+
when :none
|
|
920
|
+
options.delete(:disabled_tools)
|
|
921
|
+
options[:allowed_tools] = []
|
|
922
|
+
when :allowlist
|
|
923
|
+
options.delete(:disabled_tools)
|
|
924
|
+
options[:allowed_tools] = profile.allowed_tools
|
|
925
|
+
end
|
|
926
|
+
options[:approval_mode] = "none" if profile.approval_mode == :deny
|
|
927
|
+
options[:approval_mode] = "ask" if profile.approval_mode == :ask
|
|
928
|
+
options
|
|
929
|
+
end
|
|
930
|
+
|
|
821
931
|
def plugin_command_turn(input, attachments)
|
|
822
932
|
return [nil, ""] unless input.is_a?(String)
|
|
823
933
|
return [nil, ""] unless attachments.empty?
|
|
@@ -919,10 +1029,6 @@ module Kward
|
|
|
919
1029
|
end
|
|
920
1030
|
end
|
|
921
1031
|
|
|
922
|
-
def plugin_registry
|
|
923
|
-
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
924
|
-
end
|
|
925
|
-
|
|
926
1032
|
def reserved_plugin_command_names
|
|
927
1033
|
PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
|
|
928
1034
|
end
|
|
@@ -932,10 +1038,10 @@ module Kward
|
|
|
932
1038
|
# This is the RPC counterpart to the CLI interactive setup: attach plugin
|
|
933
1039
|
# context, create a prompt bridge for UI questions/footer output, advertise
|
|
934
1040
|
# tools with the workspace guardrail policy, and build the shared `Agent`.
|
|
935
|
-
def build_rpc_session(store, session, conversation, workspace_root)
|
|
1041
|
+
def build_rpc_session(store, session, conversation, workspace_root, execution_profile: nil)
|
|
936
1042
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
937
1043
|
id = SecureRandom.uuid
|
|
938
|
-
prompt = PromptBridge.new(
|
|
1044
|
+
prompt = PromptBridge.new(notify: method(:notify), session_id: id)
|
|
939
1045
|
hook_context = lifecycle_hook_context(conversation: conversation, session: session, workspace_root: workspace_root)
|
|
940
1046
|
hook_manager = lifecycle_hook_manager(workspace_root)
|
|
941
1047
|
tool_registry = build_tool_registry(workspace_root, prompt, hook_manager: hook_manager, hook_context: hook_context)
|
|
@@ -954,6 +1060,7 @@ module Kward
|
|
|
954
1060
|
conversation: conversation,
|
|
955
1061
|
agent: agent,
|
|
956
1062
|
tool_registry: tool_registry,
|
|
1063
|
+
execution_profile: execution_profile,
|
|
957
1064
|
prompt: prompt,
|
|
958
1065
|
plugin_output: [],
|
|
959
1066
|
queue: Queue.new,
|
|
@@ -986,7 +1093,7 @@ module Kward
|
|
|
986
1093
|
end
|
|
987
1094
|
|
|
988
1095
|
def emit_lifecycle_hook_event(event, result)
|
|
989
|
-
|
|
1096
|
+
notify("hook/event", {
|
|
990
1097
|
event: {
|
|
991
1098
|
id: event.id,
|
|
992
1099
|
name: event.name,
|
|
@@ -1014,12 +1121,12 @@ module Kward
|
|
|
1014
1121
|
args: "",
|
|
1015
1122
|
session: session,
|
|
1016
1123
|
workspace_root: workspace_root,
|
|
1017
|
-
say_callback: lambda { |message|
|
|
1124
|
+
say_callback: lambda { |message| notify("hook/message", { message: message.to_s }) }
|
|
1018
1125
|
)
|
|
1019
1126
|
end
|
|
1020
1127
|
|
|
1021
1128
|
def configured_workspace(root)
|
|
1022
|
-
|
|
1129
|
+
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
|
|
1023
1130
|
end
|
|
1024
1131
|
|
|
1025
1132
|
def workspace_guardrails_enabled?
|
|
@@ -1155,21 +1262,25 @@ module Kward
|
|
|
1155
1262
|
# This method is intentionally the only place that calls `Agent#ask` for RPC
|
|
1156
1263
|
# turns. Keep event translation near this boundary so CLI rendering and RPC
|
|
1157
1264
|
# protocol details do not leak into `Agent`.
|
|
1265
|
+
def memory_disabled?(turn)
|
|
1266
|
+
turn.execution_profile&.memory == :none
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1158
1269
|
def run_turn(rpc_session, turn)
|
|
1159
1270
|
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1160
1271
|
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
1161
1272
|
started, event = turn.mutex.synchronize do
|
|
1162
1273
|
next [false, nil] if turn.status == "canceled"
|
|
1163
1274
|
|
|
1275
|
+
rpc_session.running_turn_id = turn.id
|
|
1276
|
+
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1164
1277
|
turn.status = "running"
|
|
1165
1278
|
turn.started_at = now
|
|
1166
1279
|
[true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
|
|
1167
1280
|
end
|
|
1168
1281
|
return unless started
|
|
1169
1282
|
|
|
1170
|
-
|
|
1171
|
-
rpc_session.running_turn_id = turn.id
|
|
1172
|
-
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1283
|
+
notify("turn/event", event)
|
|
1173
1284
|
|
|
1174
1285
|
if turn.cancel_requested
|
|
1175
1286
|
finish_turn(turn, "canceled")
|
|
@@ -1180,15 +1291,17 @@ module Kward
|
|
|
1180
1291
|
run_plugin_turn(rpc_session, turn)
|
|
1181
1292
|
else
|
|
1182
1293
|
auto_name_session(rpc_session, turn.display_input || turn.input)
|
|
1183
|
-
prepare_memory_context(rpc_session.conversation, turn.input)
|
|
1294
|
+
prepare_memory_context(rpc_session.conversation, turn.input) unless memory_disabled?(turn)
|
|
1184
1295
|
rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering, options: turn.options || {}, tool_registry: turn.tool_registry) do |event|
|
|
1185
1296
|
next if turn.cancel_requested
|
|
1186
1297
|
|
|
1187
1298
|
notify_plugin_transcript_event(rpc_session, event)
|
|
1188
1299
|
handle_agent_event(turn, event)
|
|
1189
1300
|
end
|
|
1190
|
-
|
|
1191
|
-
|
|
1301
|
+
unless memory_disabled?(turn)
|
|
1302
|
+
persist_memory_state(rpc_session)
|
|
1303
|
+
auto_summarize_memory(rpc_session) unless turn.cancel_requested
|
|
1304
|
+
end
|
|
1192
1305
|
finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
|
|
1193
1306
|
end
|
|
1194
1307
|
rescue Cancellation::CancelledError
|
|
@@ -1338,7 +1451,7 @@ module Kward
|
|
|
1338
1451
|
end
|
|
1339
1452
|
return unless event
|
|
1340
1453
|
|
|
1341
|
-
|
|
1454
|
+
notify("turn/event", event)
|
|
1342
1455
|
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1343
1456
|
emit_footer_update(rpc_session) if rpc_session
|
|
1344
1457
|
end
|
|
@@ -1357,19 +1470,19 @@ module Kward
|
|
|
1357
1470
|
return if rpc_session.last_footer_text == text
|
|
1358
1471
|
|
|
1359
1472
|
rpc_session.last_footer_text = text
|
|
1360
|
-
|
|
1473
|
+
notify("ui/footer", { sessionId: rpc_session.id, text: text })
|
|
1361
1474
|
end
|
|
1362
1475
|
|
|
1363
1476
|
def clear_footer_update(rpc_session)
|
|
1364
1477
|
return if rpc_session.last_footer_text.to_s.empty?
|
|
1365
1478
|
|
|
1366
1479
|
rpc_session.last_footer_text = ""
|
|
1367
|
-
|
|
1480
|
+
notify("ui/footer", { sessionId: rpc_session.id, text: "" })
|
|
1368
1481
|
end
|
|
1369
1482
|
|
|
1370
1483
|
def emit_turn_event(turn, type, payload)
|
|
1371
1484
|
event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
|
|
1372
|
-
|
|
1485
|
+
notify("turn/event", event)
|
|
1373
1486
|
event
|
|
1374
1487
|
end
|
|
1375
1488
|
|
|
@@ -1389,7 +1502,7 @@ module Kward
|
|
|
1389
1502
|
end
|
|
1390
1503
|
|
|
1391
1504
|
def emit_session_event(rpc_session, type, payload)
|
|
1392
|
-
|
|
1505
|
+
notify("session/event", {
|
|
1393
1506
|
timestamp: now,
|
|
1394
1507
|
sessionId: rpc_session.id,
|
|
1395
1508
|
type: type,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Namespace for operating-system command sandboxing.
|
|
2
|
+
module Kward
|
|
3
|
+
module Sandbox
|
|
4
|
+
# Describes the enforcement a platform runner can provide.
|
|
5
|
+
class Capabilities
|
|
6
|
+
attr_reader :backend, :reason
|
|
7
|
+
|
|
8
|
+
def initialize(available:, filesystem_enforced:, child_network_enforced:, backend:, reason: nil)
|
|
9
|
+
@available = available == true
|
|
10
|
+
@filesystem_enforced = filesystem_enforced == true
|
|
11
|
+
@child_network_enforced = child_network_enforced == true
|
|
12
|
+
@backend = backend.to_s
|
|
13
|
+
@reason = reason&.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
@available
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filesystem_enforced?
|
|
21
|
+
@filesystem_enforced
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def child_network_enforced?
|
|
25
|
+
@child_network_enforced
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
available: available?,
|
|
31
|
+
filesystemEnforced: filesystem_enforced?,
|
|
32
|
+
childNetworkEnforced: child_network_enforced?,
|
|
33
|
+
backend: backend,
|
|
34
|
+
reason: reason
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "../local_command_runner"
|
|
2
|
+
|
|
3
|
+
# Namespace for operating-system command sandboxing.
|
|
4
|
+
module Kward
|
|
5
|
+
module Sandbox
|
|
6
|
+
# Base command runner. Platform runners provide an argv that is then executed
|
|
7
|
+
# through LocalCommandRunner, preserving Kward's timeout and cancellation behavior.
|
|
8
|
+
class CommandRunner
|
|
9
|
+
def initialize(policy:, capabilities:)
|
|
10
|
+
@policy = policy
|
|
11
|
+
@capabilities = capabilities
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :policy, :capabilities
|
|
15
|
+
|
|
16
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
17
|
+
LocalCommandRunner.new(
|
|
18
|
+
timeout_seconds: timeout_seconds,
|
|
19
|
+
max_output_bytes: max_output_bytes
|
|
20
|
+
).run(*command_argv(command, cwd: cwd), cwd: cwd, cancellation: cancellation, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def command_argv(command, cwd:)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #command_argv"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Namespace for operating-system command sandboxing.
|
|
2
|
+
module Kward
|
|
3
|
+
module Sandbox
|
|
4
|
+
# Builds a minimal environment for sandboxed command workers. In particular,
|
|
5
|
+
# credentials and language-runtime injection variables are not inherited.
|
|
6
|
+
module Environment
|
|
7
|
+
SAFE_VARIABLES = %w[PATH LANG LC_ALL LC_CTYPE TERM COLORTERM NO_COLOR].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def command_worker(temporary_root, source: ENV)
|
|
12
|
+
SAFE_VARIABLES.each_with_object({}) do |name, environment|
|
|
13
|
+
value = source[name].to_s
|
|
14
|
+
environment[name] = value unless value.empty?
|
|
15
|
+
end.merge(
|
|
16
|
+
"HOME" => temporary_root,
|
|
17
|
+
"TMPDIR" => temporary_root,
|
|
18
|
+
"TMP" => temporary_root,
|
|
19
|
+
"TEMP" => temporary_root
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "command_runner"
|
|
4
|
+
require_relative "environment"
|
|
5
|
+
|
|
6
|
+
# Namespace for operating-system command sandboxing.
|
|
7
|
+
module Kward
|
|
8
|
+
module Sandbox
|
|
9
|
+
# Runs a command in a Bubblewrap mount namespace on Linux.
|
|
10
|
+
class LinuxBubblewrapRunner < CommandRunner
|
|
11
|
+
def self.executable
|
|
12
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
|
|
13
|
+
path = File.expand_path("bwrap", directory)
|
|
14
|
+
path if File.file?(path) && File.executable?(path)
|
|
15
|
+
end.first
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.capabilities(platform: RUBY_PLATFORM, executable: self.executable)
|
|
19
|
+
available = platform.to_s.include?("linux") && !executable.to_s.empty?
|
|
20
|
+
return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "linux_bubblewrap") if available
|
|
21
|
+
|
|
22
|
+
Capabilities.new(
|
|
23
|
+
available: false,
|
|
24
|
+
filesystem_enforced: false,
|
|
25
|
+
child_network_enforced: false,
|
|
26
|
+
backend: "linux_bubblewrap",
|
|
27
|
+
reason: "Bubblewrap is unavailable"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(policy:, executable: self.class.executable, capabilities: self.class.capabilities(executable: executable))
|
|
32
|
+
raise ArgumentError, "Linux Bubblewrap is unavailable: #{capabilities.reason}" unless capabilities.available?
|
|
33
|
+
|
|
34
|
+
super(policy:, capabilities:)
|
|
35
|
+
@executable = executable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
39
|
+
temporary_root = Dir.mktmpdir("kward-sandbox")
|
|
40
|
+
environment = Environment.command_worker(temporary_root)
|
|
41
|
+
|
|
42
|
+
LocalCommandRunner.new(
|
|
43
|
+
timeout_seconds: timeout_seconds,
|
|
44
|
+
max_output_bytes: max_output_bytes
|
|
45
|
+
).run(
|
|
46
|
+
*command_argv(command, cwd: cwd, temporary_root: temporary_root),
|
|
47
|
+
env: environment,
|
|
48
|
+
cwd: cwd,
|
|
49
|
+
cancellation: cancellation,
|
|
50
|
+
unsetenv_others: true,
|
|
51
|
+
&block
|
|
52
|
+
)
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def command_argv(command, cwd:, temporary_root: nil)
|
|
58
|
+
temporary_root ||= Dir.tmpdir
|
|
59
|
+
workspace_root = policy.workspace_root
|
|
60
|
+
writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
|
|
61
|
+
argv = [@executable, "--die-with-parent", "--new-session", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
|
|
62
|
+
argv << "--unshare-net" unless policy.child_network_allowed?
|
|
63
|
+
argv.concat(["--bind", workspace_root, workspace_root]) if policy.workspace_write?
|
|
64
|
+
writable_roots.reject { |path| path == workspace_root }.each { |path| argv.concat(["--bind", path, path]) }
|
|
65
|
+
argv.concat(["--ro-bind", File.join(workspace_root, ".git"), File.join(workspace_root, ".git")]) if policy.protect_git_metadata? && File.exist?(File.join(workspace_root, ".git"))
|
|
66
|
+
argv.concat(["--setenv", "TMPDIR", temporary_root, "--setenv", "TMP", temporary_root, "--setenv", "TEMP", temporary_root])
|
|
67
|
+
argv.concat(["--chdir", cwd.to_s, "--", "/bin/sh", "-lc", command.to_s])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|