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/agent.rb CHANGED
@@ -4,9 +4,13 @@ require_relative "compactor"
4
4
  require_relative "model/context_overflow"
5
5
  require_relative "conversation"
6
6
  require_relative "events"
7
+ require_relative "deep_copy"
8
+ require_relative "hooks"
9
+ require_relative "message_access"
7
10
  require_relative "steering"
8
11
  require_relative "telemetry/logger"
9
12
  require_relative "tools/registry"
13
+ require_relative "tools/tool_call"
10
14
 
11
15
  # Namespace for the Kward CLI agent runtime.
12
16
  module Kward
@@ -24,11 +28,13 @@ module Kward
24
28
  # lowest layer that owns the behavior, and use `Agent` only for cross-step turn
25
29
  # coordination.
26
30
  class Agent
27
- def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new)
31
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new, hook_manager: nil, hook_context: nil)
28
32
  @client = client
29
33
  @tool_registry = tool_registry
30
34
  @conversation = conversation
31
35
  @telemetry_logger = telemetry_logger
36
+ @hook_manager = hook_manager
37
+ @hook_context = hook_context
32
38
  end
33
39
 
34
40
  attr_reader :conversation, :tool_registry
@@ -39,15 +45,24 @@ module Kward
39
45
  # @param display_input [String, nil] alternate text kept for transcripts
40
46
  # @yieldparam event [Object] streamed turn event for frontends
41
47
  # @return [String] final assistant answer
42
- def ask(input, display_input: nil, on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, &block)
48
+ def ask(input, display_input: nil, on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil, &block)
43
49
  started_at = @telemetry_logger.monotonic_now
44
50
  status = "completed"
45
51
  error = nil
46
52
  cancellation&.raise_if_cancelled!
53
+ turn_start = run_hook("turn_start", payload: { input: input, display_input: display_input })
54
+ return hook_denied_answer(turn_start) if turn_start.denied?
55
+
56
+ input = turn_start.payload[:input] || turn_start.payload["input"] || input
57
+ display_input = turn_start.payload[:display_input] || turn_start.payload["display_input"] || display_input
47
58
  @conversation.refresh_system_message_if_workspace_agents_changed!
48
59
  @conversation.append_user(input, display_content: display_input)
60
+ run_hook("turn_context_build_before", payload: { message_count: @conversation.messages.length })
49
61
  auto_compact_if_needed
50
- run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, &block)
62
+ run_hook("turn_context_build_after", payload: { message_count: @conversation.messages.length })
63
+ answer = run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry, &block)
64
+ run_hook("turn_end", payload: { input: input, answer: answer })
65
+ answer
51
66
  rescue StandardError => e
52
67
  status = "failed"
53
68
  error = e
@@ -61,7 +76,7 @@ module Kward
61
76
  #
62
77
  # @yieldparam event [Object] streamed turn event for frontends
63
78
  # @return [String] final assistant answer
64
- def run_turn(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
79
+ def run_turn(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
65
80
  overflow_retried = false
66
81
  steering_state = build_steering_state(steering) do |event|
67
82
  yield event if block_given?
@@ -69,7 +84,7 @@ module Kward
69
84
  loop do
70
85
  cancellation&.raise_if_cancelled!
71
86
  begin
72
- message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering) do |event|
87
+ message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry) do |event|
73
88
  yield event if block_given?
74
89
  end
75
90
  rescue StandardError => e
@@ -85,11 +100,11 @@ module Kward
85
100
  steered_after_message = append_steering_events(steering_state)
86
101
  yield Events::SteeringApplied.new(count: steered_after_message) if block_given? && steered_after_message.positive?
87
102
 
88
- tool_calls = message["tool_calls"] || message[:tool_calls] || []
103
+ tool_calls = MessageAccess.tool_calls(message)
89
104
  if tool_calls.empty?
90
105
  next if steered_after_message.positive?
91
106
 
92
- answer = safe_answer(message.fetch("content", message[:content] || ""))
107
+ answer = safe_answer(MessageAccess.content(message).to_s)
93
108
  yield Events::Answer.new(content: answer) if block_given?
94
109
  return answer
95
110
  end
@@ -102,7 +117,7 @@ module Kward
102
117
  status = "completed"
103
118
  error = nil
104
119
  begin
105
- content = @tool_registry.dispatch(tool_call, @conversation, cancellation: cancellation)
120
+ content = (tool_registry || @tool_registry).dispatch(tool_call, @conversation, cancellation: cancellation)
106
121
  rescue StandardError => e
107
122
  status = "failed"
108
123
  error = e
@@ -111,7 +126,11 @@ module Kward
111
126
  log_tool(tool_call, content: content, duration_ms: @telemetry_logger.duration_ms(tool_started_at), status: status, error: error)
112
127
  end
113
128
  cancellation&.raise_if_cancelled!
114
- yield Events::ToolResult.new(tool_call: tool_call, content: content) if block_given?
129
+ if block_given?
130
+ elapsed_ms = @telemetry_logger.duration_ms(tool_started_at)
131
+ yield Events::ToolUpdate.new(tool_call: tool_call, content: content, elapsed_ms: elapsed_ms)
132
+ yield Events::ToolResult.new(tool_call: tool_call, content: content)
133
+ end
115
134
  end
116
135
  steered_after_tools = append_steering_events(steering_state)
117
136
  yield Events::SteeringApplied.new(count: steered_after_tools) if block_given? && steered_after_tools.positive?
@@ -154,7 +173,7 @@ module Kward
154
173
 
155
174
  def log_tool(tool_call, content:, duration_ms:, status:, error:)
156
175
  payload = {
157
- "tool_name" => tool_name(tool_call),
176
+ "tool_name" => ToolCall.name(tool_call),
158
177
  "duration_ms" => duration_ms,
159
178
  "status" => status,
160
179
  "result_bytes" => content.to_s.bytesize
@@ -170,11 +189,6 @@ module Kward
170
189
  @telemetry_logger.log("errors", event, payload.merge(TelemetryLogger.error_payload(error)))
171
190
  end
172
191
 
173
- def tool_name(tool_call)
174
- function = tool_call["function"] || tool_call[:function] || {}
175
- function["name"] || function[:name]
176
- end
177
-
178
192
  def auto_compact_if_needed
179
193
  context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
180
194
  Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
@@ -195,7 +209,7 @@ module Kward
195
209
  nil
196
210
  end
197
211
 
198
- def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
212
+ def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
199
213
  reasoning_delta = lambda do |delta|
200
214
  cancellation&.raise_if_cancelled!
201
215
  on_reasoning_delta&.call(delta)
@@ -211,21 +225,37 @@ module Kward
211
225
  on_retry&.call(event)
212
226
  yield event if block_given?
213
227
  end
214
- ChatInvocation.call(
228
+ registry = tool_registry || @tool_registry
229
+ request = {
230
+ messages: @conversation.context_messages,
231
+ tools: registry.schemas,
232
+ provider: options[:provider] || @conversation.provider,
233
+ model: options[:model] || @conversation.model,
234
+ reasoning: options[:reasoning] || @conversation.reasoning_effort
235
+ }
236
+ before = run_hook("model_request_before", payload: request)
237
+ request = DeepCopy.merge(request, before.payload) if before.decision.modify?
238
+ return { "role" => "assistant", "content" => hook_denied_answer(before) } if before.denied?
239
+
240
+ run_hook("turn_model_request_before", payload: request)
241
+ response = ChatInvocation.call(
215
242
  @client,
216
- @conversation.context_messages,
243
+ request[:messages] || request["messages"],
217
244
  {
218
- tools: @tool_registry.schemas,
245
+ tools: request[:tools] || request["tools"],
219
246
  on_reasoning_delta: reasoning_delta,
220
247
  on_assistant_delta: assistant_delta,
221
248
  on_retry: retry_callback,
222
249
  cancellation: cancellation,
223
250
  steering: steering,
224
- provider: @conversation.provider,
225
- model: @conversation.model,
226
- reasoning: @conversation.reasoning_effort
251
+ provider: request[:provider] || request["provider"],
252
+ model: request[:model] || request["model"],
253
+ reasoning: request[:reasoning] || request["reasoning"]
227
254
  }
228
255
  )
256
+ run_hook("model_response_after_parse", payload: { message: response })
257
+ run_hook("turn_model_response_complete", payload: { message: response })
258
+ response
229
259
  end
230
260
 
231
261
  def update_conversation_runtime(message)
@@ -248,6 +278,26 @@ module Kward
248
278
  "I have not changed any files. I need to use write_file or edit_file successfully before claiming a file change."
249
279
  end
250
280
 
281
+ def run_hook(name, payload: {})
282
+ return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
283
+
284
+ @hook_manager.run(Hooks::Event.new(
285
+ name: name,
286
+ session: { id: @conversation.respond_to?(:session_id) ? @conversation.session_id : nil },
287
+ workspace: { root: @conversation.respond_to?(:workspace_root) ? @conversation.workspace_root : nil },
288
+ agent: {
289
+ provider: @conversation.provider,
290
+ model: @conversation.model,
291
+ reasoning: @conversation.reasoning_effort
292
+ },
293
+ payload: payload
294
+ ), context: @hook_context)
295
+ end
296
+
297
+ def hook_denied_answer(result)
298
+ "Declined: #{result.decision.message || "lifecycle hook denied the operation"}"
299
+ end
300
+
251
301
  def claims_file_edit?(text)
252
302
  text.match?(/\b(?:I|I've|I have)\s+(?:changed|updated|modified|edited|created|deleted|wrote)\s+(?:the\s+)?(?:file|files|[\w.\/-]+\.[\w-]+)\b/i)
253
303
  end
@@ -1,18 +1,17 @@
1
1
  require "base64"
2
- require "digest"
3
2
  require "json"
4
- require "net/http"
5
- require "securerandom"
6
- require "socket"
7
3
  require "time"
8
4
  require "uri"
9
5
  require_relative "file"
6
+ require_relative "oauth_helpers"
10
7
  require_relative "../config_files"
11
8
 
12
9
  # Namespace for the Kward CLI agent runtime.
13
10
  module Kward
14
11
  # OAuth helper for Anthropic Claude Pro/Max subscription credentials.
15
12
  class AnthropicOAuth
13
+ include OAuthHelpers
14
+
16
15
  AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
17
16
  TOKEN_URL = URI("https://platform.claude.com/v1/oauth/token")
18
17
  DEFAULT_PORT = 53_692
@@ -189,49 +188,8 @@ module Kward
189
188
  DEFAULT_CLIENT_ID
190
189
  end
191
190
 
192
- def generate_pkce
193
- verifier = random_urlsafe(64)
194
- challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
195
- { verifier: verifier, challenge: challenge }
196
- end
197
-
198
- def random_urlsafe(bytes)
199
- Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
200
- end
201
-
202
191
  def start_callback_server
203
- TCPServer.new("localhost", Integer(ENV.fetch("KWARD_ANTHROPIC_OAUTH_PORT", DEFAULT_PORT)))
204
- rescue Errno::EADDRINUSE
205
- TCPServer.new("localhost", 0)
206
- end
207
-
208
- def wait_for_callback(server, expected_state:, timeout_seconds:)
209
- ready = IO.select([server], nil, nil, timeout_seconds)
210
- return nil unless ready
211
-
212
- socket = server.accept
213
- request_line = socket.gets.to_s
214
- path = request_line.split[1].to_s
215
- params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
216
-
217
- code = nil
218
- status = "200 OK"
219
- body = "Login complete. You can close this window."
220
- if params["error"]
221
- body = "Login failed. Return to the terminal."
222
- elsif params["state"].to_s != expected_state.to_s
223
- status = "400 Bad Request"
224
- body = "Invalid OAuth state. Return to the terminal."
225
- else
226
- code = params["code"]
227
- end
228
-
229
- socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
230
- code
231
- rescue URI::InvalidURIError
232
- nil
233
- ensure
234
- socket&.close
192
+ oauth_callback_server(port_env: "KWARD_ANTHROPIC_OAUTH_PORT", default_port: DEFAULT_PORT)
235
193
  end
236
194
 
237
195
  def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
@@ -245,47 +203,7 @@ module Kward
245
203
  end
246
204
 
247
205
  def post_json(uri, params)
248
- request = Net::HTTP::Post.new(uri)
249
- request["Content-Type"] = "application/json"
250
- request["Accept"] = "application/json"
251
- request.body = JSON.dump(params)
252
-
253
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
254
- end
255
-
256
- def token_expired?(auth)
257
- expires_at = auth&.fetch("expires_at", nil)
258
- return false unless expires_at
259
-
260
- Time.parse(expires_at) <= Time.now.utc + 60
261
- rescue ArgumentError
262
- false
263
- end
264
-
265
- def expires_at_for(tokens)
266
- expires_in = tokens["expires_in"] || tokens[:expires_in]
267
- return tokens["expires_at"] || tokens[:expires_at] unless expires_in
268
-
269
- (Time.now.utc + expires_in.to_i).iso8601
270
- end
271
-
272
- def parse_successful_json(response, label)
273
- raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
274
-
275
- JSON.parse(response.body)
276
- rescue JSON::ParserError
277
- raise "#{label} returned invalid JSON"
278
- end
279
-
280
- def open_url(url)
281
- command = if RUBY_PLATFORM.match?(/darwin/)
282
- "open"
283
- elsif RUBY_PLATFORM.match?(/linux/)
284
- "xdg-open"
285
- end
286
- return false unless command
287
-
288
- system(command, url, out: File::NULL, err: File::NULL)
206
+ super(uri, params, headers: { "Accept" => "application/json" })
289
207
  end
290
208
  end
291
209
  end
@@ -0,0 +1,105 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "socket"
7
+ require "time"
8
+ require "uri"
9
+
10
+ # Namespace for the Kward CLI agent runtime.
11
+ module Kward
12
+ # Shared browser OAuth mechanics used by provider-specific credential flows.
13
+ module OAuthHelpers
14
+ private
15
+
16
+ def generate_pkce
17
+ verifier = random_urlsafe(64)
18
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
19
+ { verifier: verifier, challenge: challenge }
20
+ end
21
+
22
+ def random_urlsafe(bytes)
23
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
24
+ end
25
+
26
+ def oauth_callback_server(port_env:, default_port:)
27
+ TCPServer.new("localhost", Integer(ENV.fetch(port_env, default_port)))
28
+ rescue Errno::EADDRINUSE
29
+ TCPServer.new("localhost", 0)
30
+ end
31
+
32
+ def wait_for_callback(server, expected_state:, timeout_seconds:)
33
+ ready = IO.select([server], nil, nil, timeout_seconds)
34
+ return nil unless ready
35
+
36
+ socket = server.accept
37
+ request_line = socket.gets.to_s
38
+ path = request_line.split[1].to_s
39
+ params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
40
+
41
+ code = nil
42
+ status = "200 OK"
43
+ body = "Login complete. You can close this window."
44
+ if params["error"]
45
+ body = "Login failed. Return to the terminal."
46
+ elsif params["state"].to_s != expected_state.to_s
47
+ status = "400 Bad Request"
48
+ body = "Invalid OAuth state. Return to the terminal."
49
+ else
50
+ code = params["code"]
51
+ end
52
+
53
+ socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
54
+ code
55
+ rescue URI::InvalidURIError
56
+ nil
57
+ ensure
58
+ socket&.close
59
+ end
60
+
61
+ def post_json(uri, params, headers: {})
62
+ request = Net::HTTP::Post.new(uri)
63
+ request["Content-Type"] = "application/json"
64
+ headers.each { |key, value| request[key] = value }
65
+ request.body = JSON.dump(params)
66
+
67
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
68
+ end
69
+
70
+ def token_expired?(auth)
71
+ expires_at = auth&.fetch("expires_at", nil)
72
+ return false unless expires_at
73
+
74
+ Time.parse(expires_at) <= Time.now.utc + 60
75
+ rescue ArgumentError
76
+ false
77
+ end
78
+
79
+ def expires_at_for(tokens)
80
+ expires_in = tokens["expires_in"] || tokens[:expires_in]
81
+ return tokens["expires_at"] || tokens[:expires_at] unless expires_in
82
+
83
+ (Time.now.utc + expires_in.to_i).iso8601
84
+ end
85
+
86
+ def parse_successful_json(response, label)
87
+ raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
88
+
89
+ JSON.parse(response.body)
90
+ rescue JSON::ParserError
91
+ raise "#{label} returned invalid JSON"
92
+ end
93
+
94
+ def open_url(url)
95
+ command = if RUBY_PLATFORM.match?(/darwin/)
96
+ "open"
97
+ elsif RUBY_PLATFORM.match?(/linux/)
98
+ "xdg-open"
99
+ end
100
+ return false unless command
101
+
102
+ system(command, url, out: File::NULL, err: File::NULL)
103
+ end
104
+ end
105
+ end
@@ -1,17 +1,17 @@
1
1
  require "base64"
2
- require "digest"
3
2
  require "json"
4
3
  require "net/http"
5
- require "securerandom"
6
- require "socket"
7
4
  require "time"
8
5
  require "uri"
9
6
  require_relative "file"
7
+ require_relative "oauth_helpers"
10
8
 
11
9
  # Namespace for the Kward CLI agent runtime.
12
10
  module Kward
13
11
  # OAuth helper for ChatGPT/OpenAI Codex credentials.
14
12
  class OpenAIOAuth
13
+ include OAuthHelpers
14
+
15
15
  ISSUER = "https://auth.openai.com"
16
16
  TOKEN_URL = URI("#{ISSUER}/oauth/token")
17
17
  DEFAULT_PORT = 1455
@@ -200,49 +200,8 @@ module Kward
200
200
  raise "Invalid Kward config JSON: #{@config_path}"
201
201
  end
202
202
 
203
- def generate_pkce
204
- verifier = random_urlsafe(64)
205
- challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
206
- { verifier: verifier, challenge: challenge }
207
- end
208
-
209
- def random_urlsafe(bytes)
210
- Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
211
- end
212
-
213
203
  def start_callback_server
214
- TCPServer.new("localhost", Integer(ENV.fetch("KWARD_OAUTH_PORT", DEFAULT_PORT)))
215
- rescue Errno::EADDRINUSE
216
- TCPServer.new("localhost", 0)
217
- end
218
-
219
- def wait_for_callback(server, expected_state:, timeout_seconds:)
220
- ready = IO.select([server], nil, nil, timeout_seconds)
221
- return nil unless ready
222
-
223
- socket = server.accept
224
- request_line = socket.gets.to_s
225
- path = request_line.split[1].to_s
226
- params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
227
-
228
- code = nil
229
- status = "200 OK"
230
- body = "Login complete. You can close this window."
231
- if params["error"]
232
- body = "Login failed. Return to the terminal."
233
- elsif params["state"] != expected_state
234
- status = "400 Bad Request"
235
- body = "Invalid OAuth state. Return to the terminal."
236
- else
237
- code = params["code"]
238
- end
239
-
240
- socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
241
- code
242
- rescue URI::InvalidURIError
243
- nil
244
- ensure
245
- socket&.close
204
+ oauth_callback_server(port_env: "KWARD_OAUTH_PORT", default_port: DEFAULT_PORT)
246
205
  end
247
206
 
248
207
  def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
@@ -263,29 +222,6 @@ module Kward
263
222
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
264
223
  end
265
224
 
266
- def post_json(uri, params)
267
- request = Net::HTTP::Post.new(uri)
268
- request["Content-Type"] = "application/json"
269
- request.body = JSON.dump(params)
270
-
271
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
272
- end
273
-
274
- def token_expired?(auth)
275
- expires_at = auth&.fetch("expires_at", nil)
276
- return false unless expires_at
277
-
278
- Time.parse(expires_at) <= Time.now.utc + 60
279
- rescue ArgumentError
280
- false
281
- end
282
-
283
- def expires_at_for(tokens)
284
- expires_in = tokens["expires_in"] || tokens[:expires_in]
285
- return tokens["expires_at"] || tokens[:expires_at] unless expires_in
286
-
287
- (Time.now.utc + expires_in.to_i).iso8601
288
- end
289
225
 
290
226
  def extract_account_id(tokens)
291
227
  [tokens["id_token"], tokens[:id_token], tokens["access_token"], tokens[:access_token]].each do |token|
@@ -305,23 +241,5 @@ module Kward
305
241
  {}
306
242
  end
307
243
 
308
- def parse_successful_json(response, label)
309
- raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
310
-
311
- JSON.parse(response.body)
312
- rescue JSON::ParserError
313
- raise "#{label} returned invalid JSON"
314
- end
315
-
316
- def open_url(url)
317
- command = if RUBY_PLATFORM.match?(/darwin/)
318
- "open"
319
- elsif RUBY_PLATFORM.match?(/linux/)
320
- "xdg-open"
321
- end
322
- return false unless command
323
-
324
- system(command, url, out: File::NULL, err: File::NULL)
325
- end
326
244
  end
327
245
  end
@@ -53,11 +53,12 @@ module Kward
53
53
  #{command.call("kward auth status")} Show saved credential status
54
54
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
55
55
  #{command.call("kward doctor")} Check local Kward setup
56
+ #{command.call("kward hooks doctor")} Inspect lifecycle hook setup
56
57
  #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
57
58
  #{command.call("kward sysprompt")} Inspect the effective system prompt
58
59
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
59
60
  #{command.call("kward pan")} Start Pan mode web UI
60
- #{command.call("kward rpc")} Start the experimental JSON-RPC backend
61
+ #{command.call("kward rpc")} Start the JSON-RPC backend
61
62
 
62
63
  #{heading.call("Commands")}
63
64
  #{command.call("help")} Show this help
@@ -66,6 +67,7 @@ module Kward
66
67
  #{command.call("auth status|logout")} Show or clear saved credentials
67
68
  #{command.call("init")} Install starter prompts and PRINCIPLES.md
68
69
  #{command.call("doctor")} Check local Kward setup
70
+ #{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
69
71
  #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
70
72
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
71
73
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
@@ -127,6 +129,11 @@ module Kward
127
129
  description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
128
130
  examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
129
131
  },
132
+ "hooks" => {
133
+ usage: "kward hooks [list|events|logs|doctor|trust|untrust]",
134
+ description: "Inspect lifecycle hooks, recent audit records, diagnostics, and workspace hook trust.",
135
+ examples: ["kward hooks list", "kward hooks doctor", "kward hooks logs 50", "kward hooks trust"]
136
+ },
130
137
  "edit" => {
131
138
  usage: "kward edit <filename>",
132
139
  description: "Open a file in the integrated editor.",
@@ -154,7 +161,7 @@ module Kward
154
161
  },
155
162
  "rpc" => {
156
163
  usage: "kward rpc",
157
- description: "Start the experimental JSON-RPC backend for UI clients.",
164
+ description: "Start the JSON-RPC backend for trusted local UI clients.",
158
165
  examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
159
166
  }
160
167
  }
@@ -7,11 +7,18 @@ module Kward
7
7
  private
8
8
 
9
9
  def compact_context(agent, argument)
10
+ before = run_lifecycle_hook("session_compact_before", conversation: agent.conversation, payload: { instructions: argument.to_s })
11
+ if before.denied? || before.approval_required?
12
+ runtime_output("Declined: #{before.decision.message || "session compaction denied"}")
13
+ return
14
+ end
15
+
10
16
  result = Compactor.new(
11
17
  conversation: agent.conversation,
12
18
  client: @client,
13
19
  tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
14
20
  ).compact(custom_instructions: argument)
21
+ run_lifecycle_hook("session_compact_after", conversation: agent.conversation, payload: { old_message_count: result.old_message_count, new_message_count: result.new_message_count })
15
22
  runtime_output("Compacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.")
16
23
  render_transcript_block("Assistant", result.summary)
17
24
  rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e