kward 0.83.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -0,0 +1,136 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Finds fenced Markdown code blocks and manages their adjacent output fields.
4
+ class MarkdownCodeBlock
5
+ Block = Struct.new(
6
+ :source, :open_line, :close_line, :language, :body_start, :body_end, :close_end, :next_fence_start,
7
+ keyword_init: true
8
+ ) do
9
+ def code
10
+ source[body_start...body_end].to_s
11
+ end
12
+
13
+ def output_match
14
+ limit = next_fence_start || source.length
15
+ match = source.match(/<output\b[^>]*>.*?<\/output>/mi, close_end)
16
+ match if match && match.begin(0) < limit && match.end(0) <= limit
17
+ end
18
+
19
+ def output_edit(output)
20
+ replacement = MarkdownCodeBlock.format_output(output, newline: newline)
21
+ match = output_match
22
+ if match
23
+ [match.begin(0), match.end(0), replacement]
24
+ else
25
+ insertion = if source[close_end - newline.length, newline.length] == newline
26
+ "#{newline}#{replacement}#{newline}"
27
+ else
28
+ "#{newline}#{newline}#{replacement}#{newline}"
29
+ end
30
+ [close_end, close_end, insertion]
31
+ end
32
+ end
33
+
34
+ def with_output(output)
35
+ start_index, end_index, replacement = output_edit(output)
36
+ source.dup.tap { |content| content[start_index...end_index] = replacement }
37
+ end
38
+
39
+ private
40
+
41
+ def newline
42
+ source[body_end...close_end].to_s.end_with?("\r\n") ? "\r\n" : "\n"
43
+ end
44
+ end
45
+
46
+ Fence = Struct.new(:line, :start, :finish, :marker, :length, :language, :close_line, :close_start, :close_end, keyword_init: true)
47
+
48
+ class << self
49
+ def for_selection(source, first_line, last_line)
50
+ blocks = parse(source)
51
+ candidates = blocks.select do |block|
52
+ full_fence = first_line == block.open_line && last_line == block.close_line
53
+ body_only = block.open_line < first_line && last_line < block.close_line
54
+ full_fence || body_only
55
+ end
56
+ candidates.min_by { |block| block.close_line - block.open_line }
57
+ end
58
+
59
+ def for_cursor(source, line)
60
+ parse(source).select do |block|
61
+ block.open_line <= line && line <= block.close_line
62
+ end.min_by { |block| block.close_line - block.open_line }
63
+ end
64
+
65
+ def parse(source)
66
+ source = source.to_s
67
+ lines = source.lines
68
+ fences = []
69
+ open_fences = []
70
+ offset = 0
71
+
72
+ lines.each_with_index do |line, line_number|
73
+ content = line.chomp
74
+ if (match = content.match(/\A\s*(`{3,}|~{3,})(.*)\z/))
75
+ marker = match[1][0]
76
+ length = match[1].length
77
+ rest = match[2]
78
+ closing = rest.match?(/\A\s*\z/)
79
+
80
+ if closing && open_fences.last&.marker == marker && length >= open_fences.last.length
81
+ opening = open_fences.pop
82
+ fences << Fence.new(
83
+ line: line_number,
84
+ start: offset,
85
+ finish: offset + line.length,
86
+ marker: marker,
87
+ length: length,
88
+ language: opening.language
89
+ )
90
+ opening.close_line = line_number
91
+ opening.close_start = offset
92
+ opening.close_end = offset + line.length
93
+ offset += line.length
94
+ next
95
+ end
96
+
97
+ next if closing
98
+
99
+ opening = Fence.new(
100
+ line: line_number,
101
+ start: offset,
102
+ finish: offset + line.length,
103
+ marker: marker,
104
+ length: length,
105
+ language: rest.strip.split(/\s+/, 2).first
106
+ )
107
+ open_fences << opening
108
+ fences << opening
109
+ end
110
+ offset += line.length
111
+ end
112
+
113
+ completed = fences.select { |fence| fence.close_line }
114
+ completed.map do |opening|
115
+ next_fence = fences.map(&:start).select { |start| start > opening.close_end }.min
116
+ Block.new(
117
+ source: source,
118
+ open_line: opening.line,
119
+ close_line: opening.close_line,
120
+ language: opening.language,
121
+ body_start: opening.finish,
122
+ body_end: opening.close_start,
123
+ close_end: opening.close_end,
124
+ next_fence_start: next_fence
125
+ )
126
+ end
127
+ end
128
+
129
+ def format_output(output, newline: "\n")
130
+ body = output.to_s.gsub(/\r\n?/, "\n").chomp
131
+ body = body.gsub("\n", newline)
132
+ body.empty? ? "<output>#{newline}</output>" : "<output>#{newline}#{body}#{newline}</output>"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -37,12 +37,12 @@ module Kward
37
37
  "lm_studio" => "http://127.0.0.1:1234/v1",
38
38
  "llama_cpp" => "http://127.0.0.1:8080/v1"
39
39
  }.freeze
40
- AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
41
- OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or sign in with an API key."
42
- OPENAI_API_AUTH_ERROR = "No OpenAI API key found. Set OPENAI_API_KEY or sign in with an API key."
43
- AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found. Set AZURE_OPENAI_API_KEY or sign in with an API key."
44
- COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
45
- ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."
40
+ AUTH_ERROR = "No OpenAI OAuth login found. Run `kward login`, or set OPENAI_ACCESS_TOKEN or OPENROUTER_API_KEY."
41
+ OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Run `kward login openrouter`, or set OPENROUTER_API_KEY."
42
+ OPENAI_API_AUTH_ERROR = "No OpenAI API key found. Run `kward login`, or set OPENAI_API_KEY."
43
+ AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found. Run `kward login`, or set AZURE_OPENAI_API_KEY."
44
+ COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `kward login github`, or set COPILOT_GITHUB_TOKEN."
45
+ ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `kward login anthropic`."
46
46
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
47
47
  DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
48
48
  RETRY_DELAYS = [1, 2].freeze
@@ -61,6 +61,8 @@ module Kward
61
61
  /(?:exceeded|reached).*(?:usage|quota|credit|budget|balance)/i
62
62
  ].freeze
63
63
 
64
+ AuthenticationError = Class.new(RuntimeError)
65
+
64
66
  RequestError = Class.new(StandardError) do
65
67
  attr_reader :provider, :code, :body
66
68
 
@@ -108,11 +110,12 @@ module Kward
108
110
  @local_models = nil
109
111
  end
110
112
 
111
- def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
113
+ def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil, provider_required: false)
112
114
  cancellation&.raise_if_cancelled!
113
115
  requested_provider = provider
116
+ validate_required_provider!(requested_provider) if provider_required
114
117
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
115
- if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
118
+ if token.to_s.empty? && authentication_required?(resolved_provider) && !provider_required && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
116
119
  url, token, resolved_provider, account_id = credentials
117
120
  model = nil
118
121
  reasoning = nil
@@ -593,20 +596,22 @@ module Kward
593
596
  end
594
597
 
595
598
  def auth_error_for(provider)
596
- case provider
597
- when "Azure OpenAI"
598
- AZURE_OPENAI_AUTH_ERROR
599
- when "OpenAI"
600
- OPENAI_API_AUTH_ERROR
601
- when "OpenRouter"
602
- OPENROUTER_AUTH_ERROR
603
- when "Copilot"
604
- COPILOT_AUTH_ERROR
605
- when "Anthropic"
606
- ANTHROPIC_AUTH_ERROR
607
- else
608
- AUTH_ERROR
609
- end
599
+ message =
600
+ case provider
601
+ when "Azure OpenAI"
602
+ AZURE_OPENAI_AUTH_ERROR
603
+ when "OpenAI"
604
+ OPENAI_API_AUTH_ERROR
605
+ when "OpenRouter"
606
+ OPENROUTER_AUTH_ERROR
607
+ when "Copilot"
608
+ COPILOT_AUTH_ERROR
609
+ when "Anthropic"
610
+ ANTHROPIC_AUTH_ERROR
611
+ else
612
+ AUTH_ERROR
613
+ end
614
+ AuthenticationError.new(message)
610
615
  end
611
616
 
612
617
  def with_retries(provider, model, request_bytes: nil, on_retry: nil, cancellation: nil)
@@ -1063,6 +1068,13 @@ module Kward
1063
1068
  ModelInfo.reasoning_effort(config: @config, provider: provider)
1064
1069
  end
1065
1070
 
1071
+ def validate_required_provider!(provider)
1072
+ provider_id = provider.to_s.strip.downcase
1073
+ return if ProviderCatalog.configuration_ids.include?(provider_id)
1074
+
1075
+ raise ArgumentError, "Unknown provider: #{provider}"
1076
+ end
1077
+
1066
1078
  def authentication_required?(provider)
1067
1079
  provider != "Local"
1068
1080
  end
@@ -71,9 +71,14 @@ module Kward
71
71
  Definition.new(id: "xai", name: "xAI", api_key_env: ["XAI_API_KEY"], protocol: "openai_chat", oauth_name: "xAI Grok"),
72
72
  Definition.new(id: "copilot", name: "GitHub Copilot", api_key_env: [], protocol: "copilot", oauth_name: "GitHub Copilot")
73
73
  ].freeze
74
+ CONFIGURATION_IDS = (["codex", "openai_api", "local"] + DEFINITIONS.map(&:id).reject { |id| id == "openai" }).freeze
74
75
 
75
76
  module_function
76
77
 
78
+ def configuration_ids
79
+ CONFIGURATION_IDS
80
+ end
81
+
77
82
  def all
78
83
  DEFINITIONS
79
84
  end
@@ -347,7 +347,7 @@ module Kward
347
347
  when "response.content_part.added"
348
348
  codex_content_part_added(state, event["part"])
349
349
  when "response.output_text.delta", "response.refusal.delta"
350
- codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
350
+ codex_output_text_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta)
351
351
  when "response.reasoning_summary_part.added"
352
352
  codex_reasoning_summary_part_added(state, event["part"])
353
353
  when "response.reasoning_summary_text.delta"
@@ -417,7 +417,7 @@ module Kward
417
417
  state[:current_text_content_part] = item["content"].last
418
418
  end
419
419
 
420
- def codex_output_text_delta(state, delta, on_assistant_delta: nil)
420
+ def codex_output_text_delta(state, delta, on_reasoning_delta: nil, on_assistant_delta: nil)
421
421
  text = delta.to_s
422
422
  return if text.empty?
423
423
 
@@ -432,6 +432,9 @@ module Kward
432
432
  if codex_streamable_message_item?(item)
433
433
  on_assistant_delta&.call(text)
434
434
  state[:emitted_message_keys] << codex_item_key(item)
435
+ elsif codex_commentary_message_item?(item)
436
+ on_reasoning_delta&.call(text)
437
+ state[:emitted_message_keys] << codex_item_key(item)
435
438
  end
436
439
  end
437
440
  state[:raw_content] << text
@@ -647,10 +650,19 @@ module Kward
647
650
  case item["type"]
648
651
  when "message"
649
652
  text = text_from_codex_items([item])
650
- return if text.empty? || !codex_streamable_message_item?(item)
653
+ return if text.empty?
651
654
 
652
- state[:content] << text
653
655
  key = codex_item_key(item)
656
+ if codex_commentary_message_item?(item)
657
+ unless state[:emitted_message_keys].include?(key)
658
+ on_reasoning_delta&.call(text)
659
+ state[:emitted_message_keys] << key
660
+ end
661
+ return
662
+ end
663
+ return unless codex_streamable_message_item?(item)
664
+
665
+ state[:content] << text
654
666
  unless state[:emitted_message_keys].include?(key)
655
667
  on_assistant_delta&.call(text)
656
668
  state[:emitted_message_keys] << key
@@ -774,6 +786,10 @@ module Kward
774
786
  phase.empty? || phase == "final_answer"
775
787
  end
776
788
 
789
+ def codex_commentary_message_item?(item)
790
+ codex_message_phase(item) == "commentary"
791
+ end
792
+
777
793
  def codex_message_phase(item)
778
794
  item["phase"].to_s
779
795
  end
@@ -162,7 +162,7 @@
162
162
  <button class="icon-button" id="open-sessions" type="button" aria-label="Open sessions">☰</button>
163
163
  <div class="title-block">
164
164
  <div class="title-row"><span class="live-dot"></span><h1 id="conversation-title">New session</h1></div>
165
- <div class="top-meta" id="session-meta">Secure LAN workspace</div>
165
+ <div class="top-meta" id="session-meta">Authenticated HTTP workspace</div>
166
166
  </div>
167
167
  <button class="text-button" id="capture-skill" type="button">Capture skill</button>
168
168
  <button class="text-button" id="rename-session" type="button">Rename</button>
@@ -176,7 +176,7 @@
176
176
  <div class="composer-shell">
177
177
  <form id="prompt-form">
178
178
  <div class="composer">
179
- <textarea id="prompt" name="prompt" rows="1" autocomplete="off" enterkeyhint="send" aria-label="Prompt" placeholder="Give <%= ERB::Util.html_escape(@assistant_label) %> an order…"></textarea>
179
+ <textarea id="prompt" name="prompt" rows="1" autocomplete="off" enterkeyhint="send" aria-label="Prompt" placeholder="Ask <%= ERB::Util.html_escape(@assistant_label) %> to inspect, explain, or change code…"></textarea>
180
180
  <button class="send" id="send" type="submit" aria-label="Send prompt">↑</button>
181
181
  </div>
182
182
  <div class="composer-meta"><span id="status">Connecting…</span><span>Return to send · Shift Return for a new line</span></div>
@@ -437,7 +437,7 @@
437
437
  if (!activeSession) return;
438
438
  titleEl.textContent = activeSession.title || activeSession.name || 'New session';
439
439
  const details = [activeSession.model, activeSession.messageCount != null ? `${activeSession.messageCount} messages` : null].filter(Boolean);
440
- sessionMeta.textContent = details.join(' · ') || 'Secure LAN workspace';
440
+ sessionMeta.textContent = details.join(' · ') || 'Authenticated HTTP workspace';
441
441
  }
442
442
 
443
443
  async function changeSession(action, values = {}) {
@@ -1,5 +1,6 @@
1
1
  require "base64"
2
2
  require "erb"
3
+ require "ipaddr"
3
4
  require "json"
4
5
  require "socket"
5
6
  require "thread"
@@ -24,7 +25,7 @@ require_relative "../workspace_factory"
24
25
  module Kward
25
26
  # Local HTTP server for the mobile-friendly Pan web UI.
26
27
  class PanServer
27
- DEFAULT_HOST = "0.0.0.0"
28
+ DEFAULT_HOST = "127.0.0.1"
28
29
  DEFAULT_PORT = 8765
29
30
  MAX_REQUEST_BODY_BYTES = 64 * 1024
30
31
  ROUTING_PROBE_ADDRESS = "8.8.8.8"
@@ -44,8 +45,11 @@ module Kward
44
45
  @host = @config.fetch("host", DEFAULT_HOST).to_s
45
46
  @port = positive_port(@config.fetch("port", DEFAULT_PORT))
46
47
  @username = @config["username"].to_s
47
- @password = @config["password"].to_s
48
- raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
48
+ environment_password = ENV["KWARD_PAN_PASSWORD"].to_s
49
+ @password = environment_password.empty? ? @config["password"].to_s : environment_password
50
+ if @username.empty? || @password.empty?
51
+ raise "Pan mode requires pan_mode.username and either pan_mode.password or KWARD_PAN_PASSWORD"
52
+ end
49
53
 
50
54
  @session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
51
55
  @session = @session_store.create(
@@ -69,6 +73,7 @@ module Kward
69
73
  attr_reader :host, :port, :session, :workspace
70
74
 
71
75
  def run
76
+ warn_if_exposed
72
77
  start_worker
73
78
  @server = TCPServer.new(@host, @port)
74
79
  actual_port = @server.addr[1]
@@ -595,6 +600,21 @@ module Kward
595
600
  lan_address || "<lan-address>"
596
601
  end
597
602
 
603
+ def warn_if_exposed
604
+ return if loopback_host?
605
+
606
+ @output.puts "Warning: Pan is exposed over plain HTTP on #{@host}."
607
+ @output.puts "Use it only on a trusted network, or bind pan_mode.host to 127.0.0.1."
608
+ end
609
+
610
+ def loopback_host?
611
+ return true if @host.casecmp?("localhost")
612
+
613
+ IPAddr.new(@host).loopback?
614
+ rescue IPAddr::InvalidAddressError
615
+ false
616
+ end
617
+
598
618
  def lan_address
599
619
  socket = @udp_socket_class.new
600
620
  socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
@@ -3,8 +3,10 @@ require "pty"
3
3
  require "securerandom"
4
4
  require "shellwords"
5
5
  require_relative "ansi"
6
- require_relative "ekwsh"
6
+ require_relative "kwsh"
7
+ require_relative "kwshrc"
7
8
  require_relative "local_pty_command_runner"
9
+ require_relative "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"