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
@@ -0,0 +1,233 @@
1
+ require "shellwords"
2
+ require_relative "kwsh"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Parses the declarative subset of a shell startup file used by kwsh.
7
+ #
8
+ # Only aliases, exports, and source directives are handled. Other shell
9
+ # syntax is intentionally ignored until kwsh supports scripting.
10
+ class Kwshrc
11
+ VARIABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
+ VARIABLE_PATTERN = /[A-Za-z_][A-Za-z0-9_]*/
13
+
14
+ class ParseError < StandardError
15
+ end
16
+
17
+ def self.read(paths, env: ENV.to_h)
18
+ new(env: env).read(paths)
19
+ end
20
+
21
+ def self.read_file(path, env: ENV.to_h)
22
+ parser = new(env: env)
23
+ parser.read_path(path)
24
+ end
25
+
26
+ def self.resolve_path(path, cwd:, env: ENV.to_h)
27
+ parser = new(env: env)
28
+ expanded = parser.send(:expand_variables, path.to_s)
29
+ expanded = expanded.sub(/\A~(?=\/|\z)/, Dir.home)
30
+ File.expand_path(expanded, cwd)
31
+ end
32
+
33
+ def initialize(env: ENV.to_h)
34
+ @environment = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
35
+ @exports = {}
36
+ @aliases = {}
37
+ @loading = []
38
+ end
39
+
40
+ def read(paths)
41
+ Array(paths).each { |path| read_file(path, required: false) }
42
+ configuration
43
+ end
44
+
45
+ def read_path(path)
46
+ read_file(path, required: true)
47
+ configuration
48
+ end
49
+
50
+ private
51
+
52
+ def configuration
53
+ { env: @exports, aliases: @aliases }
54
+ end
55
+
56
+ def read_file(path, required:)
57
+ path = File.expand_path(path.to_s)
58
+ unless File.file?(path)
59
+ raise ParseError, "#{path}: file not found" if required
60
+ return
61
+ end
62
+ raise ParseError, "#{path}: recursive source" if @loading.include?(path)
63
+
64
+ @loading << path
65
+ File.foreach(path).with_index(1) do |line, line_number|
66
+ parse_line(strip_comment(line).strip, path, line_number)
67
+ end
68
+ rescue Errno::EACCES, Errno::EISDIR, Errno::ENOENT => e
69
+ raise ParseError, "#{path}: #{e.message}"
70
+ ensure
71
+ @loading.delete(path)
72
+ end
73
+
74
+ def parse_line(line, path, line_number)
75
+ return if line.empty?
76
+
77
+ words = Shellwords.split(line)
78
+ return if words.empty?
79
+
80
+ case words.shift
81
+ when "alias"
82
+ parse_aliases(words)
83
+ when "export"
84
+ parse_exports(expand_variables(line), path, line_number)
85
+ when "source", "."
86
+ parse_source(expand_variables(line), path, line_number)
87
+ end
88
+ rescue ArgumentError => e
89
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
90
+ end
91
+
92
+ def parse_aliases(words)
93
+ words.each do |assignment|
94
+ name, command = assignment.split("=", 2)
95
+ next unless command && Kwsh.valid_alias_name?(name)
96
+ next if command.empty?
97
+
98
+ @aliases[name] = command
99
+ end
100
+ end
101
+
102
+ def parse_exports(line, path, line_number)
103
+ words = Shellwords.split(line)
104
+ assignments = words.drop(1)
105
+ raise ParseError, "#{path}:#{line_number}: export requires a variable" if assignments.empty?
106
+
107
+ assignments.each do |assignment|
108
+ name, value = assignment.split("=", 2)
109
+ next unless name.match?(VARIABLE_NAME)
110
+
111
+ value = @environment[name] if value.nil? && @environment.key?(name)
112
+ next if value.nil?
113
+
114
+ @environment[name] = value
115
+ @exports[name] = value
116
+ end
117
+ rescue ArgumentError => e
118
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
119
+ end
120
+
121
+ def parse_source(line, path, line_number)
122
+ words = Shellwords.split(line)
123
+ source_path = words[1]
124
+ if words.length != 2 || source_path.to_s.empty?
125
+ raise ParseError, "#{path}:#{line_number}: source requires one file"
126
+ end
127
+
128
+ source_path = File.expand_path(source_path, File.dirname(path))
129
+ read_file(source_path, required: true)
130
+ rescue ArgumentError => e
131
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
132
+ end
133
+
134
+ def strip_comment(line)
135
+ result = +""
136
+ quote = nil
137
+ escaped = false
138
+
139
+ line.each_char do |character|
140
+ if escaped
141
+ result << character
142
+ escaped = false
143
+ elsif character == "\\" && quote != "'"
144
+ result << character
145
+ escaped = true
146
+ elsif quote
147
+ result << character
148
+ quote = nil if character == quote
149
+ elsif character == "'" || character == '"'
150
+ result << character
151
+ quote = character
152
+ elsif character == "#"
153
+ break
154
+ else
155
+ result << character
156
+ end
157
+ end
158
+ result
159
+ end
160
+
161
+ def expand_variables(text)
162
+ result = +""
163
+ quote = nil
164
+ escaped = false
165
+ index = 0
166
+
167
+ while index < text.length
168
+ character = text[index]
169
+ if escaped
170
+ result << character
171
+ escaped = false
172
+ index += 1
173
+ next
174
+ end
175
+ if character == "\\" && quote != "'"
176
+ result << character
177
+ escaped = true
178
+ index += 1
179
+ next
180
+ end
181
+ if character == "'"
182
+ quote = if quote == "'"
183
+ nil
184
+ elsif quote.nil?
185
+ character
186
+ else
187
+ quote
188
+ end
189
+ result << character
190
+ index += 1
191
+ next
192
+ end
193
+ if character == '"'
194
+ quote = if quote == '"'
195
+ nil
196
+ elsif quote.nil?
197
+ character
198
+ else
199
+ quote
200
+ end
201
+ result << character
202
+ index += 1
203
+ next
204
+ end
205
+
206
+ if character == "$" && quote != "'"
207
+ name, length = variable_at(text, index)
208
+ if name
209
+ result << @environment.fetch(name, "")
210
+ index += length
211
+ next
212
+ end
213
+ end
214
+
215
+ result << character
216
+ index += 1
217
+ end
218
+ result
219
+ end
220
+
221
+ def variable_at(text, index)
222
+ if text[index + 1] == "{"
223
+ closing = text.index("}", index + 2)
224
+ name = text[(index + 2)...closing] if closing
225
+ return [name, closing - index + 1] if name&.match?(VARIABLE_NAME)
226
+ else
227
+ match = text[(index + 1)..].match(/\A#{VARIABLE_PATTERN}/)
228
+ return [match[0], match[0].length + 1] if match
229
+ end
230
+ nil
231
+ end
232
+ end
233
+ end
@@ -2,9 +2,11 @@ require "io/console"
2
2
  require "pty"
3
3
  require "securerandom"
4
4
  require "shellwords"
5
- require_relative "ansi"
6
- require_relative "ekwsh"
7
- require_relative "local_pty_command_runner"
5
+ require_relative "../terminal/ansi"
6
+ require_relative "kwsh"
7
+ require_relative "kwshrc"
8
+ require_relative "../pty/local_pty_runner"
9
+ require_relative "../pty/detached_run"
8
10
 
9
11
  # Namespace for the Kward CLI agent runtime.
10
12
  module Kward
@@ -13,8 +15,8 @@ module Kward
13
15
  # The process is kept alive between commands so shell variables, functions,
14
16
  # aliases, directory changes, and child-shell state belong to one session.
15
17
  class PersistentShellSession
16
- Result = Ekwsh::Result
17
- InteractiveResult = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
18
+ Result = Kwsh::Result
19
+ InteractiveResult = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
18
20
 
19
21
  class CommandInterrupted < StandardError
20
22
  attr_reader :protocol
@@ -38,14 +40,14 @@ module Kward
38
40
 
39
41
  attr_reader :cwd, :last_command, :timeout_seconds
40
42
 
41
- def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: Ekwsh::DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: Ekwsh::DEFAULT_TIMEOUT_SECONDS, max_output_bytes: Ekwsh::DEFAULT_MAX_OUTPUT_BYTES, window_size_provider: nil)
43
+ def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: Kwsh::DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS, max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES, window_size_provider: nil)
42
44
  @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
43
45
  @base_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
44
46
  @configured_env = configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
45
47
  @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
46
- @shell = shell.to_s.empty? ? Ekwsh::DEFAULT_SHELL : shell.to_s
47
- @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : Ekwsh::DEFAULT_TIMEOUT_SECONDS
48
- @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : Ekwsh::DEFAULT_MAX_OUTPUT_BYTES
48
+ @shell = shell.to_s.empty? ? Kwsh::DEFAULT_SHELL : shell.to_s
49
+ @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : Kwsh::DEFAULT_TIMEOUT_SECONDS
50
+ @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : Kwsh::DEFAULT_MAX_OUTPUT_BYTES
49
51
  @window_size_provider = window_size_provider
50
52
  @last_command = nil
51
53
  @last_result = nil
@@ -93,6 +95,8 @@ module Kward
93
95
  capture_result(command, expanded_command, cancellation: cancellation, &block)
94
96
  elsif words.first == "pty"
95
97
  interactive_result(command, expanded_command)
98
+ elsif %w[source .].include?(words.first)
99
+ source_result(command, words)
96
100
  elsif (editor_result = editor_command_result(command))
97
101
  editor_result
98
102
  elsif stateful_command?(words)
@@ -103,7 +107,7 @@ module Kward
103
107
  remember_result(command, result)
104
108
  result
105
109
  rescue ArgumentError => e
106
- result = Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
110
+ result = Result.new(output: "#{command_echo(command)}kwsh: #{e.message}\n", exit_status: 2)
107
111
  remember_result(command, result)
108
112
  result
109
113
  end
@@ -129,14 +133,16 @@ module Kward
129
133
 
130
134
  # Runs an external command through the same persistent shell process while
131
135
  # the caller owns the terminal.
132
- def run_interactive(command, input:, sink:)
136
+ def run_interactive(command, input:, sink:, tab_action_handler: nil, on_detach: nil)
133
137
  protocol = with_raw_input(input) do
134
- execute_protocol(command, input: input, sink: sink)
138
+ execute_protocol(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
135
139
  end
136
- update_cwd(protocol[:cwd])
140
+ update_cwd(protocol[:cwd]) unless protocol[:background]
137
141
  InteractiveResult.new(
138
142
  exit_status: protocol[:status] || 1,
139
- input_forwarded: protocol[:input_forwarded]
143
+ input_forwarded: protocol[:input_forwarded],
144
+ tab_action: protocol[:tab_action],
145
+ background: protocol[:background]
140
146
  )
141
147
  ensure
142
148
  sink.finish if sink.respond_to?(:finish)
@@ -150,7 +156,7 @@ module Kward
150
156
  end
151
157
 
152
158
  # Returns bounded shell state for an explicit shell-agent prompt.
153
- def context_snapshot(max_output_bytes: Ekwsh::CONTEXT_OUTPUT_BYTES)
159
+ def context_snapshot(max_output_bytes: Kwsh::CONTEXT_OUTPUT_BYTES)
154
160
  {
155
161
  cwd: @cwd,
156
162
  last_command: @last_command,
@@ -207,7 +213,7 @@ module Kward
207
213
  end
208
214
 
209
215
  def build_routing_shell
210
- Ekwsh.new(
216
+ Kwsh.new(
211
217
  cwd: @cwd,
212
218
  env: @env || @base_env,
213
219
  shell: @shell,
@@ -253,7 +259,7 @@ module Kward
253
259
  update_cwd(protocol[:cwd])
254
260
  end
255
261
 
256
- def execute_protocol(command, input: nil, sink: nil, timeout_seconds: nil, max_output_bytes: @max_output_bytes, cancellation: nil, suppress_git_pager: false)
262
+ def execute_protocol(command, input: nil, sink: nil, timeout_seconds: nil, max_output_bytes: @max_output_bytes, cancellation: nil, suppress_git_pager: false, tab_action_handler: nil, on_detach: nil)
257
263
  @command_mutex.synchronize do
258
264
  ensure_open
259
265
  cancellation&.raise_if_cancelled!
@@ -265,11 +271,13 @@ module Kward
265
271
  sink: sink,
266
272
  timeout_seconds: timeout_seconds,
267
273
  max_output_bytes: max_output_bytes,
268
- cancellation: cancellation
274
+ cancellation: cancellation,
275
+ tab_action_handler: tab_action_handler,
276
+ on_detach: on_detach
269
277
  )
270
278
  end
271
279
  rescue CommandInterrupted => e
272
- restart_process
280
+ restart_process unless e.protocol[:background]
273
281
  e.protocol
274
282
  rescue IOError
275
283
  close
@@ -320,8 +328,8 @@ module Kward
320
328
  [setup, restore]
321
329
  end
322
330
 
323
- def read_until_marker(marker, input:, sink:, timeout_seconds:, max_output_bytes:, cancellation:)
324
- buffer = +"".b
331
+ def read_until_marker(marker, input:, sink:, timeout_seconds:, max_output_bytes:, cancellation:, tab_action_handler: nil, on_detach: nil, initial_buffer: nil)
332
+ buffer = initial_buffer ? initial_buffer.dup : +"".b
325
333
  captured = +"".b
326
334
  captured_bytes = 0
327
335
  truncated = false
@@ -411,9 +419,45 @@ module Kward
411
419
  if input_chunk.nil?
412
420
  input = nil
413
421
  elsif input_chunk != :wait_readable
414
- notify_input_forwarded(sink)
415
- write_raw(input_chunk)
416
- input_forwarded = true
422
+ filtered = tab_action_handler&.call(input_chunk)
423
+ input_chunk = filtered[:input].to_s if filtered
424
+ unless input_chunk.empty?
425
+ notify_input_forwarded(sink)
426
+ write_raw(input_chunk)
427
+ input_forwarded = true
428
+ end
429
+ if filtered && filtered[:tab_action]
430
+ if on_detach
431
+ detached_sink = on_detach.call
432
+ detached = detach_protocol(
433
+ marker,
434
+ sink: detached_sink,
435
+ timeout_seconds: timeout_seconds,
436
+ max_output_bytes: max_output_bytes,
437
+ initial_buffer: buffer,
438
+ input_forwarded: input_forwarded
439
+ )
440
+ raise CommandInterrupted.new(
441
+ output: captured,
442
+ status: 0,
443
+ cwd: @cwd,
444
+ input_forwarded: input_forwarded,
445
+ tab_action: filtered[:tab_action],
446
+ truncated: truncated,
447
+ background: detached
448
+ )
449
+ end
450
+
451
+ interrupt_process
452
+ raise CommandInterrupted.new(
453
+ output: captured,
454
+ status: 130,
455
+ cwd: @cwd,
456
+ input_forwarded: input_forwarded,
457
+ tab_action: filtered[:tab_action],
458
+ truncated: truncated
459
+ )
460
+ end
417
461
  end
418
462
  end
419
463
  update_window_size
@@ -422,6 +466,30 @@ module Kward
422
466
  raise IOError, "The embedded shell exited before completing the command."
423
467
  end
424
468
 
469
+ def detach_protocol(marker, sink:, timeout_seconds:, max_output_bytes:, initial_buffer:, input_forwarded:)
470
+ DetachedRun.new(sink: sink, canceler: method(:interrupt_process)) do
471
+ begin
472
+ protocol = read_until_marker(
473
+ marker,
474
+ input: nil,
475
+ sink: sink,
476
+ timeout_seconds: timeout_seconds,
477
+ max_output_bytes: max_output_bytes,
478
+ cancellation: nil,
479
+ initial_buffer: initial_buffer
480
+ )
481
+ update_cwd(protocol[:cwd])
482
+ InteractiveResult.new(
483
+ exit_status: protocol[:status] || 1,
484
+ input_forwarded: input_forwarded || protocol[:input_forwarded],
485
+ background: nil
486
+ )
487
+ ensure
488
+ sink.finish if sink.respond_to?(:finish)
489
+ end
490
+ end
491
+ end
492
+
425
493
  def extract_marker(buffer, marker)
426
494
  match = buffer.match(/#{Regexp.escape(marker)}:(-?\d+):(.*?)\r?\n/m)
427
495
  return nil unless match
@@ -494,10 +562,10 @@ module Kward
494
562
  append_output_newline(output)
495
563
  status = protocol[:status] || 1
496
564
  if protocol[:timed_out]
497
- output << "ekwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
565
+ output << "kwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
498
566
  status = 1
499
567
  elsif protocol[:truncated]
500
- output << "ekwsh: output exceeded #{@max_output_bytes} bytes; output truncated\n"
568
+ output << "kwsh: output exceeded #{@max_output_bytes} bytes; output truncated\n"
501
569
  status = 1
502
570
  elsif cancellation&.cancelled?
503
571
  output << "^C\n" unless output.end_with?("^C\n")
@@ -509,6 +577,31 @@ module Kward
509
577
  Result.new(output: output, exit_status: status, streamed: block_given?)
510
578
  end
511
579
 
580
+ def source_result(command, words)
581
+ unless words.length == 2
582
+ return Result.new(output: "#{command_echo(command)}Usage: source <file>\n", exit_status: 2)
583
+ end
584
+
585
+ path = Kwshrc.resolve_path(words[1], cwd: @cwd, env: @env)
586
+ config = Kwshrc.read_file(path, env: @env)
587
+ exports = config[:env]
588
+ if exports.any?
589
+ assignments = exports.map { |key, value| "#{key}=#{Shellwords.escape(value)}" }
590
+ protocol = execute_protocol("export #{assignments.join(" ")}", timeout_seconds: STARTUP_TIMEOUT_SECONDS, max_output_bytes: nil)
591
+ unless protocol[:status].to_i.zero?
592
+ return Result.new(output: "#{command_echo(command)}kwsh: source: could not apply environment\n", exit_status: 1)
593
+ end
594
+ end
595
+
596
+ @env.merge!(exports)
597
+ @aliases.merge!(config[:aliases])
598
+ @routing_shell = build_routing_shell
599
+ @completion_shell = nil
600
+ Result.new(output: command_echo(command), exit_status: 0)
601
+ rescue Kwshrc::ParseError => e
602
+ Result.new(output: "#{command_echo(command)}kwsh: source: #{e.message}\n", exit_status: 1)
603
+ end
604
+
512
605
  def capture_result(command, expanded_command, cancellation: nil, &block)
513
606
  captured_command = expanded_command.sub(/\A\s*capture(?:\s+|\z)/, "")
514
607
  if captured_command.empty?
@@ -535,7 +628,7 @@ module Kward
535
628
 
536
629
  def exit_result(command, words)
537
630
  if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
538
- return Result.new(output: "#{command_echo(command)}ekwsh: #{words.first}: numeric status expected\n", exit_status: 2)
631
+ return Result.new(output: "#{command_echo(command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
539
632
  end
540
633
 
541
634
  Result.new(output: command_echo(command), exit_status: words[1].to_i, exit_shell: true)
@@ -555,7 +648,7 @@ module Kward
555
648
  before = @aliases.dup
556
649
  words.drop(1).each do |assignment|
557
650
  name, value = assignment.split("=", 2)
558
- @aliases[name] = value.to_s if value && Ekwsh.valid_alias_name?(name)
651
+ @aliases[name] = value.to_s if value && Kwsh.valid_alias_name?(name)
559
652
  end
560
653
  before != @aliases
561
654
  when "unalias"
@@ -16,6 +16,8 @@ module Kward
16
16
 
17
17
  Use prepare_shell_command when the user asks you to suggest or prepare a command. It places the complete command in the shell prompt but does not execute it; the user must press Enter. Never claim that a prepared command was executed.
18
18
 
19
+ When available, use open_editor if the user explicitly asks to open an existing workspace file in Kward's integrated editor. Opening the editor does not modify or save the file.
20
+
19
21
  Do not execute interactive commands, request passwords, or silently run commands that the user did not explicitly request. If a command needs terminal input, prepare it for the user instead.
20
22
  PROMPT
21
23
  }
@@ -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 "digest"
2
2
  require "json"
3
3
  require "pathname"
4
- require_relative "../path_guard"
4
+ require_relative "../workspace/path_guard"
5
5
  require_relative "../private_file"
6
6
 
7
7
  # Namespace for Agent Skills discovery and trust management.