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
@@ -73,6 +73,8 @@ module Kward
73
73
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
74
74
 
75
75
  case code
76
+ when 9
77
+ modified_tab_sequence?(sequence) ? true : select_toggle_search
76
78
  when 13
77
79
  select_input_active? ? select_input_action_result : select_current_choice
78
80
  when 27
@@ -501,25 +503,43 @@ module Kward
501
503
 
502
504
  def select_typed_key(key)
503
505
  return select_insert_key(key) if select_input_active?
504
- return select_begin_search if key == "/" && !select_search_active?
506
+ return select_toggle_search if select_search_toggle_key?(key)
505
507
  return select_action_key(key) unless select_search_active?
506
508
 
507
509
  select_insert_key(key)
508
510
  end
509
511
 
512
+ def select_search_toggle_key?(key)
513
+ TerminalKeys::TAB.include?(key)
514
+ end
515
+
516
+ def modified_tab_sequence?(sequence)
517
+ modifier = sequence[:modifier]
518
+ modifier && modifier != 1
519
+ end
520
+
521
+ def select_toggle_search
522
+ select_search_active? ? select_pause_search : select_begin_search
523
+ end
524
+
510
525
  def select_begin_search
511
526
  return unless @select_state
512
527
 
513
528
  @select_state[:search_active] = true
514
- self.composer_input = ""
515
- self.composer_cursor = 0
516
529
  true
517
530
  end
518
531
 
519
- def select_cancel_search
532
+ def select_pause_search
520
533
  return unless @select_state
521
534
 
522
535
  @select_state[:search_active] = false
536
+ true
537
+ end
538
+
539
+ def select_cancel_search
540
+ return unless @select_state
541
+
542
+ select_pause_search
523
543
  self.composer_input = ""
524
544
  self.composer_cursor = 0
525
545
  @select_state[:selection_index] = 0
@@ -607,7 +627,7 @@ module Kward
607
627
 
608
628
  def selection_matches
609
629
  choices = @select_state ? @select_state[:choices] : []
610
- filter = select_search_active? ? composer_input.downcase.strip : ""
630
+ filter = (select_search_active? || !composer_input.strip.empty?) ? composer_input.downcase.strip : ""
611
631
  matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
612
632
  clamp_selection_index(matches.length)
613
633
  matches
@@ -666,7 +686,7 @@ module Kward
666
686
  return "Renaming · Enter save · Esc cancel" if select_input_active?
667
687
 
668
688
  text = "↑/↓ select · Enter open"
669
- text = "#{text} · / search" unless select_search_active?
689
+ text = "#{text} · Tab search" unless select_search_active?
670
690
  action_keys = @select_state ? @select_state[:action_keys].to_h : {}
671
691
  action_keys.each do |key, action|
672
692
  text = "#{text} · #{key} #{action[:action]}"
@@ -12,6 +12,7 @@ require_relative "ansi"
12
12
  require_relative "terminal_sequences"
13
13
  require_relative "terminal_keys"
14
14
  require_relative "editor_mode"
15
+ require_relative "diff_view_mode"
15
16
  require_relative "prompt_interface/banner"
16
17
  require_relative "prompt_interface/composer_state"
17
18
  require_relative "prompt_interface/editor/state"
@@ -125,7 +126,7 @@ module Kward
125
126
  end
126
127
  end
127
128
 
128
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil)
129
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
129
130
  @input_io = input
130
131
  @output_io = output
131
132
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
@@ -206,6 +207,8 @@ module Kward
206
207
  @editor_bar_cursor_source = editor_bar_cursor_source
207
208
  @editor_line_numbers = normalize_editor_line_numbers(editor_line_numbers)
208
209
  @editor_line_numbers_source = editor_line_numbers_source
210
+ @diff_view = DiffViewMode.normalize(diff_view)
211
+ @diff_view_source = diff_view_source
209
212
  end
210
213
 
211
214
  def start(render: true)
@@ -8,9 +8,8 @@ module Kward
8
8
  { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
9
9
  { name: "quit", description: "Exit the interactive session.", argument_hint: "" },
10
10
  { name: "new", description: "Start a new session.", argument_hint: "" },
11
- { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
- { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
13
- { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
11
+ { name: "session", description: "Open saved sessions or name the current session.", argument_hint: "[path]|name [name]" },
12
+ { name: "resume", description: "Alias for /session.", argument_hint: "[path]" },
14
13
  { name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
15
14
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
16
15
  { name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
@@ -36,6 +35,7 @@ module Kward
36
35
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
37
36
  { name: "status", description: "Show the current status message.", argument_hint: "" },
38
37
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
38
+ { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
39
39
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
40
40
  ].freeze
41
41
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
data/lib/kward/prompts.rb CHANGED
@@ -25,7 +25,8 @@ module Kward
25
25
  sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
26
26
  sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
27
27
  end
28
- sections << prompt_section("Configured skills", skills_prompt, source: ConfigFiles.skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
28
+ skills = ConfigFiles.skills(workspace_root: workspace_root)
29
+ sections << prompt_section("Configured skills", skills_prompt(skills), source: skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
29
30
  sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
30
31
  sections.compact
31
32
  end
@@ -97,18 +98,17 @@ module Kward
97
98
  { label: label, content: content, source: source }
98
99
  end
99
100
 
100
- def skills_prompt
101
- skills = ConfigFiles.skills
101
+ def skills_prompt(skills = ConfigFiles.skills)
102
102
  return nil if skills.empty?
103
103
 
104
104
  lines = [
105
- "Configured skills are available in the Kward config directory.",
106
- "When a task matches a skill, use read_skill to load its instructions before proceeding.",
105
+ "Agent Skills are available.",
106
+ "When a task matches a skill's description, use read_skill to load its instructions before proceeding.",
107
+ "Use read_skill with a relative path to read referenced files inside a skill directory.",
107
108
  "Available skills:"
108
109
  ]
109
110
  skills.each do |skill|
110
- description = skill.description.empty? ? "No description provided." : skill.description
111
- lines << "- #{skill.name}: #{description}"
111
+ lines << "- #{skill.name}: #{skill.description}"
112
112
  end
113
113
  lines.join("\n")
114
114
  end
@@ -0,0 +1,50 @@
1
+ require_relative "../mcp/server_config"
2
+ require_relative "redactor"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
7
+ module RPC
8
+ # Builds RPC-visible MCP server status payloads.
9
+ class MCPStatus
10
+ def initialize(config_manager:)
11
+ @config_manager = config_manager
12
+ end
13
+
14
+ def to_h
15
+ config = @config_manager.read(redacted: false)
16
+ clients = MCP::ServerConfig.clients_from_config(config).to_h { |client| [client.name, client] }
17
+ servers = MCP::ServerConfig.configured_servers(config).map do |name, settings|
18
+ server_status(name, settings, clients[name.to_s])
19
+ end
20
+ { servers: servers }
21
+ end
22
+
23
+ private
24
+
25
+ def server_status(name, settings, client)
26
+ base = { name: name.to_s, transport: "stdio", toolCount: 0 }
27
+ unless client
28
+ return base.merge(status: "unavailable", error: "command not configured") if settings["command"].to_s.empty?
29
+
30
+ return base.merge(status: "unavailable", error: "unsupported MCP server configuration")
31
+ end
32
+
33
+ tools = client.list_tools
34
+ base.merge(status: "available", toolCount: tools.length)
35
+ rescue StandardError => e
36
+ base.merge(status: "unavailable", toolCount: 0, error: redacted_error(e.message, client))
37
+ ensure
38
+ client&.close
39
+ end
40
+
41
+ def redacted_error(message, client)
42
+ text = Redactor.redact_string(message.to_s)
43
+ command = client&.transport&.respond_to?(:command) ? client.transport.command.to_s : ""
44
+ return text if command.empty?
45
+
46
+ text.gsub(command, File.basename(command))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -17,6 +17,32 @@ module Kward
17
17
  @pending_requests = {}
18
18
  end
19
19
 
20
+ def request_tool_approval(tool_call_id:, tool_name:, args:, cancellation: nil)
21
+ request_id = SecureRandom.uuid
22
+ @mutex.synchronize { @pending_requests[request_id] = true }
23
+ cancellation&.on_cancel { cancel_request(request_id) }
24
+ unless cancellation&.cancelled?
25
+ @server.notify("tool/approvalRequested", {
26
+ sessionId: @session_id,
27
+ approvalRequestId: request_id,
28
+ toolCallId: tool_call_id,
29
+ toolName: tool_name,
30
+ args: args
31
+ })
32
+ end
33
+
34
+ @mutex.synchronize do
35
+ @condition.wait(@mutex) until @answers.key?(request_id)
36
+ answer = @answers.delete(request_id)
37
+ @pending_requests.delete(request_id)
38
+ approved_answer?(answer)
39
+ end
40
+ end
41
+
42
+ def answer_tool_approval(request_id, approved:)
43
+ answer_raw(request_id, !!approved)
44
+ end
45
+
20
46
  def ask_user_question(questions, cancellation: nil)
21
47
  questions = validate_questions(questions)
22
48
  request_id = SecureRandom.uuid
@@ -41,28 +67,36 @@ module Kward
41
67
  end
42
68
 
43
69
  def answer(request_id, answers)
70
+ answer_raw(request_id, normalize_answers(answers))
71
+ end
72
+
73
+ def cancel_request(request_id)
44
74
  @mutex.synchronize do
45
75
  request_id = request_id.to_s
46
76
  return unless @pending_requests.key?(request_id)
47
77
  return if @answers.key?(request_id)
48
78
 
49
- @answers[request_id] = normalize_answers(answers)
79
+ @answers[request_id] = nil
50
80
  @condition.broadcast
51
81
  end
52
82
  end
53
83
 
54
- def cancel_request(request_id)
84
+ private
85
+
86
+ def answer_raw(request_id, value)
55
87
  @mutex.synchronize do
56
88
  request_id = request_id.to_s
57
89
  return unless @pending_requests.key?(request_id)
58
90
  return if @answers.key?(request_id)
59
91
 
60
- @answers[request_id] = nil
92
+ @answers[request_id] = value
61
93
  @condition.broadcast
62
94
  end
63
95
  end
64
96
 
65
- private
97
+ def approved_answer?(answer)
98
+ answer == true
99
+ end
66
100
 
67
101
  def normalize_answers(answers)
68
102
  return nil if answers.nil?
@@ -2,7 +2,7 @@
2
2
  module Kward
3
3
  # JSON-RPC backend namespace used by UI clients.
4
4
  module RPC
5
- # Builders for Tauren-compatible runtime state payloads.
5
+ # Builders for frontend-neutral runtime state payloads.
6
6
  module RuntimePayloads
7
7
  module_function
8
8
 
@@ -11,6 +11,7 @@ require_relative "../telemetry/logger"
11
11
  require_relative "../telemetry/stats"
12
12
  require_relative "auth_manager"
13
13
  require_relative "config_manager"
14
+ require_relative "mcp_status"
14
15
  require_relative "redactor"
15
16
  require_relative "session_manager"
16
17
  require_relative "transport"
@@ -19,7 +20,7 @@ require_relative "transport"
19
20
  module Kward
20
21
  # JSON-RPC backend namespace used by UI clients.
21
22
  module RPC
22
- # Experimental JSON-RPC backend for UI clients.
23
+ # JSON-RPC backend for trusted local UI clients.
23
24
  #
24
25
  # The server speaks LSP-style Content-Length framing over stdin/stdout,
25
26
  # exposes capabilities during `initialize`, redacts secrets in errors and
@@ -47,14 +48,15 @@ module Kward
47
48
  PROTOCOL_METHODS = ["initialize", "shutdown"].freeze
48
49
  WORKSPACE_METHODS = ["workspace/validate", "workspace/info"].freeze
49
50
  TOOL_METHODS = ["tools/list"].freeze
51
+ MCP_METHODS = ["mcp/status"].freeze
50
52
  PROMPT_METHODS = ["prompts/list", "prompts/expand"].freeze
51
53
  SESSION_METHODS = [
52
54
  "sessions/create", "sessions/resume", "sessions/list", "sessions/rename",
53
55
  "sessions/clone", "sessions/compact", "sessions/forkMessages", "sessions/fork",
54
56
  "sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
55
- "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
57
+ "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript", "sessions/active"
56
58
  ].freeze
57
- TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events"].freeze
59
+ TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events", "turns/list", "turns/listActive"].freeze
58
60
  MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
59
61
  RUNTIME_METHODS = ["runtime/state", "runtime/stats"].freeze
60
62
  RUNTIME_SETTING_METHODS = ["runtime/updateSetting", "runtime/reload"].freeze
@@ -73,16 +75,21 @@ module Kward
73
75
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
74
76
  CONFIG_METHODS = ["config/read", "config/update"].freeze
75
77
  LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
78
+ LIFECYCLE_HOOK_METHODS = ["hooks/logs"].freeze
76
79
  UI_METHODS = ["ui/answerQuestion"].freeze
80
+ TOOL_APPROVAL_METHODS = ["tool/answerApproval"].freeze
77
81
  SESSION_EVENT_NOTIFICATION = "session/event"
78
82
  SESSION_UPDATED_NOTIFICATION = "session/updated"
79
83
  TURN_EVENT_NOTIFICATION = "turn/event"
80
84
  UI_QUESTION_NOTIFICATION = "ui/question"
81
85
  UI_FOOTER_NOTIFICATION = "ui/footer"
86
+ TOOL_APPROVAL_NOTIFICATION = "tool/approvalRequested"
87
+ HOOK_EVENT_NOTIFICATION = "hook/event"
82
88
  METHOD_GROUPS = {
83
89
  protocol: PROTOCOL_METHODS,
84
90
  workspace: WORKSPACE_METHODS,
85
91
  tools: TOOL_METHODS,
92
+ mcp: MCP_METHODS,
86
93
  prompts: PROMPT_METHODS,
87
94
  sessions: SESSION_METHODS,
88
95
  turns: TURN_METHODS,
@@ -96,7 +103,9 @@ module Kward
96
103
  startup_resources: STARTUP_RESOURCE_METHODS,
97
104
  config: CONFIG_METHODS,
98
105
  logging: LOGGING_METHODS,
99
- ui: UI_METHODS
106
+ lifecycle_hooks: LIFECYCLE_HOOK_METHODS,
107
+ ui: UI_METHODS,
108
+ tool_approval: TOOL_APPROVAL_METHODS
100
109
  }.freeze
101
110
  RPC_METHODS = METHOD_GROUPS.values.flatten.freeze
102
111
 
@@ -201,7 +210,9 @@ module Kward
201
210
  when WORKSPACE_METHODS[1]
202
211
  workspace_info(params["workspaceRoot"] || Dir.pwd)
203
212
  when TOOL_METHODS[0]
204
- { tools: ToolRegistry.new(workspace: configured_workspace).schemas }
213
+ @session_manager.tool_schemas(session_id: params["sessionId"])
214
+ when MCP_METHODS[0]
215
+ MCPStatus.new(config_manager: @config_manager).to_h
205
216
  when PROMPT_METHODS[0]
206
217
  prompts_list
207
218
  when PROMPT_METHODS[1]
@@ -236,6 +247,8 @@ module Kward
236
247
  logging_stats(params)
237
248
  when LOGGING_METHODS[1]
238
249
  logging_token_csv(params)
250
+ when LIFECYCLE_HOOK_METHODS[0]
251
+ lifecycle_hook_logs(params)
239
252
  when MEMORY_METHODS[0]
240
253
  @session_manager.memory_status
241
254
  when MEMORY_METHODS[1]
@@ -316,12 +329,16 @@ module Kward
316
329
  @session_manager.close_session(session_id: params.fetch("sessionId"))
317
330
  when SESSION_METHODS[14]
318
331
  @session_manager.transcript(session_id: params.fetch("sessionId"))
332
+ when SESSION_METHODS[15]
333
+ @session_manager.active_sessions
319
334
  when TURN_METHODS[0]
320
335
  @session_manager.start_turn(
321
336
  session_id: params.fetch("sessionId"),
322
337
  input: params.fetch("input"),
323
338
  streaming_behavior: params["streamingBehavior"],
324
- attachments: params["attachments"] || []
339
+ attachments: params["attachments"] || [],
340
+ options: params["options"] || {},
341
+ context: params["context"]
325
342
  )
326
343
  when TURN_METHODS[1]
327
344
  @session_manager.cancel_turn(turn_id: params.fetch("turnId"))
@@ -329,8 +346,14 @@ module Kward
329
346
  @session_manager.turn_status(turn_id: params.fetch("turnId"))
330
347
  when TURN_METHODS[3]
331
348
  @session_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
349
+ when TURN_METHODS[4]
350
+ @session_manager.list_turns(session_id: params["sessionId"])
351
+ when TURN_METHODS[5]
352
+ @session_manager.list_turns(session_id: params["sessionId"], active: true)
332
353
  when UI_METHODS[0]
333
354
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
355
+ when TOOL_APPROVAL_METHODS[0]
356
+ @session_manager.answer_tool_approval(session_id: params.fetch("sessionId"), approval_request_id: params.fetch("approvalRequestId"), approved: params.fetch("approved"))
334
357
  else
335
358
  raise NoMethodError, method
336
359
  end
@@ -340,7 +363,7 @@ module Kward
340
363
  {
341
364
  protocolVersion: PROTOCOL_VERSION,
342
365
  serverName: "kward",
343
- experimental: true,
366
+ experimental: false,
344
367
  capabilities: capabilities
345
368
  }
346
369
  end
@@ -349,7 +372,7 @@ module Kward
349
372
  {
350
373
  framing: "content-length",
351
374
  transcript: {
352
- format: "tauren-transcript-v1",
375
+ format: "kward-transcript-v1",
353
376
  messagesNormalized: true,
354
377
  supportsImages: true,
355
378
  supportsToolCalls: true,
@@ -363,10 +386,11 @@ module Kward
363
386
  methods: SESSION_METHODS,
364
387
  startupResume: { supported: true, method: SESSION_METHODS[0], parameter: "resumeLast", default: session_auto_resume_enabled?, immediateTranscript: true, sessionActivePersonaLabel: true },
365
388
  list: { supported: true, source: "rpc", ancestry: true, treeFields: true },
389
+ active: { supported: true, method: SESSION_METHODS[15] },
366
390
  fork: { supported: true, methods: SESSION_METHODS.values_at(6, 7), entryIdFormat: "entry-id", selectedMessage: "excludedFromForkComposerTextReturned" },
367
391
  compact: { supported: true, method: SESSION_METHODS[5], notification: SESSION_EVENT_NOTIFICATION, events: ["compactionStart", "compactionEnd"] },
368
392
  import: { supported: false },
369
- tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "tauren-tree-items-v1" },
393
+ tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
370
394
  updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
371
395
  },
372
396
  turns: {
@@ -383,7 +407,21 @@ module Kward
383
407
  queuedTurns: "cancel-before-run",
384
408
  runningTurns: "stop-emitting-events-when-possible"
385
409
  },
386
- eventReplay: { behavior: "recent-in-memory", persisted: false, limit: SessionManager::RECENT_EVENT_LIMIT }
410
+ eventReplay: { behavior: "recent-in-memory", persisted: false, limit: SessionManager::RECENT_EVENT_LIMIT },
411
+ options: {
412
+ supported: true,
413
+ method: TURN_METHODS[0],
414
+ fields: ["provider", "model", "reasoningEffort", "allowedTools", "disabledTools", "approvalMode"],
415
+ perTurnModel: true,
416
+ perTurnReasoning: true,
417
+ perTurnToolScope: true
418
+ },
419
+ list: { supported: true, method: TURN_METHODS[4], activeMethod: TURN_METHODS[5] },
420
+ context: {
421
+ supported: true,
422
+ method: TURN_METHODS[0],
423
+ fields: ["activeFile", "openFiles", "selection", "diagnostics"]
424
+ }
387
425
  },
388
426
  events: {
389
427
  notification: TURN_EVENT_NOTIFICATION,
@@ -391,7 +429,7 @@ module Kward
391
429
  reasoning: { start: false, delta: true, end: false },
392
430
  modelRetry: { supported: true, event: "modelRetry" },
393
431
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
394
- tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
432
+ tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
395
433
  errors: true,
396
434
  sessionUpdates: false
397
435
  },
@@ -417,6 +455,20 @@ module Kward
417
455
  state: { supported: true },
418
456
  stats: { messageCounts: true, tokens: false, cost: false, contextUsage: true, contextUsageEstimated: true }
419
457
  },
458
+ lifecycleHooks: {
459
+ supported: true,
460
+ events: Hooks::Catalog.event_names,
461
+ decisions: Hooks::Decision::VALID_DECISIONS,
462
+ approvals: { supported: true, via: "toolApproval", requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
463
+ notifications: [HOOK_EVENT_NOTIFICATION, "hook/message"],
464
+ methods: LIFECYCLE_HOOK_METHODS,
465
+ auditLog: { supported: true, path: File.join(ConfigFiles.config_dir, "logs", "hooks.jsonl") },
466
+ commandHooks: true,
467
+ pluginHooks: true,
468
+ workspaceHooks: { supported: true, trustRequired: true, trustCommand: "/hooks trust" },
469
+ httpHooks: { supported: true, protocol: "jsonPost" },
470
+ asyncHooks: { supported: true, semantics: "observeOnly" }
471
+ },
420
472
  runtimeSettings: {
421
473
  supported: true,
422
474
  methods: RUNTIME_SETTING_METHODS,
@@ -424,7 +476,7 @@ module Kward
424
476
  },
425
477
  auth: {
426
478
  supported: true,
427
- providerFormat: "tauren-auth-v1",
479
+ providerFormat: "kward-auth-v1",
428
480
  methods: AUTH_METHODS,
429
481
  oauthProviders: ["openai", "anthropic", "github"],
430
482
  unsupportedOAuthProviders: { github: "CLI-only GitHub login for Copilot scaffolding; RPC login is not implemented yet." },
@@ -433,8 +485,21 @@ module Kward
433
485
  },
434
486
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
435
487
  workers: workers_capability,
488
+ stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: ["workers"] },
436
489
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
437
- mcp: { supported: true, transport: "stdio", config: "mcpServers", exposes: ["tools"], unsupported: ["resources", "prompts", "sampling", "streamableHttp"] },
490
+ mcp: {
491
+ supported: true,
492
+ transport: "stdio",
493
+ config: "mcpServers",
494
+ exposes: ["tools"],
495
+ discovery: {
496
+ supported: true,
497
+ methods: TOOL_METHODS + MCP_METHODS,
498
+ toolMetadata: true,
499
+ serverStatus: true
500
+ },
501
+ unsupported: ["resources", "prompts", "sampling", "streamableHttp"]
502
+ },
438
503
  startupResources: { supported: true, method: STARTUP_RESOURCE_METHODS.first },
439
504
  starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
440
505
  shell: { supported: false, reason: "interactiveTuiOnly" },
@@ -456,7 +521,7 @@ module Kward
456
521
  },
457
522
  security: {
458
523
  workspaceMutationGuard: "none",
459
- toolApproval: "none",
524
+ toolApproval: { supported: true, defaultMode: "none", modes: ["none", "ask"], requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
460
525
  canRunShell: true,
461
526
  canWriteFiles: true
462
527
  },
@@ -564,6 +629,21 @@ module Kward
564
629
  raise ArgumentError, e.message == TelemetryStats::USAGE ? e.message : "#{e.message} #{TelemetryStats::USAGE}"
565
630
  end
566
631
 
632
+ def lifecycle_hook_logs(params)
633
+ limit = params.fetch("limit", 20).to_i
634
+ limit = 20 unless limit.positive?
635
+ limit = 200 if limit > 200
636
+ path = File.join(ConfigFiles.config_dir, "logs", "hooks.jsonl")
637
+ return { path: path, records: [] } unless File.file?(path)
638
+
639
+ records = File.readlines(path, chomp: true).last(limit).filter_map do |line|
640
+ JSON.parse(line)
641
+ rescue JSON::ParserError
642
+ nil
643
+ end
644
+ { path: path, records: records }
645
+ end
646
+
567
647
  def commands_list(params)
568
648
  @session_manager.runtime_state(session_id: params.fetch("sessionId"))
569
649
  prompts = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).map do |template|