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
@@ -1,8 +1,9 @@
1
1
  require "io/console"
2
2
  require "pty"
3
3
  require "timeout"
4
- require_relative "local_pty_command_runner"
5
- require_relative "pty_output_sink"
4
+ require_relative "local_pty_runner"
5
+ require_relative "output_sink"
6
+ require_relative "detached_run"
6
7
 
7
8
  # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
@@ -10,7 +11,7 @@ module Kward
10
11
  # This is intentionally low level: UI orchestration decides when terminal
11
12
  # ownership is handed to the child process and how the result is presented.
12
13
  class InteractivePtyRunner
13
- Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
14
+ Result = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
14
15
 
15
16
  READ_SIZE = 4096
16
17
 
@@ -19,28 +20,43 @@ module Kward
19
20
  @window_size = nil
20
21
  end
21
22
 
22
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
23
- pid = nil
23
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:, tab_action_handler: nil, on_detach: nil)
24
+ reader, writer, pid = PTY.spawn(env.to_h, *command, chdir: cwd.to_s)
24
25
  status = nil
25
- writer = nil
26
26
  input_forwarded = false
27
-
28
- PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, child_writer, child_pid|
29
- pid = child_pid
30
- writer = child_writer
31
- update_window_size(reader, pid)
32
- with_raw_input(input) do
33
- input_forwarded = drain_initial_input(input, writer, sink)
34
- status, forwarded = forward_io(reader, writer, pid, input, sink)
27
+ tab_action = nil
28
+ forwarded = false
29
+ detached = nil
30
+
31
+ update_window_size(reader, pid)
32
+ with_raw_input(input) do
33
+ input_forwarded, tab_action = drain_initial_input(input, writer, sink, tab_action_handler: tab_action_handler)
34
+ if tab_action && on_detach
35
+ detached_sink = on_detach.call
36
+ sink.finish if sink.respond_to?(:finish)
37
+ detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
38
+ reader = writer = pid = nil
39
+ elsif tab_action
40
+ status = terminate_and_reap(pid)
41
+ else
42
+ status, forwarded, tab_action = forward_io(reader, writer, pid, input, sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
35
43
  input_forwarded ||= forwarded
44
+ if tab_action && on_detach
45
+ detached_sink = on_detach.call
46
+ sink.finish if sink.respond_to?(:finish)
47
+ detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
48
+ reader = writer = pid = nil
49
+ end
36
50
  end
37
- sink.finish if sink.respond_to?(:finish)
38
- status ||= wait_for_status(pid)
51
+ input_forwarded ||= forwarded
39
52
  end
53
+ sink.finish if sink.respond_to?(:finish) && !detached
54
+ status ||= wait_for_status(pid) if pid
40
55
 
41
- Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
56
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded, tab_action: tab_action, background: detached)
42
57
  ensure
43
58
  writer&.close unless writer&.closed?
59
+ reader&.close unless reader&.closed?
44
60
  terminate_and_reap(pid) if pid && status.nil?
45
61
  end
46
62
 
@@ -60,23 +76,39 @@ module Kward
60
76
  yield
61
77
  end
62
78
 
63
- def drain_initial_input(input, writer, sink)
79
+ def drain_initial_input(input, writer, sink, tab_action_handler: nil)
64
80
  forwarded = false
65
81
  loop do
66
82
  chunk = input.read_nonblock(READ_SIZE, exception: false)
67
83
  break if chunk.nil? || chunk == :wait_readable
68
84
 
85
+ filtered = tab_action_handler&.call(chunk)
86
+ if filtered
87
+ tab_action = filtered[:tab_action]
88
+ chunk = filtered[:input].to_s
89
+ if tab_action
90
+ unless chunk.empty?
91
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
92
+ writer.write(chunk)
93
+ writer.flush
94
+ forwarded = true
95
+ end
96
+ return [forwarded, tab_action]
97
+ end
98
+ end
99
+ next if chunk.empty?
100
+
69
101
  sink.input_forwarded if sink.respond_to?(:input_forwarded)
70
102
  writer.write(chunk)
71
103
  forwarded = true
72
104
  end
73
105
  writer.flush
74
- forwarded
106
+ [forwarded, nil]
75
107
  rescue Errno::EIO, Errno::EPIPE, IOError
76
- forwarded
108
+ [forwarded, nil]
77
109
  end
78
110
 
79
- def forward_io(reader, writer, pid, input, sink)
111
+ def forward_io(reader, writer, pid, input, sink, tab_action_handler: nil, on_detach: nil)
80
112
  input_open = true
81
113
  input_forwarded = false
82
114
  loop do
@@ -86,19 +118,45 @@ module Kward
86
118
  readable = IO.select(readers, nil, nil, 0.02)&.first || []
87
119
  forward_pty_output(reader, sink) if readable.include?(reader)
88
120
  if input_open && readable.include?(input)
89
- input_open, forwarded = forward_input(input, writer, sink)
121
+ input_open, forwarded, tab_action = forward_input(input, writer, sink, tab_action_handler: tab_action_handler)
90
122
  input_forwarded ||= forwarded
123
+ return [nil, input_forwarded, tab_action] if tab_action && on_detach
124
+ return [terminate_and_reap(pid), input_forwarded, tab_action] if tab_action
91
125
  end
92
126
 
93
127
  status = finished_status(pid)
94
128
  next unless status
95
129
 
96
- return [finish_stopped_process(pid), input_forwarded] if status.stopped?
130
+ return [finish_stopped_process(pid), input_forwarded, nil] if status.stopped?
97
131
 
98
132
  drain_pty_output(reader, sink)
99
- return [status, input_forwarded]
133
+ return [status, input_forwarded, nil]
100
134
  rescue Errno::EIO
101
- return [wait_for_status(pid), input_forwarded]
135
+ return [wait_for_status(pid), input_forwarded, nil]
136
+ end
137
+ end
138
+
139
+ def detach_process(reader, writer, pid, sink, input_forwarded)
140
+ DetachedRun.new(sink: sink, canceler: -> { terminate_process_group(pid) }) do
141
+ status = nil
142
+ begin
143
+ loop do
144
+ readable, = IO.select([reader], nil, nil, 0.02)&.first
145
+ forward_pty_output(reader, sink) if readable
146
+ status = finished_status(pid)
147
+ next unless status
148
+
149
+ drain_pty_output(reader, sink)
150
+ break
151
+ end
152
+ rescue Errno::EIO
153
+ status = wait_for_status(pid)
154
+ ensure
155
+ sink.finish if sink.respond_to?(:finish)
156
+ writer.close unless writer.closed?
157
+ reader.close unless reader.closed?
158
+ end
159
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
102
160
  end
103
161
  end
104
162
 
@@ -125,17 +183,33 @@ module Kward
125
183
  nil
126
184
  end
127
185
 
128
- def forward_input(input, writer, sink)
186
+ def forward_input(input, writer, sink, tab_action_handler: nil)
129
187
  chunk = input.read_nonblock(READ_SIZE, exception: false)
130
- return [false, false] if chunk.nil?
131
- return [true, false] if chunk == :wait_readable
188
+ return [false, false, nil] if chunk.nil?
189
+ return [true, false, nil] if chunk == :wait_readable
190
+
191
+ filtered = tab_action_handler&.call(chunk)
192
+ if filtered
193
+ tab_action = filtered[:tab_action]
194
+ chunk = filtered[:input].to_s
195
+ if tab_action
196
+ unless chunk.empty?
197
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
198
+ writer.write(chunk)
199
+ writer.flush
200
+ return [true, true, tab_action]
201
+ end
202
+ return [true, false, tab_action]
203
+ end
204
+ end
205
+ return [true, false, nil] if chunk.empty?
132
206
 
133
207
  sink.input_forwarded if sink.respond_to?(:input_forwarded)
134
208
  writer.write(chunk)
135
209
  writer.flush
136
- [true, true]
210
+ [true, true, nil]
137
211
  rescue Errno::EIO, Errno::EPIPE, IOError
138
- [false, false]
212
+ [false, false, nil]
139
213
  end
140
214
 
141
215
  def update_window_size(reader, pid)
@@ -1,7 +1,7 @@
1
1
  require "open3"
2
2
  require "thread"
3
3
  require "timeout"
4
- require_relative "cancellation"
4
+ require_relative "../cancellation"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -1,7 +1,7 @@
1
1
  require "io/console"
2
2
  require "pty"
3
3
  require "timeout"
4
- require_relative "cancellation"
4
+ require_relative "../cancellation"
5
5
  require_relative "local_command_runner"
6
6
 
7
7
  # Namespace for the Kward CLI agent runtime.
@@ -52,7 +52,8 @@ module Kward
52
52
  readable, = IO.select([reader], nil, nil, 0.02)
53
53
  next unless readable
54
54
 
55
- chunk = read_chunk(reader)
55
+ chunk = reader.read_nonblock(READ_SIZE, exception: false)
56
+ next if chunk == :wait_readable
56
57
  break if chunk.nil?
57
58
 
58
59
  chunk = normalize_line_endings(chunk)
@@ -102,13 +103,6 @@ module Kward
102
103
  [DEFAULT_ROWS, DEFAULT_COLUMNS]
103
104
  end
104
105
 
105
- def read_chunk(reader)
106
- reader.read_nonblock(READ_SIZE, exception: false).tap do |chunk|
107
- return nil if chunk.nil?
108
- return nil if chunk == :wait_readable
109
- end
110
- end
111
-
112
106
  def normalize_line_endings(chunk)
113
107
  chunk.gsub("\r\r\n", "\n").gsub("\r\n", "\n")
114
108
  end
@@ -1,5 +1,57 @@
1
+ require "thread"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
5
+ # Retains output while a detached PTY command runs without terminal ownership.
6
+ class BufferedPtyOutputSink
7
+ def initialize(max_capture_bytes:, initial_output: nil, initial_truncated: false)
8
+ @max_capture_bytes = max_capture_bytes
9
+ @captured_output = +"".b
10
+ @truncated = initial_truncated
11
+ @mutex = Mutex.new
12
+ write(initial_output) unless initial_output.to_s.empty?
13
+ end
14
+
15
+ def captured_output
16
+ @mutex.synchronize { @captured_output.dup }
17
+ end
18
+
19
+ def write(chunk)
20
+ value = chunk.to_s.b
21
+ return if value.empty?
22
+
23
+ @mutex.synchronize do
24
+ remaining = @max_capture_bytes - @captured_output.bytesize
25
+ if value.bytesize > remaining
26
+ @captured_output << value.byteslice(0, remaining) if remaining.positive?
27
+ @truncated = true
28
+ else
29
+ @captured_output << value
30
+ end
31
+ end
32
+ end
33
+
34
+ def flush
35
+ nil
36
+ end
37
+
38
+ def finish
39
+ nil
40
+ end
41
+
42
+ def transcript_safe?
43
+ true
44
+ end
45
+
46
+ def pre_input_capture_only?
47
+ true
48
+ end
49
+
50
+ def truncated?
51
+ @mutex.synchronize { @truncated }
52
+ end
53
+ end
54
+
3
55
  # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
56
  #
5
57
  # The runner only depends on the sink's `write` method and optional `flush`
@@ -1,4 +1,4 @@
1
- require_relative "ansi"
1
+ require_relative "../terminal/ansi"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
@@ -1,7 +1,7 @@
1
1
  require_relative "../cancellation"
2
2
  require_relative "../image_attachments"
3
- require_relative "../plugin_chat_runtime"
4
- require_relative "../plugin_registry"
3
+ require_relative "../plugins/chat_runtime"
4
+ require_relative "../plugins/registry"
5
5
  require_relative "attachment_normalizer"
6
6
  require_relative "transcript_normalizer"
7
7
 
@@ -30,21 +30,25 @@ module Kward
30
30
  @runtime.supported_types(surface: :rpc)
31
31
  end
32
32
 
33
+ def type_entries
34
+ supported_types.map { |type| type_payload(type) }
35
+ end
36
+
33
37
  def list
34
- { chats: supported_types.map { |type| type_payload(type) } }
38
+ { chats: type_entries }
35
39
  end
36
40
 
37
41
  def open(type_id:)
38
42
  chat = @runtime.open(type_id: type_id, surface: :rpc, scope_key: "owner")
39
43
  payload = chat_payload(chat)
40
- return payload if chat.driver.respond_to?(:transcript_page)
44
+ return payload if transcript_paging?(chat)
41
45
 
42
46
  payload.merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
43
47
  end
44
48
 
45
49
  def transcript(chat_id:, limit: nil, before: nil)
46
50
  chat = fetch_chat(chat_id)
47
- page = transcript_page(chat.driver, limit: limit, before: before)
51
+ page = transcript_page(chat, limit: limit, before: before)
48
52
  {
49
53
  chat: chat_payload(chat),
50
54
  messages: TranscriptNormalizer.new(page.fetch(:messages)).normalize,
@@ -70,7 +74,7 @@ module Kward
70
74
  normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
71
75
  turn = @runtime.start_turn(
72
76
  chat_id: chat.id,
73
- input: @runtime.input_with_attachments(input, normalized_attachments),
77
+ input: @runtime.input_with_attachments(input, normalized_attachments, capabilities: chat.type.capabilities),
74
78
  display_input: input.to_s
75
79
  )
76
80
  turn_payload(turn)
@@ -121,19 +125,35 @@ module Kward
121
125
  end
122
126
 
123
127
  def type_payload(type)
124
- {
128
+ payload = {
125
129
  id: type.id,
126
130
  name: type.name,
127
131
  title: type.title,
128
132
  singleton: type.singleton,
129
133
  transport: type.transport == true ? true : nil
130
134
  }.compact
135
+ return payload unless type.capabilities.declared?
136
+
137
+ capabilities = type.capabilities.to_h
138
+ payload.merge(
139
+ apiVersion: capabilities.fetch(:api_version),
140
+ capabilities: {
141
+ attachments: capabilities.fetch(:attachments),
142
+ steering: capabilities.fetch(:steering),
143
+ transcriptPaging: capabilities.fetch(:transcript_paging)
144
+ }
145
+ )
146
+ end
147
+
148
+ def transcript_paging?(chat)
149
+ capabilities = chat.type.capabilities
150
+ capabilities.declared? ? capabilities.transcript_paging? : chat.driver.respond_to?(:transcript_page)
131
151
  end
132
152
 
133
- def transcript_page(driver, limit:, before:)
134
- return { messages: driver.messages, has_more: false } unless limit && driver.respond_to?(:transcript_page)
153
+ def transcript_page(chat, limit:, before:)
154
+ return { messages: chat.driver.messages, has_more: false } unless limit && transcript_paging?(chat)
135
155
 
136
- driver.transcript_page(limit: limit, before: before)
156
+ chat.driver.transcript_page(limit: limit, before: before)
137
157
  end
138
158
 
139
159
  def chat_payload(chat)
@@ -74,6 +74,31 @@ module Kward
74
74
  answer_raw(request_id, normalize_answers(answers))
75
75
  end
76
76
 
77
+ def request_plugin_ui(kind, payload, cancellation: nil)
78
+ request_id = SecureRandom.uuid
79
+ @mutex.synchronize { @pending_requests[request_id] = true }
80
+ cancellation&.on_cancel { cancel_request(request_id) }
81
+ unless cancellation&.cancelled?
82
+ @notify.call("ui/request", {
83
+ sessionId: @session_id,
84
+ requestId: request_id,
85
+ kind: kind.to_s,
86
+ payload: payload
87
+ })
88
+ end
89
+
90
+ @mutex.synchronize do
91
+ @condition.wait(@mutex) until @answers.key?(request_id)
92
+ answer = @answers.delete(request_id)
93
+ @pending_requests.delete(request_id)
94
+ answer
95
+ end
96
+ end
97
+
98
+ def answer_plugin_ui(request_id, value)
99
+ answer_raw(request_id, value)
100
+ end
101
+
77
102
  def cancel_request(request_id)
78
103
  @mutex.synchronize do
79
104
  request_id = request_id.to_s
@@ -2,11 +2,11 @@ require "json"
2
2
  require_relative "../model/client"
3
3
  require_relative "../config_files"
4
4
  require_relative "../memory/manager"
5
- require_relative "../plugin_registry"
5
+ require_relative "../plugins/registry"
6
6
  require_relative "../transport"
7
7
  require_relative "../prompts/commands"
8
8
  require_relative "../tools/registry"
9
- require_relative "../workspace_factory"
9
+ require_relative "../workspace/factory"
10
10
  require_relative "../telemetry/logger"
11
11
  require_relative "../telemetry/stats"
12
12
  require_relative "auth_manager"
@@ -75,18 +75,22 @@ module Kward
75
75
  "memory/why", "memory/summarize"
76
76
  ].freeze
77
77
  COMMAND_METHODS = ["commands/list", "commands/run"].freeze
78
+ PLUGIN_ACTION_METHODS = ["pluginActions/list", "pluginActions/run"].freeze
78
79
  SKILL_CAPTURE_METHODS = ["skills/captureSessions", "skills/captureDraft", "skills/saveCapturedDraft"].freeze
79
80
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
80
81
  CONFIG_METHODS = ["config/read", "config/update"].freeze
81
82
  LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
82
83
  LIFECYCLE_HOOK_METHODS = ["hooks/logs"].freeze
83
- UI_METHODS = ["ui/answerQuestion"].freeze
84
+ UI_METHODS = ["ui/answerQuestion", "ui/answerRequest"].freeze
84
85
  TOOL_APPROVAL_METHODS = ["tool/answerApproval"].freeze
85
86
  TRANSPORT_METHODS = ["transports/list", "transports/status"].freeze
86
87
  SESSION_EVENT_NOTIFICATION = "session/event"
87
88
  SESSION_UPDATED_NOTIFICATION = "session/updated"
88
89
  TURN_EVENT_NOTIFICATION = "turn/event"
89
90
  UI_QUESTION_NOTIFICATION = "ui/question"
91
+ UI_REQUEST_NOTIFICATION = "ui/request"
92
+ UI_PROGRESS_NOTIFICATION = "ui/progress"
93
+ UI_NOTIFICATION = "ui/notification"
90
94
  UI_FOOTER_NOTIFICATION = "ui/footer"
91
95
  TOOL_APPROVAL_NOTIFICATION = "tool/approvalRequested"
92
96
  HOOK_EVENT_NOTIFICATION = "hook/event"
@@ -105,6 +109,7 @@ module Kward
105
109
  auth: AUTH_METHODS,
106
110
  memory: MEMORY_METHODS,
107
111
  commands: COMMAND_METHODS,
112
+ plugin_actions: PLUGIN_ACTION_METHODS,
108
113
  skill_capture: SKILL_CAPTURE_METHODS,
109
114
  startup_resources: STARTUP_RESOURCE_METHODS,
110
115
  config: CONFIG_METHODS,
@@ -176,6 +181,7 @@ module Kward
176
181
  @transport_manager.shutdown
177
182
  @plugin_chat_manager.shutdown
178
183
  @session_manager.shutdown_sessions
184
+ @session_manager.shutdown_plugins
179
185
  @shutdown_complete = true
180
186
  end
181
187
 
@@ -284,6 +290,10 @@ module Kward
284
290
  commands_list(params)
285
291
  when COMMAND_METHODS[1]
286
292
  commands_run(params)
293
+ when PLUGIN_ACTION_METHODS[0]
294
+ plugin_actions_list(params)
295
+ when PLUGIN_ACTION_METHODS[1]
296
+ plugin_actions_run(params)
287
297
  when SKILL_CAPTURE_METHODS[0]
288
298
  { sessions: @session_manager.skill_capture_sessions }
289
299
  when SKILL_CAPTURE_METHODS[1]
@@ -421,6 +431,8 @@ module Kward
421
431
  @plugin_chat_manager.list_turns(chat_id: params["chatId"], active: true)
422
432
  when UI_METHODS[0]
423
433
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
434
+ when UI_METHODS[1]
435
+ @session_manager.answer_plugin_ui(session_id: params.fetch("sessionId"), request_id: params.fetch("requestId"), value: params["value"])
424
436
  when TOOL_APPROVAL_METHODS[0]
425
437
  @session_manager.answer_tool_approval(session_id: params.fetch("sessionId"), approval_request_id: params.fetch("approvalRequestId"), approved: params.fetch("approved"))
426
438
  else
@@ -469,13 +481,27 @@ module Kward
469
481
  startMode: "cliOnly",
470
482
  entries: @transport_manager.list
471
483
  },
484
+ plugins: {
485
+ apiVersion: PluginRegistry::PLUGIN_API_VERSION,
486
+ identified: @session_manager.plugin_registry.plugins.map do |plugin|
487
+ { id: plugin.id, version: plugin.version, apiVersion: plugin.api_version }
488
+ end
489
+ },
490
+ pluginTools: {
491
+ supported: true,
492
+ registered: @session_manager.plugin_registry.tools.length,
493
+ discoveryMethod: TOOL_METHODS.first,
494
+ source: "plugin",
495
+ executionProfileFiltering: true,
496
+ permissionPolicy: true
497
+ },
472
498
  pluginChats: {
473
499
  supported: @plugin_chat_manager.supported_types.any?,
474
500
  methods: PLUGIN_CHAT_METHODS,
475
501
  notification: "pluginChat/event",
476
502
  subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
477
503
  attachments: { supported: true, method: PLUGIN_CHAT_METHODS[5], encoding: "base64", mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES, maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES },
478
- types: @plugin_chat_manager.supported_types.map { |type| { id: type.id, name: type.name, title: type.title, singleton: type.singleton, transport: type.transport == true ? true : nil }.compact }
504
+ types: @plugin_chat_manager.type_entries
479
505
  },
480
506
  turns: {
481
507
  mode: "async",
@@ -513,6 +539,7 @@ module Kward
513
539
  reasoning: { start: false, delta: true, boundary: true, end: false },
514
540
  modelRetry: { supported: true, event: "modelRetry" },
515
541
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
542
+ pluginCommands: { structuredResult: "pluginCommandResult" },
516
543
  tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, firstChangedLine: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
517
544
  errors: true,
518
545
  sessionUpdates: false
@@ -577,7 +604,31 @@ module Kward
577
604
  },
578
605
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
579
606
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
580
- commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
607
+ commands: {
608
+ supported: true,
609
+ methods: COMMAND_METHODS,
610
+ method: COMMAND_METHODS[0],
611
+ runMethod: COMMAND_METHODS[1],
612
+ sources: ["builtin", "prompt", "skill", "plugin"],
613
+ executableSources: ["builtin", "plugin"],
614
+ typedArguments: { supported: true, schema: "jsonSchemaObject", textSyntax: "shellFlags", legacyRawStrings: true },
615
+ structuredResults: true,
616
+ modelTurns: {
617
+ supported: true, method: "turns/start", synchronous: false,
618
+ inputRole: "system", codexInputRole: "developer", scope: "turn", maxSystemBytes: PluginTurnRequest::MAX_SYSTEM_BYTES,
619
+ pluginChats: false, pan: false
620
+ }
621
+ },
622
+ pluginActions: {
623
+ supported: true,
624
+ methods: PLUGIN_ACTION_METHODS,
625
+ registered: @session_manager.plugin_actions.length,
626
+ namespace: "pluginId/actionName",
627
+ arguments: "jsonSchemaObject",
628
+ structuredResults: true,
629
+ blockingUi: false,
630
+ localTui: false
631
+ },
581
632
  skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
582
633
  projectSkillTrust: { supported: false, trustRequired: true, reason: "RPC has no interactive trust decision bridge; project skills remain skipped unless globally enabled." },
583
634
  mcp: {
@@ -599,12 +650,14 @@ module Kward
599
650
  scratchpad: { supported: false, reason: "interactiveTuiOnly" },
600
651
  extensionUi: {
601
652
  question: { supported: true, notification: UI_QUESTION_NOTIFICATION, method: UI_METHODS.first, maxQuestions: 4, multiSelect: false, preview: false },
602
- select: false,
603
- confirm: false,
604
- input: false,
653
+ select: { supported: true, notification: UI_REQUEST_NOTIFICATION, method: UI_METHODS[1], maxOptions: PluginUI::MAX_OPTIONS },
654
+ confirm: { supported: true, notification: UI_REQUEST_NOTIFICATION, method: UI_METHODS[1] },
655
+ input: { supported: true, notification: UI_REQUEST_NOTIFICATION, method: UI_METHODS[1], maxBytes: PluginUI::MAX_TEXT_BYTES },
656
+ progress: { supported: true, notification: UI_PROGRESS_NOTIFICATION },
657
+ notify: { supported: true, notification: UI_NOTIFICATION, levels: PluginUI::NOTIFICATION_LEVELS.map(&:to_s) },
605
658
  editor: false,
606
659
  widgets: false,
607
- footer: { supported: true, notification: UI_FOOTER_NOTIFICATION },
660
+ footer: { supported: true, notification: UI_FOOTER_NOTIFICATION, segments: true, tooltip: true, priorities: PluginRegistry::STATUS_PRIORITIES.keys.map(&:to_s) },
608
661
  custom: false,
609
662
  terminalInput: false
610
663
  },
@@ -789,8 +842,12 @@ module Kward
789
842
  argumentHint: command.argument_hint,
790
843
  source: "plugin",
791
844
  path: command.path,
792
- executable: true
793
- }
845
+ pluginId: command.plugin_id,
846
+ executable: true,
847
+ typed: command.typed?,
848
+ schema: command.schema,
849
+ positionals: command.positionals
850
+ }.compact
794
851
  end
795
852
  { commands: builtins + prompts + skills + plugins }
796
853
  end
@@ -799,7 +856,29 @@ module Kward
799
856
  @session_manager.run_command(
800
857
  session_id: params.fetch("sessionId"),
801
858
  command: params.fetch("name"),
802
- arguments: params["arguments"] || ""
859
+ arguments: params.key?("arguments") ? params["arguments"] : ""
860
+ )
861
+ end
862
+
863
+ def plugin_actions_list(params)
864
+ @session_manager.runtime_state(session_id: params.fetch("sessionId"))
865
+ actions = @session_manager.plugin_actions.map do |action|
866
+ {
867
+ id: action.id,
868
+ name: action.name,
869
+ pluginId: action.plugin_id,
870
+ description: action.description,
871
+ schema: action.schema
872
+ }
873
+ end
874
+ { actions: actions }
875
+ end
876
+
877
+ def plugin_actions_run(params)
878
+ @session_manager.run_plugin_action(
879
+ session_id: params.fetch("sessionId"),
880
+ id: params.fetch("id"),
881
+ arguments: params.key?("arguments") ? params["arguments"] : {}
803
882
  )
804
883
  end
805
884