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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +75 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/rpc.md +0 -22
  17. data/doc/security.md +180 -0
  18. data/doc/usage.md +15 -32
  19. data/lib/kward/ansi.rb +2 -2
  20. data/lib/kward/cancellation.rb +2 -2
  21. data/lib/kward/cli/commands.rb +1 -3
  22. data/lib/kward/cli/interactive_turn.rb +0 -17
  23. data/lib/kward/cli/plugins.rb +2 -7
  24. data/lib/kward/cli/prompt_interface.rb +1 -0
  25. data/lib/kward/cli/runtime_helpers.rb +0 -38
  26. data/lib/kward/cli/sessions.rb +14 -5
  27. data/lib/kward/cli/settings.rb +22 -0
  28. data/lib/kward/cli/slash_commands.rb +1 -561
  29. data/lib/kward/cli/tabs.rb +14 -2
  30. data/lib/kward/cli.rb +1 -17
  31. data/lib/kward/compactor.rb +3 -3
  32. data/lib/kward/config_files.rb +18 -0
  33. data/lib/kward/ekwsh.rb +0 -4
  34. data/lib/kward/hooks/catalog.rb +0 -5
  35. data/lib/kward/image_attachments.rb +1 -1
  36. data/lib/kward/pan/index.html.erb +459 -64
  37. data/lib/kward/pan/kward_logo.png +0 -0
  38. data/lib/kward/pan/server.rb +248 -23
  39. data/lib/kward/plugin_registry.rb +18 -0
  40. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  41. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  42. data/lib/kward/prompt_interface/layout.rb +0 -4
  43. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  44. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  46. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  47. data/lib/kward/prompt_interface.rb +18 -1
  48. data/lib/kward/prompts/commands.rb +1 -3
  49. data/lib/kward/prompts.rb +20 -0
  50. data/lib/kward/rpc/server.rb +13 -41
  51. data/lib/kward/rpc/session_manager.rb +75 -25
  52. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  53. data/lib/kward/rpc/tool_metadata.rb +23 -0
  54. data/lib/kward/rpc/transcript_normalizer.rb +4 -0
  55. data/lib/kward/session_store.rb +55 -16
  56. data/lib/kward/skills/registry.rb +21 -1
  57. data/lib/kward/tools/base.rb +14 -0
  58. data/lib/kward/tools/registry.rb +11 -16
  59. data/lib/kward/tools/search/code.rb +1 -1
  60. data/lib/kward/tools/search/web.rb +1 -1
  61. data/lib/kward/tools/tool_call.rb +0 -4
  62. data/lib/kward/transcript_export.rb +1 -1
  63. data/lib/kward/version.rb +1 -1
  64. data/templates/default/kward_navigation.rb +5 -1
  65. data/templates/default/layout/html/layout.erb +0 -2
  66. metadata +5 -12
  67. data/lib/kward/workers/git_guard.rb +0 -93
  68. data/lib/kward/workers/job.rb +0 -99
  69. data/lib/kward/workers/live_view.rb +0 -49
  70. data/lib/kward/workers/manager.rb +0 -328
  71. data/lib/kward/workers/queue_runner.rb +0 -166
  72. data/lib/kward/workers/queue_store.rb +0 -112
  73. data/lib/kward/workers/store.rb +0 -72
  74. data/lib/kward/workers/tool_policy.rb +0 -23
  75. data/lib/kward/workers/worker.rb +0 -82
  76. data/lib/kward/workers/write_lock.rb +0 -38
  77. 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"
@@ -68,7 +68,6 @@ module Kward
68
68
  return false unless sequence
69
69
 
70
70
  code = sequence[:code]
71
- modifiers = sequence[:modifiers]
72
71
  modifier = sequence[:modifier]
73
72
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
74
73
 
@@ -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 prompt overlays.", argument_hint: "" },
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)
@@ -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
- def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new, experimental_workers: false)
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
- workers: workers_capability,
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
- store.recent(limit: requested_limit, keep_empty_path: current_session_path)
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) if session_idle?(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.cancel_requested = true
383
- turn.cancellation&.cancel!
384
- emit_turn_event(turn, "turnCancelRequested", {})
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
- turn: turn_payload(turn),
400
- events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
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
- return if ["completed", "failed", "canceled"].include?(turn.status)
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.status = status
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