kward 0.76.0 → 0.77.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 +8 -1
- data/CHANGELOG.md +36 -0
- data/Gemfile.lock +2 -2
- data/README.md +12 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/composer.md +219 -0
- data/doc/configuration.md +75 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/rpc.md +0 -22
- data/doc/security.md +180 -0
- data/doc/usage.md +15 -32
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +2 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/runtime_helpers.rb +0 -38
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +14 -2
- data/lib/kward/cli.rb +1 -17
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +18 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/plugin_registry.rb +18 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +18 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/server.rb +13 -41
- data/lib/kward/rpc/session_manager.rb +75 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -0
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/registry.rb +11 -16
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/search/web.rb +1 -1
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +5 -1
- data/templates/default/layout/html/layout.erb +0 -2
- metadata +5 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
|
@@ -10,10 +10,6 @@ module Kward
|
|
|
10
10
|
@banner.rows(width, message: message)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def banner_logo_rows
|
|
14
|
-
@banner.logo_rows(screen_width)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
13
|
def transcript_redraw_row_count(height = screen_height)
|
|
18
14
|
[[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
|
|
19
15
|
end
|
|
@@ -12,6 +12,28 @@ module Kward
|
|
|
12
12
|
PROJECT_BROWSER_ROOT = "".freeze
|
|
13
13
|
PROJECT_BROWSER_RESULT_LIMIT = 200
|
|
14
14
|
PROJECT_BROWSER_STATE_VERSION = 1
|
|
15
|
+
PROJECT_BROWSER_FILENAME_ICONS = {
|
|
16
|
+
".gitignore" => "",
|
|
17
|
+
"Gemfile" => "",
|
|
18
|
+
"Rakefile" => "",
|
|
19
|
+
"README" => "",
|
|
20
|
+
"README.md" => "",
|
|
21
|
+
"package.json" => ""
|
|
22
|
+
}.freeze
|
|
23
|
+
PROJECT_BROWSER_EXTENSION_ICONS = {
|
|
24
|
+
"css" => "",
|
|
25
|
+
"html" => "",
|
|
26
|
+
"js" => "",
|
|
27
|
+
"json" => "",
|
|
28
|
+
"md" => "",
|
|
29
|
+
"rb" => "",
|
|
30
|
+
"sh" => "",
|
|
31
|
+
"ts" => "",
|
|
32
|
+
"yaml" => "",
|
|
33
|
+
"yml" => ""
|
|
34
|
+
}.freeze
|
|
35
|
+
PROJECT_BROWSER_DIRECTORY_ICON = ""
|
|
36
|
+
PROJECT_BROWSER_FILE_ICON = ""
|
|
15
37
|
|
|
16
38
|
def open_project_browser
|
|
17
39
|
@mutex.synchronize do
|
|
@@ -399,8 +421,25 @@ module Kward
|
|
|
399
421
|
else
|
|
400
422
|
" "
|
|
401
423
|
end
|
|
424
|
+
icon = project_browser_icon(row)
|
|
402
425
|
suffix = row[:directory] ? "/" : ""
|
|
403
|
-
"#{indent}#{marker}#{row[:name]}#{suffix}"
|
|
426
|
+
"#{indent}#{marker}#{icon}#{row[:name]}#{suffix}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def project_browser_icon(row)
|
|
430
|
+
return "" unless @project_browser_icon_theme == "nerd-font"
|
|
431
|
+
|
|
432
|
+
icon = if row[:directory]
|
|
433
|
+
PROJECT_BROWSER_DIRECTORY_ICON
|
|
434
|
+
else
|
|
435
|
+
project_browser_file_icon(row[:path])
|
|
436
|
+
end
|
|
437
|
+
"#{icon} "
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def project_browser_file_icon(path)
|
|
441
|
+
name = File.basename(path.to_s)
|
|
442
|
+
PROJECT_BROWSER_FILENAME_ICONS[name] || PROJECT_BROWSER_EXTENSION_ICONS[File.extname(name).delete_prefix(".").downcase] || PROJECT_BROWSER_FILE_ICON
|
|
404
443
|
end
|
|
405
444
|
|
|
406
445
|
def saved_project_browser_state
|
|
@@ -74,6 +74,10 @@ module Kward
|
|
|
74
74
|
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def normalize_project_browser_icon_theme(value)
|
|
78
|
+
value.to_s == "nerd-font" ? "nerd-font" : "off"
|
|
79
|
+
end
|
|
80
|
+
|
|
77
81
|
def normalize_tab_keybindings(value)
|
|
78
82
|
text = value.to_s.downcase
|
|
79
83
|
return "ctrl" if text == "ctrl"
|
|
@@ -40,10 +40,6 @@ module Kward
|
|
|
40
40
|
@transcript_buffer.append(text.to_s)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def invalidate_transcript_display_rows_cache
|
|
44
|
-
@transcript_buffer.invalidate_display_rows_cache
|
|
45
|
-
end
|
|
46
|
-
|
|
47
43
|
def ensure_transcript_block_separator_locked
|
|
48
44
|
return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
|
|
49
45
|
|
|
@@ -65,10 +61,6 @@ module Kward
|
|
|
65
61
|
@output_io.print(terminal_newlines(rows.join("\n")))
|
|
66
62
|
end
|
|
67
63
|
|
|
68
|
-
def transcript_viewport_text(row_count, width)
|
|
69
|
-
transcript_viewport_rows(row_count, width).join("\n")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
64
|
def transcript_viewport_rows(row_count, width)
|
|
73
65
|
return [] unless row_count.positive?
|
|
74
66
|
|
|
@@ -89,10 +81,6 @@ module Kward
|
|
|
89
81
|
@transcript_buffer.display_rows(width)
|
|
90
82
|
end
|
|
91
83
|
|
|
92
|
-
def transcript_text_display_rows(width)
|
|
93
|
-
@transcript_buffer.text_display_rows(width)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
84
|
def reset_stream_position_from_transcript_locked(width = screen_width)
|
|
97
85
|
@stream_state.reset_position_from_rows(transcript_display_rows(width), width)
|
|
98
86
|
end
|
|
@@ -126,7 +126,7 @@ module Kward
|
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
129
|
+
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
130
130
|
@input_io = input
|
|
131
131
|
@output_io = output
|
|
132
132
|
@reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
|
|
@@ -186,6 +186,7 @@ module Kward
|
|
|
186
186
|
@editor_bar_cursor_active = false
|
|
187
187
|
@synchronized_output_depth = 0
|
|
188
188
|
@overlay_settings = normalize_overlay_settings(overlay_settings)
|
|
189
|
+
@project_browser_icon_theme = normalize_project_browser_icon_theme(project_browser_icon_theme)
|
|
189
190
|
@footer = footer
|
|
190
191
|
@composer_status = composer_status
|
|
191
192
|
@busy_help = busy_help
|
|
@@ -211,6 +212,15 @@ module Kward
|
|
|
211
212
|
@diff_view_source = diff_view_source
|
|
212
213
|
end
|
|
213
214
|
|
|
215
|
+
def update_slash_commands(commands)
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
@slash_commands = normalize_slash_commands(commands)
|
|
218
|
+
reset_slash_selection
|
|
219
|
+
@slash_overlay_dismissed_input = nil
|
|
220
|
+
render_prompt_locked if @started
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
214
224
|
def start(render: true)
|
|
215
225
|
@mutex.synchronize do
|
|
216
226
|
return if @started
|
|
@@ -661,6 +671,13 @@ module Kward
|
|
|
661
671
|
end
|
|
662
672
|
end
|
|
663
673
|
|
|
674
|
+
def update_project_browser_icon_theme(theme)
|
|
675
|
+
@mutex.synchronize do
|
|
676
|
+
@project_browser_icon_theme = normalize_project_browser_icon_theme(theme)
|
|
677
|
+
render_prompt_locked if @started && @asking && project_browser_visible?
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
664
681
|
def begin_busy_input(message = "You>", activity: "streaming")
|
|
665
682
|
start
|
|
666
683
|
@mutex.synchronize do
|
|
@@ -19,13 +19,11 @@ module Kward
|
|
|
19
19
|
{ name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
|
|
20
20
|
{ name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
|
|
21
21
|
{ name: "redraw", description: "Refresh the visible terminal.", argument_hint: "" },
|
|
22
|
-
{ name: "settings", description: "Configure
|
|
22
|
+
{ name: "settings", description: "Configure Kward interactively.", argument_hint: "" },
|
|
23
23
|
{ name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
|
|
24
24
|
{ name: "model", description: "Select the default model.", argument_hint: "" },
|
|
25
25
|
{ name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
|
|
26
26
|
{ name: "reload", description: "Reload installed plugins.", argument_hint: "" },
|
|
27
|
-
{ name: "workers", description: "Open the worker pipeline.", argument_hint: "[new|do <task>]" },
|
|
28
|
-
{ name: "queue", description: "Manage the tab-backed worker queue.", argument_hint: "[add|list|open|run|suspend|resume]" },
|
|
29
27
|
{ name: "git", description: "Review uncommitted changes and commit them.", argument_hint: "" },
|
|
30
28
|
{ name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
|
|
31
29
|
{ name: "files", description: "Browse project files.", argument_hint: "" },
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -3,9 +3,25 @@ require_relative "config_files"
|
|
|
3
3
|
# Namespace for the Kward CLI agent runtime.
|
|
4
4
|
module Kward
|
|
5
5
|
# System prompt assembly from config, workspace instructions, memory, and plugins.
|
|
6
|
+
#
|
|
7
|
+
# Call {system_message} for the provider-ready message or {prompt_sections}
|
|
8
|
+
# when a frontend needs to inspect the labeled sources separately.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
6
11
|
module Prompts
|
|
7
12
|
module_function
|
|
8
13
|
|
|
14
|
+
# Builds the system message sent to a model provider.
|
|
15
|
+
#
|
|
16
|
+
# @param workspace_root [String] active workspace root
|
|
17
|
+
# @param include_workspace_personality [Boolean] include persona and plugin context
|
|
18
|
+
# @param model [String, nil] model used for persona matching
|
|
19
|
+
# @param reasoning_effort [String, nil] reasoning effort used for persona matching
|
|
20
|
+
# @param now [Time] time used for persona modifiers
|
|
21
|
+
# @param memory_context [String, nil] retrieved memory text
|
|
22
|
+
# @param plugin_context [String, nil] trusted plugin-provided instructions
|
|
23
|
+
# @return [Hash] role/content system message
|
|
24
|
+
# @api public
|
|
9
25
|
def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
10
26
|
{
|
|
11
27
|
role: "system",
|
|
@@ -17,6 +33,10 @@ module Kward
|
|
|
17
33
|
prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
|
|
18
34
|
end
|
|
19
35
|
|
|
36
|
+
# Returns labeled prompt sections for inspection by CLI and RPC frontends.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Hash>] section hashes with label, content, and optional source
|
|
39
|
+
# @api public
|
|
20
40
|
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
21
41
|
sections = [prompt_section("Built-in system prompt", base_prompt)]
|
|
22
42
|
sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -5,7 +5,6 @@ require_relative "../memory/manager"
|
|
|
5
5
|
require_relative "../plugin_registry"
|
|
6
6
|
require_relative "../prompts/commands"
|
|
7
7
|
require_relative "../tools/registry"
|
|
8
|
-
require_relative "../workers"
|
|
9
8
|
require_relative "../workspace"
|
|
10
9
|
require_relative "../telemetry/logger"
|
|
11
10
|
require_relative "../telemetry/stats"
|
|
@@ -33,6 +32,7 @@ module Kward
|
|
|
33
32
|
# `SessionManager`, `AuthManager`, and `ConfigManager`. When adding an RPC
|
|
34
33
|
# feature, update dispatch, capabilities, docs, and tests together so clients
|
|
35
34
|
# can trust `initialize` as the source of supported behavior.
|
|
35
|
+
# @api public
|
|
36
36
|
class Server
|
|
37
37
|
PROTOCOL_VERSION = 1
|
|
38
38
|
JSONRPC_VERSION = "2.0"
|
|
@@ -70,7 +70,6 @@ module Kward
|
|
|
70
70
|
"memory/forget", "memory/promote", "memory/relax", "memory/inspect",
|
|
71
71
|
"memory/why", "memory/summarize"
|
|
72
72
|
].freeze
|
|
73
|
-
WORKER_METHODS = ["workers/list", "workers/show"].freeze
|
|
74
73
|
COMMAND_METHODS = ["commands/list", "commands/run"].freeze
|
|
75
74
|
STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
|
|
76
75
|
CONFIG_METHODS = ["config/read", "config/update"].freeze
|
|
@@ -98,7 +97,6 @@ module Kward
|
|
|
98
97
|
runtime_settings: RUNTIME_SETTING_METHODS,
|
|
99
98
|
auth: AUTH_METHODS,
|
|
100
99
|
memory: MEMORY_METHODS,
|
|
101
|
-
workers: WORKER_METHODS,
|
|
102
100
|
commands: COMMAND_METHODS,
|
|
103
101
|
startup_resources: STARTUP_RESOURCE_METHODS,
|
|
104
102
|
config: CONFIG_METHODS,
|
|
@@ -110,15 +108,18 @@ module Kward
|
|
|
110
108
|
RPC_METHODS = METHOD_GROUPS.values.flatten.freeze
|
|
111
109
|
|
|
112
110
|
# Creates the RPC server and its stateful managers.
|
|
113
|
-
|
|
111
|
+
#
|
|
112
|
+
# @param input [IO] framed JSON-RPC input stream
|
|
113
|
+
# @param output [IO] framed JSON-RPC output stream
|
|
114
|
+
# @param error_output [IO, nil] redacted diagnostic stream
|
|
115
|
+
# @param client [Client] model-provider client shared by sessions
|
|
116
|
+
def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
|
|
114
117
|
@transport = Transport.new(input: input, output: output)
|
|
115
118
|
@error_output = error_output
|
|
116
119
|
@client = client
|
|
117
120
|
@config_manager = ConfigManager.new
|
|
118
121
|
@session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
|
|
119
122
|
@auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
|
|
120
|
-
@worker_store = Workers::Store.new
|
|
121
|
-
@experimental_workers = experimental_workers
|
|
122
123
|
@shutdown = false
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -162,6 +163,10 @@ module Kward
|
|
|
162
163
|
})
|
|
163
164
|
end
|
|
164
165
|
|
|
166
|
+
# Writes a redacted server diagnostic without exposing error payload data.
|
|
167
|
+
#
|
|
168
|
+
# @param error [Exception]
|
|
169
|
+
# @return [void]
|
|
165
170
|
def log_error(error)
|
|
166
171
|
@error_output.puts("Kward RPC error: #{Redactor.redact_string(error.message)}") if @error_output
|
|
167
172
|
end
|
|
@@ -277,12 +282,6 @@ module Kward
|
|
|
277
282
|
@session_manager.memory_why(session_id: params["sessionId"])
|
|
278
283
|
when MEMORY_METHODS[13]
|
|
279
284
|
@session_manager.memory_summarize(session_id: params.fetch("sessionId"))
|
|
280
|
-
when WORKER_METHODS[0]
|
|
281
|
-
require_experimental_workers!
|
|
282
|
-
workers_list(params)
|
|
283
|
-
when WORKER_METHODS[1]
|
|
284
|
-
require_experimental_workers!
|
|
285
|
-
workers_show(params)
|
|
286
285
|
when AUTH_METHODS[0]
|
|
287
286
|
@auth_manager.status
|
|
288
287
|
when AUTH_METHODS[1]
|
|
@@ -429,7 +428,7 @@ module Kward
|
|
|
429
428
|
reasoning: { start: false, delta: true, boundary: true, end: false },
|
|
430
429
|
modelRetry: { supported: true, event: "modelRetry" },
|
|
431
430
|
steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
|
|
432
|
-
tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
|
|
431
|
+
tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, firstChangedLine: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
|
|
433
432
|
errors: true,
|
|
434
433
|
sessionUpdates: false
|
|
435
434
|
},
|
|
@@ -484,8 +483,7 @@ module Kward
|
|
|
484
483
|
logout: true
|
|
485
484
|
},
|
|
486
485
|
memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
|
|
487
|
-
|
|
488
|
-
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: ["workers"] },
|
|
486
|
+
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
|
|
489
487
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
490
488
|
mcp: {
|
|
491
489
|
supported: true,
|
|
@@ -543,18 +541,6 @@ module Kward
|
|
|
543
541
|
}
|
|
544
542
|
end
|
|
545
543
|
|
|
546
|
-
def workers_capability
|
|
547
|
-
return { supported: false, reason: "experimentalWorkersFlagRequired", flag: "--experimental-workers" } unless @experimental_workers
|
|
548
|
-
|
|
549
|
-
{ supported: true, methods: WORKER_METHODS, roles: ["implementation", "request"], statuses: Workers::Worker::STATUSES, transcriptStorage: "sessions", metadataStorage: "json" }
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def require_experimental_workers!
|
|
553
|
-
return if @experimental_workers
|
|
554
|
-
|
|
555
|
-
raise NoMethodError, "workers require --experimental-workers"
|
|
556
|
-
end
|
|
557
|
-
|
|
558
544
|
def workspace_info(root)
|
|
559
545
|
root = @session_manager.validate_workspace_root(root)
|
|
560
546
|
{ root: root, basename: File.basename(root), writable: File.writable?(root) }
|
|
@@ -715,20 +701,6 @@ module Kward
|
|
|
715
701
|
{ sections: sections }
|
|
716
702
|
end
|
|
717
703
|
|
|
718
|
-
def workers_list(params)
|
|
719
|
-
include_archived = params["includeArchived"] == true
|
|
720
|
-
workers = @worker_store.list(include_archived: include_archived)
|
|
721
|
-
{ workers: workers }
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
def workers_show(params)
|
|
725
|
-
id = params.fetch("id").to_s.delete_prefix("#")
|
|
726
|
-
worker = @worker_store.find(id)
|
|
727
|
-
return { worker: worker } if worker
|
|
728
|
-
|
|
729
|
-
raise ArgumentError, "Unknown worker: #{id}"
|
|
730
|
-
end
|
|
731
|
-
|
|
732
704
|
def auth_login_with_api_key(params)
|
|
733
705
|
result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
|
|
734
706
|
@session_manager.refresh_client_config
|
|
@@ -62,10 +62,11 @@ module Kward
|
|
|
62
62
|
RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
|
|
63
63
|
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
64
64
|
FOOTER_REFRESH_INTERVAL = 1.0
|
|
65
|
+
WORKER_STOP_TIMEOUT = 2.0
|
|
65
66
|
WORKER_STOP = Object.new.freeze
|
|
66
67
|
|
|
67
68
|
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
68
|
-
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, keyword_init: true)
|
|
69
|
+
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, :mutex, keyword_init: true)
|
|
69
70
|
|
|
70
71
|
# Creates an object for RPC session lifecycle and turn coordination.
|
|
71
72
|
def initialize(
|
|
@@ -74,7 +75,8 @@ module Kward
|
|
|
74
75
|
config_dir: ConfigFiles.config_dir,
|
|
75
76
|
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
76
77
|
context_usage: ContextUsage.new,
|
|
77
|
-
session_trash: SessionTrash.new
|
|
78
|
+
session_trash: SessionTrash.new,
|
|
79
|
+
worker_stop_timeout: WORKER_STOP_TIMEOUT
|
|
78
80
|
)
|
|
79
81
|
@server = server
|
|
80
82
|
@client = client
|
|
@@ -83,6 +85,7 @@ module Kward
|
|
|
83
85
|
@context_usage = context_usage
|
|
84
86
|
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
85
87
|
@session_trash = session_trash
|
|
88
|
+
@worker_stop_timeout = worker_stop_timeout
|
|
86
89
|
@sessions = {}
|
|
87
90
|
@turns = {}
|
|
88
91
|
@mutex = Mutex.new
|
|
@@ -140,7 +143,11 @@ module Kward
|
|
|
140
143
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
141
144
|
requested_limit = limit.to_i if limit
|
|
142
145
|
requested_limit = nil unless requested_limit&.positive?
|
|
143
|
-
|
|
146
|
+
live_paths = @mutex.synchronize do
|
|
147
|
+
@sessions.values.filter_map { |rpc_session| rpc_session.session.path if rpc_session.workspace_root == root }
|
|
148
|
+
end
|
|
149
|
+
live_paths << current_session_path unless current_session_path.to_s.empty?
|
|
150
|
+
store.recent(limit: requested_limit, keep_empty_path: live_paths)
|
|
144
151
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
145
152
|
end
|
|
146
153
|
|
|
@@ -325,7 +332,7 @@ module Kward
|
|
|
325
332
|
# Stops all live RPC session workers during server shutdown.
|
|
326
333
|
def shutdown_sessions
|
|
327
334
|
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
328
|
-
rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session)
|
|
335
|
+
rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) }
|
|
329
336
|
{ closed: true }
|
|
330
337
|
end
|
|
331
338
|
|
|
@@ -368,7 +375,8 @@ module Kward
|
|
|
368
375
|
plugin_command_name: plugin_command&.name,
|
|
369
376
|
plugin_arguments: plugin_arguments,
|
|
370
377
|
options: normalized_options,
|
|
371
|
-
tool_registry: scoped_tool_registry(rpc_session, normalized_options)
|
|
378
|
+
tool_registry: scoped_tool_registry(rpc_session, normalized_options),
|
|
379
|
+
mutex: Mutex.new
|
|
372
380
|
)
|
|
373
381
|
@mutex.synchronize { @turns[turn.id] = turn }
|
|
374
382
|
rpc_session.queue << turn.id
|
|
@@ -379,12 +387,13 @@ module Kward
|
|
|
379
387
|
|
|
380
388
|
def cancel_turn(turn_id:)
|
|
381
389
|
turn = fetch_turn(turn_id)
|
|
382
|
-
turn.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if turn.status == "queued"
|
|
386
|
-
finish_turn(turn, "canceled")
|
|
390
|
+
queued, event = turn.mutex.synchronize do
|
|
391
|
+
turn.cancel_requested = true
|
|
392
|
+
[turn.status == "queued", append_turn_event_locked(turn, "turnCancelRequested", {})]
|
|
387
393
|
end
|
|
394
|
+
@server.notify("turn/event", event)
|
|
395
|
+
turn.cancellation&.cancel!
|
|
396
|
+
finish_turn(turn, "canceled") if queued
|
|
388
397
|
turn_payload(turn)
|
|
389
398
|
end
|
|
390
399
|
|
|
@@ -395,10 +404,12 @@ module Kward
|
|
|
395
404
|
def turn_events(turn_id:, after_sequence: 0)
|
|
396
405
|
turn = fetch_turn(turn_id)
|
|
397
406
|
after_sequence = after_sequence.to_i
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
407
|
+
turn.mutex.synchronize do
|
|
408
|
+
{
|
|
409
|
+
turn: turn_payload_unlocked(turn),
|
|
410
|
+
events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
|
|
411
|
+
}
|
|
412
|
+
end
|
|
402
413
|
end
|
|
403
414
|
|
|
404
415
|
def list_turns(session_id: nil, active: false)
|
|
@@ -1043,6 +1054,8 @@ module Kward
|
|
|
1043
1054
|
end
|
|
1044
1055
|
|
|
1045
1056
|
def close_rpc_session(rpc_session, delete_unused: true)
|
|
1057
|
+
turns = cancel_session_turns(rpc_session)
|
|
1058
|
+
turns.each { |turn| finish_turn(turn, "canceled") } if stop_worker(rpc_session, wait: true)
|
|
1046
1059
|
remove_live_session(rpc_session)
|
|
1047
1060
|
rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
|
|
1048
1061
|
end
|
|
@@ -1066,12 +1079,28 @@ module Kward
|
|
|
1066
1079
|
stop_footer_worker(rpc_session)
|
|
1067
1080
|
end
|
|
1068
1081
|
|
|
1069
|
-
def stop_worker(rpc_session)
|
|
1082
|
+
def stop_worker(rpc_session, wait: false)
|
|
1070
1083
|
worker = rpc_session.worker
|
|
1071
|
-
return unless worker&.alive?
|
|
1072
|
-
return if worker == Thread.current
|
|
1084
|
+
return false unless worker&.alive?
|
|
1085
|
+
return false if worker == Thread.current
|
|
1073
1086
|
|
|
1074
1087
|
rpc_session.queue << WORKER_STOP
|
|
1088
|
+
return false unless wait
|
|
1089
|
+
|
|
1090
|
+
worker.join(@worker_stop_timeout)
|
|
1091
|
+
return false unless worker.alive?
|
|
1092
|
+
|
|
1093
|
+
worker.kill
|
|
1094
|
+
worker.join
|
|
1095
|
+
true
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def cancel_session_turns(rpc_session)
|
|
1099
|
+
turns = @mutex.synchronize do
|
|
1100
|
+
@turns.values.select { |turn| turn.session_id == rpc_session.id && ["queued", "running"].include?(turn.status) }
|
|
1101
|
+
end
|
|
1102
|
+
turns.each { |turn| cancel_turn(turn_id: turn.id) }
|
|
1103
|
+
turns
|
|
1075
1104
|
end
|
|
1076
1105
|
|
|
1077
1106
|
def start_footer_worker(rpc_session)
|
|
@@ -1128,11 +1157,18 @@ module Kward
|
|
|
1128
1157
|
def run_turn(rpc_session, turn)
|
|
1129
1158
|
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1130
1159
|
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
1160
|
+
started, event = turn.mutex.synchronize do
|
|
1161
|
+
next [false, nil] if turn.status == "canceled"
|
|
1162
|
+
|
|
1163
|
+
turn.status = "running"
|
|
1164
|
+
turn.started_at = now
|
|
1165
|
+
[true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
|
|
1166
|
+
end
|
|
1167
|
+
return unless started
|
|
1168
|
+
|
|
1169
|
+
@server.notify("turn/event", event)
|
|
1131
1170
|
rpc_session.running_turn_id = turn.id
|
|
1132
1171
|
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1133
|
-
turn.status = "running"
|
|
1134
|
-
turn.started_at = now
|
|
1135
|
-
emit_turn_event(turn, "turnStarted", { status: "running" })
|
|
1136
1172
|
|
|
1137
1173
|
if turn.cancel_requested
|
|
1138
1174
|
finish_turn(turn, "canceled")
|
|
@@ -1292,11 +1328,16 @@ module Kward
|
|
|
1292
1328
|
end
|
|
1293
1329
|
|
|
1294
1330
|
def finish_turn(turn, status)
|
|
1295
|
-
|
|
1331
|
+
event = turn.mutex.synchronize do
|
|
1332
|
+
next nil if ["completed", "failed", "canceled"].include?(turn.status)
|
|
1333
|
+
|
|
1334
|
+
turn.status = status
|
|
1335
|
+
turn.finished_at = now
|
|
1336
|
+
append_turn_event_locked(turn, "turnFinished", { status: status, error: turn.error })
|
|
1337
|
+
end
|
|
1338
|
+
return unless event
|
|
1296
1339
|
|
|
1297
|
-
turn
|
|
1298
|
-
turn.finished_at = now
|
|
1299
|
-
emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
|
|
1340
|
+
@server.notify("turn/event", event)
|
|
1300
1341
|
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1301
1342
|
emit_footer_update(rpc_session) if rpc_session
|
|
1302
1343
|
end
|
|
@@ -1326,6 +1367,12 @@ module Kward
|
|
|
1326
1367
|
end
|
|
1327
1368
|
|
|
1328
1369
|
def emit_turn_event(turn, type, payload)
|
|
1370
|
+
event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
|
|
1371
|
+
@server.notify("turn/event", event)
|
|
1372
|
+
event
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
def append_turn_event_locked(turn, type, payload)
|
|
1329
1376
|
event = {
|
|
1330
1377
|
sequence: turn.next_sequence,
|
|
1331
1378
|
timestamp: now,
|
|
@@ -1337,7 +1384,6 @@ module Kward
|
|
|
1337
1384
|
turn.next_sequence += 1
|
|
1338
1385
|
turn.events << event
|
|
1339
1386
|
turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
|
|
1340
|
-
@server.notify("turn/event", event)
|
|
1341
1387
|
event
|
|
1342
1388
|
end
|
|
1343
1389
|
|
|
@@ -1351,6 +1397,10 @@ module Kward
|
|
|
1351
1397
|
end
|
|
1352
1398
|
|
|
1353
1399
|
def turn_payload(turn)
|
|
1400
|
+
turn.mutex.synchronize { turn_payload_unlocked(turn) }
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
def turn_payload_unlocked(turn)
|
|
1354
1404
|
{
|
|
1355
1405
|
id: turn.id,
|
|
1356
1406
|
sessionId: turn.session_id,
|
|
@@ -65,6 +65,8 @@ module Kward
|
|
|
65
65
|
if mutation_tool?
|
|
66
66
|
diff = ToolMetadata.extract_unified_diff(text)
|
|
67
67
|
result[:diff] = diff if diff
|
|
68
|
+
first_changed_line = ToolMetadata.first_changed_line(diff)
|
|
69
|
+
result[:firstChangedLine] = first_changed_line if first_changed_line
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
files = changed_files
|
|
@@ -75,6 +75,29 @@ module Kward
|
|
|
75
75
|
index ? text.to_s[index..] : nil
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Returns the first changed line in the post-edit file from a unified diff.
|
|
79
|
+
# A deletion-only hunk uses the next surviving new-file line as its anchor.
|
|
80
|
+
def first_changed_line(diff)
|
|
81
|
+
new_line = nil
|
|
82
|
+
|
|
83
|
+
diff.to_s.each_line do |line|
|
|
84
|
+
if (match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
|
|
85
|
+
new_line = match[1].to_i
|
|
86
|
+
elsif new_line
|
|
87
|
+
case line
|
|
88
|
+
when /^\+[^+]/
|
|
89
|
+
return new_line
|
|
90
|
+
when /^-[^-]/
|
|
91
|
+
return new_line
|
|
92
|
+
when /^ /
|
|
93
|
+
new_line += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
78
101
|
def changed_files_from_result(text, matching_call = nil)
|
|
79
102
|
path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
|
|
80
103
|
return [path] if path
|
|
@@ -246,6 +246,8 @@ module Kward
|
|
|
246
246
|
|
|
247
247
|
diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
|
|
248
248
|
details[:diff] = diff if diff
|
|
249
|
+
first_changed_line = details[:firstChangedLine] || details["firstChangedLine"] || ToolMetadata.first_changed_line(diff)
|
|
250
|
+
details[:firstChangedLine] = first_changed_line if first_changed_line
|
|
249
251
|
|
|
250
252
|
changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
|
|
251
253
|
details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
|
|
@@ -257,6 +259,8 @@ module Kward
|
|
|
257
259
|
allowed = {}
|
|
258
260
|
diff = ToolCall.value(details, :diff)
|
|
259
261
|
allowed[:diff] = diff if diff
|
|
262
|
+
first_changed_line = ToolCall.value(details, :firstChangedLine) || ToolCall.value(details, :first_changed_line)
|
|
263
|
+
allowed[:firstChangedLine] = first_changed_line if first_changed_line.is_a?(Integer) && first_changed_line.positive?
|
|
260
264
|
changed_files = ToolCall.value(details, :changedFiles) || ToolCall.value(details, :changed_files)
|
|
261
265
|
allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
|
|
262
266
|
allowed
|