kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -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)
@@ -957,6 +968,7 @@ module Kward
957
968
  prompt: prompt,
958
969
  allowed_tool_names: allowed_tool_names,
959
970
  tool_approval: tool_approval,
971
+ approval_for_allowed_tools: !tool_approval.nil?,
960
972
  hook_manager: hook_manager,
961
973
  hook_context: hook_context
962
974
  )
@@ -1043,6 +1055,8 @@ module Kward
1043
1055
  end
1044
1056
 
1045
1057
  def close_rpc_session(rpc_session, delete_unused: true)
1058
+ turns = cancel_session_turns(rpc_session)
1059
+ turns.each { |turn| finish_turn(turn, "canceled") } if stop_worker(rpc_session, wait: true)
1046
1060
  remove_live_session(rpc_session)
1047
1061
  rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
1048
1062
  end
@@ -1066,12 +1080,28 @@ module Kward
1066
1080
  stop_footer_worker(rpc_session)
1067
1081
  end
1068
1082
 
1069
- def stop_worker(rpc_session)
1083
+ def stop_worker(rpc_session, wait: false)
1070
1084
  worker = rpc_session.worker
1071
- return unless worker&.alive?
1072
- return if worker == Thread.current
1085
+ return false unless worker&.alive?
1086
+ return false if worker == Thread.current
1073
1087
 
1074
1088
  rpc_session.queue << WORKER_STOP
1089
+ return false unless wait
1090
+
1091
+ worker.join(@worker_stop_timeout)
1092
+ return false unless worker.alive?
1093
+
1094
+ worker.kill
1095
+ worker.join
1096
+ true
1097
+ end
1098
+
1099
+ def cancel_session_turns(rpc_session)
1100
+ turns = @mutex.synchronize do
1101
+ @turns.values.select { |turn| turn.session_id == rpc_session.id && ["queued", "running"].include?(turn.status) }
1102
+ end
1103
+ turns.each { |turn| cancel_turn(turn_id: turn.id) }
1104
+ turns
1075
1105
  end
1076
1106
 
1077
1107
  def start_footer_worker(rpc_session)
@@ -1128,11 +1158,18 @@ module Kward
1128
1158
  def run_turn(rpc_session, turn)
1129
1159
  previous_turn_id = Thread.current[:kward_rpc_turn_id]
1130
1160
  Thread.current[:kward_rpc_turn_id] = turn.id
1161
+ started, event = turn.mutex.synchronize do
1162
+ next [false, nil] if turn.status == "canceled"
1163
+
1164
+ turn.status = "running"
1165
+ turn.started_at = now
1166
+ [true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
1167
+ end
1168
+ return unless started
1169
+
1170
+ @server.notify("turn/event", event)
1131
1171
  rpc_session.running_turn_id = turn.id
1132
1172
  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
1173
 
1137
1174
  if turn.cancel_requested
1138
1175
  finish_turn(turn, "canceled")
@@ -1292,11 +1329,16 @@ module Kward
1292
1329
  end
1293
1330
 
1294
1331
  def finish_turn(turn, status)
1295
- return if ["completed", "failed", "canceled"].include?(turn.status)
1332
+ event = turn.mutex.synchronize do
1333
+ next nil if ["completed", "failed", "canceled"].include?(turn.status)
1334
+
1335
+ turn.status = status
1336
+ turn.finished_at = now
1337
+ append_turn_event_locked(turn, "turnFinished", { status: status, error: turn.error })
1338
+ end
1339
+ return unless event
1296
1340
 
1297
- turn.status = status
1298
- turn.finished_at = now
1299
- emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
1341
+ @server.notify("turn/event", event)
1300
1342
  rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
1301
1343
  emit_footer_update(rpc_session) if rpc_session
1302
1344
  end
@@ -1326,6 +1368,12 @@ module Kward
1326
1368
  end
1327
1369
 
1328
1370
  def emit_turn_event(turn, type, payload)
1371
+ event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
1372
+ @server.notify("turn/event", event)
1373
+ event
1374
+ end
1375
+
1376
+ def append_turn_event_locked(turn, type, payload)
1329
1377
  event = {
1330
1378
  sequence: turn.next_sequence,
1331
1379
  timestamp: now,
@@ -1337,7 +1385,6 @@ module Kward
1337
1385
  turn.next_sequence += 1
1338
1386
  turn.events << event
1339
1387
  turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
1340
- @server.notify("turn/event", event)
1341
1388
  event
1342
1389
  end
1343
1390
 
@@ -1351,6 +1398,10 @@ module Kward
1351
1398
  end
1352
1399
 
1353
1400
  def turn_payload(turn)
1401
+ turn.mutex.synchronize { turn_payload_unlocked(turn) }
1402
+ end
1403
+
1404
+ def turn_payload_unlocked(turn)
1354
1405
  {
1355
1406
  id: turn.id,
1356
1407
  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
@@ -54,10 +54,13 @@ module Kward
54
54
  end
55
55
 
56
56
  def normalize_user_message(message)
57
- {
58
- role: "user",
59
- content: normalize_content(MessageAccess.content(message))
60
- }
57
+ with_timestamp(
58
+ {
59
+ role: "user",
60
+ content: normalize_content(MessageAccess.content(message))
61
+ },
62
+ message
63
+ )
61
64
  end
62
65
 
63
66
  def normalize_assistant_message(message)
@@ -76,7 +79,7 @@ module Kward
76
79
  result = { role: "assistant", content: content }
77
80
  error_message = ToolCall.value(message, :errorMessage) || ToolCall.value(message, :error_message)
78
81
  result[:errorMessage] = error_message unless error_message.to_s.empty?
79
- result
82
+ with_timestamp(result, message)
80
83
  end
81
84
 
82
85
  def normalize_tool_result_message(message)
@@ -96,6 +99,12 @@ module Kward
96
99
 
97
100
  details = tool_result_details(message, matching_call, content)
98
101
  result[:details] = details unless details.empty?
102
+ with_timestamp(result, message)
103
+ end
104
+
105
+ def with_timestamp(result, message)
106
+ timestamp = ToolCall.value(message, :timestamp)
107
+ result[:timestamp] = timestamp if timestamp
99
108
  result
100
109
  end
101
110
 
@@ -246,6 +255,8 @@ module Kward
246
255
 
247
256
  diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
248
257
  details[:diff] = diff if diff
258
+ first_changed_line = details[:firstChangedLine] || details["firstChangedLine"] || ToolMetadata.first_changed_line(diff)
259
+ details[:firstChangedLine] = first_changed_line if first_changed_line
249
260
 
250
261
  changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
251
262
  details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
@@ -257,6 +268,8 @@ module Kward
257
268
  allowed = {}
258
269
  diff = ToolCall.value(details, :diff)
259
270
  allowed[:diff] = diff if diff
271
+ first_changed_line = ToolCall.value(details, :firstChangedLine) || ToolCall.value(details, :first_changed_line)
272
+ allowed[:firstChangedLine] = first_changed_line if first_changed_line.is_a?(Integer) && first_changed_line.positive?
260
273
  changed_files = ToolCall.value(details, :changedFiles) || ToolCall.value(details, :changed_files)
261
274
  allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
262
275
  allowed
@@ -309,11 +309,10 @@ module Kward
309
309
  # Lists recent non-empty sessions for this workspace.
310
310
  #
311
311
  # @param limit [Integer, nil] maximum number of sessions, or nil for all
312
- # @param keep_empty_path [String, nil] empty session path to keep visible
312
+ # @param keep_empty_path [String, Array<String>, nil] live empty session path(s) to preserve while omitting them from results
313
313
  # @return [Array<SessionInfo>] newest sessions first
314
314
  def recent(limit: 20, keep_empty_path: nil)
315
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
316
- limit ? sessions.first(limit) : sessions
315
+ recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
317
316
  end
318
317
 
319
318
  # Persists the last active session pointer for workspace auto-resume.
@@ -344,9 +343,7 @@ module Kward
344
343
  #
345
344
  # @return [Array<SessionInfo>] recent sessions with tree depth fields
346
345
  def recent_tree(limit: 20, keep_empty_path: nil)
347
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
348
- sessions = sessions.first(limit) if limit
349
- decorate_tree(sessions)
346
+ decorate_tree(recent_sessions(limit: limit, keep_empty_path: keep_empty_path))
350
347
  end
351
348
 
352
349
  # Deletes an empty unnamed session file.
@@ -770,20 +767,30 @@ module Kward
770
767
  nil
771
768
  end
772
769
 
773
- def recent_sessions(keep_empty_path: nil)
774
- keep_empty_path = File.expand_path(keep_empty_path) unless keep_empty_path.to_s.empty?
775
- Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
776
- info = session_info(path)
777
- next unless info
778
- next if delete_empty_unnamed_session_info(info, keep_empty_path: keep_empty_path)
770
+ def recent_sessions(limit: nil, keep_empty_path: nil)
771
+ keep_empty_paths = Array(keep_empty_path).filter_map do |path|
772
+ File.expand_path(path) unless path.to_s.empty?
773
+ end
774
+ paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_mtime(path) }.reverse
775
+ sessions = []
776
+
777
+ paths.each do |path|
778
+ if limit.nil? || sessions.length < limit
779
+ info = session_info(path)
780
+ next unless info
781
+ next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
779
782
 
780
- info
781
- end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
783
+ sessions << info
784
+ else
785
+ delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
786
+ end
787
+ end
788
+ sessions
782
789
  end
783
790
 
784
- def delete_empty_unnamed_session_info(info, keep_empty_path: nil)
791
+ def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
785
792
  return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
786
- return true if keep_empty_path && File.expand_path(info.path) == keep_empty_path
793
+ return true if keep_empty_paths.include?(File.expand_path(info.path))
787
794
 
788
795
  File.delete(info.path)
789
796
  true
@@ -791,6 +798,38 @@ module Kward
791
798
  false
792
799
  end
793
800
 
801
+ def session_file_mtime(path)
802
+ File.mtime(path)
803
+ rescue StandardError
804
+ Time.at(0)
805
+ end
806
+
807
+ def delete_empty_unnamed_session_path(path, keep_empty_paths: [])
808
+ return false unless empty_unnamed_session_path?(path)
809
+ return true if keep_empty_paths.include?(File.expand_path(path))
810
+
811
+ File.delete(path)
812
+ true
813
+ rescue StandardError
814
+ false
815
+ end
816
+
817
+ def empty_unnamed_session_path?(path)
818
+ header = false
819
+ name = nil
820
+ File.foreach(path) do |line|
821
+ record = JSON.parse(line)
822
+ header = true if record["type"] == "session" && !record["id"].to_s.empty?
823
+ name = record["name"] if record["type"] == "session_info" && record.key?("name")
824
+ next unless record["type"] == "message"
825
+
826
+ return false if ["user", "assistant", "tool"].include?(message_role(record["message"] || {}))
827
+ end
828
+ header && name.to_s.strip.empty?
829
+ rescue JSON::ParserError
830
+ false
831
+ end
832
+
794
833
  def decorate_tree(sessions)
795
834
  by_parent = Hash.new { |hash, key| hash[key] = [] }
796
835
  ids = sessions.map(&:id).to_h { |id| [id, true] }
@@ -4,7 +4,13 @@ require "pathname"
4
4
  module Kward
5
5
  # Skill discovery and metadata parsing from configured skill folders.
6
6
  module Skills
7
- # Parsed skill metadata and instruction path.
7
+ # Discovers Agent Skills and reads their bounded instruction resources.
8
+ #
9
+ # Project sources are included only when the caller marks them trusted. If
10
+ # duplicate skill names exist, the first source in documented precedence
11
+ # order wins.
12
+ #
13
+ # @api public
8
14
  class Registry
9
15
  SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
10
16
 
@@ -18,6 +24,10 @@ module Kward
18
24
  @inside_directory = inside_directory
19
25
  end
20
26
 
27
+ # Returns discovered, validated skills in precedence order.
28
+ #
29
+ # @return [Array<ConfigFiles::Skill>]
30
+ # @api public
21
31
  def skills
22
32
  seen = {}
23
33
  skill_sources.flat_map do |source|
@@ -39,6 +49,16 @@ module Kward
39
49
  []
40
50
  end
41
51
 
52
+ # Reads a skill's main instructions or a bounded relative resource.
53
+ #
54
+ # Absolute paths, paths escaping the skill folder, missing files, and files
55
+ # above the configured size limit return user-facing error strings.
56
+ #
57
+ # @param name [String, #to_s] discovered skill name
58
+ # @param relative_path [String, nil] file relative to the skill directory;
59
+ # defaults to `SKILL.md`
60
+ # @return [String] skill content or an error message
61
+ # @api public
42
62
  def read_skill_file(name, relative_path = nil)
43
63
  skill = skills.find { |candidate| candidate.name == name.to_s }
44
64
  return "Error: unknown skill: #{name}" unless skill
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require "uri"
7
7
  require "zlib"
8
8
  require_relative "config_files"
9
+ require_relative "http"
9
10
 
10
11
  # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
@@ -56,8 +57,9 @@ module Kward
56
57
 
57
58
  def download(url)
58
59
  uri = URI(url)
60
+ request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
59
61
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
60
- http.get(uri.request_uri)
62
+ http.request(request)
61
63
  end
62
64
  raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
63
65
 
@@ -0,0 +1,87 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Adapts a session-backed agent to the tab runtime interface. Plugin tab
4
+ # drivers implement the same small surface without becoming Kward sessions.
5
+ class SessionTabDriver
6
+ attr_reader :session, :agent
7
+
8
+ def initialize(session:, agent:)
9
+ @session = session
10
+ @agent = agent
11
+ end
12
+
13
+ def messages
14
+ agent.conversation.messages
15
+ end
16
+
17
+ def conversation
18
+ agent.conversation
19
+ end
20
+
21
+ def submit(input, display_input:, cancellation:, steering: nil, &block)
22
+ options = { cancellation: cancellation }
23
+ options[:display_input] = display_input unless display_input.nil?
24
+ options[:steering] = steering if steering
25
+ agent.ask(input, **options, &block)
26
+ end
27
+
28
+ def descriptor
29
+ { "kind" => "session", "session_path" => session.path }
30
+ end
31
+
32
+ def session?
33
+ true
34
+ end
35
+
36
+ def supports_steering?
37
+ true
38
+ end
39
+
40
+ def assistant_label
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Represents a persisted plugin tab whose provider plugin is unavailable.
46
+ # Its descriptor is retained so reinstalling the plugin restores the tab.
47
+ class UnavailableTabDriver
48
+ attr_reader :descriptor
49
+
50
+ def initialize(descriptor:, message:)
51
+ @descriptor = descriptor
52
+ @message = message
53
+ end
54
+
55
+ def messages
56
+ [{ role: "assistant", content: @message }]
57
+ end
58
+
59
+ def submit(*)
60
+ raise @message
61
+ end
62
+
63
+ def session?
64
+ false
65
+ end
66
+
67
+ def supports_steering?
68
+ false
69
+ end
70
+
71
+ def assistant_label
72
+ "Plugin"
73
+ end
74
+ end
75
+
76
+ # Dependencies made available to a plugin tab factory. The host deliberately
77
+ # exposes provider transport and frontend-neutral facts, not CLI internals or
78
+ # workspace session state.
79
+ class PluginTabHost
80
+ attr_reader :client, :workspace_root
81
+
82
+ def initialize(client:, workspace_root:)
83
+ @client = client
84
+ @workspace_root = workspace_root
85
+ end
86
+ end
87
+ end
@@ -5,32 +5,45 @@ require_relative "private_file"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
8
- # Persists the terminal UI's open session tabs per workspace.
8
+ # Persists the terminal UI's open tabs per workspace.
9
9
  class TabStore
10
+ VERSION = 2
11
+
10
12
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
11
13
  @config_dir = config_dir
12
14
  @cwd = File.expand_path(cwd)
13
15
  end
14
16
 
15
17
  def load
16
- return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
18
+ return empty_state unless File.file?(path)
17
19
 
18
20
  data = JSON.parse(File.read(path))
19
- paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
20
- labels = Array(data["labels"]).map(&:to_s)
21
- active_index = data["active_index"].to_i
22
- { "session_paths" => paths, "labels" => labels, "active_index" => active_index }
21
+ return typed_state(data) if data["tabs"].is_a?(Array)
22
+
23
+ legacy_state(data)
23
24
  rescue JSON::ParserError
24
- { "session_paths" => [], "active_index" => 0 }
25
+ empty_state
25
26
  end
26
27
 
27
- def save(session_paths:, active_index:, labels: [])
28
- paths = Array(session_paths).map(&:to_s).reject(&:empty?)
28
+ # `session_paths` remains supported for callers and layouts written before
29
+ # typed tab descriptors were introduced.
30
+ def save(session_paths: nil, tabs: nil, active_index:, labels: [])
31
+ tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
32
+ path = session_path.to_s
33
+ next if path.empty?
34
+
35
+ {
36
+ "kind" => "session",
37
+ "session_path" => path,
38
+ "label" => Array(labels)[index].to_s
39
+ }
40
+ end
41
+ tabs = normalize_tabs(tabs)
29
42
  PrivateFile.write_json(path, {
43
+ "version" => VERSION,
30
44
  "cwd" => @cwd,
31
- "session_paths" => paths,
32
- "labels" => Array(labels).map(&:to_s),
33
- "active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
45
+ "tabs" => tabs,
46
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
34
47
  })
35
48
  end
36
49
 
@@ -40,6 +53,55 @@ module Kward
40
53
 
41
54
  private
42
55
 
56
+ def empty_state
57
+ { "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
58
+ end
59
+
60
+ def typed_state(data)
61
+ tabs = normalize_tabs(data["tabs"])
62
+ state_for(tabs, data["active_index"])
63
+ end
64
+
65
+ def legacy_state(data)
66
+ paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
67
+ labels = Array(data["labels"]).map(&:to_s)
68
+ tabs = paths.each_with_index.map do |session_path, index|
69
+ { "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
70
+ end
71
+ state_for(tabs, data["active_index"])
72
+ end
73
+
74
+ def state_for(tabs, active_index)
75
+ {
76
+ "tabs" => tabs,
77
+ "session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
78
+ "labels" => tabs.map { |tab| tab["label"].to_s },
79
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
80
+ }
81
+ end
82
+
83
+ def normalize_tabs(tabs)
84
+ session_paths = {}
85
+
86
+ Array(tabs).filter_map do |tab|
87
+ next unless tab.is_a?(Hash)
88
+
89
+ normalized = tab.transform_keys(&:to_s)
90
+ kind = normalized["kind"].to_s
91
+ next if kind.empty?
92
+ next if kind == "session" && normalized["session_path"].to_s.empty?
93
+
94
+ if kind == "session"
95
+ session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
96
+ next if session_paths[session_path]
97
+
98
+ session_paths[session_path] = true
99
+ end
100
+
101
+ normalized
102
+ end
103
+ end
104
+
43
105
  def workspace_key
44
106
  Digest::SHA256.hexdigest(@cwd)[0, 24]
45
107
  end