kward 0.71.0 → 0.73.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/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +288 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +451 -57
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +67 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "pty"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "local_pty_command_runner"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Runs a command in a PTY while forwarding caller-owned input and output IOs.
|
|
9
|
+
# This is intentionally low level: UI orchestration decides when terminal
|
|
10
|
+
# ownership is handed to the child process and how the result is presented.
|
|
11
|
+
class InteractivePtyRunner
|
|
12
|
+
Result = Struct.new(:exit_status, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
READ_SIZE = 4096
|
|
15
|
+
|
|
16
|
+
def initialize(window_size_provider: nil)
|
|
17
|
+
@window_size_provider = window_size_provider
|
|
18
|
+
@window_size = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
|
|
22
|
+
pid = nil
|
|
23
|
+
status = nil
|
|
24
|
+
|
|
25
|
+
PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
|
|
26
|
+
pid = child_pid
|
|
27
|
+
update_window_size(reader, pid)
|
|
28
|
+
with_raw_input(input) do
|
|
29
|
+
drain_initial_input(input, writer)
|
|
30
|
+
loop do
|
|
31
|
+
update_window_size(reader, pid)
|
|
32
|
+
readable = IO.select([reader, input], nil, nil, 0.02)&.first || []
|
|
33
|
+
forward_pty_output(reader, output) if readable.include?(reader)
|
|
34
|
+
forward_input(input, writer) if readable.include?(input)
|
|
35
|
+
if (finished_status = finished_status(pid))
|
|
36
|
+
status = finished_status
|
|
37
|
+
break
|
|
38
|
+
end
|
|
39
|
+
rescue Errno::EIO, IOError
|
|
40
|
+
break
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
status ||= wait_for_status(pid)
|
|
44
|
+
ensure
|
|
45
|
+
writer&.close unless writer&.closed?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Result.new(exit_status: exit_status(status))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def with_raw_input(input)
|
|
54
|
+
return yield unless input.respond_to?(:raw)
|
|
55
|
+
|
|
56
|
+
input.raw { yield }
|
|
57
|
+
rescue Errno::ENOTTY
|
|
58
|
+
yield
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drain_initial_input(input, writer)
|
|
62
|
+
loop do
|
|
63
|
+
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
64
|
+
break if chunk.nil? || chunk == :wait_readable
|
|
65
|
+
|
|
66
|
+
writer.write(chunk)
|
|
67
|
+
end
|
|
68
|
+
writer.flush
|
|
69
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def forward_pty_output(reader, output)
|
|
74
|
+
chunk = reader.read_nonblock(READ_SIZE, exception: false)
|
|
75
|
+
return if chunk.nil? || chunk == :wait_readable
|
|
76
|
+
|
|
77
|
+
output.write(chunk)
|
|
78
|
+
output.flush if output.respond_to?(:flush)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def forward_input(input, writer)
|
|
82
|
+
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
83
|
+
return if chunk.nil? || chunk == :wait_readable
|
|
84
|
+
|
|
85
|
+
writer.write(chunk)
|
|
86
|
+
writer.flush
|
|
87
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def update_window_size(reader, pid)
|
|
92
|
+
window_size = terminal_window_size
|
|
93
|
+
return if window_size == @window_size
|
|
94
|
+
|
|
95
|
+
@window_size = window_size
|
|
96
|
+
reader.winsize = window_size
|
|
97
|
+
signal_process("WINCH", -pid) || signal_process("WINCH", pid)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def terminal_window_size
|
|
103
|
+
rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
|
|
104
|
+
rows = LocalPtyCommandRunner::DEFAULT_ROWS unless rows.to_i.positive?
|
|
105
|
+
columns = LocalPtyCommandRunner::DEFAULT_COLUMNS unless columns.to_i.positive?
|
|
106
|
+
[rows, columns]
|
|
107
|
+
rescue StandardError
|
|
108
|
+
[LocalPtyCommandRunner::DEFAULT_ROWS, LocalPtyCommandRunner::DEFAULT_COLUMNS]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finished_status(pid)
|
|
112
|
+
return unless pid
|
|
113
|
+
|
|
114
|
+
finished_pid, status = Process.wait2(pid, Process::WNOHANG)
|
|
115
|
+
status if finished_pid
|
|
116
|
+
rescue Errno::ECHILD
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def wait_for_status(pid)
|
|
121
|
+
return unless pid
|
|
122
|
+
|
|
123
|
+
_, status = Process.wait2(pid)
|
|
124
|
+
status
|
|
125
|
+
rescue Errno::ECHILD
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def exit_status(status)
|
|
130
|
+
return 1 unless status
|
|
131
|
+
return status.exitstatus if status.exited?
|
|
132
|
+
return 128 + status.termsig if status.signaled?
|
|
133
|
+
|
|
134
|
+
1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def process_running?(pid)
|
|
138
|
+
Process.kill(0, pid)
|
|
139
|
+
true
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def signal_process(signal, pid)
|
|
145
|
+
Process.kill(signal, pid)
|
|
146
|
+
true
|
|
147
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "thread"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Low-level local process runner with bounded capture, timeout, cancellation,
|
|
9
|
+
# and optional output streaming. Callers own command semantics and formatting.
|
|
10
|
+
class LocalCommandRunner
|
|
11
|
+
Result = Struct.new(:stdout, :stderr, :exit_status, :timed_out, :truncated, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
READ_SIZE = 4096
|
|
14
|
+
|
|
15
|
+
def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false)
|
|
16
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
|
|
17
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
|
|
18
|
+
@terminate_on_output_limit = terminate_on_output_limit
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
22
|
+
cancellation&.raise_if_cancelled!
|
|
23
|
+
stdout_buffer = +""
|
|
24
|
+
stderr_buffer = +""
|
|
25
|
+
captured_bytes = 0
|
|
26
|
+
truncated = false
|
|
27
|
+
timed_out = false
|
|
28
|
+
queue = Queue.new
|
|
29
|
+
|
|
30
|
+
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
31
|
+
stdin.close
|
|
32
|
+
readers = [
|
|
33
|
+
read_stream(stdout, :stdout, queue),
|
|
34
|
+
read_stream(stderr, :stderr, queue)
|
|
35
|
+
]
|
|
36
|
+
cancellation&.on_cancel { terminate_process_group(wait_thread.pid) }
|
|
37
|
+
|
|
38
|
+
status = wait_for_process(wait_thread, readers, queue, cancellation: cancellation) do |stream, chunk|
|
|
39
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(
|
|
40
|
+
stream,
|
|
41
|
+
chunk,
|
|
42
|
+
stdout_buffer,
|
|
43
|
+
stderr_buffer,
|
|
44
|
+
captured_bytes,
|
|
45
|
+
truncated
|
|
46
|
+
)
|
|
47
|
+
block&.call(stream, captured_chunk) unless captured_chunk.empty?
|
|
48
|
+
terminate_process_group(wait_thread.pid) if truncated && @terminate_on_output_limit
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
join_readers(readers)
|
|
52
|
+
drain_queue(queue) do |stream, chunk|
|
|
53
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(
|
|
54
|
+
stream,
|
|
55
|
+
chunk,
|
|
56
|
+
stdout_buffer,
|
|
57
|
+
stderr_buffer,
|
|
58
|
+
captured_bytes,
|
|
59
|
+
truncated
|
|
60
|
+
)
|
|
61
|
+
block&.call(stream, captured_chunk) unless captured_chunk.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: status.exitstatus || 1, timed_out: false, truncated: truncated)
|
|
65
|
+
rescue Timeout::Error
|
|
66
|
+
timed_out = true
|
|
67
|
+
terminate_process_group(wait_thread.pid)
|
|
68
|
+
join_readers(readers)
|
|
69
|
+
Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: nil, timed_out: timed_out, truncated: truncated)
|
|
70
|
+
ensure
|
|
71
|
+
readers&.each { |reader| reader.kill if reader&.alive? }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_stream(io, stream, queue)
|
|
78
|
+
Thread.new do
|
|
79
|
+
loop do
|
|
80
|
+
queue << [stream, io.readpartial(READ_SIZE)]
|
|
81
|
+
rescue EOFError
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def wait_for_process(wait_thread, readers, queue, cancellation:)
|
|
88
|
+
deadline = Time.now + @timeout_seconds
|
|
89
|
+
loop do
|
|
90
|
+
cancellation&.raise_if_cancelled!
|
|
91
|
+
drain_queue(queue) { |stream, chunk| yield(stream, chunk) }
|
|
92
|
+
if wait_thread.join(0.02)
|
|
93
|
+
cancellation&.raise_if_cancelled!
|
|
94
|
+
return wait_thread.value
|
|
95
|
+
end
|
|
96
|
+
raise Timeout::Error if Time.now >= deadline
|
|
97
|
+
end
|
|
98
|
+
rescue Cancellation::CancelledError
|
|
99
|
+
terminate_process_group(wait_thread.pid)
|
|
100
|
+
join_readers(readers)
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def drain_queue(queue)
|
|
105
|
+
loop do
|
|
106
|
+
stream, chunk = queue.pop(true)
|
|
107
|
+
yield(stream, chunk)
|
|
108
|
+
rescue ThreadError
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def capture_chunk(stream, chunk, stdout_buffer, stderr_buffer, captured_bytes, truncated)
|
|
114
|
+
return [captured_bytes, truncated, ""] if truncated
|
|
115
|
+
|
|
116
|
+
remaining = @max_output_bytes - captured_bytes
|
|
117
|
+
if chunk.bytesize > remaining
|
|
118
|
+
truncated = true
|
|
119
|
+
chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
stream == :stderr ? stderr_buffer << chunk : stdout_buffer << chunk
|
|
123
|
+
[captured_bytes + chunk.bytesize, truncated, chunk]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def join_readers(readers)
|
|
127
|
+
readers.to_a.each { |reader| reader.join(0.1) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def terminate_process_group(pid)
|
|
131
|
+
signal_process("TERM", -pid) || signal_process("TERM", pid)
|
|
132
|
+
deadline = Time.now + 0.2
|
|
133
|
+
while Time.now < deadline
|
|
134
|
+
return unless process_running?(pid)
|
|
135
|
+
|
|
136
|
+
sleep 0.02
|
|
137
|
+
end
|
|
138
|
+
signal_process("KILL", -pid) || signal_process("KILL", pid)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_running?(pid)
|
|
142
|
+
Process.kill(0, pid)
|
|
143
|
+
true
|
|
144
|
+
rescue Errno::ESRCH
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def signal_process(signal, pid)
|
|
149
|
+
Process.kill(signal, pid)
|
|
150
|
+
true
|
|
151
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "pty"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
require_relative "local_command_runner"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
# Low-level pseudo-terminal command runner with bounded capture, timeout,
|
|
10
|
+
# cancellation, and optional output streaming. This gives child processes a
|
|
11
|
+
# TTY without trying to emulate an interactive terminal.
|
|
12
|
+
class LocalPtyCommandRunner
|
|
13
|
+
Result = LocalCommandRunner::Result
|
|
14
|
+
|
|
15
|
+
READ_SIZE = 4096
|
|
16
|
+
DEFAULT_ROWS = 24
|
|
17
|
+
DEFAULT_COLUMNS = 80
|
|
18
|
+
|
|
19
|
+
def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false, window_size_provider: nil)
|
|
20
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
|
|
21
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
|
|
22
|
+
@terminate_on_output_limit = terminate_on_output_limit
|
|
23
|
+
@window_size_provider = window_size_provider
|
|
24
|
+
@window_size = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
28
|
+
cancellation&.raise_if_cancelled!
|
|
29
|
+
output = +""
|
|
30
|
+
captured_bytes = 0
|
|
31
|
+
truncated = false
|
|
32
|
+
timed_out = false
|
|
33
|
+
cancelled = false
|
|
34
|
+
pid = nil
|
|
35
|
+
status = nil
|
|
36
|
+
|
|
37
|
+
PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, _writer, child_pid|
|
|
38
|
+
pid = child_pid
|
|
39
|
+
update_window_size(reader, pid)
|
|
40
|
+
cancellation&.on_cancel do
|
|
41
|
+
cancelled = true
|
|
42
|
+
terminate_process_group(pid)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
deadline = Time.now + @timeout_seconds
|
|
47
|
+
loop do
|
|
48
|
+
cancellation&.raise_if_cancelled!
|
|
49
|
+
raise Timeout::Error if Time.now >= deadline
|
|
50
|
+
|
|
51
|
+
update_window_size(reader, pid)
|
|
52
|
+
readable, = IO.select([reader], nil, nil, 0.02)
|
|
53
|
+
next unless readable
|
|
54
|
+
|
|
55
|
+
chunk = read_chunk(reader)
|
|
56
|
+
break if chunk.nil?
|
|
57
|
+
|
|
58
|
+
chunk = normalize_line_endings(chunk)
|
|
59
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(chunk, output, captured_bytes, truncated)
|
|
60
|
+
block&.call(:stdout, captured_chunk) unless captured_chunk.empty?
|
|
61
|
+
terminate_process_group(pid) if truncated && @terminate_on_output_limit
|
|
62
|
+
end
|
|
63
|
+
rescue Errno::EIO
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
status = wait_for_status(pid)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
cancellation&.raise_if_cancelled! if cancelled
|
|
71
|
+
Result.new(stdout: output, stderr: "", exit_status: exit_status(status), timed_out: false, truncated: truncated)
|
|
72
|
+
rescue Timeout::Error
|
|
73
|
+
timed_out = true
|
|
74
|
+
terminate_process_group(pid) if pid
|
|
75
|
+
wait_for_status(pid) if pid
|
|
76
|
+
Result.new(stdout: output, stderr: "", exit_status: nil, timed_out: timed_out, truncated: truncated)
|
|
77
|
+
rescue Cancellation::CancelledError
|
|
78
|
+
terminate_process_group(pid) if pid
|
|
79
|
+
wait_for_status(pid) if pid
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def update_window_size(reader, pid)
|
|
86
|
+
window_size = terminal_window_size
|
|
87
|
+
return if window_size == @window_size
|
|
88
|
+
|
|
89
|
+
@window_size = window_size
|
|
90
|
+
reader.winsize = window_size
|
|
91
|
+
signal_process("WINCH", -pid) || signal_process("WINCH", pid)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def terminal_window_size
|
|
97
|
+
rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
|
|
98
|
+
rows = DEFAULT_ROWS unless rows.to_i.positive?
|
|
99
|
+
columns = DEFAULT_COLUMNS unless columns.to_i.positive?
|
|
100
|
+
[rows, columns]
|
|
101
|
+
rescue StandardError
|
|
102
|
+
[DEFAULT_ROWS, DEFAULT_COLUMNS]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_chunk(reader)
|
|
106
|
+
reader.read_nonblock(READ_SIZE, exception: false).tap do |chunk|
|
|
107
|
+
return nil if chunk.nil?
|
|
108
|
+
return nil if chunk == :wait_readable
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_line_endings(chunk)
|
|
113
|
+
chunk.gsub("\r\r\n", "\n").gsub("\r\n", "\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def capture_chunk(chunk, output, captured_bytes, truncated)
|
|
117
|
+
return [captured_bytes, truncated, ""] if truncated
|
|
118
|
+
|
|
119
|
+
remaining = @max_output_bytes - captured_bytes
|
|
120
|
+
if chunk.bytesize > remaining
|
|
121
|
+
truncated = true
|
|
122
|
+
chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
output << chunk
|
|
126
|
+
[captured_bytes + chunk.bytesize, truncated, chunk]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wait_for_status(pid)
|
|
130
|
+
return unless pid
|
|
131
|
+
|
|
132
|
+
_, status = Process.wait2(pid)
|
|
133
|
+
status
|
|
134
|
+
rescue Errno::ECHILD
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def exit_status(status)
|
|
139
|
+
return 1 unless status
|
|
140
|
+
return status.exitstatus if status.exited?
|
|
141
|
+
return 128 + status.termsig if status.signaled?
|
|
142
|
+
|
|
143
|
+
1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def terminate_process_group(pid)
|
|
147
|
+
signal_process("TERM", -pid) || signal_process("TERM", pid)
|
|
148
|
+
deadline = Time.now + 0.2
|
|
149
|
+
while Time.now < deadline
|
|
150
|
+
return unless process_running?(pid)
|
|
151
|
+
|
|
152
|
+
sleep 0.02
|
|
153
|
+
end
|
|
154
|
+
signal_process("KILL", -pid) || signal_process("KILL", pid)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def process_running?(pid)
|
|
158
|
+
Process.kill(0, pid)
|
|
159
|
+
true
|
|
160
|
+
rescue Errno::ESRCH
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def signal_process(signal, pid)
|
|
165
|
+
Process.kill(signal, pid)
|
|
166
|
+
true
|
|
167
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -12,7 +12,7 @@ module Kward
|
|
|
12
12
|
def call(provider:, model:, context_window:, context_parts:)
|
|
13
13
|
return nil unless context_window
|
|
14
14
|
|
|
15
|
-
parts = redact_image_data(
|
|
15
|
+
parts = redact_image_data(stringify_top_level_keys(context_parts || {}))
|
|
16
16
|
return nil unless contains_session_content?(parts)
|
|
17
17
|
|
|
18
18
|
payload = prompt_payload(parts)
|
|
@@ -71,7 +71,7 @@ module Kward
|
|
|
71
71
|
["data", "image_url"].include?(key.to_s)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def
|
|
74
|
+
def stringify_top_level_keys(value)
|
|
75
75
|
return value unless value.is_a?(Hash)
|
|
76
76
|
|
|
77
77
|
value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../image_attachments"
|
|
3
3
|
require_relative "../message_access"
|
|
4
|
+
require_relative "../tools/tool_call"
|
|
4
5
|
require_relative "model_info"
|
|
5
6
|
|
|
6
7
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -255,11 +256,7 @@ module Kward
|
|
|
255
256
|
end
|
|
256
257
|
|
|
257
258
|
def parse_tool_arguments(arguments)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
|
|
261
|
-
rescue JSON::ParserError
|
|
262
|
-
{}
|
|
259
|
+
ToolCall.parse_arguments(arguments)
|
|
263
260
|
end
|
|
264
261
|
|
|
265
262
|
def anthropic_tool_schema(tool)
|
|
@@ -23,6 +23,15 @@ module Kward
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Registered interactive command that takes over the composer region with a
|
|
27
|
+
# Kward-driven render and input loop. Like a slash command but with canvas
|
|
28
|
+
# rendering capabilities for games, dashboards, viewers, and similar uses.
|
|
29
|
+
InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
|
|
30
|
+
def entry
|
|
31
|
+
{ name: name, description: description, argument_hint: argument_hint }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# Read-only event passed to plugin transcript observers.
|
|
27
36
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
28
37
|
def to_h
|
|
@@ -167,6 +176,24 @@ module Kward
|
|
|
167
176
|
def prompt_context(&block)
|
|
168
177
|
@registry.register_prompt_context(path: @path, &block)
|
|
169
178
|
end
|
|
179
|
+
|
|
180
|
+
# Registers an interactive command that takes over the composer region with
|
|
181
|
+
# a Kward-driven render and input loop. The handler receives an
|
|
182
|
+
# interactive controller object with a canvas API for drawing colored
|
|
183
|
+
# cells and reading keys. Useful for games, dashboards, and viewers.
|
|
184
|
+
#
|
|
185
|
+
# @param name [String, #to_s] command name without the leading slash
|
|
186
|
+
# @param rows [Integer] fixed canvas height in terminal rows
|
|
187
|
+
# @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
|
|
188
|
+
# @param description [String] short text shown in command listings
|
|
189
|
+
# @param argument_hint [String] optional usage hint for arguments
|
|
190
|
+
# @yieldparam ui [Object] interactive controller with canvas and key API
|
|
191
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @api public
|
|
194
|
+
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
195
|
+
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
196
|
+
end
|
|
170
197
|
end
|
|
171
198
|
|
|
172
199
|
# Mutable singleton guard used while loading trusted plugin files.
|
|
@@ -207,6 +234,7 @@ module Kward
|
|
|
207
234
|
def initialize(reserved_commands: [])
|
|
208
235
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
209
236
|
@commands = {}
|
|
237
|
+
@interactive_commands = {}
|
|
210
238
|
@footer = nil
|
|
211
239
|
@footer_path = nil
|
|
212
240
|
@transcript_event_handlers = []
|
|
@@ -228,6 +256,14 @@ module Kward
|
|
|
228
256
|
@commands[name.to_s]
|
|
229
257
|
end
|
|
230
258
|
|
|
259
|
+
def interactive_commands
|
|
260
|
+
@interactive_commands.values
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def interactive_command_for(name)
|
|
264
|
+
@interactive_commands[name.to_s]
|
|
265
|
+
end
|
|
266
|
+
|
|
231
267
|
def footer_renderer
|
|
232
268
|
@footer
|
|
233
269
|
end
|
|
@@ -306,6 +342,31 @@ module Kward
|
|
|
306
342
|
)
|
|
307
343
|
end
|
|
308
344
|
|
|
345
|
+
def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
|
|
346
|
+
name = name.to_s
|
|
347
|
+
raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
348
|
+
raise "Interactive command /#{name} requires a handler" unless handler
|
|
349
|
+
|
|
350
|
+
if @reserved_commands.include?(name) || @commands.key?(name)
|
|
351
|
+
warn "Warning: skipping Kward interactive command /#{name}: reserved command"
|
|
352
|
+
return nil
|
|
353
|
+
end
|
|
354
|
+
if @interactive_commands.key?(name)
|
|
355
|
+
warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
|
|
356
|
+
return nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
@interactive_commands[name] = InteractiveCommand.new(
|
|
360
|
+
name: name,
|
|
361
|
+
description: description.to_s,
|
|
362
|
+
argument_hint: argument_hint.to_s,
|
|
363
|
+
rows: [[rows.to_i, 1].max, 1].max,
|
|
364
|
+
fps: [[fps.to_f, 1].max, 120].min,
|
|
365
|
+
path: path,
|
|
366
|
+
handler: handler
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
309
370
|
def register_footer(path: nil, &renderer)
|
|
310
371
|
raise "Plugin footer requires a renderer" unless renderer
|
|
311
372
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "find"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Discovers project files for prompt UI features.
|
|
8
|
+
module ProjectFiles
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def list(root: Dir.pwd)
|
|
12
|
+
paths = git_paths(root)
|
|
13
|
+
paths = scanned_paths(root) if paths.empty?
|
|
14
|
+
paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def git_paths(root)
|
|
18
|
+
output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
|
|
19
|
+
return [] unless status.success?
|
|
20
|
+
|
|
21
|
+
output.lines.map(&:chomp).reject(&:empty?)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scanned_paths(root)
|
|
27
|
+
root_path = Pathname.new(root)
|
|
28
|
+
paths = []
|
|
29
|
+
Find.find(root_path.to_s) do |path|
|
|
30
|
+
relative = Pathname.new(path).relative_path_from(root_path).to_s
|
|
31
|
+
if File.directory?(path)
|
|
32
|
+
Find.prune if ignored_directory?(relative)
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
paths << relative unless ignored_file?(relative)
|
|
37
|
+
end
|
|
38
|
+
paths
|
|
39
|
+
rescue StandardError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ignored_directory?(relative)
|
|
44
|
+
ignored_directories = %w[.git .yardoc _yardoc node_modules rdoc tmp vendor/bundle]
|
|
45
|
+
ignored_directories.include?(relative) || relative.start_with?(".git/")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ignored_file?(relative)
|
|
49
|
+
relative.start_with?(".git/")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|