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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. 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 "../workspace"
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
- rpc_session = build_rpc_session(store, session, conversation, root)
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
- rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
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
- rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
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
- normalized_options = normalize_turn_options(options)
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
- plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
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
- @server.notify("turn/event", event)
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) ? File.mtime(session.path) : nil
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 new_conversation(workspace_root: Dir.pwd)
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(server: @server, session_id: id)
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
- @server.notify("hook/event", {
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| @server.notify("hook/message", { message: message.to_s }) }
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
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
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
- @server.notify("turn/event", event)
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
- persist_memory_state(rpc_session)
1191
- auto_summarize_memory(rpc_session) unless turn.cancel_requested
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
- @server.notify("turn/event", event)
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
- @server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
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
- @server.notify("ui/footer", { sessionId: rpc_session.id, text: "" })
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
- @server.notify("turn/event", event)
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
- @server.notify("session/event", {
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