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,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
@@ -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)
@@ -906,7 +911,7 @@ module Kward
906
911
  request["Content-Type"] = "application/json"
907
912
  request["Accept"] = "text/event-stream"
908
913
  request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
909
- apply_codex_identity(request, luna: luna_request?(request.body))
914
+ request["originator"] = "kward"
910
915
 
911
916
  message = nil
912
917
  Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
@@ -931,22 +936,6 @@ module Kward
931
936
  raise e
932
937
  end
933
938
 
934
- def apply_codex_identity(request, luna:)
935
- request["originator"] = "kward"
936
- return unless luna
937
-
938
- # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
939
- request["originator"] = "codex_cli_rs"
940
- request["User-Agent"] = "codex_cli_rs/0.144.1"
941
- request["x-openai-internal-codex-responses-lite"] = "true"
942
- end
943
-
944
- def luna_request?(request_body)
945
- JSON.parse(request_body)["model"] == "gpt-5.6-luna"
946
- rescue JSON::ParserError
947
- false
948
- end
949
-
950
939
  def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
951
940
  ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
952
941
  end
@@ -1079,6 +1068,13 @@ module Kward
1079
1068
  ModelInfo.reasoning_effort(config: @config, provider: provider)
1080
1069
  end
1081
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
+
1082
1078
  def authentication_required?(provider)
1083
1079
  provider != "Local"
1084
1080
  end
@@ -12,6 +12,7 @@ module Kward
12
12
  OPENAI_MODEL_CHOICES = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
13
13
  ANTHROPIC_MODEL_CHOICES = %w[
14
14
  claude-fable-5
15
+ claude-opus-5
15
16
  claude-opus-4-8
16
17
  claude-sonnet-5
17
18
  claude-sonnet-4-6
@@ -113,7 +114,7 @@ module Kward
113
114
  [/\Agpt-3\.5-turbo/, 16_385]
114
115
  ].freeze
115
116
  ANTHROPIC_CONTEXT_WINDOWS = [
116
- [/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)/, 1_000_000],
117
+ [/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)/, 1_000_000],
117
118
  [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
118
119
  [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
119
120
  [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
@@ -454,7 +455,7 @@ module Kward
454
455
 
455
456
  def anthropic_reasoning_effort_choices(id)
456
457
  text = normalize_anthropic_model(id)
457
- return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
458
+ return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
458
459
  return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
459
460
  return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
460
461
 
@@ -189,8 +189,6 @@ module Kward
189
189
  unless reasoning == false
190
190
  effort = reasoning || reasoning_effort(provider)
191
191
  payload[:reasoning] = { effort: effort, summary: "auto" }
192
- # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
193
- payload[:reasoning][:context] = "all_turns" if provider == "Codex" && parts[:model] == "gpt-5.6-luna"
194
192
  end
195
193
  payload
196
194
  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 = {}) {