kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,639 @@
1
+ require "json"
2
+ require_relative "../model/client"
3
+ require_relative "../config_files"
4
+ require_relative "../memory/manager"
5
+ require_relative "../plugin_registry"
6
+ require_relative "../prompts/commands"
7
+ require_relative "../tools/registry"
8
+ require_relative "../workspace"
9
+ require_relative "../telemetry/logger"
10
+ require_relative "../telemetry/stats"
11
+ require_relative "auth_manager"
12
+ require_relative "config_manager"
13
+ require_relative "redactor"
14
+ require_relative "session_manager"
15
+ require_relative "transport"
16
+
17
+ module Kward
18
+ module RPC
19
+ # Experimental JSON-RPC backend for UI clients.
20
+ #
21
+ # The server speaks LSP-style Content-Length framing over stdin/stdout,
22
+ # exposes capabilities during `initialize`, redacts secrets in errors and
23
+ # notifications, and coordinates auth, config, sessions, turns, tools,
24
+ # memory, commands, and startup resources.
25
+ class Server
26
+ PROTOCOL_VERSION = 1
27
+ JSONRPC_VERSION = "2.0"
28
+ BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
29
+
30
+ ERROR_CODES = {
31
+ parse_error: -32_700,
32
+ invalid_request: -32_600,
33
+ method_not_found: -32_601,
34
+ invalid_params: -32_602,
35
+ internal_error: -32_603
36
+ }.freeze
37
+
38
+ def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
39
+ @transport = Transport.new(input: input, output: output)
40
+ @error_output = error_output
41
+ @session_manager = SessionManager.new(server: self, client: client)
42
+ @config_manager = ConfigManager.new
43
+ @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
44
+ @shutdown = false
45
+ end
46
+
47
+ # Reads framed JSON-RPC messages until shutdown or EOF.
48
+ #
49
+ # @return [void]
50
+ def run
51
+ until @shutdown
52
+ begin
53
+ message = @transport.read_message
54
+ break unless message
55
+
56
+ handle_message(message)
57
+ rescue JSON::ParserError => e
58
+ write_error(nil, ERROR_CODES[:parse_error], "Parse error", e)
59
+ rescue StandardError => e
60
+ write_error(nil, ERROR_CODES[:invalid_request], e.message, e)
61
+ end
62
+ end
63
+ ensure
64
+ @session_manager.cleanup_unused_sessions
65
+ end
66
+
67
+ # Sends a redacted JSON-RPC notification to the client.
68
+ #
69
+ # @param method [String] notification method name
70
+ # @param params [Hash] notification params
71
+ def notify(method, params = {})
72
+ @transport.write_message({ jsonrpc: JSONRPC_VERSION, method: method, params: Redactor.redact(params) })
73
+ end
74
+
75
+ # Builds redacted diagnostics suitable for JSON-RPC error data.
76
+ #
77
+ # @param error [Exception]
78
+ # @return [Hash]
79
+ def error_payload(error)
80
+ Redactor.redact({
81
+ code: error.class.name,
82
+ message: error.message,
83
+ backtrace: Array(error.backtrace).first(8)
84
+ })
85
+ end
86
+
87
+ def log_error(error)
88
+ @error_output.puts("Kward RPC error: #{Redactor.redact_string(error.message)}") if @error_output
89
+ end
90
+
91
+ private
92
+
93
+ def handle_message(message)
94
+ unless message.is_a?(Hash) && (message["jsonrpc"] == JSONRPC_VERSION || message[:jsonrpc] == JSONRPC_VERSION)
95
+ write_error(message_id(message), ERROR_CODES[:invalid_request], "Invalid Request")
96
+ return
97
+ end
98
+
99
+ id = message_id(message)
100
+ method = message["method"] || message[:method]
101
+ params = message["params"] || message[:params] || {}
102
+
103
+ unless method.is_a?(String) && !method.empty?
104
+ write_error(id, ERROR_CODES[:invalid_request], "Invalid Request") if id
105
+ return
106
+ end
107
+
108
+ if id.nil?
109
+ dispatch(method, params)
110
+ else
111
+ result = dispatch(method, params)
112
+ write_result(id, result)
113
+ end
114
+ rescue NoMethodError => e
115
+ write_error(id, ERROR_CODES[:method_not_found], "Method not found: #{method}", e) if id
116
+ rescue ArgumentError => e
117
+ write_error(id, ERROR_CODES[:invalid_params], e.message, e) if id
118
+ rescue StandardError => e
119
+ write_error(id, ERROR_CODES[:internal_error], e.message, e) if id
120
+ end
121
+
122
+ def dispatch(method, params)
123
+ params = stringify_keys(params || {})
124
+ case method
125
+ when "initialize"
126
+ initialize_result
127
+ when "shutdown"
128
+ @shutdown = true
129
+ { ok: true }
130
+ when "workspace/validate"
131
+ { root: @session_manager.validate_workspace_root(params["workspaceRoot"] || Dir.pwd) }
132
+ when "workspace/info"
133
+ workspace_info(params["workspaceRoot"] || Dir.pwd)
134
+ when "tools/list"
135
+ { tools: ToolRegistry.new.schemas }
136
+ when "prompts/list"
137
+ prompts_list
138
+ when "prompts/expand"
139
+ prompts_expand(params)
140
+ when "models/list"
141
+ models_list
142
+ when "openrouter/catalog"
143
+ openrouter_catalog
144
+ when "models/current"
145
+ models_current
146
+ when "models/set"
147
+ models_set(params)
148
+ when "reasoning/set"
149
+ reasoning_set(params)
150
+ when "runtime/state"
151
+ @session_manager.runtime_state(session_id: params.fetch("sessionId"))
152
+ when "runtime/stats"
153
+ @session_manager.runtime_stats(session_id: params.fetch("sessionId"))
154
+ when "runtime/updateSetting"
155
+ runtime_update_setting(params)
156
+ when "runtime/reload"
157
+ runtime_reload(params)
158
+ when "commands/list"
159
+ commands_list(params)
160
+ when "commands/run"
161
+ commands_run(params)
162
+ when "resources/startup"
163
+ startup_resources(params)
164
+ when "config/read"
165
+ { path: @config_manager.config_path, config: @config_manager.read(redacted: params.fetch("redacted", true)) }
166
+ when "config/update"
167
+ config_update(params)
168
+ when "logging/stats"
169
+ logging_stats(params)
170
+ when "logging/tokenCsv"
171
+ logging_token_csv(params)
172
+ when "memory/status"
173
+ @session_manager.memory_status
174
+ when "memory/enable"
175
+ @session_manager.memory_enable
176
+ when "memory/disable"
177
+ @session_manager.memory_disable
178
+ when "memory/autoSummary/enable"
179
+ @session_manager.memory_auto_summary_enable
180
+ when "memory/autoSummary/disable"
181
+ @session_manager.memory_auto_summary_disable
182
+ when "memory/list"
183
+ @session_manager.memory_list(include_inactive: params["includeInactive"] || false)
184
+ when "memory/add"
185
+ @session_manager.memory_add(text: params.fetch("text"), scope: params["scope"], tags: params["tags"] || [])
186
+ when "memory/addCore"
187
+ @session_manager.memory_add_core(text: params.fetch("text"), scope: params["scope"], tags: params["tags"] || [])
188
+ when "memory/forget"
189
+ @session_manager.memory_forget(id: params.fetch("id"))
190
+ when "memory/promote"
191
+ @session_manager.memory_promote(id: params.fetch("id"))
192
+ when "memory/inspect"
193
+ @session_manager.memory_inspect
194
+ when "memory/why"
195
+ @session_manager.memory_why(session_id: params["sessionId"])
196
+ when "memory/summarize"
197
+ @session_manager.memory_summarize(session_id: params.fetch("sessionId"))
198
+ when "auth/status"
199
+ @auth_manager.status
200
+ when "auth/providers"
201
+ @auth_manager.providers
202
+ when "auth/loginWithApiKey"
203
+ auth_login_with_api_key(params)
204
+ when "auth/logoutProvider"
205
+ auth_logout_provider(params)
206
+ when "auth/loginWithOAuth"
207
+ @auth_manager.login_with_oauth(provider_id: params.fetch("providerId"), timeout_seconds: params["timeoutSeconds"] || 120)
208
+ when "auth/startOpenAILogin"
209
+ @auth_manager.start_openai_login(timeout_seconds: params["timeoutSeconds"] || 120)
210
+ when "auth/submitOpenAICode"
211
+ @auth_manager.submit_openai_code(login_id: params.fetch("loginId"), code: params.fetch("code"))
212
+ when "auth/loginStatus"
213
+ @auth_manager.login_status(login_id: params.fetch("loginId"))
214
+ when "sessions/create"
215
+ @session_manager.create_session(workspace_root: params["workspaceRoot"] || Dir.pwd, name: params["name"])
216
+ when "sessions/resume"
217
+ @session_manager.resume_session(path: params.fetch("path"), workspace_root: params["workspaceRoot"])
218
+ when "sessions/list"
219
+ { sessions: @session_manager.list_sessions(workspace_root: params["workspaceRoot"] || Dir.pwd, limit: params["limit"] || 20) }
220
+ when "sessions/rename"
221
+ @session_manager.rename_session(session_id: params.fetch("sessionId"), name: params["name"])
222
+ when "sessions/clone"
223
+ @session_manager.clone_session(session_id: params.fetch("sessionId"))
224
+ when "sessions/compact"
225
+ @session_manager.compact_session(session_id: params.fetch("sessionId"), custom_instructions: params["customInstructions"] || "")
226
+ when "sessions/forkMessages"
227
+ @session_manager.fork_messages(session_id: params.fetch("sessionId"))
228
+ when "sessions/fork"
229
+ @session_manager.fork_session(session_id: params.fetch("sessionId"), entry_id: params.fetch("entryId"))
230
+ when "sessions/export"
231
+ @session_manager.export_session(session_id: params.fetch("sessionId"), path: params["path"], format: params["format"])
232
+ when "sessions/delete"
233
+ @session_manager.delete_session(session_id: params.fetch("sessionId"))
234
+ when "sessions/close"
235
+ @session_manager.close_session(session_id: params.fetch("sessionId"))
236
+ when "sessions/transcript"
237
+ @session_manager.transcript(session_id: params.fetch("sessionId"))
238
+ when "turns/start"
239
+ @session_manager.start_turn(
240
+ session_id: params.fetch("sessionId"),
241
+ input: params.fetch("input"),
242
+ streaming_behavior: params["streamingBehavior"],
243
+ attachments: params["attachments"] || []
244
+ )
245
+ when "turns/cancel"
246
+ @session_manager.cancel_turn(turn_id: params.fetch("turnId"))
247
+ when "turns/status"
248
+ @session_manager.turn_status(turn_id: params.fetch("turnId"))
249
+ when "turns/events"
250
+ @session_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
251
+ when "ui/answerQuestion"
252
+ @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
253
+ else
254
+ raise NoMethodError, method
255
+ end
256
+ end
257
+
258
+ def initialize_result
259
+ {
260
+ protocolVersion: PROTOCOL_VERSION,
261
+ serverName: "kward",
262
+ experimental: true,
263
+ capabilities: capabilities
264
+ }
265
+ end
266
+
267
+ def capabilities
268
+ {
269
+ framing: "content-length",
270
+ asyncTurns: true,
271
+ turnCancellation: "best-effort",
272
+ turnEventReplay: true,
273
+ uiQuestions: true,
274
+ authLogin: true,
275
+ configUpdate: true,
276
+ transcript: {
277
+ format: "tauren-transcript-v1",
278
+ messagesNormalized: true,
279
+ supportsImages: true,
280
+ supportsToolCalls: true,
281
+ supportsToolResults: true,
282
+ supportsCompactionSummaries: true,
283
+ supportsReasoningRestore: true
284
+ },
285
+ sessions: {
286
+ mode: "explicit",
287
+ persistence: "jsonl",
288
+ methods: ["sessions/create", "sessions/resume", "sessions/list", "sessions/rename", "sessions/clone", "sessions/compact", "sessions/forkMessages", "sessions/fork", "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"],
289
+ list: { supported: true, source: "rpc", ancestry: true, treeFields: true },
290
+ fork: { supported: true, methods: ["sessions/forkMessages", "sessions/fork"], entryIdFormat: "message-index", selectedMessage: "excludedFromForkComposerTextReturned" },
291
+ compact: { supported: true, method: "sessions/compact", notification: "session/event", events: ["compactionStart", "compactionEnd"] },
292
+ import: { supported: false },
293
+ tree: { supported: false, labels: false, navigate: false, summarize: false },
294
+ updates: { supported: false, notification: "session/updated" }
295
+ },
296
+ turns: {
297
+ mode: "async",
298
+ perSessionConcurrency: 1,
299
+ busyInput: {
300
+ steer: @session_manager.in_flight_steer_supported? ? "native" : "unsupported",
301
+ followUp: "queue",
302
+ defaultWhenIdle: "newTurn",
303
+ defaultWhenBusy: @session_manager.in_flight_steer_supported? ? "steer" : "followUp"
304
+ },
305
+ cancellation: {
306
+ behavior: "best-effort",
307
+ queuedTurns: "cancel-before-run",
308
+ runningTurns: "stop-emitting-events-when-possible"
309
+ },
310
+ eventReplay: { behavior: "recent-in-memory", persisted: false, limit: SessionManager::RECENT_EVENT_LIMIT }
311
+ },
312
+ events: {
313
+ notification: "turn/event",
314
+ assistantText: "assistantDelta",
315
+ reasoning: { start: false, delta: true, end: false },
316
+ modelRetry: { supported: true, event: "modelRetry" },
317
+ steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
318
+ tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false },
319
+ errors: true,
320
+ sessionUpdates: false
321
+ },
322
+ attachments: {
323
+ input: {
324
+ supported: true,
325
+ method: "turns/start",
326
+ encoding: "base64",
327
+ mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES,
328
+ maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES
329
+ }
330
+ },
331
+ models: {
332
+ supported: true,
333
+ methods: ["models/list", "models/current", "models/set", "reasoning/set", "openrouter/catalog"],
334
+ fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
335
+ scopedModels: false
336
+ },
337
+ runtime: {
338
+ supported: true,
339
+ methods: ["runtime/state", "runtime/stats"],
340
+ state: { supported: true },
341
+ stats: { messageCounts: true, tokens: false, cost: false, contextUsage: true, contextUsageEstimated: true }
342
+ },
343
+ runtimeSettings: {
344
+ supported: true,
345
+ methods: ["runtime/updateSetting", "runtime/reload"],
346
+ settings: ["defaultModel", "defaultThinkingLevel"]
347
+ },
348
+ auth: {
349
+ supported: true,
350
+ providerFormat: "tauren-auth-v1",
351
+ methods: ["auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider", "auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"],
352
+ oauthProviders: ["openai", "github"],
353
+ unsupportedOAuthProviders: { github: "CLI-only GitHub login for Copilot scaffolding; RPC login is not implemented yet." },
354
+ apiKeyProviders: ["openrouter"],
355
+ logout: true
356
+ },
357
+ memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: ["memory/status", "memory/enable", "memory/disable", "memory/autoSummary/enable", "memory/autoSummary/disable", "memory/list", "memory/add", "memory/addCore", "memory/forget", "memory/promote", "memory/inspect", "memory/why", "memory/summarize"] },
358
+ commands: { supported: true, methods: ["commands/list", "commands/run"], method: "commands/list", runMethod: "commands/run", sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
359
+ startupResources: { supported: true, method: "resources/startup" },
360
+ starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
361
+ extensionUi: {
362
+ question: { supported: true, notification: "ui/question", method: "ui/answerQuestion", maxQuestions: 4, multiSelect: false, preview: false },
363
+ select: false,
364
+ confirm: false,
365
+ input: false,
366
+ editor: false,
367
+ widgets: false,
368
+ footer: false,
369
+ custom: false,
370
+ terminalInput: false
371
+ },
372
+ composer: {
373
+ sessionDiff: { supported: false, reason: "interactiveComposerOnly" },
374
+ copy: { supported: false, reason: "clientClipboardOwnedByUi" }
375
+ },
376
+ security: {
377
+ workspaceMutationGuard: "none",
378
+ toolApproval: "none",
379
+ canRunShell: true,
380
+ canWriteFiles: true
381
+ },
382
+ export: { supported: true, formats: ["markdown", "html"], defaultFormat: "markdown" },
383
+ logging: {
384
+ supported: true,
385
+ defaultEnabled: false,
386
+ methods: ["logging/stats", "logging/tokenCsv"],
387
+ stats: { supported: true, method: "logging/stats", defaultRange: TelemetryStats::DEFAULT_RANGE, units: %w[minutes hours days weeks months years] },
388
+ usageCsv: { supported: true, method: "logging/tokenCsv", defaultRange: TelemetryStats::DEFAULT_RANGE, buckets: %w[second minute hour day week month year] },
389
+ config: "logging",
390
+ envPrefix: "KWARD_LOGGING",
391
+ directory: File.join(ConfigFiles.config_dir, "logs"),
392
+ format: "jsonl",
393
+ categories: ["tokens", "performance", "tools", "errors"],
394
+ rotation: { maxBytes: TelemetryLogger::DEFAULT_MAX_BYTES, retention: "manual" },
395
+ content: "redacted-metadata-only"
396
+ },
397
+ session: { mode: "explicit", persistence: "jsonl" },
398
+ cancellation: { behavior: "best-effort", queuedTurns: "cancel-before-run", runningTurns: "stop-emitting-events-when-possible" },
399
+ eventReplay: { behavior: "recent-in-memory", persisted: false, limit: SessionManager::RECENT_EVENT_LIMIT },
400
+ uiQuestion: { supported: true, method: "ui/answerQuestion", notification: "ui/question", maxQuestions: 4, multiSelect: false },
401
+ prompts: { supported: true, methods: ["prompts/list", "prompts/expand"] },
402
+ skills: { supported: true, tool: "read_skill" },
403
+ tools: { supported: true, method: "tools/list", eventMetadata: true },
404
+ config: { supported: true, methods: ["config/read", "config/update"] }
405
+ }
406
+ end
407
+
408
+ def workspace_info(root)
409
+ root = @session_manager.validate_workspace_root(root)
410
+ { root: root, basename: File.basename(root), writable: File.writable?(root) }
411
+ end
412
+
413
+ def prompts_list
414
+ templates = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
415
+ {
416
+ prompts: templates.map do |template|
417
+ {
418
+ command: template.command,
419
+ description: template.description,
420
+ argumentHint: template.argument_hint,
421
+ path: template.path
422
+ }
423
+ end
424
+ }
425
+ end
426
+
427
+ def prompts_expand(params)
428
+ command = params.fetch("command").to_s.delete_prefix("/")
429
+ template = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).find { |candidate| candidate.command == command }
430
+ raise "Unknown prompt template: #{command}" unless template
431
+
432
+ { command: command, text: template.expand(params["arguments"].to_s) }
433
+ end
434
+
435
+ def models_list
436
+ { models: @session_manager.available_models }
437
+ end
438
+
439
+ def openrouter_catalog
440
+ { models: @session_manager.openrouter_catalog }
441
+ end
442
+
443
+ def config_update(params)
444
+ config = @config_manager.update(params.fetch("values"))
445
+ @session_manager.refresh_client_config
446
+ { path: @config_manager.config_path, config: config }
447
+ end
448
+
449
+ def runtime_update_setting(params)
450
+ session_id = params.fetch("sessionId")
451
+ @session_manager.runtime_state(session_id: session_id)
452
+ setting_id = params.fetch("settingId").to_s
453
+ value = params.fetch("value")
454
+ case setting_id
455
+ when "defaultModel"
456
+ provider, model = provider_model_from(value)
457
+ @config_manager.set_model(model, provider: provider)
458
+ when "defaultThinkingLevel"
459
+ @config_manager.set_reasoning_effort(value, provider: @session_manager.current_model[:provider])
460
+ else
461
+ raise ArgumentError, "Unsupported runtime setting: #{setting_id}"
462
+ end
463
+ @session_manager.refresh_client_config
464
+ { applied: "live", message: runtime_setting_message(setting_id) }
465
+ end
466
+
467
+ def runtime_reload(params)
468
+ @session_manager.runtime_state(session_id: params.fetch("sessionId"))
469
+ @session_manager.refresh_client_config
470
+ { ok: true, message: "Resources reloaded." }
471
+ end
472
+
473
+ def logging_stats(params)
474
+ TelemetryStats.new.collect(params["range"].to_s).to_h
475
+ rescue ArgumentError => e
476
+ raise ArgumentError, e.message == TelemetryStats::USAGE ? e.message : "#{e.message} #{TelemetryStats::USAGE}"
477
+ end
478
+
479
+ def logging_token_csv(params)
480
+ { csv: TelemetryStats.new.token_usage_csv(params["range"].to_s, bucket: params["bucket"]) }
481
+ rescue ArgumentError => e
482
+ raise ArgumentError, e.message == TelemetryStats::USAGE ? e.message : "#{e.message} #{TelemetryStats::USAGE}"
483
+ end
484
+
485
+ def commands_list(params)
486
+ @session_manager.runtime_state(session_id: params.fetch("sessionId"))
487
+ prompts = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).map do |template|
488
+ {
489
+ name: template.command,
490
+ description: template.description,
491
+ source: "prompt",
492
+ location: template.path,
493
+ path: template.path,
494
+ sourceInfo: {}
495
+ }
496
+ end
497
+ skills = ConfigFiles.skills.map do |skill|
498
+ {
499
+ name: "skill:#{skill.name}",
500
+ description: skill.description,
501
+ source: "skill",
502
+ path: skill.path
503
+ }
504
+ end
505
+ builtins = [
506
+ {
507
+ name: "crew",
508
+ description: "Reserved for future crew commands.",
509
+ argumentHint: "",
510
+ source: "builtin",
511
+ executable: false,
512
+ unsupported: true,
513
+ reason: "notImplemented"
514
+ },
515
+ {
516
+ name: "copy",
517
+ description: "CLI-only clipboard copy; RPC clients own their clipboard.",
518
+ argumentHint: "[last|transcript]",
519
+ source: "builtin",
520
+ executable: false,
521
+ unsupported: true,
522
+ reason: "clientClipboardOwnedByUi"
523
+ }
524
+ ]
525
+ plugins = @session_manager.plugin_commands.map do |command|
526
+ {
527
+ name: command.name,
528
+ description: command.description,
529
+ argumentHint: command.argument_hint,
530
+ source: "plugin",
531
+ path: command.path,
532
+ executable: true
533
+ }
534
+ end
535
+ { commands: builtins + prompts + skills + plugins }
536
+ end
537
+
538
+ def commands_run(params)
539
+ @session_manager.run_command(
540
+ session_id: params.fetch("sessionId"),
541
+ command: params.fetch("name"),
542
+ arguments: params["arguments"] || ""
543
+ )
544
+ end
545
+
546
+ def startup_resources(params)
547
+ @session_manager.runtime_state(session_id: params.fetch("sessionId"))
548
+ sections = []
549
+ agents_path = File.join(ConfigFiles.config_dir, "AGENTS.md")
550
+ sections << { name: "Context", items: ["AGENTS.md"] } if File.exist?(agents_path)
551
+ skills = ConfigFiles.skills.map(&:name)
552
+ prompts = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).map { |template| "/#{template.command}" }
553
+ plugins = @session_manager.plugin_commands.map { |command| "/#{command.name}" }
554
+ sections << { name: "Skills", items: skills } unless skills.empty?
555
+ sections << { name: "Prompts", items: prompts } unless prompts.empty?
556
+ sections << { name: "Plugins", items: plugins } unless plugins.empty?
557
+ { sections: sections }
558
+ end
559
+
560
+ def auth_login_with_api_key(params)
561
+ result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
562
+ @session_manager.refresh_client_config
563
+ result
564
+ end
565
+
566
+ def auth_logout_provider(params)
567
+ result = @auth_manager.logout_provider(provider_id: params.fetch("providerId"))
568
+ @session_manager.refresh_client_config
569
+ result
570
+ end
571
+
572
+ def models_current
573
+ @session_manager.current_model
574
+ end
575
+
576
+ def models_set(params)
577
+ provider = params["provider"] || @session_manager.current_model[:provider]
578
+ @config_manager.set_model(params.fetch("model"), provider: provider)
579
+ @session_manager.refresh_client_config
580
+ @session_manager.current_model
581
+ end
582
+
583
+ def reasoning_set(params)
584
+ provider = params["provider"] || @session_manager.current_model[:provider]
585
+ @config_manager.set_reasoning_effort(params.fetch("effort"), provider: provider)
586
+ @session_manager.refresh_client_config
587
+ @session_manager.current_model
588
+ end
589
+
590
+ def provider_model_from(value)
591
+ text = value.to_s.strip
592
+ raise ArgumentError, "Model must be a non-empty string" if text.empty?
593
+
594
+ provider, model = text.split("/", 2)
595
+ if model.to_s.empty?
596
+ [@session_manager.current_model[:provider], text]
597
+ else
598
+ [provider, model]
599
+ end
600
+ end
601
+
602
+ def runtime_setting_message(setting_id)
603
+ case setting_id
604
+ when "defaultModel"
605
+ "Model updated for this session."
606
+ when "defaultThinkingLevel"
607
+ "Thinking level updated for this session."
608
+ end
609
+ end
610
+
611
+ def write_result(id, result)
612
+ @transport.write_message({ jsonrpc: JSONRPC_VERSION, id: id, result: Redactor.redact(result) })
613
+ end
614
+
615
+ def write_error(id, code, message, exception = nil)
616
+ error = { code: code, message: Redactor.redact_string(message.to_s) }
617
+ error[:data] = error_payload(exception) if exception
618
+ @transport.write_message({ jsonrpc: JSONRPC_VERSION, id: id, error: error })
619
+ end
620
+
621
+ def message_id(message)
622
+ return nil unless message.is_a?(Hash)
623
+
624
+ message.key?("id") ? message["id"] : message[:id]
625
+ end
626
+
627
+ def stringify_keys(value)
628
+ case value
629
+ when Hash
630
+ value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify_keys(item) }
631
+ when Array
632
+ value.map { |item| stringify_keys(item) }
633
+ else
634
+ value
635
+ end
636
+ end
637
+ end
638
+ end
639
+ end