kward 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -3,7 +3,7 @@ require "set"
3
3
  require_relative "context_budget_meter"
4
4
  require_relative "image_attachments"
5
5
  require_relative "message_access"
6
- require_relative "plugin_registry"
6
+ require_relative "plugins/registry"
7
7
  require_relative "prompts"
8
8
 
9
9
  # Namespace for the Kward CLI agent runtime.
@@ -176,9 +176,38 @@ module Kward
176
176
  "toolout_#{digest}"
177
177
  end
178
178
 
179
+ # Scopes plugin instructions to one host-owned turn, including retries.
180
+ # The overlay is deliberately not persisted or folded into the base prompt.
181
+ def with_system_turn(request)
182
+ previous = @system_turn
183
+ raise ArgumentError, "A system-instruction turn is already active" if previous
184
+
185
+ @system_turn = request
186
+ yield
187
+ ensure
188
+ @system_turn = previous
189
+ end
190
+
191
+ def append_system_turn(request)
192
+ append_message(request.history_message)
193
+ end
194
+
179
195
  # @return [Array<Hash>] provider request context: current system prompt plus durable transcript
180
196
  def context_messages
181
- @system_message ? [@system_message] + @messages : @messages.dup
197
+ instruction = { role: "system", content: @system_turn.system, system_turn: true } if @system_turn
198
+ inserted = false
199
+ messages = @messages.filter_map do |message|
200
+ record = message[:plugin_system_turn] || message["plugin_system_turn"]
201
+ next message unless record
202
+ next unless @system_turn && (record[:id] || record["id"]) == @system_turn.id
203
+
204
+ inserted = true
205
+ instruction
206
+ end
207
+ # Compaction can remove the history marker; reissue the current directive
208
+ # after the compacted context rather than demoting it to the base prompt.
209
+ messages << instruction if instruction && !inserted
210
+ @system_message ? [@system_message] + messages : messages
182
211
  end
183
212
 
184
213
  # Rebuilds the system message from current config, memory, plugins, and
@@ -4,7 +4,7 @@ require "open3"
4
4
  require "shellwords"
5
5
  require "tmpdir"
6
6
  require "uri"
7
- require_relative "terminal_image_support"
7
+ require_relative "terminal/image_support"
8
8
 
9
9
  # Namespace for the Kward CLI agent runtime.
10
10
  module Kward
@@ -8,7 +8,7 @@ require_relative "../auth/openai_oauth"
8
8
  require_relative "../cancellation"
9
9
  require_relative "../config_files"
10
10
  require_relative "../http"
11
- require_relative "../openrouter_model_cache"
11
+ require_relative "openrouter_model_cache"
12
12
  require_relative "context_overflow"
13
13
  require_relative "azure_openai_config"
14
14
  require_relative "copilot_models"
@@ -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)
@@ -707,7 +712,7 @@ module Kward
707
712
  end
708
713
 
709
714
  def copilot_responses_model?(model)
710
- model.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
715
+ model.to_s.match?(/\Agpt-(?:5|6)(?:\.|-|\z)/)
711
716
  end
712
717
 
713
718
  def model_entry(provider, id, current: false, metadata: 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
@@ -14,7 +14,7 @@ module Kward
14
14
 
15
15
  def supported?(model)
16
16
  text = model.to_s
17
- text.match?(/\Agpt-5(?:\.|-|\z)/) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
17
+ text.match?(/\Agpt-(?:5|6)(?:\.|-|\z)/) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
18
18
  end
19
19
 
20
20
  def supported_choices(choices)
@@ -24,7 +24,7 @@ module Kward
24
24
  def resolved_chat_model(configured_model, choices)
25
25
  return configured_model if choices.empty? || choices.include?(configured_model)
26
26
 
27
- choices.find { |model| supported?(model) } || raise("No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models.")
27
+ choices.find { |model| supported?(model) } || raise("No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5/GPT-6 Responses and Gemini/GPT-4.1 chat models.")
28
28
  end
29
29
 
30
30
  def catalog_entries(body)
@@ -9,7 +9,7 @@ module Kward
9
9
  DEFAULT_COPILOT_MODEL = "gpt-5-mini"
10
10
  DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5"
11
11
  DEFAULT_REASONING_EFFORT = "medium"
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
12
+ OPENAI_MODEL_CHOICES = %w[gpt-6-astra gpt-6.1-sol gpt-6-luna 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
15
  claude-opus-5
@@ -24,6 +24,7 @@ module Kward
24
24
  ].freeze
25
25
  COPILOT_MODEL_CHOICES = %w[
26
26
  gpt-5-mini
27
+ gpt-6-astra
27
28
  gpt-5.3-codex
28
29
  gpt-5.4
29
30
  gpt-5.4-mini
@@ -54,6 +55,14 @@ module Kward
54
55
  *OPENAI_REASONING_EFFORT_CHOICES,
55
56
  ["max", "Max"]
56
57
  ].freeze
58
+ GPT_6_REASONING_EFFORT_CHOICES = [
59
+ *REASONING_EFFORT_CHOICES,
60
+ ["max", "Max"]
61
+ ].freeze
62
+ GPT_6_LUNA_REASONING_EFFORT_CHOICES = [
63
+ *OPENAI_REASONING_EFFORT_CHOICES,
64
+ ["max", "Max"]
65
+ ].freeze
57
66
  ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES = [
58
67
  ["low", "Low"],
59
68
  ["medium", "Medium"],
@@ -78,6 +87,7 @@ module Kward
78
87
  ].freeze
79
88
 
80
89
  CODEX_CONTEXT_WINDOWS = [
90
+ [/\Agpt-6(?:\.1)?-(?:astra|sol|luna)(?:\z|-)/, 1_050_000],
81
91
  [/\Agpt-5\.6/, 1_050_000],
82
92
  [/\Agpt-5\.5/, 400_000],
83
93
  [/\Agpt-5\.4-mini/, 400_000],
@@ -96,6 +106,7 @@ module Kward
96
106
  [/\Agpt-3\.5-turbo/, 16_385]
97
107
  ].freeze
98
108
  OPENAI_CONTEXT_WINDOWS = [
109
+ [/\Agpt-6(?:\.1)?-(?:astra|sol|luna)(?:\z|-)/, 1_050_000],
99
110
  [/\Agpt-5\.6/, 1_050_000],
100
111
  [/\Agpt-5\.5/, 1_050_000],
101
112
  [/\Agpt-5\.4-mini/, 400_000],
@@ -423,10 +434,12 @@ module Kward
423
434
  case provider
424
435
  when "Codex", "OpenRouter"
425
436
  true
437
+ when "OpenAI"
438
+ id.to_s.match?(/\Agpt-6(?:\.1)?-(?:astra|sol|luna)(?:\z|-)/)
426
439
  when "Anthropic"
427
440
  !reasoning_effort_choices(provider, id).empty?
428
441
  when "Copilot"
429
- id.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
442
+ id.to_s.match?(/\Agpt-(?:5|6)(?:\.|-|\z)/)
430
443
  else
431
444
  false
432
445
  end
@@ -436,10 +449,12 @@ module Kward
436
449
  case provider
437
450
  when "Codex", "OpenRouter"
438
451
  openai_reasoning_effort_choices(id)
452
+ when "OpenAI"
453
+ reasoning_supported?(provider, id) ? openai_reasoning_effort_choices(id) : []
439
454
  when "Anthropic"
440
455
  anthropic_reasoning_effort_choices(id)
441
456
  when "Copilot"
442
- id.to_s.match?(/\Agpt-5(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
457
+ id.to_s.match?(/\Agpt-(?:5|6)(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
443
458
  else
444
459
  []
445
460
  end
@@ -447,6 +462,8 @@ module Kward
447
462
 
448
463
  def openai_reasoning_effort_choices(id)
449
464
  text = id.to_s.delete_prefix("openai/")
465
+ return GPT_6_LUNA_REASONING_EFFORT_CHOICES if text.match?(/\Agpt-6-luna(?:\z|-)/)
466
+ return GPT_6_REASONING_EFFORT_CHOICES if text.match?(/\Agpt-6\.1-sol(?:\z|-)|\Agpt-6-astra(?:\z|-)/)
450
467
  return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
451
468
  return OPENAI_MAX_REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.6(?:\z|-)/)
452
469
 
@@ -3,9 +3,9 @@ require "json"
3
3
  require "net/http"
4
4
  require "time"
5
5
  require "uri"
6
- require_relative "config_files"
7
- require_relative "http"
8
- require_relative "private_file"
6
+ require_relative "../config_files"
7
+ require_relative "../http"
8
+ require_relative "../private_file"
9
9
 
10
10
  # Namespace for the Kward CLI agent runtime.
11
11
  module Kward
@@ -95,6 +95,8 @@ module Kward
95
95
 
96
96
  def anthropic_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
97
97
  parts = build_context_parts("Anthropic", messages, tools, model: model)
98
+ raise ArgumentError, "Anthropic requires conversation messages; a system-only turn is unavailable in an empty session" if parts[:messages].empty?
99
+
98
100
  system = [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }]
99
101
  system << { type: "text", text: parts[:system] } unless parts[:system].to_s.empty?
100
102
  payload = {
@@ -122,6 +124,8 @@ module Kward
122
124
  contents: messages.filter_map { |message| gemini_message(message) },
123
125
  tools: tools.empty? ? nil : [{ functionDeclarations: tools.map { |tool| gemini_tool_schema(tool) } }]
124
126
  }.compact
127
+ raise ArgumentError, "Gemini requires conversation contents; a system-only turn is unavailable in an empty session" if payload[:contents].empty?
128
+
125
129
  payload[:systemInstruction] = { parts: system_parts } unless system_parts.empty?
126
130
  payload[:generationConfig] = { maxOutputTokens: max_tokens.to_i } if max_tokens.to_i.positive?
127
131
  payload
@@ -219,7 +223,8 @@ module Kward
219
223
  tools: tools.map { |tool| codex_tool_schema(tool) }
220
224
  }
221
225
  elsif ["Codex", "OpenAI"].include?(provider)
222
- instructions, input = codex_messages(messages)
226
+ # The Codex subscription endpoint rejects inline system messages.
227
+ instructions, input = codex_messages(messages, system_turn_role: provider == "Codex" ? "developer" : "system")
223
228
  {
224
229
  provider: provider,
225
230
  model: model_for(provider, override_model: model),
@@ -335,7 +340,7 @@ module Kward
335
340
  }
336
341
  end
337
342
 
338
- def codex_messages(messages)
343
+ def codex_messages(messages, system_turn_role: "system")
339
344
  instructions = []
340
345
  input = []
341
346
 
@@ -344,7 +349,11 @@ module Kward
344
349
  content = MessageAccess.content(message) || ""
345
350
  case role.to_s
346
351
  when "system"
347
- instructions << plain_content(content).to_s
352
+ if MessageAccess.value(message, :system_turn)
353
+ input << codex_message(system_turn_role, plain_content(content).to_s)
354
+ else
355
+ instructions << plain_content(content).to_s
356
+ end
348
357
  when "tool", "toolResult"
349
358
  input << {
350
359
  type: "function_call_output",
@@ -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
@@ -0,0 +1,78 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require_relative "../cancellation"
5
+ require_relative "../http"
6
+
7
+ module Kward
8
+ # HTTP client for TypeSafe's structured System One evaluation endpoint.
9
+ class TypeSafeClient
10
+ DEFAULT_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
11
+ DEFAULT_MODEL = "jev-latest"
12
+ MAX_STATE_BYTES = 100_000
13
+ MAX_QUESTIONS = 100
14
+ HTTP_TIMEOUT_SECONDS = 30
15
+
16
+ class RequestError < StandardError
17
+ attr_reader :status
18
+
19
+ def initialize(status:, message:)
20
+ @status = status.to_i
21
+ super(message)
22
+ end
23
+ end
24
+
25
+ def initialize(api_key: ENV["TYPESAFE_API_KEY"], endpoint: DEFAULT_ENDPOINT, http: Net::HTTP)
26
+ @api_key = api_key.to_s.strip
27
+ @endpoint = URI(endpoint)
28
+ @http = http
29
+ end
30
+
31
+ def available?
32
+ !@api_key.empty?
33
+ end
34
+
35
+ def evaluate(state:, questions:, model: DEFAULT_MODEL, cancellation: nil)
36
+ raise ArgumentError, "TypeSafe API key is not configured" unless available?
37
+
38
+ cancellation&.raise_if_cancelled!
39
+ request = Net::HTTP::Post.new(@endpoint)
40
+ request["Authorization"] = "Bearer #{@api_key}"
41
+ request["Content-Type"] = "application/json"
42
+ request["Accept"] = "application/json"
43
+ Http.apply_user_agent(request)
44
+ request.body = JSON.dump({ state: state, model: model, questions: questions })
45
+
46
+ response_body = nil
47
+ @http.start(@endpoint.hostname, @endpoint.port, use_ssl: @endpoint.scheme == "https", read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
48
+ cancellation&.on_cancel { close_http(http) }
49
+ cancellation&.raise_if_cancelled!
50
+ response = http.request(request)
51
+ response_body = response.body.to_s
52
+ unless response.is_a?(Net::HTTPSuccess)
53
+ raise RequestError.new(status: response.code, message: error_message(response.code, response_body))
54
+ end
55
+ end
56
+ cancellation&.raise_if_cancelled!
57
+ JSON.parse(response_body)
58
+ rescue JSON::ParserError
59
+ raise RequestError.new(status: 200, message: "TypeSafe returned invalid JSON")
60
+ end
61
+
62
+ private
63
+
64
+ def error_message(status, body)
65
+ detail = JSON.parse(body).fetch("error", nil) rescue nil
66
+ detail = detail["message"] if detail.is_a?(Hash)
67
+ detail = detail.to_s unless detail.nil?
68
+ detail = "request failed" if detail.nil? || detail.empty?
69
+ "TypeSafe request failed (#{status}): #{detail[0, 500]}"
70
+ end
71
+
72
+ def close_http(http)
73
+ http.finish if http&.started?
74
+ rescue IOError
75
+ nil
76
+ end
77
+ end
78
+ 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"
@@ -9,22 +10,22 @@ require_relative "../agent"
9
10
  require_relative "../config_files"
10
11
  require_relative "../events"
11
12
  require_relative "../model/retry_message"
12
- require_relative "../plugin_registry"
13
+ require_relative "../plugins/registry"
13
14
  require_relative "../prompts/commands"
14
15
  require_relative "../rpc/transcript_normalizer"
15
- require_relative "../session_store"
16
+ require_relative "../sessions/store"
16
17
  require_relative "../skills/capture"
17
- require_relative "../session_trash"
18
+ require_relative "../sessions/trash"
18
19
  require_relative "../tools/tool_call"
19
20
  require_relative "../tools/registry"
20
21
  require_relative "../version"
21
- require_relative "../workspace_factory"
22
+ require_relative "../workspace/factory"
22
23
 
23
24
  # Namespace for the Kward CLI agent runtime.
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]
@@ -90,6 +95,8 @@ module Kward
90
95
  @server&.close unless @server&.closed?
91
96
  rescue IOError
92
97
  nil
98
+ ensure
99
+ @plugin_registry&.shutdown!
93
100
  end
94
101
 
95
102
  def enqueue_prompt(prompt)
@@ -125,7 +132,7 @@ module Kward
125
132
  hook_manager = lifecycle_hook_manager
126
133
  Agent.new(
127
134
  client: @client,
128
- tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
135
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, plugin_tools: plugin_registry.tools, hook_manager: hook_manager, hook_context: hook_context),
129
136
  conversation: @conversation,
130
137
  hook_manager: hook_manager,
131
138
  hook_context: hook_context
@@ -172,7 +179,7 @@ module Kward
172
179
  end
173
180
 
174
181
  def plugin_registry
175
- @plugin_registry ||= PluginRegistry.load(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES)
182
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).tap(&:start!)
176
183
  end
177
184
 
178
185
  def start_worker
@@ -236,7 +243,8 @@ module Kward
236
243
  when ["GET", "/kward-logo.png"]
237
244
  write_response(socket, 200, { "Content-Type" => "image/png", "Cache-Control" => "public, max-age=86400" }, File.binread(File.join(__dir__, "kward_logo.png")))
238
245
  when ["GET", "/transcript"]
239
- write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
246
+ write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s,
247
+ capabilities: { pluginCommandTurns: { supported: false, reason: "Pan has no plugin slash-command dispatcher; use the TUI or RPC turns/start." } })
240
248
  when ["GET", "/sessions"]
241
249
  write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
242
250
  when ["GET", "/skill-capture/sessions"]
@@ -595,6 +603,21 @@ module Kward
595
603
  lan_address || "<lan-address>"
596
604
  end
597
605
 
606
+ def warn_if_exposed
607
+ return if loopback_host?
608
+
609
+ @output.puts "Warning: Pan is exposed over plain HTTP on #{@host}."
610
+ @output.puts "Use it only on a trusted network, or bind pan_mode.host to 127.0.0.1."
611
+ end
612
+
613
+ def loopback_host?
614
+ return true if @host.casecmp?("localhost")
615
+
616
+ IPAddr.new(@host).loopback?
617
+ rescue IPAddr::InvalidAddressError
618
+ false
619
+ end
620
+
598
621
  def lan_address
599
622
  socket = @udp_socket_class.new
600
623
  socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
@@ -160,11 +160,15 @@ module Kward
160
160
  end
161
161
 
162
162
  def network?(request)
163
- NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
163
+ NETWORK_TOOLS.include?(request.fetch("tool"))
164
+ end
165
+
166
+ def extension_tool?(request)
167
+ %w[mcp plugin].include?(request["source"])
164
168
  end
165
169
 
166
170
  def risky?(request)
167
- mutating?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
171
+ mutating?(request) || request.fetch("tool") == "run_shell_command" || network?(request) || extension_tool?(request)
168
172
  end
169
173
 
170
174
  def outside_write_scopes?(request)