kward 0.74.0 → 0.75.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +72 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. metadata +25 -1
data/lib/kward/cli/git.rb CHANGED
@@ -14,6 +14,8 @@ module Kward
14
14
  return
15
15
  end
16
16
 
17
+ previous_git_hook_conversation = @git_hook_conversation
18
+ @git_hook_conversation = agent.conversation
17
19
  root = interactive_workspace_root(agent)
18
20
  git_root = git_repository_root(root)
19
21
  if git_root.empty?
@@ -33,6 +35,8 @@ module Kward
33
35
  git_commit(git_root, message)
34
36
  end
35
37
  print_git_commit_result(result)
38
+ ensure
39
+ @git_hook_conversation = previous_git_hook_conversation
36
40
  end
37
41
 
38
42
  def git_repository_root(root)
@@ -48,7 +52,9 @@ module Kward
48
52
  output, status = Open3.capture2e("git", "status", "--short", "--untracked-files=normal", chdir: root.to_s)
49
53
  return ["Unable to read Git status: #{output.strip}"] unless status.success?
50
54
 
51
- output.lines.map(&:chomp)
55
+ lines = output.lines.map(&:chomp)
56
+ git_lifecycle_hook("git_status_after", root: root, payload: { status_count: lines.length })
57
+ lines
52
58
  rescue StandardError => e
53
59
  ["Unable to read Git status: #{e.message}"]
54
60
  end
@@ -70,7 +76,13 @@ module Kward
70
76
  entry = parse_git_status_line(status_line)
71
77
  return { path: "Git diff", content: "Unable to read Git status entry.\n" } if entry.nil?
72
78
 
79
+ before = git_lifecycle_hook("git_diff_before", root: root, payload: { path: entry[:path], untracked: entry[:untracked] })
80
+ if before.denied? || before.approval_required?
81
+ return { path: entry[:path], content: "Declined: #{before.decision.message || "git diff denied"}\n" }
82
+ end
83
+
73
84
  output = entry[:untracked] ? git_untracked_file_diff(root, entry[:path]) : git_tracked_file_diff(root, entry[:path])
85
+ git_lifecycle_hook("git_diff_after", root: root, payload: { path: entry[:path], untracked: entry[:untracked], bytes: output.bytesize })
74
86
  { path: entry[:path], content: output.empty? ? "No diff for #{entry[:path]}\n" : output }
75
87
  end
76
88
 
@@ -96,8 +108,13 @@ module Kward
96
108
  entry = parse_git_status_line(status_line)
97
109
  return if entry.nil?
98
110
 
111
+ action = entry[:staged] ? "unstage" : "stage"
112
+ before = git_lifecycle_hook("git_stage_before", root: root, payload: { path: entry[:path], action: action })
113
+ return if before.denied? || before.approval_required?
114
+
99
115
  command = entry[:staged] ? ["restore", "--staged", "--", entry[:path]] : ["add", "--", entry[:path]]
100
- Open3.capture2e("git", *command, chdir: root.to_s)
116
+ output, status = Open3.capture2e("git", *command, chdir: root.to_s)
117
+ git_lifecycle_hook("git_stage_after", root: root, payload: { path: entry[:path], action: action, success: status.success?, output: output })
101
118
  rescue StandardError
102
119
  nil
103
120
  end
@@ -115,6 +132,9 @@ module Kward
115
132
  end
116
133
 
117
134
  def git_commit(root, message)
135
+ before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
136
+ return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
137
+
118
138
  return git_commit_staged(root, message) if git_staged_changes?(root)
119
139
 
120
140
  add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
@@ -134,11 +154,18 @@ module Kward
134
154
 
135
155
  def git_commit_staged(root, message)
136
156
  commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
137
- { success: commit_status.success?, output: commit_output }
157
+ result = { success: commit_status.success?, output: commit_output }
158
+ git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
159
+ result
138
160
  rescue StandardError => e
139
161
  { success: false, output: e.message }
140
162
  end
141
163
 
164
+ def git_lifecycle_hook(name, root:, payload: {})
165
+ conversation = @git_hook_conversation || new_conversation(workspace_root: root.to_s)
166
+ run_lifecycle_hook(name, conversation: conversation, payload: { root: root.to_s }.merge(payload))
167
+ end
168
+
142
169
  def print_git_commit_result(result)
143
170
  output = result[:output].to_s.strip
144
171
  output = result[:success] ? "Commit created." : "Git commit failed." if output.empty?
@@ -0,0 +1,258 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
7
+ class CLI
8
+ # Interactive lifecycle hook inspection commands.
9
+ module HookCommands
10
+ private
11
+
12
+ def handle_hooks_command(argument)
13
+ subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
14
+ subcommand = "list" if subcommand.to_s.empty?
15
+
16
+ case subcommand
17
+ when "list"
18
+ print_hooks_list
19
+ when "events"
20
+ print_hooks_events
21
+ when "logs"
22
+ print_hooks_logs(rest)
23
+ when "doctor"
24
+ print_hooks_doctor
25
+ when "trust"
26
+ trust_workspace_hooks
27
+ when "untrust"
28
+ untrust_workspace_hooks
29
+ else
30
+ runtime_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
31
+ end
32
+ end
33
+
34
+ def print_hooks_list
35
+ lines = ["Lifecycle hooks"]
36
+ entries = configured_hook_entries + workspace_hook_entries + plugin_hook_entries
37
+ if entries.empty?
38
+ lines << "No lifecycle hooks are configured."
39
+ else
40
+ entries.each do |entry|
41
+ details = [entry.fetch(:id), entry.fetch(:event)]
42
+ details << "source=#{entry.fetch(:source)}"
43
+ details << "order=#{entry.fetch(:order)}"
44
+ details << "failure_policy=#{entry[:failure_policy]}" if entry[:failure_policy]
45
+ details << "disabled" if entry[:disabled]
46
+ lines << "- #{details.join(' ')}"
47
+ end
48
+ end
49
+ runtime_output(lines.join("\n"))
50
+ end
51
+
52
+ def print_hooks_events
53
+ lines = ["Lifecycle hook events"]
54
+ Hooks::Catalog.event_names.each do |event_name|
55
+ definition = Hooks::Catalog.definition(event_name)
56
+ fields = Array(definition&.modifiable_fields)
57
+ suffix = fields.empty? ? "" : " modifies=#{fields.join(',')}"
58
+ lines << "- #{event_name} failure_policy=#{Hooks::Catalog.failure_policy(event_name)}#{suffix}"
59
+ end
60
+ runtime_output(lines.join("\n"))
61
+ end
62
+
63
+ def print_hooks_logs(argument)
64
+ count = argument.to_s.strip.empty? ? 20 : argument.to_i
65
+ count = 20 unless count.positive?
66
+ path = hooks_log_path
67
+ unless File.file?(path)
68
+ runtime_output("No lifecycle hook audit log found at #{path}.")
69
+ return
70
+ end
71
+
72
+ records = File.readlines(path, chomp: true).last(count).filter_map do |line|
73
+ JSON.parse(line)
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+ if records.empty?
78
+ runtime_output("No readable lifecycle hook audit records found at #{path}.")
79
+ return
80
+ end
81
+
82
+ lines = ["Lifecycle hook audit log: #{path}"]
83
+ records.each do |record|
84
+ lines << format_hook_log_record(record)
85
+ end
86
+ runtime_output(lines.join("\n"))
87
+ end
88
+
89
+ def print_hooks_doctor
90
+ lines = ["Lifecycle hook diagnostics"]
91
+ begin
92
+ diagnostics = hook_config_diagnostics(ConfigFiles.read_config, "config") + hook_config_diagnostics(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
93
+ manager = diagnostics.empty? ? Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(current_workspace_root)).manager : nil
94
+ lines << "Config and trusted workspace hooks: #{manager ? manager.handlers.length : "not loaded due diagnostics"}"
95
+ lines << "Plugin hooks: #{plugin_registry.hook_handlers.length}"
96
+ lines << "Audit log: #{hooks_log_path}"
97
+ lines << "Workspace hook config: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}"
98
+ lines << "Workspace hooks trusted: #{ConfigFiles.workspace_hooks_trusted?(current_workspace_root)}"
99
+ lines << "Known events: #{Hooks::Catalog.event_names.length}"
100
+ if diagnostics.empty?
101
+ lines << "OK"
102
+ else
103
+ lines.concat(diagnostics.map { |diagnostic| "Warning: #{diagnostic}" })
104
+ end
105
+ rescue StandardError => e
106
+ lines << "Error: #{e.message}"
107
+ end
108
+ runtime_output(lines.join("\n"))
109
+ end
110
+
111
+ def configured_hook_entries
112
+ hook_entries_from_config(ConfigFiles.read_config, "config")
113
+ end
114
+
115
+ def workspace_hook_entries
116
+ hook_entries_from_config(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
117
+ end
118
+
119
+ def plugin_hook_entries
120
+ plugin_registry.hook_handlers.map do |hook|
121
+ {
122
+ id: hook.id,
123
+ event: hook.event,
124
+ source: hook.path || "plugin",
125
+ order: hook.order,
126
+ failure_policy: hook.failure_policy,
127
+ disabled: false
128
+ }
129
+ end
130
+ end
131
+
132
+ def hook_entries_from_config(config, source)
133
+ hooks = config["hooks"] || config[:hooks]
134
+ return [] unless hooks.is_a?(Hash)
135
+
136
+ hooks.flat_map do |event, entries|
137
+ Array(entries).each_with_index.map do |entry, index|
138
+ entry = entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "command" => entry.to_s }
139
+ {
140
+ id: entry["id"] || "#{source}:#{event}:#{index + 1}",
141
+ event: event.to_s,
142
+ source: source,
143
+ order: entry.fetch("order", 100),
144
+ failure_policy: entry["failure_policy"],
145
+ disabled: truthy_hook_value?(entry["disabled"])
146
+ }
147
+ end
148
+ end
149
+ end
150
+
151
+ def hook_config_diagnostics(config, source)
152
+ hooks = config["hooks"] || config[:hooks]
153
+ return [] unless hooks.is_a?(Hash)
154
+
155
+ hooks.flat_map do |event, entries|
156
+ Array(entries).each_with_index.flat_map do |entry, index|
157
+ validate_hook_entry(event.to_s, normalize_hook_entry(entry), source, index + 1)
158
+ end
159
+ end
160
+ end
161
+
162
+ def normalize_hook_entry(entry)
163
+ entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "type" => "command", "command" => entry.to_s }
164
+ end
165
+
166
+ def validate_hook_entry(event, entry, source, number)
167
+ label = entry["id"] || "#{source}:#{event}:#{number}"
168
+ diagnostics = []
169
+ diagnostics << "#{label}: unknown event #{event}" unless Hooks::Catalog.known?(event)
170
+ diagnostics.concat(validate_hook_failure_policy(label, entry))
171
+ diagnostics.concat(validate_hook_timeout(label, entry))
172
+ diagnostics.concat(validate_hook_type(label, entry))
173
+ diagnostics
174
+ end
175
+
176
+ def validate_hook_failure_policy(label, entry)
177
+ policy = entry["failure_policy"]
178
+ return [] if policy.to_s.empty? || Hooks::Catalog::VALID_FAILURE_POLICIES.include?(policy.to_s)
179
+
180
+ ["#{label}: unknown failure_policy #{policy}"]
181
+ end
182
+
183
+ def validate_hook_timeout(label, entry)
184
+ value = entry["timeout_seconds"]
185
+ return [] if value.nil? || value.to_f.positive?
186
+
187
+ ["#{label}: timeout_seconds must be positive"]
188
+ end
189
+
190
+ def validate_hook_type(label, entry)
191
+ type = (entry["type"] || "command").to_s
192
+ case type
193
+ when "command"
194
+ validate_command_hook(label, entry)
195
+ when "http"
196
+ validate_http_hook(label, entry)
197
+ else
198
+ ["#{label}: unsupported hook type #{type}"]
199
+ end
200
+ end
201
+
202
+ def validate_command_hook(label, entry)
203
+ command = entry["command"].to_s.strip
204
+ return ["#{label}: command is required"] if command.empty?
205
+
206
+ executable = command.split(/\s+/, 2).first
207
+ return [] if executable.include?(File::SEPARATOR) ? File.executable?(File.expand_path(executable)) : system_command_available?(executable)
208
+
209
+ ["#{label}: command executable not found: #{executable}"]
210
+ end
211
+
212
+ def validate_http_hook(label, entry)
213
+ uri = URI.parse(entry["url"].to_s)
214
+ return [] if %w[http https].include?(uri.scheme) && uri.host
215
+
216
+ ["#{label}: url must be http or https"]
217
+ rescue URI::InvalidURIError
218
+ ["#{label}: url must be http or https"]
219
+ end
220
+
221
+ def system_command_available?(name)
222
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
223
+ File.executable?(File.join(dir, name))
224
+ end
225
+ end
226
+
227
+ def trust_workspace_hooks
228
+ ConfigFiles.trust_workspace_hooks!(current_workspace_root)
229
+ runtime_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
230
+ rescue StandardError => e
231
+ runtime_output("Workspace hook trust error: #{e.message}")
232
+ end
233
+
234
+ def untrust_workspace_hooks
235
+ ConfigFiles.untrust_workspace_hooks!(current_workspace_root)
236
+ runtime_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
237
+ rescue StandardError => e
238
+ runtime_output("Workspace hook trust error: #{e.message}")
239
+ end
240
+
241
+ def hooks_log_path
242
+ File.join(ConfigFiles.config_dir, "logs", "hooks.jsonl")
243
+ end
244
+
245
+ def format_hook_log_record(record)
246
+ parts = [record["timestamp"], record["kind"], record["event"]].compact
247
+ parts << "hook=#{record['hook_id']}" if record["hook_id"]
248
+ parts << "decision=#{record['decision']}" if record["decision"]
249
+ parts << record["message"] if record["message"]
250
+ "- #{parts.join(' ')}"
251
+ end
252
+
253
+ def truthy_hook_value?(value)
254
+ value == true || value.to_s == "true"
255
+ end
256
+ end
257
+ end
258
+ end
@@ -102,11 +102,7 @@ module Kward
102
102
  end
103
103
 
104
104
  def prepare_memory_context(conversation, input)
105
- manager = Memory::Manager.new
106
- retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
107
- conversation.last_memory_retrieval = retrieval
108
- conversation.memory_context = manager.memory_block(retrieval)
109
- conversation.refresh_system_message!
105
+ Memory::TurnContext.apply(conversation: conversation, input: input)
110
106
  rescue StandardError => e
111
107
  warn "Memory retrieval failed: #{e.message}"
112
108
  nil
@@ -37,6 +37,39 @@ module Kward
37
37
  runtime_output("Plugins reloaded.")
38
38
  end
39
39
 
40
+ def lifecycle_hook_manager(conversation)
41
+ manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(conversation.workspace_root)).manager
42
+ plugin_registry.hook_handlers.each do |hook|
43
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
44
+ hook.handler.call(event, context)
45
+ end
46
+ end
47
+ manager
48
+ end
49
+
50
+ def lifecycle_hook_context(conversation)
51
+ plugin_context(conversation, "")
52
+ end
53
+
54
+ def run_lifecycle_hook(name, conversation:, payload: {}, session: @active_session)
55
+ lifecycle_hook_manager(conversation).run(Hooks::Event.new(
56
+ name: name,
57
+ session: session_payload(session),
58
+ workspace: { root: conversation.respond_to?(:workspace_root) ? conversation.workspace_root : current_workspace_root },
59
+ payload: payload
60
+ ), context: lifecycle_hook_context(conversation))
61
+ end
62
+
63
+ def session_payload(session)
64
+ return {} unless session
65
+
66
+ {
67
+ id: session.respond_to?(:id) ? session.id : nil,
68
+ name: session.respond_to?(:name) ? session.name : nil,
69
+ path: session.respond_to?(:path) ? session.path : nil
70
+ }.compact
71
+ end
72
+
40
73
  def reserved_slash_command_names
41
74
  builtin_slash_command_names + prompt_templates.map(&:command)
42
75
  end
@@ -76,9 +109,16 @@ module Kward
76
109
  argument_hint: template.argument_hint
77
110
  }
78
111
  end
112
+ skill_entries = ConfigFiles.skills.map do |skill|
113
+ {
114
+ name: "skill:#{skill.name}",
115
+ description: skill.description,
116
+ argument_hint: ""
117
+ }
118
+ end
79
119
  plugin_entries = plugin_commands.map(&:entry)
80
120
  interactive_entries = interactive_commands.map(&:entry)
81
- builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
121
+ builtin_slash_commands + prompt_entries + skill_entries + plugin_entries + interactive_entries
82
122
  end
83
123
 
84
124
  def prompt_template_for(command)
@@ -37,7 +37,9 @@ module Kward
37
37
  editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
38
38
  editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
39
39
  editor_line_numbers: ConfigFiles.editor_line_numbers,
40
- editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
40
+ editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
41
+ diff_view: ConfigFiles.diff_view,
42
+ diff_view_source: -> { ConfigFiles.diff_view }
41
43
  )
42
44
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
43
45
  @prompt.start(render: false)
@@ -69,11 +71,13 @@ module Kward
69
71
  return unless @prompt.respond_to?(:print_visual_banner)
70
72
 
71
73
  @prompt.print_visual_banner(startup_info_screen)
74
+ refresh_startup_update_check if prompt_interface?
72
75
  end
73
76
 
74
77
  def startup_info_screen
75
78
  [
76
79
  startup_status_line,
80
+ *startup_update_notice_lines,
77
81
  "",
78
82
  startup_info_line("Workspace", startup_workspace_label),
79
83
  startup_info_line("Branch", startup_branch_value),
@@ -116,7 +120,32 @@ module Kward
116
120
  end
117
121
 
118
122
  def startup_status_line
119
- "#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
123
+ color = startup_update_notice ? :yellow : :green
124
+ "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
125
+ end
126
+
127
+ def startup_update_notice_lines
128
+ notice = startup_update_notice
129
+ return [] unless notice
130
+
131
+ [
132
+ " New version available: #{notice.latest_version}",
133
+ " Run: gem update kward"
134
+ ]
135
+ end
136
+
137
+ def startup_update_notice
138
+ return @startup_update_notice if defined?(@startup_update_notice)
139
+
140
+ @startup_update_check = UpdateCheck.new(current_version: Kward::VERSION)
141
+ @startup_update_notice = @startup_update_check.notice
142
+ end
143
+
144
+ def refresh_startup_update_check
145
+ checker = @startup_update_check || UpdateCheck.new(current_version: Kward::VERSION)
146
+ return unless checker.enabled?
147
+
148
+ Thread.new { checker.refresh_if_stale }
120
149
  end
121
150
 
122
151
  def startup_info_line(label, value)
@@ -218,7 +247,7 @@ module Kward
218
247
  def composer_context_usage(provider, model)
219
248
  context_window = composer_context_window(provider, model)
220
249
  context_parts = if @client.respond_to?(:current_context_parts)
221
- @client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas)
250
+ @client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas, provider: provider, model: model)
222
251
  else
223
252
  { provider: provider, model: model, messages: current_footer_conversation.context_messages, tools: footer_tool_schemas }
224
253
  end
@@ -183,7 +183,7 @@ module Kward
183
183
  end
184
184
 
185
185
  def tool_call_id(tool_call)
186
- tool_call["id"] || tool_call[:id]
186
+ ToolCall.id(tool_call)
187
187
  end
188
188
 
189
189
  # Writes the user transcript output for the terminal CLI flow.
@@ -52,19 +52,25 @@ module Kward
52
52
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
53
53
  workspace = configured_workspace(root: conversation.workspace_root)
54
54
  writer_id = worker_writer_id(role)
55
+ hook_manager = lifecycle_hook_manager(conversation)
56
+ hook_context = lifecycle_hook_context(conversation)
55
57
  tool_registry = ToolRegistry.new(
56
58
  workspace: workspace,
57
59
  prompt: @prompt,
58
60
  allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
59
61
  write_lock: @worker_write_lock,
60
- writer_id: writer_id
62
+ writer_id: writer_id,
63
+ hook_manager: hook_manager,
64
+ hook_context: hook_context
61
65
  )
62
66
  @footer_conversation = conversation
63
67
  @footer_tool_registry = tool_registry
64
68
  Agent.new(
65
69
  client: @client,
66
70
  tool_registry: tool_registry,
67
- conversation: conversation
71
+ conversation: conversation,
72
+ hook_manager: hook_manager,
73
+ hook_context: hook_context
68
74
  )
69
75
  end
70
76
 
@@ -356,7 +362,7 @@ module Kward
356
362
  queued_inputs = []
357
363
  result = nil
358
364
  error = nil
359
- @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
365
+ begin_local_busy_command(activity)
360
366
 
361
367
  worker = Thread.new do
362
368
  result = yield
@@ -365,7 +371,8 @@ module Kward
365
371
  end
366
372
 
367
373
  while worker.alive?
368
- collect_queued_input(queued_inputs)
374
+ poll_result = collect_queued_input(queued_inputs)
375
+ handle_busy_local_tab_action(poll_result, activity: activity) if busy_local_tab_action?(poll_result)
369
376
  sleep 0.02
370
377
  end
371
378
  worker.join
@@ -374,7 +381,7 @@ module Kward
374
381
 
375
382
  [result, queued_inputs]
376
383
  ensure
377
- @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
384
+ finish_local_busy_command(activity)
378
385
  end
379
386
 
380
387
  def run_busy_local_command_and_requeue(activity: "loading")
@@ -385,6 +392,38 @@ module Kward
385
392
  result
386
393
  end
387
394
 
395
+ def begin_local_busy_command(activity)
396
+ active_tab.local_busy_activity = activity if active_tab
397
+ @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
398
+ update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
399
+ end
400
+
401
+ def finish_local_busy_command(activity)
402
+ tab = local_busy_tab(activity)
403
+ tab.local_busy_activity = nil if tab
404
+ update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
405
+ @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input) && (!active_tab || active_tab == tab)
406
+ end
407
+
408
+ def local_busy_tab(activity)
409
+ Array(@tabs).find { |tab| tab.local_busy_activity.to_s == activity.to_s }
410
+ end
411
+
412
+ def busy_local_tab_action?(poll_result)
413
+ poll_result.is_a?(Hash) && poll_result[:tab_action]
414
+ end
415
+
416
+ def handle_busy_local_tab_action(action, activity:)
417
+ return unless respond_to?(:handle_tab_action, true)
418
+
419
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
420
+ handle_tab_action(action, @session_store)
421
+ tab = active_tab
422
+ return unless tab&.local_busy?
423
+
424
+ @prompt.begin_busy_input("You>", activity: tab.local_busy_activity) if @prompt.respond_to?(:begin_busy_input)
425
+ end
426
+
388
427
  def current_workspace_root
389
428
  return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
390
429
  return @working_directory if @working_directory
@@ -49,6 +49,7 @@ module Kward
49
49
  reset_session_diff
50
50
  conversation = new_conversation(workspace_root: session_store.cwd)
51
51
  @active_session.attach(conversation)
52
+ run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
52
53
  build_interactive_agent(conversation)
53
54
  end
54
55
 
@@ -95,6 +96,7 @@ module Kward
95
96
  cleanup_replaced_session(previous_session)
96
97
  conversation = new_conversation(workspace_root: session_store.cwd)
97
98
  @active_session.attach(conversation)
99
+ run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
98
100
  update_assistant_prompt(conversation)
99
101
  clear_prompt_transcript
100
102
  print_visual_banner
@@ -125,6 +127,7 @@ module Kward
125
127
  runtime_output("#{message}: #{@active_session.path}") if message
126
128
  render_conversation_transcript(conversation)
127
129
  end
130
+ run_lifecycle_hook("session_resume", conversation: conversation, payload: { action: "resume", path: @active_session.path })
128
131
  agent = build_interactive_agent(conversation)
129
132
  @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
130
133
  agent
@@ -413,7 +416,9 @@ module Kward
413
416
  return
414
417
  end
415
418
 
419
+ old_name = @active_session.name
416
420
  @active_session.rename(name)
421
+ run_lifecycle_hook("session_rename", conversation: new_conversation(workspace_root: current_workspace_root), session: @active_session, payload: { old_name: old_name, new_name: @active_session.name })
417
422
  label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
418
423
  runtime_output(label)
419
424
  end
@@ -425,6 +430,7 @@ module Kward
425
430
  @active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
426
431
  reset_session_diff(@active_session.path)
427
432
  cleanup_replaced_session(previous_session)
433
+ run_lifecycle_hook("session_clone", conversation: agent.conversation, payload: { source_path: previous_session&.path, path: @active_session.path })
428
434
  runtime_output("Cloned session: #{@active_session.path}")
429
435
  render_conversation_transcript(agent.conversation)
430
436
  agent
@@ -497,6 +503,7 @@ module Kward
497
503
  reset_session_diff(@active_session.path)
498
504
  cleanup_replaced_session(previous_session)
499
505
  update_assistant_prompt(conversation)
506
+ run_lifecycle_hook("session_fork", conversation: conversation, payload: { source_path: previous_session.path, path: @active_session.path, entry_id: point.dig(:entry, "id") })
500
507
  restore_prompt_transcript do
501
508
  runtime_output("Forked session: #{@active_session.path}")
502
509
  render_conversation_transcript(conversation)
@@ -679,7 +686,13 @@ module Kward
679
686
 
680
687
  def export_session(conversation, argument)
681
688
  path = export_path(argument)
689
+ before = run_lifecycle_hook("session_export_before", conversation: conversation, payload: { path: path })
690
+ if before.denied? || before.approval_required?
691
+ runtime_output("Declined: #{before.decision.message || "session export denied"}")
692
+ return
693
+ end
682
694
  File.write(path, markdown_transcript(conversation))
695
+ run_lifecycle_hook("session_export_after", conversation: conversation, payload: { path: path })
683
696
  runtime_output("Exported session: #{path}")
684
697
  rescue StandardError => e
685
698
  runtime_output("Error: #{e.message}")