kward 0.84.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -1,4 +1,4 @@
1
- require_relative "../scratchpad_languages"
1
+ require_relative "../prompt_interface/editor/scratchpad_languages"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
@@ -64,6 +64,9 @@ module Kward
64
64
  [true, nil]
65
65
  when "tab"
66
66
  [true, handle_tab_command(argument, session_store)]
67
+ when "name"
68
+ rename_active_session_and_tab(argument)
69
+ [true, nil]
67
70
  when "worktree"
68
71
  handle_worktree_command(argument)
69
72
  [true, active_tab&.agent]
@@ -81,7 +84,9 @@ module Kward
81
84
  configure_reasoning(agent.conversation)
82
85
  [true, nil]
83
86
  when "reload"
84
- run_busy_local_command_and_requeue { reload_plugins(agent.conversation) }
87
+ strict_worktree = active_tab&.driver&.respond_to?(:worktree) && active_tab.driver.worktree&.active?
88
+ registry = strict_worktree ? nil : agent.tool_registry
89
+ run_busy_local_command_and_requeue { reload_plugins(agent.conversation, tool_registry: registry) }
85
90
  [true, nil]
86
91
  when "new"
87
92
  [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
@@ -113,7 +118,11 @@ module Kward
113
118
  run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
114
119
  [true, nil]
115
120
  when "compact"
116
- run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
121
+ origin_tab = active_tab
122
+ result = run_busy_local_command_and_requeue(activity: "compacting") do |cancellation|
123
+ compact_context(agent, argument, cancellation: cancellation)
124
+ end
125
+ render_compaction_summary(result) if !origin_tab || origin_tab == active_tab
117
126
  [true, nil]
118
127
  else
119
128
  if name.to_s.start_with?("skill:")
@@ -122,7 +131,7 @@ module Kward
122
131
  elsif interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
123
132
  run_interactive_command(name, argument, agent)
124
133
  elsif plugin_command_for(name)
125
- run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
134
+ run_plugin_command_and_turn(name, argument, agent)
126
135
  else
127
136
  [false, nil]
128
137
  end
@@ -133,6 +142,38 @@ module Kward
133
142
  PromptCommands.parse(command) || [nil, ""]
134
143
  end
135
144
 
145
+ def rename_active_session_and_tab(argument)
146
+ name = argument.to_s.strip
147
+ return runtime_output("Usage: /name <name>") if name.empty?
148
+
149
+ tab = active_tab
150
+ unless @active_session && (!tab || tab.session.equal?(@active_session))
151
+ return runtime_output("The /name command is only available in a session tab.")
152
+ end
153
+
154
+ errors = []
155
+ begin
156
+ rename_session(name, require_name: true, announce: false)
157
+ rescue StandardError => error
158
+ errors << "session: #{error.message}"
159
+ end
160
+
161
+ if tab
162
+ begin
163
+ rename_active_tab(name)
164
+ rescue StandardError => error
165
+ errors << "tab: #{error.message}"
166
+ end
167
+ end
168
+
169
+ if errors.empty?
170
+ label = tab ? "Named session and tab: #{name}" : "Named session: #{name}"
171
+ runtime_output(label)
172
+ else
173
+ runtime_output("Name update incomplete (#{errors.join("; ")}).")
174
+ end
175
+ end
176
+
136
177
  def handle_sandbox_command(argument, workspace_root)
137
178
  action, value = argument.to_s.strip.split(/\s+/, 2)
138
179
  case action
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "securerandom"
2
3
  require "thread"
3
4
  require_relative "../cancellation"
4
5
 
@@ -10,14 +11,16 @@ module Kward
10
11
  module Tabs
11
12
  WORKTREE_AGENT_CONTEXT = <<~PROMPT.strip.freeze
12
13
  This is an active Git worktree tab. The workspace is sandboxed separately from the host repository metadata.
13
- When the user explicitly asks you to commit, use the advertised `git_commit` tool. Do not use `run_shell_command` for `git add` or `git commit`, because shared Git metadata is intentionally protected there.
14
- `git_commit` requires a commit message and accepts optional workspace-relative paths; omit paths to include all current changes in this worktree. Do not create a commit unless the user requested one.
14
+ Workspace tools default to the active worktree. When they advertise `target`, use `target: "origin"` to inspect the verified host repository worktree without leaving this tab. Read the origin's applicable `AGENTS.md` before changing it.
15
+ When the user explicitly asks you to commit, use the advertised `git_commit` tool. Set `target: "origin"` only when the user asks to commit there. Do not use `run_shell_command` for `git add` or `git commit`, because shared Git metadata is intentionally protected.
16
+ `git_commit` requires a commit message and accepts optional target-relative paths; omit paths to include all current changes in the selected worktree. Do not create a commit unless the user requested one.
15
17
  PROMPT
16
18
 
17
19
  TabRuntime = Struct.new(
18
20
  :session,
19
21
  :agent,
20
22
  :driver,
23
+ :plugin_host,
21
24
  :diff,
22
25
  :snapshot,
23
26
  :status,
@@ -41,6 +44,7 @@ module Kward
41
44
  :transient_shell_entries,
42
45
  :error_reported,
43
46
  :local_busy_activity,
47
+ :pending_runtime_outputs,
44
48
  keyword_init: true
45
49
  ) do
46
50
  TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
@@ -53,6 +57,11 @@ module Kward
53
57
  !local_busy_activity.to_s.empty?
54
58
  end
55
59
 
60
+ def append_pending_runtime_output(text)
61
+ self.pending_runtime_outputs ||= []
62
+ pending_runtime_outputs << text.to_s
63
+ end
64
+
56
65
  def idle?
57
66
  !running? && !local_busy?
58
67
  end
@@ -205,14 +214,23 @@ module Kward
205
214
  end
206
215
 
207
216
  tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
217
+ host = nil
208
218
  driver = if tab_type&.local
209
- host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
210
- tab_type.handler.call(host, descriptor)
219
+ descriptor["scope_key"] ||= plugin_tab_scope_key(tab_type)
220
+ host = build_plugin_tab_host(tab_type, workspace_root: session_store.cwd, scope_key: descriptor["scope_key"])
221
+ begin
222
+ created_driver = tab_type.handler.call(host, descriptor)
223
+ raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless created_driver
224
+
225
+ tab_type.capabilities.validate_driver!(created_driver)
226
+ rescue StandardError
227
+ host.shutdown
228
+ raise
229
+ end
211
230
  else
212
231
  UnavailableTabDriver.new(descriptor: descriptor, message: "Plugin tab #{descriptor["plugin_tab_type"].inspect} is unavailable.")
213
232
  end
214
- raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless driver
215
- build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || tab_type&.title)
233
+ build_tab(nil, nil, driver: driver, plugin_host: host, label: label || descriptor["label"] || tab_type&.title)
216
234
  end
217
235
 
218
236
  def open_plugin_tab(name, session_store)
@@ -225,12 +243,20 @@ module Kward
225
243
 
226
244
  save_active_tab_state
227
245
  stop_tab_live_view
228
- descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title }
229
- host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
230
- driver = tab_type.handler.call(host, descriptor)
231
- raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
246
+ scope_key = plugin_tab_scope_key(tab_type)
247
+ descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title, "scope_key" => scope_key }
248
+ host = build_plugin_tab_host(tab_type, workspace_root: session_store.cwd, scope_key: scope_key)
249
+ begin
250
+ driver = tab_type.handler.call(host, descriptor)
251
+ raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
252
+
253
+ tab_type.capabilities.validate_driver!(driver)
254
+ rescue StandardError
255
+ host.shutdown
256
+ raise
257
+ end
232
258
 
233
- @tabs << build_tab(nil, nil, driver: driver, label: tab_type.title)
259
+ @tabs << build_tab(nil, nil, driver: driver, plugin_host: host, label: tab_type.title)
234
260
  @active_tab_index = @tabs.length - 1
235
261
  activate_tab(@active_tab_index)
236
262
  rescue StandardError => e
@@ -254,21 +280,30 @@ module Kward
254
280
  strict = worktree&.active? == true
255
281
  update_worktree_agent_context(conversation, strict)
256
282
  workspace = configured_workspace(root: conversation.workspace_root, strict: strict)
283
+ workspace_targets = if strict
284
+ origin_workspace = configured_workspace(root: worktree.origin_root, strict: true)
285
+ Tools::WorkspaceTargets.new(active: workspace, targets: { origin: origin_workspace })
286
+ end
257
287
  prompt = TabQuestionPrompt.new(self)
258
288
  hook_manager = strict ? nil : lifecycle_hook_manager(conversation)
259
289
  hook_context = strict ? nil : lifecycle_hook_context(conversation)
260
290
  git_committer = if strict
261
- ->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
291
+ lambda do |message:, paths:, target: Tools::WorkspaceTargets::ACTIVE|
292
+ commit_workspace = target == "origin" ? worktree.origin_root : workspace.root.to_s
293
+ git_commit_for_agent(commit_workspace, message: message, paths: paths)
294
+ end
262
295
  end
263
296
  project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
264
297
  tool_registry = ToolRegistry.new(
265
298
  workspace: workspace,
299
+ workspace_targets: workspace_targets,
266
300
  prompt: prompt,
267
301
  skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
268
302
  tool_approval: tab_tool_approval_callback(prompt),
269
303
  hook_manager: hook_manager,
270
304
  hook_context: hook_context,
271
305
  mcp_clients: strict ? [] : nil,
306
+ plugin_tools: strict ? [] : plugin_registry.tools,
272
307
  git_committer: git_committer
273
308
  )
274
309
  @footer_conversation = conversation
@@ -301,12 +336,13 @@ module Kward
301
336
  end
302
337
  end
303
338
 
304
- def build_tab(session, agent, driver: nil, label: nil)
339
+ def build_tab(session, agent, driver: nil, plugin_host: nil, label: nil)
305
340
  driver ||= SessionTabDriver.new(session: session, agent: agent)
306
341
  TabRuntime.new(
307
342
  session: session,
308
343
  agent: agent,
309
344
  driver: driver,
345
+ plugin_host: plugin_host,
310
346
  diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
311
347
  snapshot: nil,
312
348
  status: "idle",
@@ -329,7 +365,8 @@ module Kward
329
365
  background_run: nil,
330
366
  transient_shell_entries: [],
331
367
  error_reported: false,
332
- local_busy_activity: nil
368
+ local_busy_activity: nil,
369
+ pending_runtime_outputs: []
333
370
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
334
371
  end
335
372
 
@@ -429,6 +466,7 @@ module Kward
429
466
 
430
467
  if @tabs.length <= 1
431
468
  close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
469
+ close_plugin_tab(tab)
432
470
  @tabs.clear
433
471
  persist_tabs
434
472
  return PromptInterface::EXIT_INPUT
@@ -437,7 +475,7 @@ module Kward
437
475
  stop_tab_live_view
438
476
  close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
439
477
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
440
- tab.driver.close if tab.driver.respond_to?(:close)
478
+ close_plugin_tab(tab)
441
479
  @tabs.delete_at(@active_tab_index)
442
480
  @active_tab_index = [@active_tab_index, @tabs.length - 1].min
443
481
  activate_tab(@active_tab_index)
@@ -555,8 +593,23 @@ module Kward
555
593
  end
556
594
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
557
595
  render_tab_transient_shell_entries(tab)
596
+ render_background_tab_output(tab)
558
597
  end
559
598
  restore_tab_composer_snapshot(tab.snapshot) if restore_composer
599
+ render_pending_tab_runtime_outputs(tab)
600
+ end
601
+
602
+ def render_pending_tab_runtime_outputs(tab)
603
+ outputs = Array(tab.pending_runtime_outputs)
604
+ tab.pending_runtime_outputs = []
605
+ outputs.each { |output| runtime_output(output) }
606
+ end
607
+
608
+ def flush_pending_runtime_outputs(tab)
609
+ return false unless tab && tab == active_tab
610
+
611
+ render_pending_tab_runtime_outputs(tab)
612
+ true
560
613
  end
561
614
 
562
615
  def render_tab_transient_shell_entries(tab)
@@ -569,6 +622,17 @@ module Kward
569
622
  end
570
623
  end
571
624
 
625
+ def render_background_tab_output(tab)
626
+ output = tab.background_run&.fetch(:rendered_output, "").to_s
627
+ return if output.empty?
628
+
629
+ if @prompt.respond_to?(:say)
630
+ @prompt.say(output)
631
+ elsif @prompt.respond_to?(:write_transcript)
632
+ @prompt.write_transcript(output)
633
+ end
634
+ end
635
+
572
636
  def clear_active_tab_transient_shell_entries
573
637
  active_tab&.clear_transient_shell_entries
574
638
  end
@@ -614,10 +678,15 @@ module Kward
614
678
  index ? index + 1 : active_tab_number
615
679
  end
616
680
 
617
- def register_background_tab_run(tab, run, &completion)
681
+ def register_background_tab_run(tab, run, output_kind: nil, &completion)
618
682
  return unless tab && run
619
683
 
620
- tab.background_run = { run: run, completion: completion }
684
+ tab.background_run = {
685
+ run: run,
686
+ completion: completion,
687
+ output_kind: output_kind,
688
+ rendered_output: +""
689
+ }
621
690
  tab.local_busy_activity = "running"
622
691
  tab.attention = nil
623
692
  update_prompt_tabs
@@ -628,11 +697,19 @@ module Kward
628
697
 
629
698
  @tabs.each do |tab|
630
699
  state = tab.background_run
631
- next unless state&.fetch(:run)&.complete?
700
+ next unless state
632
701
 
633
- result = state[:run].result
702
+ complete = state[:run].complete?
703
+ result = state[:run].result if complete
704
+ if state[:output_kind] && respond_to?(:flush_background_tab_output, true)
705
+ flush_background_tab_output(tab, state, result: result)
706
+ end
707
+ next unless complete
708
+
709
+ state[:completion]&.call(state[:run].sink, result, state) if result.is_a?(Struct)
710
+ rendered_output = state[:rendered_output].to_s
711
+ tab.append_transient_shell_entry(rendered_output) unless rendered_output.empty?
634
712
  tab.background_run = nil
635
- state[:completion]&.call(state[:run].sink, result) if result.is_a?(Struct)
636
713
  finish_local_busy_command_for_tab(tab)
637
714
  if tab != active_tab
638
715
  tab.unread = true
@@ -654,20 +731,39 @@ module Kward
654
731
 
655
732
  def submit_tab_input(tab, input, display_input: nil)
656
733
  return if input.to_s.strip.empty?
734
+ return unless plugin_tab_attachments_allowed?(tab, input)
657
735
 
658
736
  save_active_tab_state
659
737
  start_tab_turn(tab, input, display_input: display_input)
660
738
  start_tab_live_view(tab) if tab == active_tab
661
739
  end
662
740
 
741
+ def plugin_tab_attachments_allowed?(tab, input)
742
+ capabilities = tab.plugin_host&.capabilities
743
+ return true unless capabilities&.declared?
744
+ return true if capabilities.allows_attachment?(:image)
745
+
746
+ attached = ImageAttachments.references_from_text(input.to_s).any? { |reference| reference[:status] == :attached }
747
+ return true unless attached
748
+
749
+ runtime_output("This plugin chat does not allow image attachments.")
750
+ false
751
+ end
752
+
663
753
  def start_tab_turn(tab, input, display_input: nil)
664
754
  prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
665
- print_user_transcript(input, display_input: display_input) if prompt_interface?
755
+ print_user_transcript(input, display_input: display_input) if prompt_interface? && tab == active_tab
666
756
  tab.status = "queued"
667
757
  tab.unread = false
668
758
  tab.attention = nil
669
759
  tab.cancellation = Cancellation.new
670
- tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
760
+ steering_capability = tab.plugin_host&.capabilities
761
+ driver_supports_steering = if steering_capability&.declared?
762
+ steering_capability.steering?
763
+ else
764
+ tab.driver.supports_steering?
765
+ end
766
+ tab.steering = driver_supports_steering && steering_supported? ? Steering.new : nil
671
767
  tab.error = nil
672
768
  tab.answer = nil
673
769
  tab.error_reported = false
@@ -779,8 +875,12 @@ module Kward
779
875
  tab.queued_inputs << input
780
876
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
781
877
  return
782
- elsif busy_tab_command?(input)
783
- handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
878
+ elsif busy_tab_command?(input) || busy_name_command?(input)
879
+ if busy_tab_command?(input)
880
+ handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
881
+ else
882
+ handle_local_slash_command(input, tab.agent, @session_store)
883
+ end
784
884
  unless active_tab.equal?(tab)
785
885
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
786
886
  return :active_tab_changed
@@ -812,6 +912,10 @@ module Kward
812
912
  input.to_s.strip.match?(%r{\A/tab(?:\s|\z)})
813
913
  end
814
914
 
915
+ def busy_name_command?(input)
916
+ input.to_s.strip.match?(%r{\A/name(?:\s|\z)})
917
+ end
918
+
815
919
  def refresh_active_tab
816
920
  tab = active_tab
817
921
  return unless tab&.thread && !tab.thread.alive? && tab.running?
@@ -922,7 +1026,7 @@ module Kward
922
1026
  return :caution if tab.running? || tab.local_busy?
923
1027
  return :failure if %w[failed cancelled].include?(tab.status.to_s) || tab.attention == :failure
924
1028
  return :success if tab.attention == :success
925
- return :activity if tab.unread
1029
+ return :success if tab.unread
926
1030
 
927
1031
  nil
928
1032
  end
@@ -972,7 +1076,7 @@ module Kward
972
1076
  end
973
1077
 
974
1078
  def tab_usage
975
- "/tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label> | /tab worktree [activate|detach|status|merge|merge abort|remove]"
1079
+ "/tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label> | /tab worktree [activate|detach|status|merge [resolve|continue|abort]|remove]"
976
1080
  end
977
1081
 
978
1082
  def switch_tab_number(number)
@@ -1042,16 +1146,52 @@ module Kward
1042
1146
  end
1043
1147
  tab.background_run = nil
1044
1148
  close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
1149
+ close_plugin_tab(tab)
1045
1150
  rescue StandardError
1046
1151
  nil
1047
1152
  end
1048
1153
  end
1049
1154
 
1155
+ def build_plugin_tab_host(tab_type, workspace_root:, scope_key:)
1156
+ PluginTabHost.new(
1157
+ client: @client,
1158
+ workspace_root: workspace_root,
1159
+ plugin_host: tab_type.plugin_id && plugin_registry.plugin_for(tab_type.plugin_id),
1160
+ type_id: tab_type.id,
1161
+ surface: :local,
1162
+ scope_key: scope_key,
1163
+ capabilities: tab_type.capabilities
1164
+ )
1165
+ end
1166
+
1167
+ def plugin_tab_scope_key(tab_type)
1168
+ tab_type.singleton == :global ? "global" : SecureRandom.uuid
1169
+ end
1170
+
1171
+ def close_plugin_tab(tab)
1172
+ return unless tab&.plugin_host
1173
+
1174
+ if tab.driver.respond_to?(:close)
1175
+ tab.driver.close
1176
+ elsif tab.driver.respond_to?(:shutdown)
1177
+ tab.driver.shutdown
1178
+ end
1179
+ rescue StandardError => e
1180
+ runtime_output("Plugin tab cleanup error: #{e.message}")
1181
+ ensure
1182
+ tab&.plugin_host&.shutdown
1183
+ tab.plugin_host = nil if tab
1184
+ end
1185
+
1050
1186
  def persist_tabs
1051
1187
  return unless @tab_store
1052
1188
 
1053
1189
  @tab_store.save(
1054
- tabs: @tabs.map { |tab| tab.driver.descriptor.merge("label" => tab.label.to_s) },
1190
+ tabs: @tabs.map do |tab|
1191
+ descriptor = tab.driver.descriptor.merge("label" => tab.label.to_s)
1192
+ descriptor["scope_key"] ||= tab.plugin_host.scope_key if tab.plugin_host
1193
+ descriptor
1194
+ end,
1055
1195
  active_index: @active_tab_index
1056
1196
  )
1057
1197
  end
@@ -1,7 +1,7 @@
1
1
  require "base64"
2
- require_relative "image_attachments"
3
- require_relative "message_access"
4
- require_relative "message_text"
2
+ require_relative "../image_attachments"
3
+ require_relative "../message_access"
4
+ require_relative "../message_text"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -101,10 +101,14 @@ module Kward
101
101
  remove_active_worktree
102
102
  when "merge"
103
103
  merge_active_worktree
104
+ when "merge resolve"
105
+ resolve_active_worktree_merge
106
+ when "merge continue"
107
+ continue_active_worktree_merge
104
108
  when "merge abort"
105
109
  abort_active_worktree_merge
106
110
  else
107
- runtime_output("Usage: /tab worktree [activate|detach|status|merge|merge abort|remove]")
111
+ runtime_output("Usage: /tab worktree [activate|detach|status|merge [resolve|continue|abort]|remove]")
108
112
  end
109
113
  end
110
114
 
@@ -222,6 +226,11 @@ module Kward
222
226
  raise GitWorktreeManager::Error, "Worktree branch changed: expected #{binding.branch}, found #{info.branch || "detached HEAD"}" unless info.branch == binding.branch
223
227
  status = git_worktree_manager.status(binding.path)
224
228
  lines << "Changes: #{status.clean? ? "clean" : "#{status.entries.length} local change(s)"}"
229
+ if git_worktree_manager.merge_in_progress?(binding.origin_root)
230
+ conflicts = git_worktree_manager.merge_conflict_marker_paths(binding.origin_root, cached: false)
231
+ lines << "Merge: in progress in origin#{conflicts.empty? ? "; ready to continue" : "; #{conflicts.length} unresolved conflict(s)"}"
232
+ lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
233
+ end
225
234
  rescue GitWorktreeManager::Error => e
226
235
  lines << "State: unavailable (#{e.message})"
227
236
  end
@@ -236,6 +245,10 @@ module Kward
236
245
  return runtime_output("Activate the worktree before merging it.") unless binding.active?
237
246
 
238
247
  validate_worktree_binding!(binding)
248
+ if git_worktree_manager.merge_in_progress?(binding.origin_root)
249
+ return runtime_output("A merge is already in progress in #{binding.origin_root}. Resolve it with /worktree merge resolve, continue it with /worktree merge continue, or abort it with /worktree merge abort.")
250
+ end
251
+
239
252
  source_status = git_worktree_manager.status(binding.path)
240
253
  return runtime_output("Worktree has local changes; commit or clean it before merging.") if source_status.dirty?
241
254
 
@@ -270,12 +283,62 @@ module Kward
270
283
  if result.merged?
271
284
  runtime_output("Merged #{binding.branch} into #{target_branch}.")
272
285
  else
273
- runtime_output("Merge conflicts in #{result.conflicts.join(", ")}. Resolve them in #{binding.origin_root} or run /tab worktree merge abort.")
286
+ runtime_output(<<~MESSAGE.strip)
287
+ Merge conflicts in #{result.conflicts.join(", ")}.
288
+ Merge paused in #{binding.origin_root}; this tab remains on #{binding.branch}.
289
+ Run /worktree merge resolve to let the agent inspect and fix the conflicts here, or /worktree merge abort to cancel.
290
+ MESSAGE
274
291
  end
275
292
  rescue GitWorktreeManager::Error => e
276
293
  runtime_output("Worktree error: #{e.message}")
277
294
  end
278
295
 
296
+ def resolve_active_worktree_merge
297
+ tab = active_tab
298
+ binding = worktree_binding_for(tab)
299
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
300
+ return runtime_output("Tab #{active_tab_number} is running and cannot resolve its worktree merge yet.") if tab.running? || tab.local_busy? || tab.shell
301
+ return runtime_output("Activate the worktree before resolving its merge.") unless binding.active?
302
+ return runtime_output("No merge is in progress in the original workspace.") unless git_worktree_manager.merge_in_progress?(binding.origin_root)
303
+
304
+ validate_worktree_binding!(binding)
305
+ conflicts = git_worktree_manager.unmerged_paths(binding.origin_root)
306
+ return runtime_output("No unresolved conflicts remain in #{binding.origin_root}; run /worktree merge continue to finish the merge.") if conflicts.empty?
307
+
308
+ submit_tab_input(
309
+ tab,
310
+ <<~PROMPT.strip,
311
+ Resolve the in-progress Git merge in the verified origin workspace. Use target: "origin" for every workspace inspection, edit, and test operation. Read the applicable AGENTS.md files first, inspect the unmerged paths, resolve the conflicts, and run the relevant tests. Do not commit the merge; leave it ready for /worktree merge continue after all conflicts are resolved.
312
+
313
+ Unmerged paths: #{conflicts.join(", ")}
314
+ PROMPT
315
+ display_input: "Resolve merge conflicts in origin"
316
+ )
317
+ rescue GitWorktreeManager::Error => e
318
+ runtime_output("Worktree error: #{e.message}")
319
+ end
320
+
321
+ def continue_active_worktree_merge
322
+ tab = active_tab
323
+ binding = worktree_binding_for(tab)
324
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
325
+ return runtime_output("Tab #{active_tab_number} is running and cannot continue its worktree merge yet.") if tab.running? || tab.local_busy? || tab.shell
326
+ return runtime_output("Activate the worktree before continuing its merge.") unless binding.active?
327
+ return runtime_output("No merge is in progress in the original workspace.") unless git_worktree_manager.merge_in_progress?(binding.origin_root)
328
+
329
+ validate_worktree_binding!(binding)
330
+ markers = git_worktree_manager.merge_conflict_marker_paths(binding.origin_root, cached: false)
331
+ return runtime_output("Merge still contains conflict markers: #{markers.join(", ")}.") unless markers.empty?
332
+ target_branch = git_worktree_manager.current_branch(binding.origin_root)
333
+ return runtime_output("Original workspace is in detached HEAD state; resolve that before continuing the merge.") if target_branch.empty?
334
+ return unless confirm_worktree_action("Complete the merge of #{binding.branch} into #{target_branch} in #{binding.origin_root}?")
335
+
336
+ git_worktree_manager.continue_merge(binding.origin_root)
337
+ runtime_output("Completed the merge of #{binding.branch} into #{target_branch}.")
338
+ rescue GitWorktreeManager::Error => e
339
+ runtime_output("Worktree error: #{e.message}")
340
+ end
341
+
279
342
  def abort_active_worktree_merge
280
343
  tab = active_tab
281
344
  binding = worktree_binding_for(tab)