kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -9,24 +9,24 @@ require_relative "../config_files"
9
9
  require_relative "../model/context_usage"
10
10
  require_relative "../conversation"
11
11
  require_relative "../events"
12
- require_relative "../export_path"
12
+ require_relative "../transcripts/export_path"
13
13
  require_relative "../memory/manager"
14
14
  require_relative "../memory/turn_context"
15
15
  require_relative "../message_access"
16
16
  require_relative "../message_text"
17
- require_relative "../session_tree_tool_display"
17
+ require_relative "../sessions/tree_tool_display"
18
18
  require_relative "../skills/capture"
19
19
  require_relative "../model/model_info"
20
- require_relative "../plugin_registry"
20
+ require_relative "../plugins/registry"
21
21
  require_relative "../prompts/commands"
22
- require_relative "../session_store"
23
- require_relative "../session_naming"
24
- require_relative "../session_trash"
22
+ require_relative "../sessions/store"
23
+ require_relative "../sessions/naming"
24
+ require_relative "../sessions/trash"
25
25
  require_relative "../steering"
26
26
  require_relative "../tools/tool_call"
27
27
  require_relative "../tools/registry"
28
- require_relative "../transcript_export"
29
- require_relative "../workspace_factory"
28
+ require_relative "../transcripts/transcript_export"
29
+ require_relative "../workspace/factory"
30
30
  require_relative "attachment_normalizer"
31
31
  require_relative "config_manager"
32
32
  require_relative "memory_methods"
@@ -66,7 +66,7 @@ module Kward
66
66
  WORKER_STOP_TIMEOUT = 2.0
67
67
  WORKER_STOP = Object.new.freeze
68
68
 
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)
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, :last_footer_segments, keyword_init: true)
70
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)
71
71
 
72
72
  # Creates an object for RPC session lifecycle and turn coordination.
@@ -215,7 +215,7 @@ module Kward
215
215
 
216
216
  # Returns the plugin registry shared by RPC sessions and plugin chats.
217
217
  def plugin_registry
218
- @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
218
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names).tap(&:start!)
219
219
  end
220
220
 
221
221
  # Renames the persisted session attached to an RPC session id.
@@ -392,6 +392,11 @@ module Kward
392
392
  { closed: true }
393
393
  end
394
394
 
395
+ def shutdown_plugins
396
+ @plugin_registry&.shutdown!
397
+ { closed: true }
398
+ end
399
+
395
400
  # Returns the normalized transcript for the active RPC session.
396
401
  def transcript(session_id:)
397
402
  rpc_session = fetch_session(session_id)
@@ -490,6 +495,12 @@ module Kward
490
495
  { ok: true }
491
496
  end
492
497
 
498
+ def answer_plugin_ui(session_id:, request_id:, value:)
499
+ rpc_session = fetch_session(session_id)
500
+ rpc_session.prompt.answer_plugin_ui(request_id, value)
501
+ { ok: true }
502
+ end
503
+
493
504
  def answer_tool_approval(session_id:, approval_request_id:, approved:)
494
505
  rpc_session = fetch_session(session_id)
495
506
  rpc_session.prompt.answer_tool_approval(approval_request_id, approved: approved)
@@ -525,18 +536,39 @@ module Kward
525
536
 
526
537
  def run_plugin_command(session_id:, command:, arguments: "")
527
538
  rpc_session = fetch_session(session_id)
539
+ raise ArgumentError, "Plugin commands are disabled for this session" if rpc_session.execution_profile && !rpc_session.execution_profile.plugin_commands
540
+
528
541
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
542
+ arguments = command.parse_arguments(arguments)
529
543
  output = []
530
- context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
531
- result = command.handler.call(arguments.to_s, context)
544
+ context = plugin_context(rpc_session, args: arguments, say_callback: lambda { |message| output << message.to_s })
545
+ result = command.normalize_result(command.handler.call(arguments, context))
532
546
  output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
533
- { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
547
+ serialized_result = command.typed? ? result.to_h : (result.nil? ? nil : result.to_s)
548
+ { command: command.name, output: output, result: serialized_result }
534
549
  end
535
550
 
536
551
  def plugin_commands
537
552
  plugin_registry.commands
538
553
  end
539
554
 
555
+ def plugin_actions
556
+ plugin_registry.actions
557
+ end
558
+
559
+ def run_plugin_action(session_id:, id:, arguments: {})
560
+ rpc_session = fetch_session(session_id)
561
+ raise ArgumentError, "Plugin actions are disabled for this session" if rpc_session.execution_profile && !rpc_session.execution_profile.plugin_commands
562
+
563
+ action = plugin_registry.action_for(id) || raise(ArgumentError, "Unknown plugin action: #{id}")
564
+ arguments = action.parse_arguments(arguments)
565
+ output = []
566
+ context = plugin_context(rpc_session, args: arguments, say_callback: lambda { |message| output << message.to_s })
567
+ result = action.normalize_result(action.handler.call(arguments, context))
568
+ output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
569
+ { action: action.id, output: output, result: result.to_h }
570
+ end
571
+
540
572
  def available_models
541
573
  models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
542
574
  normalized = models.map { |model| normalize_model(model) }
@@ -628,6 +660,8 @@ module Kward
628
660
 
629
661
  def reload_plugins
630
662
  registry = PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
663
+ @plugin_registry&.reload!
664
+ registry.start!
631
665
  sessions = @mutex.synchronize do
632
666
  @plugin_registry = registry
633
667
  @sessions.values
@@ -636,7 +670,7 @@ module Kward
636
670
  rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
637
671
  rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
638
672
  rebuild_session_tools(rpc_session)
639
- if registry.footer_renderer
673
+ if registry.status?
640
674
  start_footer_worker(rpc_session)
641
675
  emit_footer_update(rpc_session)
642
676
  else
@@ -714,7 +748,9 @@ module Kward
714
748
  hook_context = lifecycle_hook_context(
715
749
  conversation: rpc_session.conversation,
716
750
  session: rpc_session.session,
717
- workspace_root: rpc_session.workspace_root
751
+ workspace_root: rpc_session.workspace_root,
752
+ prompt: rpc_session.prompt,
753
+ session_id: rpc_session.id
718
754
  )
719
755
  hook_manager = lifecycle_hook_manager(rpc_session.workspace_root)
720
756
  tool_registry = build_tool_registry(
@@ -1042,7 +1078,13 @@ module Kward
1042
1078
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
1043
1079
  id = SecureRandom.uuid
1044
1080
  prompt = PromptBridge.new(notify: method(:notify), session_id: id)
1045
- hook_context = lifecycle_hook_context(conversation: conversation, session: session, workspace_root: workspace_root)
1081
+ hook_context = lifecycle_hook_context(
1082
+ conversation: conversation,
1083
+ session: session,
1084
+ workspace_root: workspace_root,
1085
+ prompt: prompt,
1086
+ session_id: id
1087
+ )
1046
1088
  hook_manager = lifecycle_hook_manager(workspace_root)
1047
1089
  tool_registry = build_tool_registry(workspace_root, prompt, hook_manager: hook_manager, hook_context: hook_context)
1048
1090
  agent = Agent.new(
@@ -1074,6 +1116,7 @@ module Kward
1074
1116
  workspace: configured_workspace(workspace_root),
1075
1117
  prompt: prompt,
1076
1118
  allowed_tool_names: allowed_tool_names,
1119
+ plugin_tools: plugin_registry.tools,
1077
1120
  tool_approval: tool_approval,
1078
1121
  approval_for_allowed_tools: !tool_approval.nil?,
1079
1122
  hook_manager: hook_manager,
@@ -1115,13 +1158,16 @@ module Kward
1115
1158
  })
1116
1159
  end
1117
1160
 
1118
- def lifecycle_hook_context(conversation:, session:, workspace_root:)
1161
+ def lifecycle_hook_context(conversation:, session:, workspace_root:, prompt: nil, session_id: nil)
1162
+ say_callback = lambda { |message| notify("hook/message", { message: message.to_s }) }
1119
1163
  PluginRegistry::Context.new(
1120
1164
  conversation: conversation,
1121
1165
  args: "",
1122
1166
  session: session,
1123
1167
  workspace_root: workspace_root,
1124
- say_callback: lambda { |message| notify("hook/message", { message: message.to_s }) }
1168
+ say_callback: say_callback,
1169
+ ui: rpc_plugin_ui(prompt: prompt, session_id: session_id, say_callback: say_callback, requests: false),
1170
+ tool_ui: rpc_plugin_ui(prompt: prompt, session_id: session_id, say_callback: say_callback, requests: !prompt.nil?)
1125
1171
  )
1126
1172
  end
1127
1173
 
@@ -1212,7 +1258,7 @@ module Kward
1212
1258
  end
1213
1259
 
1214
1260
  def start_footer_worker(rpc_session)
1215
- return unless plugin_registry.footer_renderer
1261
+ return unless plugin_registry.status?
1216
1262
  return if rpc_session.footer_worker&.alive?
1217
1263
 
1218
1264
  rpc_session.footer_worker = Thread.new do
@@ -1290,18 +1336,7 @@ module Kward
1290
1336
  if turn.plugin_command_name
1291
1337
  run_plugin_turn(rpc_session, turn)
1292
1338
  else
1293
- auto_name_session(rpc_session, turn.display_input || turn.input)
1294
- prepare_memory_context(rpc_session.conversation, turn.input) unless memory_disabled?(turn)
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|
1296
- next if turn.cancel_requested
1297
-
1298
- notify_plugin_transcript_event(rpc_session, event)
1299
- handle_agent_event(turn, event)
1300
- end
1301
- unless memory_disabled?(turn)
1302
- persist_memory_state(rpc_session)
1303
- auto_summarize_memory(rpc_session) unless turn.cancel_requested
1304
- end
1339
+ run_session_model_turn(rpc_session, turn, input: turn.input, display_input: turn_display_input(turn))
1305
1340
  finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
1306
1341
  end
1307
1342
  rescue Cancellation::CancelledError
@@ -1316,6 +1351,22 @@ module Kward
1316
1351
  rpc_session.running_turn_id = nil
1317
1352
  end
1318
1353
 
1354
+ def run_session_model_turn(rpc_session, turn, input:, display_input: nil)
1355
+ auto_name_session(rpc_session, display_input || turn.display_input || input)
1356
+ memory_input = input.is_a?(PluginTurnRequest) ? input.to_s : input
1357
+ prepare_memory_context(rpc_session.conversation, memory_input) unless memory_disabled?(turn)
1358
+ rpc_session.agent.ask(input, display_input: display_input, cancellation: turn.cancellation, steering: turn.steering, options: turn.options || {}, tool_registry: turn.tool_registry) do |event|
1359
+ next if turn.cancel_requested
1360
+
1361
+ notify_plugin_transcript_event(rpc_session, event)
1362
+ handle_agent_event(turn, event)
1363
+ end
1364
+ unless memory_disabled?(turn)
1365
+ persist_memory_state(rpc_session)
1366
+ auto_summarize_memory(rpc_session) unless turn.cancel_requested
1367
+ end
1368
+ end
1369
+
1319
1370
  def build_steering(_turn)
1320
1371
  Steering.new
1321
1372
  end
@@ -1371,12 +1422,44 @@ module Kward
1371
1422
  turn_payload(turn)
1372
1423
  end
1373
1424
 
1374
- def plugin_context(rpc_session, args: nil, say_callback:)
1425
+ def plugin_context(rpc_session, args: nil, say_callback:, requests: false, cancellation: nil, turn_command: nil)
1375
1426
  PluginRegistry::Context.new(
1376
1427
  conversation: rpc_session.conversation,
1377
1428
  args: args,
1378
1429
  session: rpc_session.session,
1379
1430
  workspace_root: rpc_session.workspace_root,
1431
+ say_callback: say_callback,
1432
+ cancellation: cancellation,
1433
+ turn_command: turn_command,
1434
+ ui: rpc_plugin_ui(
1435
+ prompt: rpc_session.prompt,
1436
+ session_id: rpc_session.id,
1437
+ say_callback: say_callback,
1438
+ requests: requests
1439
+ )
1440
+ )
1441
+ end
1442
+
1443
+ def rpc_plugin_ui(prompt:, session_id:, say_callback:, requests:)
1444
+ capabilities = { progress: true, notify: true }
1445
+ requester = nil
1446
+ if requests && prompt
1447
+ capabilities.merge!(question: true, select: true, confirm: true, input: true)
1448
+ requester = lambda do |kind, payload, cancellation: nil|
1449
+ if kind == :question
1450
+ prompt.ask_user_question(payload.fetch(:questions), cancellation: cancellation)
1451
+ else
1452
+ prompt.request_plugin_ui(kind, payload, cancellation: cancellation)
1453
+ end
1454
+ end
1455
+ end
1456
+ emitter = lambda do |kind, payload|
1457
+ method = kind == :progress ? "ui/progress" : "ui/notification"
1458
+ payload = payload.merge(level: payload[:level].to_s) if payload[:level]
1459
+ notify(method, { sessionId: session_id }.merge(payload))
1460
+ end
1461
+ PluginUI.new(
1462
+ backend: PluginUI::Backend.new(capabilities: capabilities, requester: requester, emitter: emitter),
1380
1463
  say_callback: say_callback
1381
1464
  )
1382
1465
  end
@@ -1384,13 +1467,30 @@ module Kward
1384
1467
  def run_plugin_turn(rpc_session, turn)
1385
1468
  turn.cancellation&.raise_if_cancelled!
1386
1469
  command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1470
+ arguments = command.parse_arguments(turn.plugin_arguments)
1387
1471
  output = []
1388
- context = plugin_context(rpc_session, args: turn.plugin_arguments.to_s, say_callback: lambda { |message| output << message.to_s })
1389
- result = command.handler.call(turn.plugin_arguments.to_s, context)
1472
+ context = plugin_context(
1473
+ rpc_session,
1474
+ args: arguments,
1475
+ say_callback: lambda { |message| output << message.to_s },
1476
+ requests: true,
1477
+ cancellation: turn.cancellation,
1478
+ turn_command: command
1479
+ )
1480
+ result = command.normalize_result(command.handler.call(arguments, context))
1481
+ turn.cancellation&.raise_if_cancelled!
1482
+ if command.typed?
1483
+ emit_turn_event(turn, "pluginCommandResult", { command: command.name, result: result.to_h })
1484
+ result = result.message
1485
+ end
1390
1486
  answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
1391
1487
  unless answer.empty?
1392
1488
  emit_turn_event(turn, "assistantDelta", { delta: answer })
1393
- emit_turn_event(turn, "answer", { content: answer })
1489
+ emit_turn_event(turn, "answer", { content: answer }) unless context.requested_turn
1490
+ end
1491
+ if context.requested_turn
1492
+ turn.steering = build_steering(turn) if supports_in_flight_steer?
1493
+ run_session_model_turn(rpc_session, turn, input: context.requested_turn, display_input: context.requested_turn.to_s)
1394
1494
  end
1395
1495
  finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
1396
1496
  end
@@ -1457,27 +1557,30 @@ module Kward
1457
1557
  end
1458
1558
 
1459
1559
  def emit_footer_update(rpc_session)
1460
- renderer = plugin_registry.footer_renderer
1461
- return clear_footer_update(rpc_session) unless renderer
1560
+ return clear_footer_update(rpc_session) unless plugin_registry.status?
1462
1561
 
1463
- text = begin
1562
+ segments = begin
1464
1563
  context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1465
- renderer.call(context).to_s.gsub(/\s+/, " ").strip
1564
+ plugin_registry.status_segments(context)
1466
1565
  rescue StandardError => e
1467
1566
  warn "Warning: Kward plugin footer error: #{e.message}"
1468
- ""
1567
+ []
1469
1568
  end
1470
- return if rpc_session.last_footer_text == text
1569
+ segment_payloads = segments.map(&:to_h)
1570
+ text = plugin_registry.compose_status(segments)
1571
+ return if rpc_session.last_footer_text == text && rpc_session.last_footer_segments == segment_payloads
1471
1572
 
1472
1573
  rpc_session.last_footer_text = text
1473
- notify("ui/footer", { sessionId: rpc_session.id, text: text })
1574
+ rpc_session.last_footer_segments = segment_payloads
1575
+ notify("ui/footer", { sessionId: rpc_session.id, text: text, segments: segment_payloads })
1474
1576
  end
1475
1577
 
1476
1578
  def clear_footer_update(rpc_session)
1477
- return if rpc_session.last_footer_text.to_s.empty?
1579
+ return if rpc_session.last_footer_text.to_s.empty? && Array(rpc_session.last_footer_segments).empty?
1478
1580
 
1479
1581
  rpc_session.last_footer_text = ""
1480
- notify("ui/footer", { sessionId: rpc_session.id, text: "" })
1582
+ rpc_session.last_footer_segments = []
1583
+ notify("ui/footer", { sessionId: rpc_session.id, text: "", segments: [] })
1481
1584
  end
1482
1585
 
1483
1586
  def emit_turn_event(turn, type, payload)
@@ -1,7 +1,7 @@
1
1
  require_relative "../message_access"
2
2
  require_relative "../message_text"
3
- require_relative "../session_tree_nodes"
4
- require_relative "../session_tree_tool_display"
3
+ require_relative "../sessions/tree_nodes"
4
+ require_relative "../sessions/tree_tool_display"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -1,5 +1,5 @@
1
1
  require_relative "../tools/tool_call"
2
- require_relative "../workspace"
2
+ require_relative "../workspace/workspace"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -173,10 +173,14 @@ module Kward
173
173
  when "reasoning"
174
174
  reasoning_item_blocks(item).map { |thinking| { type: "thinking", thinking: thinking } }
175
175
  when "message"
176
- next [] if ToolCall.value(item, :phase).to_s == "commentary"
177
-
178
176
  text = response_message_item_text(item)
179
- text.empty? ? [] : [{ type: "text", text: text }]
177
+ next [] if text.empty?
178
+
179
+ if ToolCall.value(item, :phase).to_s == "commentary"
180
+ [{ type: "thinking", thinking: text }]
181
+ else
182
+ [{ type: "text", text: text }]
183
+ end
180
184
  else
181
185
  []
182
186
  end
@@ -1,4 +1,4 @@
1
- require_relative "../local_command_runner"
1
+ require_relative "../pty/local_command_runner"
2
2
 
3
3
  # Namespace for operating-system command sandboxing.
4
4
  module Kward
@@ -1,5 +1,5 @@
1
1
  require "json"
2
- require_relative "private_file"
2
+ require_relative "../private_file"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -3,15 +3,15 @@ require "digest"
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
- require_relative "config_files"
7
- require_relative "conversation"
8
- require_relative "message_access"
9
- require_relative "message_text"
10
- require_relative "private_file"
11
- require_relative "rpc/tool_event_normalizer"
12
- require_relative "session_catalog"
13
- require_relative "tools/tool_call"
14
- require_relative "workspace"
6
+ require_relative "../config_files"
7
+ require_relative "../conversation"
8
+ require_relative "../message_access"
9
+ require_relative "../message_text"
10
+ require_relative "../private_file"
11
+ require_relative "../rpc/tool_event_normalizer"
12
+ require_relative "catalog"
13
+ require_relative "../tools/tool_call"
14
+ require_relative "../workspace/workspace"
15
15
 
16
16
  # Namespace for the Kward CLI agent runtime.
17
17
  module Kward
@@ -1,6 +1,6 @@
1
- require_relative "message_access"
2
- require_relative "message_text"
3
- require_relative "tools/tool_call"
1
+ require_relative "../message_access"
2
+ require_relative "../message_text"
3
+ require_relative "../tools/tool_call"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
6
6
  module Kward
@@ -1,7 +1,7 @@
1
- require_relative "message_access"
2
- require_relative "message_text"
3
- require_relative "session_tree_nodes"
4
- require_relative "session_tree_tool_display"
1
+ require_relative "../message_access"
2
+ require_relative "../message_text"
3
+ require_relative "tree_nodes"
4
+ require_relative "tree_tool_display"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -1,5 +1,5 @@
1
1
  require "json"
2
- require_relative "tools/tool_call"
2
+ require_relative "../tools/tool_call"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -1,8 +1,8 @@
1
1
  require "shellwords"
2
- require_relative "ansi"
3
- require_relative "local_command_runner"
2
+ require_relative "../terminal/ansi"
3
+ require_relative "../pty/local_command_runner"
4
4
  begin
5
- require_relative "local_pty_command_runner"
5
+ require_relative "../pty/local_pty_runner"
6
6
  rescue LoadError
7
7
  nil
8
8
  end
@@ -10,10 +10,10 @@ end
10
10
  # Namespace for the Kward CLI agent runtime.
11
11
  module Kward
12
12
  # Kward-native embedded shell command runner.
13
- class Ekwsh
13
+ class Kwsh
14
14
  Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
15
15
  Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
16
- BUILTINS = %w[alias capture cd pwd export unset unalias clear exit logout pty].freeze
16
+ BUILTINS = %w[alias capture cd pwd export source unset unalias clear exit logout pty].freeze
17
17
  DEFAULT_SHELL = "/bin/sh"
18
18
  DEFAULT_TIMEOUT_SECONDS = 300
19
19
  DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
@@ -60,7 +60,7 @@ module Kward
60
60
  end
61
61
 
62
62
  # Runs a command for the shell assistant without handing it the terminal.
63
- # Built-ins still execute in this Ekwsh instance so their state persists.
63
+ # Built-ins still execute in this Kwsh instance so their state persists.
64
64
  def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
65
65
  command = input.to_s.strip
66
66
  return run(command, cancellation: cancellation) if command.empty?
@@ -322,12 +322,12 @@ module Kward
322
322
  return nil unless %w[exit logout].include?(words.first)
323
323
 
324
324
  if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
325
- return Result.new(output: "#{command_echo(display_command)}ekwsh: #{words.first}: numeric status expected\n", exit_status: 2)
325
+ return Result.new(output: "#{command_echo(display_command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
326
326
  end
327
327
 
328
328
  Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
329
329
  rescue ArgumentError => e
330
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
330
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
331
331
  end
332
332
 
333
333
  def builtin_result(command, display_command: command, cancellation: nil, &block)
@@ -347,6 +347,8 @@ module Kward
347
347
  print_working_directory(display_command, words)
348
348
  when "export"
349
349
  export_variables(display_command, words)
350
+ when "source", "."
351
+ source_file(display_command, words)
350
352
  when "unset"
351
353
  unset_variables(display_command, words)
352
354
  when "clear"
@@ -359,7 +361,7 @@ module Kward
359
361
  nil
360
362
  end
361
363
  rescue ArgumentError => e
362
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
364
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
363
365
  end
364
366
 
365
367
  def captured_command_result(command, display_command:, cancellation: nil, &block)
@@ -399,7 +401,7 @@ module Kward
399
401
  invalid << name
400
402
  end
401
403
  end
402
- return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
404
+ return Result.new(output: "#{command_echo(command)}kwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
403
405
 
404
406
  names = @aliases.keys.sort if names.empty? && assignments.empty?
405
407
  lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
@@ -417,7 +419,7 @@ module Kward
417
419
  end
418
420
 
419
421
  missing = words.drop(1).reject { |name| @aliases.delete(name) }
420
- return Result.new(output: "#{command_echo(command)}ekwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
422
+ return Result.new(output: "#{command_echo(command)}kwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
421
423
 
422
424
  Result.new(output: command_echo(command), exit_status: 0)
423
425
  end
@@ -455,7 +457,7 @@ module Kward
455
457
  path = File.expand_path(words[2], @cwd)
456
458
  Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
457
459
  rescue ArgumentError => e
458
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
460
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
459
461
  end
460
462
 
461
463
  def kward_edit_command?(words)
@@ -489,7 +491,7 @@ module Kward
489
491
 
490
492
  def change_directory(command, words)
491
493
  if words.length > 2
492
- return Result.new(output: "#{command_echo(command)}ekwsh: cd: too many arguments\n", exit_status: 2)
494
+ return Result.new(output: "#{command_echo(command)}kwsh: cd: too many arguments\n", exit_status: 2)
493
495
  end
494
496
 
495
497
  target = words[1]
@@ -497,7 +499,7 @@ module Kward
497
499
  target = @previous_cwd || @cwd if target == "-"
498
500
  path = File.expand_path(target, @cwd)
499
501
  unless File.directory?(path)
500
- return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
502
+ return Result.new(output: "#{command_echo(command)}kwsh: cd: no such directory: #{target}\n", exit_status: 1)
501
503
  end
502
504
 
503
505
  @previous_cwd = @cwd
@@ -528,10 +530,27 @@ module Kward
528
530
  if invalid.empty?
529
531
  Result.new(output: command_echo(command), exit_status: 0)
530
532
  else
531
- Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
533
+ Result.new(output: "#{command_echo(command)}kwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
532
534
  end
533
535
  end
534
536
 
537
+ def source_file(command, words)
538
+ unless words.length == 2
539
+ return Result.new(output: "#{command_echo(command)}Usage: source <file>\n", exit_status: 2)
540
+ end
541
+
542
+ require_relative "kwshrc"
543
+ path = Kwshrc.resolve_path(words[1], cwd: @cwd, env: @env)
544
+ config = Kwshrc.read_file(path, env: @env)
545
+ path_changed = config[:env].key?("PATH")
546
+ @env.merge!(config[:env])
547
+ @aliases.merge!(config[:aliases])
548
+ invalidate_path_executables_cache if path_changed
549
+ Result.new(output: command_echo(command), exit_status: 0)
550
+ rescue Kwshrc::ParseError => e
551
+ Result.new(output: "#{command_echo(command)}kwsh: source: #{e.message}\n", exit_status: 1)
552
+ end
553
+
535
554
  def unset_variables(command, words)
536
555
  names = words.drop(1)
537
556
  names.shift if names.first == "--"
@@ -541,7 +560,7 @@ module Kward
541
560
  if invalid.empty?
542
561
  Result.new(output: command_echo(command), exit_status: 0)
543
562
  else
544
- Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
563
+ Result.new(output: "#{command_echo(command)}kwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
545
564
  end
546
565
  end
547
566
 
@@ -575,8 +594,8 @@ module Kward
575
594
  end
576
595
  append_output_newline(output) { |text| yield text if streamed }
577
596
  exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
578
- append_streamed(output, "ekwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
579
- append_streamed(output, "ekwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
597
+ append_streamed(output, "kwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
598
+ append_streamed(output, "kwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
580
599
  append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
581
600
  Result.new(output: output, exit_status: exit_status, streamed: streamed)
582
601
  rescue Cancellation::CancelledError
@@ -584,7 +603,7 @@ module Kward
584
603
  append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
585
604
  Result.new(output: output, exit_status: 130, streamed: streamed)
586
605
  rescue Errno::ENOENT => e
587
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
606
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 127)
588
607
  end
589
608
 
590
609
  def effective_timeout(timeout_seconds)