kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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 +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -0,0 +1,843 @@
1
+ require "io/console"
2
+ require "pty"
3
+ require "securerandom"
4
+ require "shellwords"
5
+ require_relative "ansi"
6
+ require_relative "kwsh"
7
+ require_relative "kwshrc"
8
+ require_relative "local_pty_command_runner"
9
+ require_relative "detached_run"
10
+
11
+ # Namespace for the Kward CLI agent runtime.
12
+ module Kward
13
+ # Owns one interactive shell process and serializes user and agent commands.
14
+ #
15
+ # The process is kept alive between commands so shell variables, functions,
16
+ # aliases, directory changes, and child-shell state belong to one session.
17
+ class PersistentShellSession
18
+ Result = Kwsh::Result
19
+ InteractiveResult = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
20
+
21
+ class CommandInterrupted < StandardError
22
+ attr_reader :protocol
23
+
24
+ def initialize(protocol)
25
+ @protocol = protocol
26
+ super("The embedded shell did not respond to an interrupt.")
27
+ end
28
+ end
29
+
30
+ READ_SIZE = 4_096
31
+ STARTUP_TIMEOUT_SECONDS = 5
32
+ STTY_COMMAND = if File.executable?("/bin/stty")
33
+ "/bin/stty"
34
+ elsif File.executable?("/usr/bin/stty")
35
+ "/usr/bin/stty"
36
+ else
37
+ "stty"
38
+ end.freeze
39
+ STATEFUL_COMMANDS = %w[alias cd export pwd unset unalias].freeze
40
+
41
+ attr_reader :cwd, :last_command, :timeout_seconds
42
+
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)
44
+ @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
45
+ @base_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
46
+ @configured_env = configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
47
+ @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
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
51
+ @window_size_provider = window_size_provider
52
+ @last_command = nil
53
+ @last_result = nil
54
+ @command_mutex = Mutex.new
55
+ @write_mutex = Mutex.new
56
+ @marker_counter = 0
57
+ @marker_prefix = "__KWARD_SHELL_#{SecureRandom.hex(16)}_"
58
+ @closed = false
59
+ @routing_shell = build_routing_shell
60
+ @env = @routing_shell.child_env
61
+
62
+ start_process
63
+ initialize_protocol
64
+ rescue StandardError
65
+ close
66
+ raise
67
+ end
68
+
69
+ def command_shell
70
+ @shell
71
+ end
72
+
73
+ def child_env(interactive: false)
74
+ @env.dup
75
+ end
76
+
77
+ def prompt_label
78
+ "Shell #{display_cwd} $"
79
+ end
80
+
81
+ def run(input, cancellation: nil, &block)
82
+ command = input.to_s.strip
83
+ return Result.new(output: "", exit_status: 0) if command.empty?
84
+
85
+ expanded_command = expand_alias(command)
86
+ words = shell_words(expanded_command)
87
+ sync_runtime_state(words)
88
+ result = if words.empty?
89
+ Result.new(output: "", exit_status: 0)
90
+ elsif words.first == "clear"
91
+ Result.new(output: "", exit_status: 0, clear: true)
92
+ elsif %w[exit logout].include?(words.first)
93
+ exit_result(command, words)
94
+ elsif words.first == "capture"
95
+ capture_result(command, expanded_command, cancellation: cancellation, &block)
96
+ elsif words.first == "pty"
97
+ interactive_result(command, expanded_command)
98
+ elsif %w[source .].include?(words.first)
99
+ source_result(command, words)
100
+ elsif (editor_result = editor_command_result(command))
101
+ editor_result
102
+ elsif stateful_command?(words)
103
+ execute_command(expanded_command, display_command: command, cancellation: cancellation, &block)
104
+ else
105
+ Result.new(output: command_echo(command), exit_status: 0, interactive_command: expanded_command)
106
+ end
107
+ remember_result(command, result)
108
+ result
109
+ rescue ArgumentError => e
110
+ result = Result.new(output: "#{command_echo(command)}kwsh: #{e.message}\n", exit_status: 2)
111
+ remember_result(command, result)
112
+ result
113
+ end
114
+
115
+ # Runs a command for the shell assistant without handing it the terminal.
116
+ def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
117
+ command = input.to_s.strip
118
+ return run(command, cancellation: cancellation) if command.empty?
119
+
120
+ result = run(command, cancellation: cancellation)
121
+ return result unless result.interactive_command
122
+
123
+ result = execute_command(
124
+ result.interactive_command,
125
+ display_command: command,
126
+ timeout_seconds: timeout_seconds,
127
+ cancellation: cancellation,
128
+ suppress_git_pager: true
129
+ )
130
+ remember_result(command, result)
131
+ result
132
+ end
133
+
134
+ # Runs an external command through the same persistent shell process while
135
+ # the caller owns the terminal.
136
+ def run_interactive(command, input:, sink:, tab_action_handler: nil, on_detach: nil)
137
+ protocol = with_raw_input(input) do
138
+ execute_protocol(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
139
+ end
140
+ update_cwd(protocol[:cwd]) unless protocol[:background]
141
+ InteractiveResult.new(
142
+ exit_status: protocol[:status] || 1,
143
+ input_forwarded: protocol[:input_forwarded],
144
+ tab_action: protocol[:tab_action],
145
+ background: protocol[:background]
146
+ )
147
+ ensure
148
+ sink.finish if sink.respond_to?(:finish)
149
+ end
150
+
151
+ # Records the safe output captured after an interactive terminal handoff.
152
+ def record_interactive_result(output:, exit_status:)
153
+ return unless @last_command
154
+
155
+ @last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
156
+ end
157
+
158
+ # Returns bounded shell state for an explicit shell-agent prompt.
159
+ def context_snapshot(max_output_bytes: Kwsh::CONTEXT_OUTPUT_BYTES)
160
+ {
161
+ cwd: @cwd,
162
+ last_command: @last_command,
163
+ exit_status: @last_result&.exit_status,
164
+ last_output: context_output(@last_result&.output, max_output_bytes)
165
+ }
166
+ end
167
+
168
+ def complete(input, cursor)
169
+ completion_shell.complete(input, cursor)
170
+ end
171
+
172
+ def expand_alias(command, interactive: false)
173
+ @routing_shell.expand_alias(command, interactive: interactive)
174
+ end
175
+
176
+ def editor_command_result(command, display_command: command)
177
+ @routing_shell.editor_command_result(command, display_command: display_command)
178
+ end
179
+
180
+ def close
181
+ return if @closed
182
+
183
+ @closed = true
184
+ begin
185
+ terminate_process_group(@pid) if @pid
186
+ ensure
187
+ @write_mutex.synchronize do
188
+ @reader&.close unless @reader&.closed?
189
+ @writer&.close unless @writer&.closed?
190
+ end
191
+ @process_thread&.join(0.5)
192
+ @process_thread&.kill if @process_thread&.alive?
193
+ @pid = nil
194
+ end
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ private
200
+
201
+ def with_raw_input(input)
202
+ return yield unless input.respond_to?(:raw)
203
+
204
+ yielded = false
205
+ input.raw do
206
+ yielded = true
207
+ yield
208
+ end
209
+ rescue Errno::ENOTTY
210
+ raise if yielded
211
+
212
+ yield
213
+ end
214
+
215
+ def build_routing_shell
216
+ Kwsh.new(
217
+ cwd: @cwd,
218
+ env: @env || @base_env,
219
+ shell: @shell,
220
+ configured_env: @env ? {} : @configured_env,
221
+ aliases: @aliases,
222
+ timeout_seconds: @timeout_seconds,
223
+ max_output_bytes: @max_output_bytes
224
+ )
225
+ end
226
+
227
+ def completion_shell
228
+ @completion_shell ||= build_routing_shell
229
+ end
230
+
231
+ def start_process
232
+ ready = Queue.new
233
+ process_env = @env.merge("PS1" => "", "PS2" => "")
234
+ @process_thread = Thread.new do
235
+ begin
236
+ PTY.spawn(process_env, @shell, "-i", chdir: @cwd) do |reader, writer, pid|
237
+ @reader = reader
238
+ @writer = writer
239
+ @pid = pid
240
+ ready << true
241
+ sleep 0.05 until @closed
242
+ end
243
+ rescue StandardError => e
244
+ ready << e
245
+ end
246
+ end
247
+ started = ready.pop
248
+ raise started if started.is_a?(Exception)
249
+
250
+ @reader.sync = true
251
+ @writer.sync = true
252
+ update_window_size
253
+ end
254
+
255
+ def initialize_protocol
256
+ protocol = execute_protocol(":", timeout_seconds: STARTUP_TIMEOUT_SECONDS, max_output_bytes: nil)
257
+ raise IOError, "The embedded shell did not start." unless protocol[:status] == 0
258
+
259
+ update_cwd(protocol[:cwd])
260
+ end
261
+
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)
263
+ @command_mutex.synchronize do
264
+ ensure_open
265
+ cancellation&.raise_if_cancelled!
266
+ marker = next_marker
267
+ write_protocol_command(command, marker, suppress_git_pager: suppress_git_pager)
268
+ read_until_marker(
269
+ marker,
270
+ input: input,
271
+ sink: sink,
272
+ timeout_seconds: timeout_seconds,
273
+ max_output_bytes: max_output_bytes,
274
+ cancellation: cancellation,
275
+ tab_action_handler: tab_action_handler,
276
+ on_detach: on_detach
277
+ )
278
+ end
279
+ rescue CommandInterrupted => e
280
+ restart_process unless e.protocol[:background]
281
+ e.protocol
282
+ rescue IOError
283
+ close
284
+ raise
285
+ end
286
+
287
+ def write_protocol_command(command, marker, suppress_git_pager: false)
288
+ escaped_command = Shellwords.escape(command.to_s)
289
+ pager_setup, pager_restore = git_pager_protocol(marker, suppress: suppress_git_pager)
290
+ protocol = <<~SHELL
291
+ if true; then
292
+ if [ -n "${ZSH_VERSION-}" ]; then unsetopt zle 2>/dev/null; fi
293
+ PS1=''
294
+ PS2=''
295
+ export PS1 PS2
296
+ #{STTY_COMMAND} echo
297
+ #{pager_setup}
298
+ if eval #{escaped_command}; then __kward_status=$?; else __kward_status=$?; fi
299
+ #{pager_restore}
300
+ __kward_cwd=$(pwd 2>/dev/null)
301
+ #{STTY_COMMAND} -echo
302
+ printf '#{marker}:%s:%s\\n' "$__kward_status" "$__kward_cwd"
303
+ fi
304
+ SHELL
305
+ write_raw(protocol)
306
+ end
307
+
308
+ def git_pager_protocol(marker, suppress:)
309
+ return ["", ""] unless suppress
310
+
311
+ was_set = "#{marker}_GIT_PAGER_WAS_SET"
312
+ saved_value = "#{marker}_GIT_PAGER_VALUE"
313
+ setup = <<~SHELL
314
+ #{was_set}=${GIT_PAGER+x}
315
+ #{saved_value}=${GIT_PAGER-}
316
+ GIT_PAGER=cat
317
+ export GIT_PAGER
318
+ SHELL
319
+ restore = <<~SHELL
320
+ if [ "$#{was_set}" = x ]; then
321
+ GIT_PAGER="$#{saved_value}"
322
+ export GIT_PAGER
323
+ else
324
+ unset GIT_PAGER
325
+ fi
326
+ unset #{was_set} #{saved_value}
327
+ SHELL
328
+ [setup, restore]
329
+ end
330
+
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
333
+ captured = +"".b
334
+ captured_bytes = 0
335
+ truncated = false
336
+ input_forwarded = false
337
+ timed_out = false
338
+ interrupted = false
339
+ deadline = timeout_seconds && (Time.now + effective_timeout(timeout_seconds))
340
+
341
+ loop do
342
+ if cancellation&.cancelled? && !interrupted
343
+ interrupted = true
344
+ interrupt_process
345
+ deadline = Time.now + 1
346
+ end
347
+ if deadline && Time.now >= deadline
348
+ unless interrupted
349
+ interrupted = true
350
+ timed_out = true
351
+ interrupt_process
352
+ deadline = Time.now + 1
353
+ else
354
+ interrupted_protocol = {
355
+ output: captured,
356
+ status: 130,
357
+ cwd: @cwd,
358
+ input_forwarded: input_forwarded,
359
+ timed_out: timed_out,
360
+ truncated: truncated
361
+ }
362
+ raise CommandInterrupted.new(interrupted_protocol)
363
+ end
364
+ end
365
+
366
+ readers = [@reader]
367
+ readers << input if input
368
+ readable = IO.select(readers, nil, nil, 0.02)&.first || []
369
+ if readable.include?(@reader)
370
+ chunk = read_chunk
371
+ if chunk.nil?
372
+ raise IOError, "The embedded shell exited before completing the command."
373
+ end
374
+ unless chunk == :wait_readable
375
+ buffer << chunk
376
+ end
377
+ if chunk == :wait_readable
378
+ next
379
+ end
380
+ parsed = extract_marker(buffer, marker)
381
+ if parsed
382
+ before, status, cwd = parsed
383
+ captured_bytes, truncated = emit_output(
384
+ before,
385
+ sink,
386
+ captured,
387
+ captured_bytes,
388
+ truncated,
389
+ max_output_bytes
390
+ )
391
+ drain_prompt_output
392
+ return {
393
+ output: captured,
394
+ status: status,
395
+ cwd: cwd,
396
+ input_forwarded: input_forwarded,
397
+ timed_out: timed_out,
398
+ truncated: truncated
399
+ }
400
+ end
401
+
402
+ flush_bytes = flushable_output_bytes(buffer, marker)
403
+ if flush_bytes.positive?
404
+ chunk_to_emit = buffer.byteslice(0, flush_bytes)
405
+ buffer = buffer.byteslice(flush_bytes..).to_s.b
406
+ captured_bytes, truncated = emit_output(
407
+ chunk_to_emit,
408
+ sink,
409
+ captured,
410
+ captured_bytes,
411
+ truncated,
412
+ max_output_bytes
413
+ )
414
+ end
415
+ end
416
+
417
+ if input && readable.include?(input)
418
+ input_chunk = input.read_nonblock(READ_SIZE, exception: false)
419
+ if input_chunk.nil?
420
+ input = nil
421
+ elsif input_chunk != :wait_readable
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
461
+ end
462
+ end
463
+ update_window_size
464
+ end
465
+ rescue Errno::EIO
466
+ raise IOError, "The embedded shell exited before completing the command."
467
+ end
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
+
493
+ def extract_marker(buffer, marker)
494
+ match = buffer.match(/#{Regexp.escape(marker)}:(-?\d+):(.*?)\r?\n/m)
495
+ return nil unless match
496
+
497
+ before = buffer.byteslice(0, match.begin(0)).to_s
498
+ [before, match[1].to_i, match[2].to_s]
499
+ end
500
+
501
+ def flushable_output_bytes(buffer, marker)
502
+ candidate_start = buffer.rindex(marker.getbyte(0).chr)
503
+ if candidate_start
504
+ candidate = buffer.byteslice(candidate_start..).to_s
505
+ return candidate_start if marker.start_with?(candidate) || candidate.start_with?(marker)
506
+ end
507
+
508
+ max_prefix = [buffer.bytesize, marker.bytesize - 1].min
509
+ suffix_length = (1..max_prefix).reverse_each.find do |length|
510
+ buffer.end_with?(marker.byteslice(0, length))
511
+ end || 0
512
+ buffer.bytesize - suffix_length
513
+ end
514
+
515
+ def drain_prompt_output
516
+ loop do
517
+ readable = IO.select([@reader], nil, nil, 0.01)&.first || []
518
+ break unless readable.include?(@reader)
519
+
520
+ chunk = read_chunk
521
+ break if chunk.nil? || chunk == :wait_readable
522
+ end
523
+ rescue IOError, Errno::EIO
524
+ nil
525
+ end
526
+
527
+ def emit_output(text, sink, captured, captured_bytes, truncated, max_output_bytes)
528
+ return [captured_bytes, truncated] if text.to_s.empty?
529
+
530
+ if sink
531
+ sink.write(text)
532
+ sink.flush if sink.respond_to?(:flush)
533
+ return [captured_bytes, truncated]
534
+ end
535
+
536
+ return [captured_bytes, truncated] unless max_output_bytes
537
+
538
+ remaining = max_output_bytes - captured_bytes
539
+ if remaining <= 0
540
+ return [captured_bytes, true]
541
+ end
542
+
543
+ bytes = text.byteslice(0, remaining).to_s
544
+ captured << bytes
545
+ truncated ||= bytes.bytesize < text.bytesize
546
+ [captured_bytes + bytes.bytesize, truncated]
547
+ end
548
+
549
+ def notify_input_forwarded(sink)
550
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
551
+ end
552
+
553
+ def execute_command(command, display_command:, timeout_seconds: nil, cancellation: nil, suppress_git_pager: false, &block)
554
+ protocol = execute_protocol(
555
+ command,
556
+ timeout_seconds: timeout_seconds || @timeout_seconds,
557
+ cancellation: cancellation,
558
+ suppress_git_pager: suppress_git_pager
559
+ )
560
+ output = command_echo(display_command)
561
+ output << clean_output(protocol[:output])
562
+ append_output_newline(output)
563
+ status = protocol[:status] || 1
564
+ if protocol[:timed_out]
565
+ output << "kwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
566
+ status = 1
567
+ elsif protocol[:truncated]
568
+ output << "kwsh: output exceeded #{@max_output_bytes} bytes; output truncated\n"
569
+ status = 1
570
+ elsif cancellation&.cancelled?
571
+ output << "^C\n" unless output.end_with?("^C\n")
572
+ status = 130
573
+ end
574
+ output << "Exit status: #{status}\n" unless status.zero?
575
+ update_cwd(protocol[:cwd])
576
+ block&.call(output)
577
+ Result.new(output: output, exit_status: status, streamed: block_given?)
578
+ end
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
+
605
+ def capture_result(command, expanded_command, cancellation: nil, &block)
606
+ captured_command = expanded_command.sub(/\A\s*capture(?:\s+|\z)/, "")
607
+ if captured_command.empty?
608
+ return Result.new(output: "#{command_echo(command)}Usage: capture <command>\n", exit_status: 2)
609
+ end
610
+
611
+ execute_command(
612
+ captured_command,
613
+ display_command: command,
614
+ cancellation: cancellation,
615
+ suppress_git_pager: true,
616
+ &block
617
+ )
618
+ end
619
+
620
+ def interactive_result(command, expanded_command)
621
+ interactive_command = expanded_command.sub(/\A\s*pty(?:\s+|\z)/, "")
622
+ if interactive_command.empty?
623
+ return Result.new(output: "#{command_echo(command)}Usage: pty <command>\n", exit_status: 2)
624
+ end
625
+
626
+ Result.new(output: command_echo(command), exit_status: 0, interactive_command: interactive_command)
627
+ end
628
+
629
+ def exit_result(command, words)
630
+ if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
631
+ return Result.new(output: "#{command_echo(command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
632
+ end
633
+
634
+ Result.new(output: command_echo(command), exit_status: words[1].to_i, exit_shell: true)
635
+ end
636
+
637
+ def sync_runtime_state(words)
638
+ changed = sync_runtime_aliases(words) || sync_runtime_environment(words)
639
+ return unless changed
640
+
641
+ @routing_shell = build_routing_shell
642
+ @completion_shell = nil
643
+ end
644
+
645
+ def sync_runtime_aliases(words)
646
+ case words.first
647
+ when "alias"
648
+ before = @aliases.dup
649
+ words.drop(1).each do |assignment|
650
+ name, value = assignment.split("=", 2)
651
+ @aliases[name] = value.to_s if value && Kwsh.valid_alias_name?(name)
652
+ end
653
+ before != @aliases
654
+ when "unalias"
655
+ before = @aliases.dup
656
+ if words == ["unalias", "-a"]
657
+ @aliases.clear
658
+ else
659
+ words.drop(1).reject { |name| name.start_with?("-") }.each { |name| @aliases.delete(name) }
660
+ end
661
+ before != @aliases
662
+ else
663
+ false
664
+ end
665
+ end
666
+
667
+ def sync_runtime_environment(words)
668
+ before = @env.dup
669
+ case words.first
670
+ when "export"
671
+ words.drop(1).reject { |assignment| assignment.start_with?("-") }.each do |assignment|
672
+ key, value = assignment.split("=", 2)
673
+ @env[key] = value.to_s if value && valid_environment_key?(key)
674
+ end
675
+ when "unset"
676
+ words.drop(1).reject { |key| key == "--" || key.start_with?("-") }.each do |key|
677
+ @env.delete(key) if valid_environment_key?(key)
678
+ end
679
+ else
680
+ if words.all? { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=.*/) }
681
+ words.each do |assignment|
682
+ key, value = assignment.split("=", 2)
683
+ @env[key] = value.to_s if valid_environment_key?(key)
684
+ end
685
+ end
686
+ end
687
+ before != @env
688
+ end
689
+
690
+ def valid_environment_key?(key)
691
+ key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
692
+ end
693
+
694
+ def stateful_command?(words)
695
+ STATEFUL_COMMANDS.include?(words.first) || words.all? { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=.*/) }
696
+ end
697
+
698
+ def shell_words(command)
699
+ Shellwords.shellsplit(command)
700
+ end
701
+
702
+ def command_echo(command)
703
+ ANSI.sanitize_transcript("$ #{command}\n")
704
+ end
705
+
706
+ def clean_output(output)
707
+ ANSI.sanitize_transcript(output.to_s.gsub("\r\n", "\n").gsub("\r", "\n"))
708
+ end
709
+
710
+ def append_output_newline(output)
711
+ output << "\n" unless output.empty? || output.end_with?("\n")
712
+ end
713
+
714
+ def remember_result(command, result)
715
+ @last_command = command.to_s
716
+ @last_result = result
717
+ end
718
+
719
+ def update_cwd(value)
720
+ path = value.to_s
721
+ return if path.empty? || !File.directory?(path)
722
+
723
+ return if path == @cwd
724
+
725
+ @cwd = File.expand_path(path)
726
+ @completion_shell = nil
727
+ @routing_shell = build_routing_shell
728
+ @env = @routing_shell.child_env
729
+ rescue ArgumentError
730
+ nil
731
+ end
732
+
733
+ def display_cwd
734
+ home = Dir.home.to_s
735
+ return "~" if @cwd == home
736
+ return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
737
+
738
+ @cwd
739
+ rescue ArgumentError
740
+ @cwd
741
+ end
742
+
743
+ def context_output(output, max_output_bytes)
744
+ text = ANSI.sanitize_transcript(output.to_s)
745
+ limit = max_output_bytes.to_i
746
+ return text if limit <= 0 || text.bytesize <= limit
747
+
748
+ prefix = "[Earlier output omitted; showing the end of the command output.]\n"
749
+ tail_limit = [limit - prefix.bytesize, 0].max
750
+ tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
751
+ value = "#{prefix}#{tail}"
752
+ ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
753
+ end
754
+
755
+ def next_marker
756
+ @marker_counter += 1
757
+ "#{@marker_prefix}#{@marker_counter}__"
758
+ end
759
+
760
+ def effective_timeout(timeout_seconds)
761
+ timeout = timeout_seconds.to_i
762
+ timeout.positive? ? timeout : @timeout_seconds
763
+ end
764
+
765
+ def write_raw(value)
766
+ @write_mutex.synchronize do
767
+ ensure_open
768
+ @writer.write(value)
769
+ @writer.flush
770
+ end
771
+ end
772
+
773
+ def interrupt_process
774
+ write_raw("\u0003")
775
+ rescue IOError, Errno::EIO
776
+ nil
777
+ end
778
+
779
+ def restart_process
780
+ close
781
+ @closed = false
782
+ start_process
783
+ initialize_protocol
784
+ rescue StandardError
785
+ close
786
+ raise
787
+ end
788
+
789
+ def read_chunk
790
+ chunk = @reader.read_nonblock(READ_SIZE, exception: false)
791
+ return nil if chunk.nil?
792
+ return :wait_readable if chunk == :wait_readable
793
+
794
+ chunk
795
+ end
796
+
797
+ def update_window_size
798
+ return unless @reader && !@reader.closed?
799
+
800
+ rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
801
+ rows = LocalPtyCommandRunner::DEFAULT_ROWS unless rows.to_i.positive?
802
+ columns = LocalPtyCommandRunner::DEFAULT_COLUMNS unless columns.to_i.positive?
803
+ size = [rows, columns]
804
+ return if size == @window_size
805
+
806
+ @window_size = size
807
+ @reader.winsize = size
808
+ rescue StandardError
809
+ nil
810
+ end
811
+
812
+ def process_running?(pid)
813
+ Process.kill(0, pid)
814
+ true
815
+ rescue Errno::ESRCH, Errno::EPERM
816
+ false
817
+ end
818
+
819
+ def terminate_process_group(pid)
820
+ return unless pid
821
+
822
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
823
+ deadline = Time.now + 0.2
824
+ while Time.now < deadline
825
+ return unless process_running?(pid)
826
+
827
+ sleep 0.02
828
+ end
829
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
830
+ end
831
+
832
+ def signal_process(signal, pid)
833
+ Process.kill(signal, pid)
834
+ true
835
+ rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
836
+ false
837
+ end
838
+
839
+ def ensure_open
840
+ raise IOError, "The embedded shell is closed." if @closed || !@reader || @reader.closed?
841
+ end
842
+ end
843
+ end