kward 0.84.0 → 0.85.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,1190 @@
1
+ require_relative "../config_files"
2
+ require_relative "../deep_copy"
3
+ require_relative "../hooks"
4
+ require_relative "../transport"
5
+ require_relative "actions"
6
+ require_relative "chat_contract"
7
+ require_relative "host"
8
+ require_relative "ui"
9
+ require_relative "turn_request"
10
+
11
+ # Namespace for the Kward CLI agent runtime.
12
+ module Kward
13
+ # Loads trusted user plugin files and provides the plugin DSL.
14
+ #
15
+ # Plugins live in the user plugin directory, run as local Ruby code, and can
16
+ # register slash commands, namespaced actions, model-callable tools, lifecycle
17
+ # callbacks, composable status renderers, prompt context, and live
18
+ # transcript-event observers for CLI and RPC frontends.
19
+ #
20
+ # This registry is intentionally trust-based, not a sandbox. Keep plugin loading
21
+ # restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
22
+ # load path, and expose immutable transcript views so plugins can observe state
23
+ # without corrupting active conversations.
24
+ class PluginRegistry
25
+ PLUGIN_API_VERSION = "1"
26
+ COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
27
+
28
+ # Public registration types retained under the registry namespace.
29
+ Command = PluginCommand
30
+ Action = PluginAction
31
+
32
+ # Registered model-callable tool exposed through each normal agent tool
33
+ # registry. The handler receives parsed arguments and a runtime context.
34
+ Tool = Struct.new(:name, :description, :schema, :path, :handler, keyword_init: true)
35
+
36
+ STATUS_PRIORITIES = { low: 0, normal: 1, high: 2 }.freeze
37
+ STATUS_SEPARATOR = " · "
38
+
39
+ # Registered footer/status contribution. Display order is independent from
40
+ # priority: order places segments, while priority decides which segments are
41
+ # removed first when the terminal is narrow.
42
+ Status = Struct.new(:id, :order, :priority, :path, :renderer, :sequence, keyword_init: true)
43
+
44
+ # Rendered, frontend-neutral status contribution.
45
+ StatusSegment = Struct.new(:id, :text, :tooltip, :priority, :order, keyword_init: true) do
46
+ def to_h
47
+ { id: id, text: text, tooltip: tooltip, priority: priority.to_s, order: order }.compact
48
+ end
49
+ end
50
+
51
+ # Registered interactive command that takes over the composer region with a
52
+ # Kward-driven render and input loop. Like a slash command but with canvas
53
+ # rendering capabilities for games, dashboards, viewers, and similar uses.
54
+ InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
55
+ def entry
56
+ { name: name, description: description, argument_hint: argument_hint }
57
+ end
58
+ end
59
+
60
+ # Registered plugin-owned tab runtime. Its factory receives a
61
+ # `PluginTabHost` and its persisted descriptor, then returns a driver.
62
+ TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transport, :local, :transcript_events, :capabilities, :plugin_id, :path, :handler, keyword_init: true)
63
+
64
+ # Registered external transport. The factory receives a transport host and
65
+ # configuration when the transport is started, not while plugins load.
66
+ TransportType = Struct.new(:id, :name, :capabilities, :execution_profile, :path, :handler, keyword_init: true)
67
+
68
+ # Read-only event passed to plugin transcript observers.
69
+ TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
70
+ def to_h
71
+ { type: type, payload: payload }
72
+ end
73
+ end
74
+
75
+ # Registered lifecycle hook handler.
76
+ HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
77
+
78
+ # Plugin-runtime callback invoked when an identified plugin starts, reloads,
79
+ # or shuts down.
80
+ LifecycleHandler = Struct.new(:event, :host, :path, :handler, keyword_init: true)
81
+
82
+ # Read-only transcript view exposed to plugin code.
83
+ class Transcript
84
+ # Creates an object for trusted plugin loading and dispatch.
85
+ def initialize(conversation)
86
+ @conversation = conversation
87
+ end
88
+
89
+ # Returns a deep-frozen copy of the active conversation messages.
90
+ #
91
+ # @return [Array<Hash>] immutable transcript message data
92
+ def messages
93
+ DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
94
+ end
95
+ end
96
+
97
+ # Runtime context passed to plugin commands, tools, footers, prompt context
98
+ # renderers, hooks, and transcript event handlers.
99
+ class Context
100
+ attr_reader :args, :workspace_root, :cancellation, :ui, :requested_turn
101
+
102
+ # Creates an object for trusted plugin loading and dispatch.
103
+ def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil, cancellation: nil, ui: nil, tool_ui: nil, turn_command: nil)
104
+ @conversation = conversation
105
+ @args = args.is_a?(Hash) ? DeepCopy.freeze(DeepCopy.dup(args)) : args.to_s
106
+ @session = session
107
+ @workspace_root = workspace_root
108
+ @say_callback = say_callback
109
+ @cancellation = cancellation
110
+ @ui = (ui || PluginUI.new(say_callback: say_callback)).with_cancellation(cancellation)
111
+ @tool_ui = tool_ui
112
+ @turn_command = turn_command
113
+ end
114
+
115
+ # Whether this command runs on a host that can execute a session turn.
116
+ def turn_requests_supported?
117
+ !@turn_command.nil?
118
+ end
119
+
120
+ # Stages one turn for dispatch after the command successfully returns.
121
+ # Available only to asynchronous, session-backed plugin commands.
122
+ # @return [nil]
123
+ def request_turn(system:)
124
+ raise ArgumentError, "Model turn requests are unavailable here; use a session command through the TUI or RPC turns/start" unless turn_requests_supported?
125
+ @cancellation&.raise_if_cancelled!
126
+ raise ArgumentError, "Only one model turn may be requested per command" if @requested_turn
127
+
128
+ @requested_turn = PluginTurnRequest.new(system: system, command: @turn_command.name, plugin_id: @turn_command.plugin_id)
129
+ nil
130
+ end
131
+
132
+ # @return [Transcript] read-only transcript wrapper
133
+ def transcript
134
+ Transcript.new(@conversation)
135
+ end
136
+
137
+ # Emits command output to the active frontend when available.
138
+ #
139
+ # @param message [#to_s] message to display
140
+ # @return [nil]
141
+ def say(message)
142
+ @say_callback&.call(message.to_s)
143
+ nil
144
+ end
145
+
146
+ # @return [String, nil] active session identifier
147
+ def session_id
148
+ @session&.id
149
+ end
150
+
151
+ # @return [String, nil] human-readable active session name
152
+ def session_name
153
+ @session&.name
154
+ end
155
+
156
+ # @return [String, nil] saved active session path
157
+ def session_path
158
+ @session&.path
159
+ end
160
+
161
+ # Requests that the conversation rebuild its system message after plugin
162
+ # state changes that affect prompt context.
163
+ #
164
+ # @return [nil]
165
+ def refresh_system_message!
166
+ @conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
167
+ nil
168
+ end
169
+
170
+ # Builds a structured result for a typed command or plugin action.
171
+ #
172
+ # @param message [#to_s, nil] optional user-facing result text
173
+ # @param data [Object, nil] optional JSON-compatible machine-readable data
174
+ # @return [PluginResult]
175
+ def result(message: nil, data: nil)
176
+ PluginResult.new(message: message, data: data)
177
+ end
178
+
179
+ # Returns whether the active plugin operation has been cancelled.
180
+ # Contexts without a cancellable operation return false.
181
+ def cancelled?
182
+ @cancellation&.cancelled? == true
183
+ end
184
+
185
+ # Builds a fresh context for one model-callable plugin tool invocation.
186
+ # @api private
187
+ def for_tool(conversation:, cancellation: nil)
188
+ self.class.new(
189
+ conversation: conversation,
190
+ session: @session,
191
+ workspace_root: @workspace_root,
192
+ say_callback: @say_callback,
193
+ cancellation: cancellation,
194
+ ui: @tool_ui || @ui
195
+ )
196
+ end
197
+
198
+ # Allows the current lifecycle event to continue.
199
+ # @return [Hooks::Decision]
200
+ def allow(message = nil, metadata: nil)
201
+ Hooks::Decision.allow(message, metadata: metadata)
202
+ end
203
+
204
+ # Denies the current lifecycle event.
205
+ # @return [Hooks::Decision]
206
+ def deny(message = nil, metadata: nil)
207
+ Hooks::Decision.deny(message, metadata: metadata)
208
+ end
209
+
210
+ # Requests frontend approval for the current lifecycle event.
211
+ # @return [Hooks::Decision]
212
+ def ask(message = nil, metadata: nil)
213
+ Hooks::Decision.ask(message, metadata: metadata)
214
+ end
215
+
216
+ # Continues with an event-specific payload replacement.
217
+ # @param payload [Hash] replacement fields supported by the event
218
+ # @return [Hooks::Decision]
219
+ def modify(payload, message: nil, metadata: nil)
220
+ Hooks::Decision.modify(payload, message: message, metadata: metadata)
221
+ end
222
+
223
+ # Allows the event while recording a warning.
224
+ # @return [Hooks::Decision]
225
+ def warn(message = nil, metadata: nil)
226
+ Hooks::Decision.warn(message, metadata: metadata)
227
+ end
228
+
229
+ # Requests a retry when the current event supports it.
230
+ # @return [Hooks::Decision]
231
+ def retry(message = nil, payload: nil, metadata: nil)
232
+ Hooks::Decision.retry(message, payload: payload, metadata: metadata)
233
+ end
234
+
235
+ # Defers the event when the current workflow supports it.
236
+ # @return [Hooks::Decision]
237
+ def defer(message = nil, payload: nil, metadata: nil)
238
+ Hooks::Decision.defer(message, payload: payload, metadata: metadata)
239
+ end
240
+ end
241
+
242
+ # Public DSL object yielded by `Kward.plugin` blocks.
243
+ #
244
+ # Plugin files normally interact with this object only through a block:
245
+ #
246
+ # @example Register a plugin command
247
+ # Kward.plugin do |plugin|
248
+ # plugin.command "hello", description: "Say hello" do |args, ctx|
249
+ # name = args.strip.empty? ? "there" : args.strip
250
+ # ctx.say "Hello, #{name}."
251
+ # end
252
+ # end
253
+ #
254
+ # @api public
255
+ class DSL
256
+ # Creates an object for trusted plugin loading and dispatch.
257
+ def initialize(registry, path, host: nil)
258
+ @registry = registry
259
+ @path = path
260
+ @host = host
261
+ end
262
+
263
+ # Shared metadata, configuration, storage, secrets, and logging services.
264
+ # Legacy plugins without declared identity return nil.
265
+ #
266
+ # @return [PluginHost, nil]
267
+ attr_reader :host
268
+
269
+ # Registers a slash command.
270
+ #
271
+ # The command is available in the interactive CLI and through the RPC
272
+ # command bridge. Command names do not include the leading `/`.
273
+ #
274
+ # @param name [String, #to_s] command name without the leading slash
275
+ # @param description [String] short text shown in command listings
276
+ # @param argument_hint [String] optional usage hint for arguments
277
+ # @param schema [Hash, nil] strict object JSON Schema for typed arguments
278
+ # @param positionals [Array<String, Symbol>] schema properties filled by positional text
279
+ # @yieldparam args [String, Hash] raw text for legacy commands or parsed typed arguments
280
+ # @yieldparam ctx [Context] plugin execution context
281
+ # @return [void]
282
+ # @api public
283
+ def command(name, description: "", argument_hint: "", schema: nil, positionals: [], &block)
284
+ @registry.register_command(
285
+ name,
286
+ description: description,
287
+ argument_hint: argument_hint,
288
+ schema: schema,
289
+ positionals: positionals,
290
+ plugin_id: @host&.id,
291
+ path: @path,
292
+ &block
293
+ )
294
+ end
295
+
296
+ # Registers a namespaced typed action for trusted RPC clients.
297
+ # Identified plugin metadata is required so the action has a stable ID.
298
+ #
299
+ # @param name [String, #to_s] action name within the plugin namespace
300
+ # @param description [String] short client-facing description
301
+ # @param schema [Hash] strict object JSON Schema for typed arguments
302
+ # @yieldparam args [Hash] validated action arguments
303
+ # @yieldparam ctx [Context] plugin execution context
304
+ # @return [void]
305
+ # @api public
306
+ def action(name, description:, schema: { type: "object", properties: {} }, &block)
307
+ raise ArgumentError, "Plugin actions require stable plugin identity" unless @host
308
+
309
+ @registry.register_action(name, plugin_id: @host.id, description: description, schema: schema, path: @path, &block)
310
+ end
311
+
312
+ # Registers a model-callable tool for normal Kward agent turns.
313
+ #
314
+ # Tool arguments are described with a strict object JSON Schema. The
315
+ # handler must return model-facing text and receives the normal plugin
316
+ # context with the active cancellation token.
317
+ #
318
+ # @param name [String, #to_s] function name exposed to the model
319
+ # @param description [String] model-facing description of the operation
320
+ # @param schema [Hash] object JSON Schema for parsed tool arguments
321
+ # @yieldparam args [Hash] parsed model-provided arguments
322
+ # @yieldparam ctx [Context] plugin execution context
323
+ # @return [void]
324
+ # @api public
325
+ def tool(name, description:, schema: { type: "object", properties: {} }, &block)
326
+ @registry.register_tool(name, description: description, schema: schema, path: @path, &block)
327
+ end
328
+
329
+ # Registers a callback invoked after plugin loading when the runtime is
330
+ # ready to start owned background work.
331
+ #
332
+ # @yieldparam host [PluginHost] identified plugin host and resource owner
333
+ # @return [void]
334
+ # @api public
335
+ def on_start(&block)
336
+ register_lifecycle(:start, &block)
337
+ end
338
+
339
+ # Registers a callback invoked on the old plugin instance immediately
340
+ # before its resources are cleaned up during reload.
341
+ #
342
+ # @yieldparam host [PluginHost] identified plugin host and resource owner
343
+ # @return [void]
344
+ # @api public
345
+ def on_reload(&block)
346
+ register_lifecycle(:reload, &block)
347
+ end
348
+
349
+ # Registers a callback invoked immediately before plugin resources are
350
+ # cleaned up during process shutdown.
351
+ #
352
+ # @yieldparam host [PluginHost] identified plugin host and resource owner
353
+ # @return [void]
354
+ # @api public
355
+ def on_shutdown(&block)
356
+ register_lifecycle(:shutdown, &block)
357
+ end
358
+
359
+ # Registers a legacy footer contribution. Multiple plugin footers are
360
+ # composed rather than replacing one another. Identified plugins should
361
+ # prefer {#status} so clients receive a descriptive stable segment ID.
362
+ #
363
+ # @yieldparam ctx [Context] plugin execution context
364
+ # @return [void]
365
+ # @api public
366
+ def footer(&block)
367
+ @registry.register_footer(plugin_id: @host&.id, path: @path, &block)
368
+ end
369
+
370
+ # Registers a composable status contribution.
371
+ #
372
+ # The renderer may return a string, nil to hide the segment, or a hash with
373
+ # `text` and optional `tooltip`. Lower order values render first. On narrow
374
+ # terminals low-priority segments are removed before normal- and
375
+ # high-priority segments.
376
+ #
377
+ # @param name [String, #to_s] stable name within the plugin namespace
378
+ # @param order [Integer] display order; lower values render first
379
+ # @param priority [Symbol, String] `low`, `normal`, or `high`
380
+ # @yieldparam ctx [Context] plugin execution context
381
+ # @return [void]
382
+ # @api public
383
+ def status(name, order: 100, priority: :normal, &block)
384
+ raise ArgumentError, "Plugin status contributions require stable plugin identity" unless @host
385
+
386
+ @registry.register_status(
387
+ name,
388
+ plugin_id: @host.id,
389
+ order: order,
390
+ priority: priority,
391
+ path: @path,
392
+ &block
393
+ )
394
+ end
395
+
396
+ # Registers a live transcript event observer.
397
+ #
398
+ # Observer errors are caught and reported as warnings so a plugin cannot
399
+ # crash the active turn by raising from an event handler.
400
+ #
401
+ # @yieldparam event [TranscriptEvent] normalized transcript event
402
+ # @yieldparam ctx [Context] plugin execution context
403
+ # @return [void]
404
+ # @api public
405
+ def on_transcript_event(&block)
406
+ @registry.register_transcript_event(path: @path, &block)
407
+ end
408
+
409
+ # Registers a lifecycle hook handler.
410
+ #
411
+ # Hooks are deterministic runtime callbacks around Kward lifecycle events.
412
+ # They can return a {Hooks::Decision}, a decision hash, a decision string,
413
+ # or nil to allow the operation.
414
+ #
415
+ # @param event [String, #to_s] lifecycle event name
416
+ # @param id [String, nil] stable hook identifier for logs and diagnostics
417
+ # @param description [String] short human-readable purpose
418
+ # @param order [Integer] lower values run first
419
+ # @param match [Hash, nil] optional event selector
420
+ # @yieldparam event [Hooks::Event] immutable lifecycle event
421
+ # @yieldparam ctx [Context] plugin execution context and decision helpers
422
+ # @return [void]
423
+ # @api public
424
+ def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
425
+ @registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
426
+ end
427
+
428
+ # Registers prompt context text injected into future system prompts.
429
+ #
430
+ # Keep this text short and never include secrets. The returned string can
431
+ # be sent to the active model as part of Kward's system instructions.
432
+ #
433
+ # @yieldparam ctx [Context] plugin execution context
434
+ # @return [void]
435
+ # @api public
436
+ def prompt_context(&block)
437
+ @registry.register_prompt_context(path: @path, &block)
438
+ end
439
+
440
+ # Registers an interactive command that takes over the composer region with
441
+ # a Kward-driven render and input loop. The handler receives an
442
+ # interactive controller object with a canvas API for drawing colored
443
+ # cells and reading keys. Useful for games, dashboards, and viewers.
444
+ #
445
+ # @param name [String, #to_s] command name without the leading slash
446
+ # @param rows [Integer] fixed canvas height in terminal rows
447
+ # @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
448
+ # @param description [String] short text shown in command listings
449
+ # @param argument_hint [String] optional usage hint for arguments
450
+ # @yieldparam ui [Object] interactive controller with canvas and key API
451
+ # @yieldparam ctx [Context] plugin execution context
452
+ # @return [void]
453
+ # @api public
454
+ def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
455
+ @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
456
+ end
457
+
458
+ # Registers a plugin-owned chat type. `id` is a durable identifier used
459
+ # in persisted tab layouts and transport chat handles and must not change.
460
+ # The factory receives a `PluginTabHost` and a descriptor hash.
461
+ #
462
+ # @param name [String] command name used by `/tab open <name>`
463
+ # @param id [String] stable persisted tab type identifier
464
+ # @param title [String] default tab label
465
+ # @param singleton [Symbol] `:global` for one shared plugin runtime
466
+ # @param rpc [Boolean] expose this chat through trusted local RPC
467
+ # @param transport [Boolean] allow external transport adapters to target this chat
468
+ # @param local [Boolean] expose this chat as an interactive local tab
469
+ # @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
470
+ # @param api [Integer, nil] versioned plugin-chat contract API
471
+ # @param capabilities [Hash, nil] explicit attachments, steering, and transcript-paging support
472
+ # @yieldparam host [PluginTabHost] supported host dependencies
473
+ # @yieldparam descriptor [Hash] persisted tab descriptor
474
+ # @return [void]
475
+ # @api public
476
+ def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, &block)
477
+ @registry.register_tab_type(
478
+ name,
479
+ id: id,
480
+ title: title,
481
+ singleton: singleton,
482
+ rpc: rpc,
483
+ transport: transport,
484
+ local: local,
485
+ transcript_events: transcript_events,
486
+ api: api,
487
+ capabilities: capabilities,
488
+ plugin_id: @host&.id,
489
+ path: @path,
490
+ &block
491
+ )
492
+ end
493
+
494
+ # Registers an external messaging or event transport. The factory is
495
+ # called when the transport runtime starts.
496
+ #
497
+ # @param name [String] human-readable transport name
498
+ # @param id [String] stable transport identifier
499
+ # @param capabilities [Hash, Transport::Capabilities] supported features
500
+ # @yieldparam host [Object] transport host
501
+ # @yieldparam config [Object] transport configuration
502
+ # @return [void]
503
+ # @api public
504
+ def transport(name, id:, capabilities: nil, execution_profile: nil, &block)
505
+ @registry.register_transport(name, id: id, capabilities: capabilities, execution_profile: execution_profile, path: @path, &block)
506
+ end
507
+
508
+ private
509
+
510
+ def register_lifecycle(event, &block)
511
+ raise ArgumentError, "Plugin lifecycle callbacks require stable plugin identity" unless @host
512
+
513
+ @registry.register_lifecycle(event, host: @host, path: @path, &block)
514
+ end
515
+ end
516
+
517
+ # Mutable singleton guard used while loading trusted plugin files.
518
+ class << self
519
+ attr_accessor :loading_registry, :loading_path
520
+
521
+ def load(paths: nil, reserved_commands: [], warning_sink: nil)
522
+ warning_sink ||= ConfigFiles.warning_sink
523
+ paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
524
+ registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
525
+ paths.each { |path| registry.load_file(path) }
526
+ registry
527
+ end
528
+ end
529
+
530
+ # Creates an object for trusted plugin loading and dispatch.
531
+ def initialize(reserved_commands: [], warning_sink: nil)
532
+ @reserved_commands = reserved_commands.map(&:to_s)
533
+ @warning_sink = warning_sink
534
+ @plugins = {}
535
+ @commands = {}
536
+ @actions = {}
537
+ @tools = {}
538
+ @interactive_commands = {}
539
+ @tab_types = {}
540
+ @tab_types_by_id = {}
541
+ @transports = {}
542
+ @transports_by_id = {}
543
+ @statuses = {}
544
+ @status_sequence = 0
545
+ @footer_path = nil
546
+ @transcript_event_handlers = []
547
+ @prompt_context_renderers = []
548
+ @hook_handlers = []
549
+ @lifecycle_handlers = { start: [], reload: [], shutdown: [] }
550
+ @lifecycle_state = :loaded
551
+ @lifecycle_mutex = Mutex.new
552
+ @paths = []
553
+ end
554
+
555
+ # @return [String, nil] most recent plugin file to register legacy footer output
556
+ attr_reader :footer_path
557
+
558
+ # @return [Array<String>] plugin files successfully loaded by this registry
559
+ attr_reader :paths
560
+
561
+ # @return [Array<PluginHost>] identified plugins loaded by this registry
562
+ def plugins
563
+ @plugins.values
564
+ end
565
+
566
+ def plugin_for(id)
567
+ @plugins[id.to_s]
568
+ end
569
+
570
+ def commands
571
+ @commands.values
572
+ end
573
+
574
+ def command_for(name)
575
+ @commands[name.to_s]
576
+ end
577
+
578
+ def actions
579
+ @actions.values
580
+ end
581
+
582
+ def action_for(id)
583
+ @actions[id.to_s]
584
+ end
585
+
586
+ def tools
587
+ @tools.values
588
+ end
589
+
590
+ def tool_for(name)
591
+ @tools[name.to_s]
592
+ end
593
+
594
+ def interactive_commands
595
+ @interactive_commands.values
596
+ end
597
+
598
+ def interactive_command_for(name)
599
+ @interactive_commands[name.to_s]
600
+ end
601
+
602
+ def tab_types
603
+ @tab_types.values
604
+ end
605
+
606
+ def tab_type_for(name)
607
+ @tab_types[name.to_s]
608
+ end
609
+
610
+ def tab_type_for_id(id)
611
+ @tab_types_by_id[id.to_s]
612
+ end
613
+
614
+ def transport_tab_types
615
+ @tab_types.values.select(&:transport)
616
+ end
617
+
618
+ def transports
619
+ @transports.values
620
+ end
621
+
622
+ def transport_for(name)
623
+ @transports[name.to_s]
624
+ end
625
+
626
+ def transport_for_id(id)
627
+ @transports_by_id[id.to_s]
628
+ end
629
+
630
+ def status?
631
+ !@statuses.empty?
632
+ end
633
+
634
+ # Backward-compatible aggregate footer renderer.
635
+ def footer_renderer
636
+ return nil unless status?
637
+
638
+ lambda do |context|
639
+ compose_status(status_segments(context))
640
+ end
641
+ end
642
+
643
+ # Evaluates every status renderer independently and returns display-ordered
644
+ # frontend-neutral segments. A broken renderer cannot hide healthy segments.
645
+ def status_segments(context)
646
+ @statuses.values.sort_by { |status| [status.order, status.sequence] }.filter_map do |status|
647
+ normalize_status_segment(status, status.renderer.call(context))
648
+ rescue StandardError => e
649
+ emit_warning "Warning: Kward plugin status #{status.id} error in #{status.path}: #{e.message}"
650
+ nil
651
+ end
652
+ end
653
+
654
+ # Composes already-rendered segments. When max_width is supplied, complete
655
+ # low-priority segments are removed first; the caller remains responsible
656
+ # for truncating a final oversized segment according to frontend rules.
657
+ def compose_status(segments, max_width: nil, &measure)
658
+ visible = Array(segments).dup
659
+ return "" if visible.empty? || (!max_width.nil? && max_width.to_i <= 0)
660
+
661
+ measure ||= ->(text) { text.to_s.length }
662
+ width = max_width&.to_i
663
+ while width && visible.length > 1 && measure.call(status_text(visible)) > width
664
+ remove_lowest_priority_segment!(visible)
665
+ end
666
+ status_text(visible)
667
+ end
668
+
669
+ def transcript_event_handlers
670
+ @transcript_event_handlers.map { |entry| entry[:handler] }
671
+ end
672
+
673
+ def prompt_context_renderers
674
+ @prompt_context_renderers.map { |entry| entry[:renderer] }
675
+ end
676
+
677
+ def hook_handlers
678
+ @hook_handlers.dup
679
+ end
680
+
681
+ # Activates identified plugins and invokes their start callbacks once.
682
+ def start!
683
+ transition_lifecycle!(:loaded, :active) do
684
+ @plugins.each_value(&:activate!)
685
+ run_lifecycle_callbacks(:start)
686
+ end
687
+ self
688
+ end
689
+
690
+ # Invokes reload callbacks on the old registry and cleans up all resources.
691
+ def reload!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
692
+ stop_lifecycle!(:reload, timeout: timeout)
693
+ end
694
+
695
+ # Invokes shutdown callbacks and cleans up all resources.
696
+ def shutdown!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
697
+ stop_lifecycle!(:shutdown, timeout: timeout)
698
+ end
699
+
700
+ def hook_manager
701
+ manager = Hooks::Manager.new
702
+ @hook_handlers.each do |hook|
703
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
704
+ hook.handler.call(event, context)
705
+ end
706
+ end
707
+ manager
708
+ end
709
+
710
+ def prompt_context(context)
711
+ parts = []
712
+ @prompt_context_renderers.each do |entry|
713
+ rendered = entry[:renderer].call(context)
714
+ parts << rendered.to_s unless rendered.to_s.empty?
715
+ rescue StandardError => e
716
+ emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
717
+ end
718
+ parts.empty? ? nil : parts.join("\n\n")
719
+ end
720
+
721
+ def notify_transcript_event(event, context)
722
+ transcript_event = transcript_event_for(event)
723
+ return unless transcript_event
724
+
725
+ @transcript_event_handlers.each do |entry|
726
+ entry[:handler].call(transcript_event, context)
727
+ rescue StandardError => e
728
+ emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
729
+ end
730
+ nil
731
+ end
732
+
733
+ def load_file(path)
734
+ previous_registry = self.class.loading_registry
735
+ previous_path = self.class.loading_path
736
+ self.class.loading_registry = self
737
+ self.class.loading_path = path
738
+ Kernel.load(path, true)
739
+ @paths << path
740
+ rescue StandardError => e
741
+ emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
742
+ ensure
743
+ self.class.loading_registry = previous_registry
744
+ self.class.loading_path = previous_path
745
+ end
746
+
747
+ def evaluate(path: nil, id: nil, version: nil, api: nil, &block)
748
+ host = register_plugin_identity(id: id, version: version, api: api, path: path)
749
+ dsl = DSL.new(self, path, host: host)
750
+ block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
751
+ self
752
+ end
753
+
754
+ def register_plugin_identity(id:, version:, api:, path: nil)
755
+ values = [id, version, api]
756
+ return nil if values.all?(&:nil?)
757
+ raise ArgumentError, "Plugin id, version, and api are required together" if values.any?(&:nil?)
758
+
759
+ id = id.to_s
760
+ api = api.to_s
761
+ raise ArgumentError, "Unsupported Kward plugin API #{api.inspect} for #{id}; supported API: #{PLUGIN_API_VERSION}" unless api == PLUGIN_API_VERSION
762
+ raise ArgumentError, "Duplicate Kward plugin id: #{id}" if @plugins.key?(id)
763
+
764
+ @plugins[id] = PluginHost.new(
765
+ id: id,
766
+ version: version,
767
+ api_version: api,
768
+ source_path: path,
769
+ warning_sink: method(:emit_warning)
770
+ )
771
+ end
772
+
773
+ def register_command(name, description: "", argument_hint: "", schema: nil, positionals: [], plugin_id: nil, path: nil, &handler)
774
+ name = name.to_s
775
+ raise "Plugin command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
776
+ raise "Plugin command /#{name} requires a handler" unless handler
777
+ raise ArgumentError, "Plugin command /#{name} positionals require a schema" if schema.nil? && !Array(positionals).empty?
778
+
779
+ if @reserved_commands.include?(name)
780
+ emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
781
+ return nil
782
+ end
783
+ if @commands.key?(name)
784
+ emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
785
+ return nil
786
+ end
787
+
788
+ @commands[name] = Command.new(
789
+ name: name,
790
+ description: description.to_s,
791
+ argument_hint: argument_hint.to_s,
792
+ schema: schema,
793
+ positionals: positionals,
794
+ plugin_id: plugin_id,
795
+ path: path,
796
+ handler: handler
797
+ )
798
+ end
799
+
800
+ def register_action(name, plugin_id:, description:, schema:, path: nil, &handler)
801
+ name = name.to_s
802
+ raise "Plugin action name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
803
+ raise "Plugin action #{plugin_id}/#{name} requires a description" if description.to_s.strip.empty?
804
+ raise "Plugin action #{plugin_id}/#{name} requires a handler" unless handler
805
+
806
+ id = "#{plugin_id}/#{name}"
807
+ if @actions.key?(id)
808
+ emit_warning "Warning: skipping duplicate Kward plugin action #{id}: #{path}"
809
+ return nil
810
+ end
811
+
812
+ @actions[id] = Action.new(
813
+ name: name,
814
+ plugin_id: plugin_id,
815
+ description: description.to_s,
816
+ schema: schema,
817
+ path: path,
818
+ handler: handler
819
+ )
820
+ end
821
+
822
+ def register_tool(name, description:, schema:, path: nil, &handler)
823
+ name = name.to_s
824
+ raise "Plugin tool name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
825
+ raise "Plugin tool #{name} requires a description" if description.to_s.strip.empty?
826
+ raise "Plugin tool #{name} requires a handler" unless handler
827
+
828
+ if @tools.key?(name)
829
+ emit_warning "Warning: skipping duplicate Kward plugin tool #{name}: #{path}"
830
+ return nil
831
+ end
832
+
833
+ @tools[name] = Tool.new(
834
+ name: name,
835
+ description: description.to_s,
836
+ schema: normalize_tool_schema(name, schema),
837
+ path: path,
838
+ handler: handler
839
+ )
840
+ end
841
+
842
+ def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
843
+ name = name.to_s
844
+ raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
845
+ raise "Interactive command /#{name} requires a handler" unless handler
846
+
847
+ if @reserved_commands.include?(name) || @commands.key?(name)
848
+ emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
849
+ return nil
850
+ end
851
+ if @interactive_commands.key?(name)
852
+ emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
853
+ return nil
854
+ end
855
+
856
+ @interactive_commands[name] = InteractiveCommand.new(
857
+ name: name,
858
+ description: description.to_s,
859
+ argument_hint: argument_hint.to_s,
860
+ rows: [[rows.to_i, 1].max, 1].max,
861
+ fps: [[fps.to_f, 1].max, 120].min,
862
+ path: path,
863
+ handler: handler
864
+ )
865
+ end
866
+
867
+ def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, plugin_id: nil, path: nil, &handler)
868
+ name = name.to_s
869
+ id = id.to_s
870
+ raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
871
+ raise "Plugin tab type id is required" if id.empty?
872
+ raise "Plugin tab type #{name} requires a handler" unless handler
873
+
874
+ if @tab_types.key?(name) || @tab_types_by_id.key?(id)
875
+ emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
876
+ return nil
877
+ end
878
+
879
+ tab_type = TabType.new(
880
+ id: id,
881
+ name: name,
882
+ title: title.to_s.empty? ? name.capitalize : title.to_s,
883
+ singleton: singleton&.to_sym,
884
+ rpc: rpc == true,
885
+ transport: transport == true,
886
+ local: local == true,
887
+ transcript_events: transcript_events == true,
888
+ capabilities: PluginChatCapabilities.build(api: api, capabilities: capabilities),
889
+ plugin_id: plugin_id,
890
+ path: path,
891
+ handler: handler
892
+ )
893
+ @tab_types[name] = tab_type
894
+ @tab_types_by_id[id] = tab_type
895
+ end
896
+
897
+ def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
898
+ name = name.to_s
899
+ id = id.to_s
900
+ raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
901
+ raise "Plugin transport id is required" if id.empty?
902
+ raise "Plugin transport #{name} requires a handler" unless handler
903
+
904
+ if @transports.key?(name) || @transports_by_id.key?(id)
905
+ emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
906
+ return nil
907
+ end
908
+
909
+ capabilities = normalize_transport_capabilities(capabilities)
910
+ execution_profile = normalize_execution_profile(execution_profile)
911
+ transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
912
+ @transports[name] = transport
913
+ @transports_by_id[id] = transport
914
+ end
915
+
916
+ def register_footer(plugin_id: nil, path: nil, &renderer)
917
+ raise "Plugin footer requires a renderer" unless renderer
918
+
919
+ id = if plugin_id
920
+ "#{plugin_id}/footer"
921
+ else
922
+ available_legacy_status_id("legacy/#{legacy_status_source(path)}")
923
+ end
924
+ @status_sequence += 1 unless @statuses.key?(id)
925
+ @statuses[id] = Status.new(
926
+ id: id,
927
+ order: 100,
928
+ priority: :normal,
929
+ path: path,
930
+ renderer: renderer,
931
+ sequence: @statuses[id]&.sequence || @status_sequence
932
+ )
933
+ @footer_path = path
934
+ end
935
+
936
+ def register_status(name, plugin_id:, order: 100, priority: :normal, path: nil, &renderer)
937
+ name = name.to_s
938
+ raise "Plugin status name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
939
+ raise "Plugin status #{plugin_id}/#{name} requires a renderer" unless renderer
940
+
941
+ id = "#{plugin_id}/#{name}"
942
+ if @statuses.key?(id)
943
+ emit_warning "Warning: skipping duplicate Kward plugin status #{id}: #{path}"
944
+ return nil
945
+ end
946
+
947
+ order = Integer(order)
948
+ priority = normalize_status_priority(priority)
949
+ @status_sequence += 1
950
+ @statuses[id] = Status.new(
951
+ id: id,
952
+ order: order,
953
+ priority: priority,
954
+ path: path,
955
+ renderer: renderer,
956
+ sequence: @status_sequence
957
+ )
958
+ end
959
+
960
+ def emit_warning(message)
961
+ @warning_sink ? @warning_sink.call(message) : warn(message)
962
+ end
963
+
964
+ def register_transcript_event(path: nil, &handler)
965
+ raise "Plugin transcript event requires a handler" unless handler
966
+
967
+ @transcript_event_handlers << { path: path, handler: handler }
968
+ end
969
+
970
+ def register_prompt_context(path: nil, &renderer)
971
+ raise "Plugin prompt context requires a renderer" unless renderer
972
+
973
+ @prompt_context_renderers << { path: path, renderer: renderer }
974
+ end
975
+
976
+ def register_lifecycle(event, host:, path: nil, &handler)
977
+ event = event.to_sym
978
+ raise ArgumentError, "Unknown plugin lifecycle event: #{event}" unless @lifecycle_handlers.key?(event)
979
+ raise ArgumentError, "Plugin lifecycle #{event} requires a handler" unless handler
980
+
981
+ @lifecycle_handlers[event] << LifecycleHandler.new(event: event, host: host, path: path, handler: handler)
982
+ end
983
+
984
+ def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
985
+ event = event.to_s
986
+ raise "Plugin hook event is required" if event.empty?
987
+ raise "Plugin hook #{event} requires a handler" unless handler
988
+
989
+ @hook_handlers << HookHandler.new(
990
+ event: event,
991
+ id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
992
+ description: description.to_s,
993
+ path: path,
994
+ order: order.to_i,
995
+ match: match,
996
+ failure_policy: failure_policy,
997
+ handler: handler
998
+ )
999
+ end
1000
+
1001
+ private
1002
+
1003
+ def normalize_status_priority(priority)
1004
+ value = priority.to_s.to_sym
1005
+ return value if STATUS_PRIORITIES.key?(value)
1006
+
1007
+ raise ArgumentError, "Plugin status priority must be low, normal, or high"
1008
+ end
1009
+
1010
+ def normalize_status_segment(status, value)
1011
+ attributes = value.is_a?(Hash) ? value : { text: value }
1012
+ text = status_value(attributes, :text).to_s.gsub(/\s+/, " ").strip
1013
+ return nil if text.empty?
1014
+
1015
+ tooltip = status_value(attributes, :tooltip)
1016
+ tooltip = tooltip.to_s.gsub(/\s+/, " ").strip unless tooltip.nil?
1017
+ tooltip = nil if tooltip.to_s.empty?
1018
+ StatusSegment.new(
1019
+ id: status.id.dup.freeze,
1020
+ text: text.freeze,
1021
+ tooltip: tooltip&.freeze,
1022
+ priority: status.priority,
1023
+ order: status.order
1024
+ ).freeze
1025
+ end
1026
+
1027
+ def status_value(attributes, key)
1028
+ attributes.key?(key) ? attributes[key] : attributes[key.to_s]
1029
+ end
1030
+
1031
+ def status_text(segments)
1032
+ segments.map(&:text).join(STATUS_SEPARATOR)
1033
+ end
1034
+
1035
+ def remove_lowest_priority_segment!(segments)
1036
+ lowest_priority = segments.map { |segment| STATUS_PRIORITIES.fetch(segment.priority) }.min
1037
+ index = segments.each_index.select do |candidate|
1038
+ STATUS_PRIORITIES.fetch(segments[candidate].priority) == lowest_priority
1039
+ end.max_by { |candidate| [segments[candidate].order, candidate] }
1040
+ segments.delete_at(index)
1041
+ end
1042
+
1043
+ def available_legacy_status_id(base_id)
1044
+ return base_id unless @statuses.key?(base_id)
1045
+
1046
+ suffix = 2
1047
+ suffix += 1 while @statuses.key?("#{base_id}##{suffix}")
1048
+ "#{base_id}##{suffix}"
1049
+ end
1050
+
1051
+ def legacy_status_source(path)
1052
+ source = path.to_s
1053
+ return "plugin" if source.empty?
1054
+
1055
+ basename = File.basename(source)
1056
+ basename == "plugin.rb" ? File.basename(File.dirname(source)) : File.basename(source, ".rb")
1057
+ end
1058
+
1059
+ def transition_lifecycle!(from, to)
1060
+ should_run = @lifecycle_mutex.synchronize do
1061
+ next false unless @lifecycle_state == from
1062
+
1063
+ @lifecycle_state = to
1064
+ true
1065
+ end
1066
+ yield if should_run
1067
+ end
1068
+
1069
+ def stop_lifecycle!(event, timeout:)
1070
+ transition_lifecycle!(:active, :stopped) do
1071
+ run_lifecycle_callbacks(event)
1072
+ @plugins.each_value { |host| host.shutdown(timeout: timeout) }
1073
+ end
1074
+ self
1075
+ end
1076
+
1077
+ def run_lifecycle_callbacks(event)
1078
+ @lifecycle_handlers.fetch(event).each do |entry|
1079
+ entry.handler.arity.zero? ? entry.handler.call : entry.handler.call(entry.host)
1080
+ rescue StandardError => e
1081
+ emit_warning "Warning: Kward plugin #{event} error in #{entry.path}: #{e.message}"
1082
+ end
1083
+ end
1084
+
1085
+ def normalize_tool_schema(name, schema)
1086
+ raise ArgumentError, "Plugin tool #{name} schema must be an object" unless schema.is_a?(Hash)
1087
+
1088
+ parameters = schema.each_with_object({}) { |(key, value), result| result[key.to_sym] = DeepCopy.dup(value) }
1089
+ type = parameters.fetch(:type, "object").to_s
1090
+ raise ArgumentError, "Plugin tool #{name} schema type must be object" unless type == "object"
1091
+
1092
+ properties = parameters.fetch(:properties, {})
1093
+ required = parameters.fetch(:required, [])
1094
+ raise ArgumentError, "Plugin tool #{name} schema properties must be an object" unless properties.is_a?(Hash)
1095
+ raise ArgumentError, "Plugin tool #{name} schema required must be an array" unless required.is_a?(Array)
1096
+ if parameters[:additionalProperties] == true
1097
+ raise ArgumentError, "Plugin tool #{name} schema cannot allow additional properties"
1098
+ end
1099
+
1100
+ property_names = properties.keys.map(&:to_s)
1101
+ required = required.map(&:to_s).uniq.sort
1102
+ unknown_required = required - property_names
1103
+ unless unknown_required.empty?
1104
+ raise ArgumentError, "Plugin tool #{name} schema requires unknown properties: #{unknown_required.join(', ')}"
1105
+ end
1106
+
1107
+ parameters[:type] = "object"
1108
+ parameters[:properties] = properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
1109
+ result[key] = properties[key]
1110
+ end
1111
+ parameters[:required] = required
1112
+ parameters[:additionalProperties] = false
1113
+ DeepCopy.freeze(parameters)
1114
+ end
1115
+
1116
+ def normalize_execution_profile(profile)
1117
+ return nil if profile.nil?
1118
+ return profile if profile.is_a?(Transport::ExecutionProfile)
1119
+ raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
1120
+ end
1121
+
1122
+ def normalize_transport_capabilities(capabilities)
1123
+ return Transport.capabilities if capabilities.nil?
1124
+ return capabilities if capabilities.is_a?(Transport::Capabilities)
1125
+ raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
1126
+
1127
+ Transport.capabilities(**capabilities.transform_keys(&:to_sym))
1128
+ end
1129
+
1130
+ def transcript_event_for(event)
1131
+ case event.class.name
1132
+ when "Kward::Events::ReasoningDelta"
1133
+ transcript_event("reasoning_delta", delta: event.delta)
1134
+ when "Kward::Events::ReasoningBoundary"
1135
+ transcript_event("reasoning_boundary")
1136
+ when "Kward::Events::AssistantDelta"
1137
+ transcript_event("assistant_delta", delta: event.delta)
1138
+ when "Kward::Events::AssistantMessage"
1139
+ transcript_event("assistant_message", message: event.message)
1140
+ when "Kward::Events::Retry"
1141
+ transcript_event(
1142
+ "model_retry",
1143
+ provider: event.provider,
1144
+ model: event.model,
1145
+ attempt: event.attempt,
1146
+ max_attempts: event.max_attempts,
1147
+ delay_seconds: event.delay_seconds,
1148
+ error: event.error,
1149
+ request_bytes: event.request_bytes
1150
+ )
1151
+ when "Kward::Events::Steering"
1152
+ transcript_event("turn_steered", input: event.input, created_at: event.created_at)
1153
+ when "Kward::Events::ToolCall"
1154
+ transcript_event("tool_call", tool_call: event.tool_call)
1155
+ when "Kward::Events::ToolResult"
1156
+ transcript_event("tool_result", tool_call: event.tool_call, content: event.content)
1157
+ when "Kward::Events::Answer"
1158
+ transcript_event("answer", content: event.content)
1159
+ end
1160
+ end
1161
+
1162
+ def transcript_event(type, payload = {})
1163
+ TranscriptEvent.new(
1164
+ type: type,
1165
+ payload: DeepCopy.freeze(DeepCopy.dup(payload))
1166
+ ).freeze
1167
+ end
1168
+ end
1169
+
1170
+ # Registers a trusted local plugin.
1171
+ #
1172
+ # This method is intended for Ruby files loaded from the user plugin
1173
+ # directory. It raises if called outside plugin loading so workspace code
1174
+ # cannot silently mutate Kward's runtime by merely being required.
1175
+ #
1176
+ # @param id [String, nil] stable reverse-domain-style plugin identifier
1177
+ # @param version [String, nil] plugin release version
1178
+ # @param api [String, Integer, nil] Kward plugin API version
1179
+ # @yieldparam plugin [PluginRegistry::DSL] plugin registration DSL
1180
+ # @return [Object, nil] the plugin block result
1181
+ # @api public
1182
+ def self.plugin(id: nil, version: nil, api: nil, &block)
1183
+ registry = PluginRegistry.loading_registry
1184
+ raise "Kward.plugin can only be called while loading a plugin" unless registry
1185
+
1186
+ host = registry.register_plugin_identity(id: id, version: version, api: api, path: PluginRegistry.loading_path)
1187
+ dsl = PluginRegistry::DSL.new(registry, PluginRegistry.loading_path, host: host)
1188
+ block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
1189
+ end
1190
+ end