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
@@ -1,6 +1,7 @@
1
1
  require "nokogiri"
2
2
  require "uri"
3
3
  require_relative "web"
4
+ require_relative "../../http"
4
5
 
5
6
  # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
@@ -9,7 +10,9 @@ module Kward
9
10
  DEFAULT_MAX_BYTES = 16 * 1024
10
11
  MAX_MAX_BYTES = 128 * 1024
11
12
  MAX_REDIRECTS = 5
13
+ MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024
12
14
  HTTP_TIMEOUT_SECONDS = 10
15
+ MAX_DISCOVERED_LINKS = 50
13
16
 
14
17
  # Creates a fetcher for web content and raw resources.
15
18
  def initialize(http_client: WebSearch::NetHttpClient.new)
@@ -17,7 +20,9 @@ module Kward
17
20
  end
18
21
 
19
22
  # Fetches a URL and extracts readable text for human-facing pages.
20
- def fetch_content(args)
23
+ def fetch_content(args = nil, cancellation: nil, **keyword_args)
24
+ args ||= keyword_args
25
+ cancellation&.raise_if_cancelled!
21
26
  url = args_value(args, "url").to_s.strip
22
27
  return "Error: url is required" if url.empty?
23
28
 
@@ -25,58 +30,70 @@ module Kward
25
30
  extract = normalize_extract(args_value(args, "extract") || "auto")
26
31
  return "Error: extract must be one of: auto, text, markdown" unless extract
27
32
 
28
- response = fetch_url(url, max_bytes: max_bytes)
33
+ response = fetch_url(url, max_bytes: MAX_DOWNLOAD_BYTES, cancellation: cancellation)
29
34
  return response if response.is_a?(String)
35
+ return "Error: response exceeds #{MAX_DOWNLOAD_BYTES} byte download limit" if response[:truncated]
30
36
 
31
37
  body = response[:body].to_s
32
38
  content_type = header_value(response[:headers], "content-type")
33
- text = extract_readable_text(body, content_type: content_type, mode: extract)
34
- text = truncate_bytes(text, max_bytes)
39
+ text = extract_readable_text(body, content_type: content_type, mode: extract, base_url: response[:url])
40
+ text, truncated = truncate_bytes(text, max_bytes)
35
41
 
36
42
  [
37
43
  "# Fetched content",
38
44
  "- URL: #{response[:url]}",
39
45
  "- Content type: #{content_type.empty? ? "unknown" : content_type}",
46
+ "- Downloaded bytes: #{body.bytesize}",
40
47
  "- Bytes returned: #{text.bytesize}",
48
+ "- Truncated: #{truncated ? "yes" : "no"}",
41
49
  "",
42
50
  text.empty? ? "(No readable text extracted.)" : text
43
51
  ].join("\n")
52
+ rescue Cancellation::CancelledError
53
+ raise
44
54
  rescue StandardError => e
45
55
  "Error: fetch_content failed: #{e.message}"
46
56
  end
47
57
 
48
58
  # Fetches a URL and returns bounded raw response content.
49
- def fetch_raw(args)
59
+ def fetch_raw(args = nil, cancellation: nil, **keyword_args)
60
+ args ||= keyword_args
61
+ cancellation&.raise_if_cancelled!
50
62
  url = args_value(args, "url").to_s.strip
51
63
  return "Error: url is required" if url.empty?
52
64
 
53
65
  max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
54
66
  accept = args_value(args, "accept").to_s.strip
55
- response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept)
67
+ response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept, cancellation: cancellation)
56
68
  return response if response.is_a?(String)
57
69
 
58
- body = truncate_bytes(response[:body].to_s, max_bytes)
70
+ body = response[:body].to_s
59
71
  content_type = header_value(response[:headers], "content-type")
72
+ body = "#{body.to_s.scrub}\n... truncated to #{max_bytes} bytes" if response[:truncated]
60
73
  [
61
74
  "# Fetched raw content",
62
75
  "- URL: #{response[:url]}",
63
76
  "- Content type: #{content_type.empty? ? "unknown" : content_type}",
64
- "- Bytes returned: #{body.bytesize}",
77
+ "- Bytes returned: #{response[:body].to_s.bytesize}",
78
+ "- Truncated: #{response[:truncated] ? "yes" : "no"}",
65
79
  "",
66
80
  body
67
81
  ].join("\n")
82
+ rescue Cancellation::CancelledError
83
+ raise
68
84
  rescue StandardError => e
69
85
  "Error: fetch_raw failed: #{e.message}"
70
86
  end
71
87
 
72
88
  private
73
89
 
74
- def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
90
+ def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8", cancellation: nil)
75
91
  current_url = normalize_url(url)
76
92
  redirects = 0
77
93
 
78
94
  loop do
79
- response = @http_client.get(current_url, headers: browser_headers(accept))
95
+ cancellation&.raise_if_cancelled!
96
+ response = @http_client.get(current_url, headers: browser_headers(accept), max_bytes: max_bytes)
80
97
  code = response.code.to_i
81
98
  headers = response_headers(response)
82
99
 
@@ -93,9 +110,7 @@ module Kward
93
110
 
94
111
  return "Error: fetch failed with HTTP #{response.code}" unless code.between?(200, 299)
95
112
 
96
- body = response.body.to_s
97
- body = truncate_bytes(body, max_bytes)
98
- return { url: current_url, headers: headers, body: body }
113
+ return { url: current_url, headers: headers, body: response.body.to_s, truncated: response.respond_to?(:truncated) && response.truncated }
99
114
  end
100
115
  end
101
116
 
@@ -134,42 +149,121 @@ module Kward
134
149
  %w[auto text markdown].include?(normalized) ? normalized : nil
135
150
  end
136
151
 
137
- def extract_readable_text(body, content_type:, mode:)
138
- return clean_text(body) if mode == "text" || !html_content?(content_type, body)
152
+ def extract_readable_text(body, content_type:, mode:, base_url:)
153
+ return clean_text(body) unless html_content?(content_type, body)
139
154
 
140
155
  document = Nokogiri::HTML(body)
141
- document.css("script, style, noscript, svg, nav, footer, form").remove
142
- title = document.at_css("title")&.text.to_s.strip
143
- root = document.at_css("article") || document.at_css("main") || document.at_css("body") || document
156
+ links = discovered_links(document, base_url)
157
+ document.css("script, style, noscript, template, svg, canvas, nav, footer, #footer, [role='contentinfo'], form, iframe, [hidden], [aria-hidden='true']").remove
158
+ title = clean_text(document.at_css("title")&.text)
159
+ root = document.at_css("main") || document.at_css("[role='main']") || document.at_css("article") || document.at_css("body") || document
144
160
  parts = []
145
- parts << "# #{clean_text(title)}" unless title.empty?
146
- root.css("h1, h2, h3, h4, h5, h6, p, li, pre, code, blockquote").each do |node|
147
- text = clean_text(node.text)
148
- next if text.empty?
149
-
150
- parts << format_html_node(node, text, mode: mode)
151
- end
152
- parts.uniq.join("\n\n")
161
+ parts << (mode == "text" ? title : "# #{title}") unless title.empty?
162
+ parts.concat(readable_blocks(root, mode: mode, base_url: base_url))
163
+ parts << format_discovered_links(links, mode: mode) unless links.empty?
164
+ parts.reject(&:empty?).uniq.join("\n\n")
153
165
  end
154
166
 
155
167
  def html_content?(content_type, body)
156
168
  content_type.to_s.include?("html") || body.to_s.lstrip.start_with?("<!DOCTYPE html", "<html", "<HTML")
157
169
  end
158
170
 
159
- def format_html_node(node, text, mode:)
160
- return text if mode == "text"
171
+ def readable_blocks(root, mode:, base_url:)
172
+ selector = "h1, h2, h3, h4, h5, h6, p, ul, ol, pre, blockquote, table, a[href]"
173
+ root.css(selector).filter_map do |node|
174
+ next if nested_readable_block?(node)
175
+
176
+ format_html_node(node, mode: mode, base_url: base_url)
177
+ end
178
+ end
179
+
180
+ def nested_readable_block?(node)
181
+ block_names = %w[p ul ol pre blockquote table]
182
+ return true if node.ancestors.any? { |ancestor| block_names.include?(ancestor.name) }
183
+ return false unless node.name == "a"
184
+
185
+ node.ancestors.any? { |ancestor| ancestor.name.match?(/\Ah[1-6]\z/) }
186
+ end
161
187
 
188
+ def format_html_node(node, mode:, base_url:)
162
189
  case node.name
163
190
  when /^h([1-6])$/
164
- "#{"#" * Regexp.last_match(1).to_i} #{text}"
165
- when "li"
166
- "- #{text}"
167
- when "pre", "code"
168
- "```\n#{text}\n```"
191
+ level = Regexp.last_match(1).to_i
192
+ text = render_inline(node, mode: mode, base_url: base_url)
193
+ return "" if text.empty?
194
+
195
+ mode == "text" ? text : "#{"#" * level} #{text}"
196
+ when "p"
197
+ render_inline(node, mode: mode, base_url: base_url)
198
+ when "a"
199
+ format_link(node, mode: mode, base_url: base_url)
200
+ when "ul", "ol"
201
+ format_list(node, mode: mode, base_url: base_url)
202
+ when "pre"
203
+ text = node.text.to_s.strip
204
+ mode == "text" ? text : "```\n#{text}\n```"
169
205
  when "blockquote"
170
- "> #{text}"
206
+ text = render_inline(node, mode: mode, base_url: base_url)
207
+ mode == "text" ? text : text.lines.map { |line| "> #{line}" }.join
208
+ when "table"
209
+ format_table(node, mode: mode, base_url: base_url)
210
+ end
211
+ end
212
+
213
+ def render_inline(node, mode:, base_url:)
214
+ text = node.children.map do |child|
215
+ if child.text?
216
+ child.text
217
+ elsif child.name == "a"
218
+ format_link(child, mode: mode, base_url: base_url)
219
+ elsif child.name == "code" && mode != "text"
220
+ "`#{clean_text(child.text)}`"
221
+ elsif child.name == "br"
222
+ "\n"
223
+ else
224
+ render_inline(child, mode: mode, base_url: base_url)
225
+ end
226
+ end.join
227
+ clean_text(text)
228
+ end
229
+
230
+ def format_link(node, mode:, base_url:)
231
+ label = clean_text(node.text)
232
+ href = resolved_link(node["href"], base_url)
233
+ return label unless href
234
+
235
+ label = href if label.empty?
236
+ mode == "text" ? "#{label} (#{href})" : "[#{label}](#{href})"
237
+ end
238
+
239
+ def format_list(list, mode:, base_url:, depth: 0)
240
+ ordered = list.name == "ol"
241
+ list.element_children.select { |child| child.name == "li" }.each_with_index.map do |item, index|
242
+ nested_lists = item.element_children.select { |child| %w[ul ol].include?(child.name) }
243
+ copy = item.dup
244
+ copy.css("ul, ol").remove
245
+ text = render_inline(copy, mode: mode, base_url: base_url)
246
+ marker = ordered ? "#{index + 1}." : "-"
247
+ line = "#{" " * depth}#{marker} #{text}".rstrip
248
+ nested = nested_lists.map { |nested_list| format_list(nested_list, mode: mode, base_url: base_url, depth: depth + 1) }
249
+ ([line] + nested).reject(&:empty?).join("\n")
250
+ end.join("\n")
251
+ end
252
+
253
+ def format_table(table, mode:, base_url:)
254
+ rows = table.css("tr").map do |row|
255
+ row.css("th, td").map { |cell| render_inline(cell, mode: mode, base_url: base_url) }
256
+ end.reject(&:empty?)
257
+ return "" if rows.empty?
258
+
259
+ width = rows.map(&:length).max
260
+ rows.each { |row| row.fill("", row.length...width) }
261
+ if mode == "text"
262
+ rows.map { |row| row.join(" | ") }.join("\n")
171
263
  else
172
- text
264
+ header = rows.first
265
+ body = rows.drop(1)
266
+ (["| #{header.join(" | ")} |", "| #{Array.new(width, "---").join(" | ")} |"] + body.map { |row| "| #{row.join(" | ")} |" }).join("\n")
173
267
  end
174
268
  end
175
269
 
@@ -177,17 +271,91 @@ module Kward
177
271
  text.to_s.gsub(/\s+/, " ").strip
178
272
  end
179
273
 
274
+ def discovered_links(document, base_url)
275
+ candidates = document.css("a[href]").each_with_index.filter_map do |link, index|
276
+ href = resolved_link(link["href"], base_url)
277
+ label = clean_text(link.text)
278
+ label = clean_text(link["aria-label"] || link["title"]) if label.empty?
279
+ next if href.nil? || label.empty? || skip_link?(link, label)
280
+
281
+ [link_group(link), label, href, index]
282
+ end
283
+ priorities = { "Main content" => 0, "Primary navigation" => 1, "Page links" => 2, "Footer" => 3 }
284
+ candidates.sort_by! { |group, _label, _href, index| [priorities.fetch(group, 4), index] }
285
+
286
+ groups = Hash.new { |hash, key| hash[key] = [] }
287
+ seen = {}
288
+ candidates.each do |group, label, href, _index|
289
+ next if seen[href]
290
+
291
+ seen[href] = true
292
+ groups[group] << [label, href]
293
+ end
294
+ omitted = [seen.length - MAX_DISCOVERED_LINKS, 0].max
295
+ allowed = MAX_DISCOVERED_LINKS
296
+ groups.each_value do |links|
297
+ links.replace(links.shift(allowed))
298
+ allowed -= links.length
299
+ end
300
+ groups.delete_if { |_group, links| links.empty? }
301
+ groups["Omitted"] << [omitted.to_s, nil] if omitted.positive?
302
+ groups
303
+ end
304
+
305
+ def link_group(link)
306
+ return "Primary navigation" unless link.ancestors("nav").empty?
307
+ return "Footer" unless link.ancestors("footer, #footer, [role='contentinfo']").empty?
308
+ return "Main content" unless link.ancestors("main, [role='main']").empty?
309
+
310
+ "Page links"
311
+ end
312
+
313
+ def skip_link?(link, label)
314
+ link["class"].to_s.split.include?("kward-skip-link") || label.match?(/\Askip to (?:content|main)/i)
315
+ end
316
+
317
+ def resolved_link(href, base_url)
318
+ value = href.to_s.strip
319
+ return nil if value.empty? || value.start_with?("javascript:", "data:")
320
+
321
+ uri = URI.join(base_url, value)
322
+ return nil unless %w[http https].include?(uri.scheme)
323
+
324
+ uri.to_s
325
+ rescue URI::InvalidURIError
326
+ nil
327
+ end
328
+
329
+ def format_discovered_links(groups, mode:)
330
+ omitted = groups.delete("Omitted")&.first&.first.to_i
331
+ heading = mode == "text" ? "Links" : "## Links"
332
+ sections = groups.filter_map do |group, links|
333
+ next if links.empty?
334
+
335
+ group_heading = mode == "text" ? group : "### #{group}"
336
+ entries = links.map do |label, href|
337
+ mode == "text" ? "- #{label}: #{href}" : "- [#{label}](#{href})"
338
+ end
339
+ ([group_heading] + entries).join("\n")
340
+ end
341
+ sections << "#{omitted} additional links omitted." if omitted.positive?
342
+ ([heading] + sections).join("\n\n")
343
+ end
344
+
180
345
  def truncate_bytes(text, max_bytes)
181
- return text if text.bytesize <= max_bytes
346
+ return [text, false] if text.bytesize <= max_bytes
182
347
 
183
- "#{text.byteslice(0, max_bytes).to_s.scrub}\n... truncated to #{max_bytes} bytes"
348
+ marker = "\n... extracted content truncated ..."
349
+ available = [max_bytes - marker.bytesize, 0].max
350
+ truncated = text.byteslice(0, available).to_s.scrub.sub(/\n?[^\n]*\z/, "")
351
+ ["#{truncated}#{marker}", true]
184
352
  end
185
353
 
186
354
  def browser_headers(accept)
187
355
  {
188
356
  "Accept" => accept,
189
357
  "Accept-Language" => "en-US,en;q=0.9",
190
- "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
358
+ "User-Agent" => Http.user_agent
191
359
  }
192
360
  end
193
361
 
@@ -67,10 +67,6 @@ module Kward
67
67
  TOOL_NAME_MAP[name.to_s]
68
68
  end
69
69
 
70
- def write_lock_required?(name)
71
- %w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
72
- end
73
-
74
70
  def file_change_tool?(name)
75
71
  %w[edit_file write_file edit write].include?(name.to_s)
76
72
  end
@@ -47,7 +47,9 @@ module Kward
47
47
 
48
48
  # Executes the tool and returns model-facing output text.
49
49
  def call(args, _conversation, cancellation: nil)
50
- @web_search.search(args)
50
+ return @web_search.search(args) unless cancellation
51
+
52
+ @web_search.search(args, cancellation: cancellation)
51
53
  end
52
54
  end
53
55
  end
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require_relative "markdown_transcript"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
@@ -3,6 +3,7 @@ require "json"
3
3
  require "net/http"
4
4
  require "time"
5
5
  require "uri"
6
+ require_relative "http"
6
7
 
7
8
  # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
@@ -102,7 +103,7 @@ module Kward
102
103
 
103
104
  def fetch_latest_version
104
105
  request = Net::HTTP::Get.new(LATEST_VERSION_URL)
105
- request["User-Agent"] = "kward/#{Kward::VERSION}"
106
+ Http.apply_user_agent(request)
106
107
  response = Net::HTTP.start(LATEST_VERSION_URL.hostname, LATEST_VERSION_URL.port, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
107
108
  http.request(request)
108
109
  end
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.76.0"
4
+ VERSION = "0.78.0"
5
5
  end
@@ -9,6 +9,7 @@ module KwardDocsNavigationData
9
9
  ["Usage", "file.usage.html"],
10
10
  ["Configuration", "file.configuration.html"],
11
11
  ["Authentication", "file.authentication.html"],
12
+ ["Security and trust", "file.security.html"],
12
13
  ["Troubleshooting", "file.troubleshooting.html"]
13
14
  ]
14
15
  ],
@@ -16,10 +17,12 @@ module KwardDocsNavigationData
16
17
  "Feature guides",
17
18
  [
18
19
  ["Sessions", "file.session-management.html"],
20
+ ["Interactive composer", "file.composer.html"],
19
21
  ["Tabs", "file.tabs.html"],
20
22
  ["Memory", "file.memory.html"],
21
23
  ["Personas", "file.personas.html"],
22
- ["Skills", "file.skills.html"]
24
+ ["Skills", "file.skills.html"],
25
+ ["Pan mode", "file.pan.html"]
23
26
  ]
24
27
  ],
25
28
  [
@@ -39,12 +42,14 @@ module KwardDocsNavigationData
39
42
  [
40
43
  ["Extensibility", "file.extensibility.html"],
41
44
  ["Plugins", "file.plugins.html"],
42
- ["Lifecycle hooks", "file.lifecycle-hooks.html"]
45
+ ["Lifecycle hooks", "file.lifecycle-hooks.html"],
46
+ ["Permissions", "file.permissions.html"]
43
47
  ]
44
48
  ],
45
49
  [
46
50
  "Integrate",
47
51
  [
52
+ ["MCP servers", "file.mcp.html"],
48
53
  ["RPC protocol", "file.rpc.html"],
49
54
  ["Releasing", "file.releasing.html"]
50
55
  ]
@@ -110,7 +110,7 @@
110
110
  <article><strong>⌘</strong><h2>Project-aware agent loop</h2><p>Inspect, reason, patch, verify.</p></article>
111
111
  <article><strong>▤</strong><h2>Unix filter mode</h2><p>Pipe diffs, logs, and code through Kward.</p></article>
112
112
  <article><strong>▰</strong><h2>Branchable sessions</h2><p>Resume, rewind, fork, compact, export.</p></article>
113
- <article><strong>›_</strong><h2>Worker pipelines</h2><p>Queue focused agent tasks from the TUI.</p></article>
113
+ <article><strong>◈</strong><h2>Human-in-the-loop permissions</h2><p>Optionally approve edits, commands, web, and MCP calls.</p></article>
114
114
  <article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
115
115
  <article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
116
116
  </section>
@@ -137,7 +137,7 @@
137
137
  <li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
138
138
  <li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
139
139
  <li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
140
- <li>Dispatch worker tasks, leave them running, review the results later.</li>
140
+ <li>Use permission policies to approve or deny model-requested edits, commands, web, and MCP calls.</li>
141
141
  <li>Ground answers with live web search and public source repositories before guessing.</li>
142
142
  <li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
143
143
  <li>Build custom UIs on top of Kward’s JSON-RPC backend.</li>
@@ -29,6 +29,7 @@ module KwardDocsNavigation
29
29
  "doc/usage.md" => "file.usage.html",
30
30
  "doc/configuration.md" => "file.configuration.html",
31
31
  "doc/authentication.md" => "file.authentication.html",
32
+ "doc/permissions.md" => "file.permissions.html",
32
33
  "doc/troubleshooting.md" => "file.troubleshooting.html",
33
34
  "doc/session-management.md" => "file.session-management.html",
34
35
  "doc/tabs.md" => "file.tabs.html",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.76.0
4
+ version: 0.78.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -129,6 +129,7 @@ files:
129
129
  - doc/api.md
130
130
  - doc/authentication.md
131
131
  - doc/code-search.md
132
+ - doc/composer.md
132
133
  - doc/configuration.md
133
134
  - doc/context-budgeting.md
134
135
  - doc/context-tools.md
@@ -140,10 +141,13 @@ files:
140
141
  - doc/lifecycle-hooks.md
141
142
  - doc/mcp.md
142
143
  - doc/memory.md
144
+ - doc/pan.md
145
+ - doc/permissions.md
143
146
  - doc/personas.md
144
147
  - doc/plugins.md
145
148
  - doc/releasing.md
146
149
  - doc/rpc.md
150
+ - doc/security.md
147
151
  - doc/session-management.md
148
152
  - doc/shell.md
149
153
  - doc/skills.md
@@ -211,6 +215,7 @@ files:
211
215
  - lib/kward/hooks/http_handler.rb
212
216
  - lib/kward/hooks/manager.rb
213
217
  - lib/kward/hooks/matcher.rb
218
+ - lib/kward/http.rb
214
219
  - lib/kward/image_attachments.rb
215
220
  - lib/kward/interactive_pty_runner.rb
216
221
  - lib/kward/local_command_runner.rb
@@ -234,13 +239,16 @@ files:
234
239
  - lib/kward/model/stream_parser.rb
235
240
  - lib/kward/openrouter_model_cache.rb
236
241
  - lib/kward/pan/index.html.erb
242
+ - lib/kward/pan/kward_logo.png
237
243
  - lib/kward/pan/server.rb
238
244
  - lib/kward/path_guard.rb
245
+ - lib/kward/permissions/policy.rb
239
246
  - lib/kward/plugin_registry.rb
240
247
  - lib/kward/private_file.rb
241
248
  - lib/kward/project_files.rb
242
249
  - lib/kward/prompt_history.rb
243
250
  - lib/kward/prompt_interface.rb
251
+ - lib/kward/prompt_interface/approval_prompt.rb
244
252
  - lib/kward/prompt_interface/banner.rb
245
253
  - lib/kward/prompt_interface/composer_controller.rb
246
254
  - lib/kward/prompt_interface/composer_renderer.rb
@@ -292,6 +300,7 @@ files:
292
300
  - lib/kward/rpc/config_manager.rb
293
301
  - lib/kward/rpc/mcp_status.rb
294
302
  - lib/kward/rpc/memory_methods.rb
303
+ - lib/kward/rpc/plugin_chat_manager.rb
295
304
  - lib/kward/rpc/prompt_bridge.rb
296
305
  - lib/kward/rpc/redactor.rb
297
306
  - lib/kward/rpc/runtime_payloads.rb
@@ -316,6 +325,7 @@ files:
316
325
  - lib/kward/skills/registry.rb
317
326
  - lib/kward/starter_pack_installer.rb
318
327
  - lib/kward/steering.rb
328
+ - lib/kward/tab_driver.rb
319
329
  - lib/kward/tab_store.rb
320
330
  - lib/kward/telemetry/logger.rb
321
331
  - lib/kward/telemetry/stats.rb
@@ -348,17 +358,6 @@ files:
348
358
  - lib/kward/transcript_export.rb
349
359
  - lib/kward/update_check.rb
350
360
  - lib/kward/version.rb
351
- - lib/kward/workers.rb
352
- - lib/kward/workers/git_guard.rb
353
- - lib/kward/workers/job.rb
354
- - lib/kward/workers/live_view.rb
355
- - lib/kward/workers/manager.rb
356
- - lib/kward/workers/queue_runner.rb
357
- - lib/kward/workers/queue_store.rb
358
- - lib/kward/workers/store.rb
359
- - lib/kward/workers/tool_policy.rb
360
- - lib/kward/workers/worker.rb
361
- - lib/kward/workers/write_lock.rb
362
361
  - lib/kward/workspace.rb
363
362
  - lib/main.rb
364
363
  - templates/default/fulldoc/html/css/kward.css
@@ -1,93 +0,0 @@
1
- require "open3"
2
-
3
- module Kward
4
- module Workers
5
- # Small git boundary used by write-lane workers to keep implementation work isolated.
6
- class GitGuard
7
- def initialize(root: Dir.pwd)
8
- @root = root.to_s
9
- end
10
-
11
- def repository?
12
- success?("rev-parse", "--is-inside-work-tree")
13
- end
14
-
15
- def clean?
16
- return true unless repository?
17
-
18
- status.empty?
19
- end
20
-
21
- def dirty?
22
- !clean?
23
- end
24
-
25
- def status
26
- run("status", "--porcelain").stdout
27
- end
28
-
29
- def head
30
- result = run("rev-parse", "--verify", "HEAD")
31
- result.success? ? result.stdout.strip : nil
32
- end
33
-
34
- def commit_all(message)
35
- add = run("add", "-A")
36
- return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
37
-
38
- commit = run("commit", "-m", message)
39
- return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
40
-
41
- Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
- end
43
-
44
- def stash(message)
45
- before = stash_refs
46
- result = run("stash", "push", "--include-untracked", "-m", message)
47
- return Result.new(success: false, stdout: result.stdout, stderr: result.stderr) unless result.success?
48
- return Result.new(success: true, stdout: result.stdout, stderr: result.stderr) if result.stdout.include?("No local changes")
49
-
50
- ref = (stash_refs - before).first || stash_refs.first
51
- Result.new(success: true, stdout: result.stdout, stderr: result.stderr, commit: ref)
52
- end
53
-
54
- def apply_stash(ref)
55
- run("stash", "apply", ref.to_s)
56
- end
57
-
58
- def drop_stash(ref)
59
- run("stash", "drop", ref.to_s)
60
- end
61
-
62
- private
63
-
64
- Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
65
- def success?
66
- success
67
- end
68
-
69
- def output
70
- [stdout, stderr].compact.reject(&:empty?).join("\n")
71
- end
72
- end
73
-
74
- def success?(*args)
75
- run(*args).success?
76
- end
77
-
78
- def stash_refs
79
- result = run("stash", "list", "--format=%gd")
80
- return [] unless result.success?
81
-
82
- result.stdout.lines.map(&:strip).reject(&:empty?)
83
- end
84
-
85
- def run(*args)
86
- stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
87
- Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
88
- rescue Errno::ENOENT
89
- Result.new(success: false, stdout: "", stderr: "git executable not found")
90
- end
91
- end
92
- end
93
- end