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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +68 -21
- data/doc/editor.md +28 -13
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/platform-support.md +48 -0
- data/doc/security.md +3 -2
- data/doc/shell.md +62 -45
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +5 -5
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +6 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +118 -55
- data/lib/kward/cli/slash_commands.rb +12 -14
- data/lib/kward/cli/tabs.rb +83 -12
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +45 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/config_files.rb +82 -68
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +34 -22
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +119 -26
- data/lib/kward/project_files.rb +2 -2
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +10 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +50 -1
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +93 -18
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +2 -0
- data/lib/kward/terminal_keys.rb +12 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/registry.rb +13 -4
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +36 -8
- 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
|
data/lib/kward/model/client.rb
CHANGED
|
@@ -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 `
|
|
41
|
-
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found.
|
|
42
|
-
OPENAI_API_AUTH_ERROR = "No OpenAI API key found.
|
|
43
|
-
AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found.
|
|
44
|
-
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `
|
|
45
|
-
ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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?
|
|
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">
|
|
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="
|
|
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(' · ') || '
|
|
440
|
+
sessionMeta.textContent = details.join(' · ') || 'Authenticated HTTP workspace';
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
async function changeSession(action, values = {}) {
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
48
|
-
|
|
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 "
|
|
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 =
|
|
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:
|
|
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? ?
|
|
47
|
-
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i :
|
|
48
|
-
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i :
|
|
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)}
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 << "
|
|
565
|
+
output << "kwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
|
|
498
566
|
status = 1
|
|
499
567
|
elsif protocol[:truncated]
|
|
500
|
-
output << "
|
|
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)}
|
|
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 &&
|
|
651
|
+
@aliases[name] = value.to_s if value && Kwsh.valid_alias_name?(name)
|
|
559
652
|
end
|
|
560
653
|
before != @aliases
|
|
561
654
|
when "unalias"
|