kward 0.77.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -0
  5. data/doc/code-search.md +9 -6
  6. data/doc/configuration.md +39 -0
  7. data/doc/permissions.md +179 -0
  8. data/doc/plugins.md +58 -0
  9. data/doc/rpc.md +61 -0
  10. data/doc/security.md +4 -0
  11. data/doc/tabs.md +4 -3
  12. data/doc/web-search.md +4 -2
  13. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  14. data/lib/kward/auth/github_oauth.rb +3 -3
  15. data/lib/kward/auth/oauth_helpers.rb +4 -2
  16. data/lib/kward/auth/openai_oauth.rb +2 -1
  17. data/lib/kward/cli/plugins.rb +22 -0
  18. data/lib/kward/cli/rendering.rb +7 -1
  19. data/lib/kward/cli/runtime_helpers.rb +14 -0
  20. data/lib/kward/cli/tabs.rb +140 -34
  21. data/lib/kward/cli.rb +29 -9
  22. data/lib/kward/config_files.rb +10 -0
  23. data/lib/kward/hooks/http_handler.rb +2 -1
  24. data/lib/kward/http.rb +18 -0
  25. data/lib/kward/model/client.rb +33 -11
  26. data/lib/kward/model/payloads.rb +6 -1
  27. data/lib/kward/openrouter_model_cache.rb +2 -1
  28. data/lib/kward/permissions/policy.rb +171 -0
  29. data/lib/kward/plugin_registry.rb +53 -1
  30. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  31. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  32. data/lib/kward/prompt_interface.rb +2 -0
  33. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  34. data/lib/kward/rpc/server.rb +35 -0
  35. data/lib/kward/rpc/session_manager.rb +1 -0
  36. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  37. data/lib/kward/starter_pack_installer.rb +3 -1
  38. data/lib/kward/tab_driver.rb +87 -0
  39. data/lib/kward/tab_store.rb +74 -12
  40. data/lib/kward/tools/code_search.rb +8 -2
  41. data/lib/kward/tools/fetch_content.rb +4 -2
  42. data/lib/kward/tools/fetch_raw.rb +3 -1
  43. data/lib/kward/tools/registry.rb +37 -4
  44. data/lib/kward/tools/search/code.rb +46 -12
  45. data/lib/kward/tools/search/web.rb +60 -17
  46. data/lib/kward/tools/search/web_fetch.rb +206 -38
  47. data/lib/kward/tools/web_search.rb +3 -1
  48. data/lib/kward/update_check.rb +2 -1
  49. data/lib/kward/version.rb +1 -1
  50. data/templates/default/kward_navigation.rb +2 -1
  51. data/templates/default/layout/html/layout.erb +2 -0
  52. data/templates/default/layout/html/setup.rb +1 -0
  53. metadata +7 -1
@@ -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
 
@@ -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
@@ -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.77.0"
4
+ VERSION = "0.78.0"
5
5
  end
@@ -42,7 +42,8 @@ module KwardDocsNavigationData
42
42
  [
43
43
  ["Extensibility", "file.extensibility.html"],
44
44
  ["Plugins", "file.plugins.html"],
45
- ["Lifecycle hooks", "file.lifecycle-hooks.html"]
45
+ ["Lifecycle hooks", "file.lifecycle-hooks.html"],
46
+ ["Permissions", "file.permissions.html"]
46
47
  ]
47
48
  ],
48
49
  [
@@ -110,6 +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>Human-in-the-loop permissions</h2><p>Optionally approve edits, commands, web, and MCP calls.</p></article>
113
114
  <article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
114
115
  <article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
115
116
  </section>
@@ -136,6 +137,7 @@
136
137
  <li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
137
138
  <li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
138
139
  <li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
140
+ <li>Use permission policies to approve or deny model-requested edits, commands, web, and MCP calls.</li>
139
141
  <li>Ground answers with live web search and public source repositories before guessing.</li>
140
142
  <li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
141
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.77.0
4
+ version: 0.78.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -142,6 +142,7 @@ files:
142
142
  - doc/mcp.md
143
143
  - doc/memory.md
144
144
  - doc/pan.md
145
+ - doc/permissions.md
145
146
  - doc/personas.md
146
147
  - doc/plugins.md
147
148
  - doc/releasing.md
@@ -214,6 +215,7 @@ files:
214
215
  - lib/kward/hooks/http_handler.rb
215
216
  - lib/kward/hooks/manager.rb
216
217
  - lib/kward/hooks/matcher.rb
218
+ - lib/kward/http.rb
217
219
  - lib/kward/image_attachments.rb
218
220
  - lib/kward/interactive_pty_runner.rb
219
221
  - lib/kward/local_command_runner.rb
@@ -240,11 +242,13 @@ files:
240
242
  - lib/kward/pan/kward_logo.png
241
243
  - lib/kward/pan/server.rb
242
244
  - lib/kward/path_guard.rb
245
+ - lib/kward/permissions/policy.rb
243
246
  - lib/kward/plugin_registry.rb
244
247
  - lib/kward/private_file.rb
245
248
  - lib/kward/project_files.rb
246
249
  - lib/kward/prompt_history.rb
247
250
  - lib/kward/prompt_interface.rb
251
+ - lib/kward/prompt_interface/approval_prompt.rb
248
252
  - lib/kward/prompt_interface/banner.rb
249
253
  - lib/kward/prompt_interface/composer_controller.rb
250
254
  - lib/kward/prompt_interface/composer_renderer.rb
@@ -296,6 +300,7 @@ files:
296
300
  - lib/kward/rpc/config_manager.rb
297
301
  - lib/kward/rpc/mcp_status.rb
298
302
  - lib/kward/rpc/memory_methods.rb
303
+ - lib/kward/rpc/plugin_chat_manager.rb
299
304
  - lib/kward/rpc/prompt_bridge.rb
300
305
  - lib/kward/rpc/redactor.rb
301
306
  - lib/kward/rpc/runtime_payloads.rb
@@ -320,6 +325,7 @@ files:
320
325
  - lib/kward/skills/registry.rb
321
326
  - lib/kward/starter_pack_installer.rb
322
327
  - lib/kward/steering.rb
328
+ - lib/kward/tab_driver.rb
323
329
  - lib/kward/tab_store.rb
324
330
  - lib/kward/telemetry/logger.rb
325
331
  - lib/kward/telemetry/stats.rb