kward 0.83.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -1,11 +1,11 @@
1
1
  require "digest"
2
2
  require "securerandom"
3
3
  require "thread"
4
- require_relative "cancellation"
5
- require_relative "events"
6
- require_relative "plugin_registry"
7
- require_relative "tab_driver"
8
- require_relative "tools/tool_call"
4
+ require_relative "../cancellation"
5
+ require_relative "../events"
6
+ require_relative "registry"
7
+ require_relative "../tabs/driver"
8
+ require_relative "../tools/tool_call"
9
9
 
10
10
  # Frontend-neutral runtime for trusted plugin-owned conversational drivers.
11
11
  module Kward
@@ -21,6 +21,7 @@ module Kward
21
21
  :id,
22
22
  :type,
23
23
  :driver,
24
+ :host,
24
25
  :queue,
25
26
  :worker,
26
27
  :running_turn_id,
@@ -55,6 +56,7 @@ module Kward
55
56
  @turns = {}
56
57
  @event_listeners = []
57
58
  @mutex = Mutex.new
59
+ @shutdown = false
58
60
  end
59
61
 
60
62
  def supported_types(surface: :rpc)
@@ -79,7 +81,7 @@ module Kward
79
81
  type = supported_types(surface: surface).find { |entry| entry.id == type_id.to_s }
80
82
  raise ArgumentError, "Unknown #{surface} plugin chat: #{type_id}" unless type
81
83
 
82
- chat_for(type, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
84
+ chat_for(type, surface: surface, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
83
85
  end
84
86
 
85
87
  def chat(chat_id)
@@ -138,20 +140,31 @@ module Kward
138
140
  end
139
141
 
140
142
  def shutdown
141
- chats = @mutex.synchronize { @chats.values.dup }
143
+ chats = @mutex.synchronize do
144
+ return nil if @shutdown
145
+
146
+ @shutdown = true
147
+ @chats.values.dup
148
+ end
149
+ @mutex.synchronize { @turns.values.dup }.each { |turn| turn.cancellation.cancel! }
142
150
  chats.each do |chat|
143
151
  chat.queue << WORKER_STOP if chat.worker&.alive?
144
152
  chat.worker&.join(0.2)
153
+ close_chat(chat)
145
154
  end
155
+ @plugin_registry&.shutdown! unless @plugin_registry_provider
146
156
  nil
147
157
  end
148
158
 
149
159
  # Converts normalized image attachment hashes into the input shape accepted
150
160
  # by plugin chat drivers. RPC and transport frontends can normalize their
151
161
  # own boundary formats before calling this helper.
152
- def input_with_attachments(input, attachments)
162
+ def input_with_attachments(input, attachments, capabilities: PluginChatCapabilities.legacy)
153
163
  attachments = Array(attachments)
154
164
  return input.to_s if attachments.empty?
165
+ if capabilities.declared? && !capabilities.allows_attachment?(:image)
166
+ raise ArgumentError, "plugin chat does not allow image attachments"
167
+ end
155
168
 
156
169
  [{ type: "text", text: input.to_s }] + attachments.map do |attachment|
157
170
  {
@@ -166,30 +179,49 @@ module Kward
166
179
  private
167
180
 
168
181
  def plugin_registry
169
- return @plugin_registry_provider.call if @plugin_registry_provider
170
-
171
- @plugin_registry ||= PluginRegistry.load
182
+ registry = @plugin_registry_provider ? @plugin_registry_provider.call : (@plugin_registry ||= PluginRegistry.load)
183
+ registry.start!
184
+ registry
172
185
  end
173
186
 
174
- def chat_for(type, scope_key:, descriptor:, workspace_root:)
187
+ def chat_for(type, surface:, scope_key:, descriptor:, workspace_root:)
188
+ surface = surface.to_sym
189
+ host_surface = type.rpc && type.transport ? :shared : surface
175
190
  scope_key = normalize_scope_key(scope_key)
191
+ scope_key = "global" if type.singleton == :global && type.capabilities.declared?
176
192
  chat_id = chat_id_for(type, scope_key)
177
193
  @mutex.synchronize do
178
194
  @chats[chat_id] ||= begin
179
195
  descriptor = {
180
196
  "kind" => "plugin",
181
- "plugin_tab_type" => type.id,
182
- "label" => type.title,
183
- "scope_key" => scope_key
197
+ "label" => type.title
184
198
  }.merge(descriptor.transform_keys(&:to_s))
185
- host = PluginTabHost.new(client: @client, workspace_root: workspace_root)
186
- driver = type.handler.call(host, descriptor)
187
- raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
199
+ descriptor["plugin_tab_type"] = type.id
200
+ descriptor["scope_key"] = scope_key
201
+ host = PluginTabHost.new(
202
+ client: @client,
203
+ workspace_root: workspace_root,
204
+ plugin_host: type.plugin_id && plugin_registry.plugin_for(type.plugin_id),
205
+ type_id: type.id,
206
+ surface: host_surface,
207
+ scope_key: scope_key,
208
+ capabilities: type.capabilities
209
+ )
210
+ begin
211
+ driver = type.handler.call(host, descriptor)
212
+ raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
213
+
214
+ type.capabilities.validate_driver!(driver)
215
+ rescue StandardError
216
+ host.shutdown
217
+ raise
218
+ end
188
219
 
189
220
  Chat.new(
190
221
  id: chat_id,
191
222
  type: type,
192
223
  driver: driver,
224
+ host: host,
193
225
  queue: Queue.new,
194
226
  scope_key: scope_key,
195
227
  descriptor: descriptor,
@@ -199,6 +231,18 @@ module Kward
199
231
  end
200
232
  end
201
233
 
234
+ def close_chat(chat)
235
+ if chat.driver.respond_to?(:close)
236
+ chat.driver.close
237
+ elsif chat.driver.respond_to?(:shutdown)
238
+ chat.driver.shutdown
239
+ end
240
+ rescue StandardError => e
241
+ ConfigFiles.emit_warning("Warning: Kward plugin chat cleanup error: #{e.message}")
242
+ ensure
243
+ chat.host.shutdown
244
+ end
245
+
202
246
  def normalize_scope_key(scope_key)
203
247
  value = scope_key.to_s
204
248
  value.empty? ? "default" : value
@@ -0,0 +1,232 @@
1
+ require "digest"
2
+ require "json"
3
+ require "logger"
4
+ require "thread"
5
+ require_relative "../config_files"
6
+ require_relative "../deep_copy"
7
+ require_relative "../private_file"
8
+ require_relative "resources"
9
+
10
+ # Namespace for the Kward CLI agent runtime.
11
+ module Kward
12
+ # Private JSON-backed key/value storage scoped to one stable plugin ID.
13
+ class PluginStore
14
+ KEY_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9:._-]*\z/.freeze
15
+
16
+ attr_reader :plugin_id
17
+
18
+ def initialize(plugin_id, root: ConfigFiles.config_dir)
19
+ @plugin_id = validate_key(plugin_id, "plugin id")
20
+ @path = File.join(File.expand_path(root), "plugin_state", @plugin_id, "state.json")
21
+ @mutex = Mutex.new
22
+ @values = load_state
23
+ end
24
+
25
+ def get(key)
26
+ key = validate_key(key, "storage key")
27
+ @mutex.synchronize { copy(@values[key]) }
28
+ end
29
+
30
+ def put(key, value)
31
+ key = validate_key(key, "storage key")
32
+ @mutex.synchronize do
33
+ @values[key] = copy(value)
34
+ persist
35
+ end
36
+ value
37
+ end
38
+
39
+ def delete(key)
40
+ key = validate_key(key, "storage key")
41
+ @mutex.synchronize do
42
+ present = @values.key?(key)
43
+ value = @values.delete(key)
44
+ persist if present
45
+ copy(value)
46
+ end
47
+ end
48
+
49
+ # Returns a view whose keys cannot overlap another plugin-owned scope.
50
+ def scoped(namespace)
51
+ PluginScopedStore.new(self, namespace)
52
+ end
53
+
54
+ private
55
+
56
+ def load_state
57
+ return {} unless File.file?(@path)
58
+
59
+ state = JSON.parse(File.read(@path))
60
+ values = state.fetch("values", {})
61
+ raise JSON::ParserError, "plugin state values must be a JSON object" unless values.is_a?(Hash)
62
+
63
+ values
64
+ rescue JSON::ParserError => e
65
+ raise "Invalid plugin state #{@path}: #{e.message}"
66
+ end
67
+
68
+ def persist
69
+ PrivateFile.write_json(@path, "values" => @values)
70
+ end
71
+
72
+ def validate_key(value, name)
73
+ value = value.to_s
74
+ raise ArgumentError, "#{name} is required" unless value.match?(KEY_PATTERN)
75
+
76
+ value
77
+ end
78
+
79
+ def copy(value)
80
+ return nil if value.nil?
81
+
82
+ DeepCopy.dup(value)
83
+ end
84
+ end
85
+
86
+ # Namespaced view over an identified plugin's durable storage.
87
+ class PluginScopedStore
88
+ attr_reader :namespace
89
+
90
+ def initialize(storage, namespace)
91
+ @storage = storage
92
+ @namespace = namespace.to_s
93
+ raise ArgumentError, "plugin storage namespace is required" if @namespace.empty?
94
+
95
+ @prefix = "scope.#{Digest::SHA256.hexdigest(@namespace)[0, 24]}"
96
+ end
97
+
98
+ def get(key)
99
+ @storage.get(scoped_key(key))
100
+ end
101
+
102
+ def put(key, value)
103
+ @storage.put(scoped_key(key), value)
104
+ end
105
+
106
+ def delete(key)
107
+ @storage.delete(scoped_key(key))
108
+ end
109
+
110
+ private
111
+
112
+ def scoped_key(key)
113
+ key = key.to_s
114
+ raise ArgumentError, "storage key is required" unless key.match?(PluginStore::KEY_PATTERN)
115
+
116
+ "#{@prefix}.#{key}"
117
+ end
118
+ end
119
+
120
+ # Shared immutable metadata and managed services for one identified plugin.
121
+ class PluginHost
122
+ ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/.freeze
123
+
124
+ class LogDevice
125
+ def write(message)
126
+ ConfigFiles.emit_warning(message.to_s.chomp)
127
+ end
128
+
129
+ def close
130
+ nil
131
+ end
132
+ end
133
+ private_constant :LogDevice
134
+
135
+ attr_reader :id, :version, :api_version, :source_path, :config, :storage, :logger
136
+
137
+ def initialize(id:, version:, api_version:, source_path: nil, config: nil, storage: nil, logger: nil, env: ENV, storage_root: ConfigFiles.config_dir, warning_sink: nil)
138
+ @id = validate_id(id).freeze
139
+ @version = validate_value(version, "plugin version").freeze
140
+ @api_version = validate_value(api_version, "plugin API version").freeze
141
+ @source_path = source_path&.to_s&.freeze
142
+ @config = freeze_config(config.nil? ? configured_values : config)
143
+ @storage = storage || PluginStore.new(@id, root: storage_root)
144
+ @logger = logger || default_logger
145
+ @env = env
146
+ @resources = PluginResources.new(name: "Kward plugin #{id}", warning_sink: warning_sink)
147
+ end
148
+
149
+ # Starts cooperative background work owned by this plugin. The block may
150
+ # accept a cancellation token and must stop cooperatively when cancelled.
151
+ #
152
+ # @return [PluginTask]
153
+ def background(name: nil, cancellation: nil, &block)
154
+ @resources.background(name: name, cancellation: cancellation, &block)
155
+ end
156
+
157
+ # Registers idempotent cleanup for a subscription or other plugin resource.
158
+ # The returned disposable may be invoked early; otherwise Kward invokes it
159
+ # during plugin reload or shutdown.
160
+ #
161
+ # @return [PluginDisposable]
162
+ def on_cleanup(resource = nil, &block)
163
+ @resources.on_cleanup(resource, &block)
164
+ end
165
+
166
+ alias manage on_cleanup
167
+
168
+ # Activates managed runtime services after plugin loading completes.
169
+ # @api private
170
+ def activate!
171
+ @resources.activate!
172
+ self
173
+ end
174
+
175
+ # Cancels background work and invokes registered cleanup callbacks.
176
+ # @api private
177
+ def shutdown(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
178
+ @resources.shutdown(timeout: timeout)
179
+ self
180
+ end
181
+
182
+ # Reads a secret from private plugin config, an explicit environment
183
+ # variable, or the plugin's conventional KWARD_PLUGIN_* variable.
184
+ def secret(key, env: nil)
185
+ key = validate_value(key, "secret key")
186
+ value = config[key]
187
+ value = @env[env.to_s] if value.nil? && env
188
+ value = @env[default_secret_env_name(key)] if value.nil?
189
+ value.to_s unless value.nil?
190
+ end
191
+
192
+ # Returns public metadata suitable for diagnostics and capability reports.
193
+ def to_h
194
+ { id: id, version: version, api_version: api_version }.freeze
195
+ end
196
+
197
+ private
198
+
199
+ def configured_values
200
+ ConfigFiles.plugin_config(@id)
201
+ end
202
+
203
+ def freeze_config(value)
204
+ raise ArgumentError, "Kward plugin config for #{@id} must be an object" unless value.is_a?(Hash)
205
+
206
+ DeepCopy.freeze(DeepCopy.dup(value))
207
+ end
208
+
209
+ def validate_id(value)
210
+ value = value.to_s
211
+ raise ArgumentError, "plugin id is invalid: #{value}" unless value.match?(ID_PATTERN)
212
+
213
+ value
214
+ end
215
+
216
+ def validate_value(value, name)
217
+ value = value.to_s
218
+ raise ArgumentError, "#{name} is required" if value.empty?
219
+
220
+ value
221
+ end
222
+
223
+ def default_secret_env_name(key)
224
+ parts = [id, key].map { |part| part.gsub(/[^A-Za-z0-9]/, "_").upcase }
225
+ "KWARD_PLUGIN_#{parts.join("_")}"
226
+ end
227
+
228
+ def default_logger
229
+ Logger.new(LogDevice.new).tap { |logger| logger.progname = "Kward plugin #{id}" }
230
+ end
231
+ end
232
+ end