kward 0.79.0 → 0.80.1
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +65 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/skills.md +2 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/agent.rb +11 -6
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/memory_commands.rb +2 -2
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +44 -1
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +5 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/slash_commands.rb +1 -1
- data/lib/kward/cli/tabs.rb +103 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -0
- data/lib/kward/compactor.rb +7 -2
- data/lib/kward/config_files.rb +51 -12
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/hooks/audit_log.rb +5 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +99 -19
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts/templates.rb +11 -6
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/skills/registry.rb +16 -11
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/telemetry/logger.rb +5 -2
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
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"
|
|
9
|
+
|
|
10
|
+
# Frontend-neutral runtime for trusted plugin-owned conversational drivers.
|
|
11
|
+
module Kward
|
|
12
|
+
# Coordinates plugin chat drivers without depending on RPC or the terminal UI.
|
|
13
|
+
# Frontends use this runtime for queuing, cancellation, event replay, and
|
|
14
|
+
# transcript access while the plugin remains responsible for its own storage,
|
|
15
|
+
# model settings, and domain behavior.
|
|
16
|
+
class PluginChatRuntime
|
|
17
|
+
EVENT_LIMIT = 1_000
|
|
18
|
+
WORKER_STOP = Object.new.freeze
|
|
19
|
+
|
|
20
|
+
Chat = Struct.new(
|
|
21
|
+
:id,
|
|
22
|
+
:type,
|
|
23
|
+
:driver,
|
|
24
|
+
:queue,
|
|
25
|
+
:worker,
|
|
26
|
+
:running_turn_id,
|
|
27
|
+
:scope_key,
|
|
28
|
+
:descriptor,
|
|
29
|
+
:workspace_root,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
)
|
|
32
|
+
Turn = Struct.new(
|
|
33
|
+
:id,
|
|
34
|
+
:chat_id,
|
|
35
|
+
:input,
|
|
36
|
+
:display_input,
|
|
37
|
+
:context,
|
|
38
|
+
:status,
|
|
39
|
+
:cancellation,
|
|
40
|
+
:created_at,
|
|
41
|
+
:started_at,
|
|
42
|
+
:finished_at,
|
|
43
|
+
:events,
|
|
44
|
+
:next_sequence,
|
|
45
|
+
:error,
|
|
46
|
+
:mutex,
|
|
47
|
+
keyword_init: true
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def initialize(client:, plugin_registry_provider: nil, message_normalizer: nil)
|
|
51
|
+
@client = client
|
|
52
|
+
@plugin_registry_provider = plugin_registry_provider
|
|
53
|
+
@message_normalizer = message_normalizer
|
|
54
|
+
@chats = {}
|
|
55
|
+
@turns = {}
|
|
56
|
+
@event_listeners = []
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def supported_types(surface: :rpc)
|
|
61
|
+
case surface.to_sym
|
|
62
|
+
when :rpc
|
|
63
|
+
plugin_registry.tab_types.select(&:rpc)
|
|
64
|
+
when :transport
|
|
65
|
+
plugin_registry.transport_tab_types
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "Unknown plugin chat surface: #{surface}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def subscribe_events(&block)
|
|
72
|
+
raise ArgumentError, "plugin chat event subscription requires a block" unless block
|
|
73
|
+
|
|
74
|
+
@mutex.synchronize { @event_listeners << block }
|
|
75
|
+
block
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open(type_id:, surface: :rpc, scope_key: nil, descriptor: {}, workspace_root: Dir.pwd)
|
|
79
|
+
type = supported_types(surface: surface).find { |entry| entry.id == type_id.to_s }
|
|
80
|
+
raise ArgumentError, "Unknown #{surface} plugin chat: #{type_id}" unless type
|
|
81
|
+
|
|
82
|
+
chat_for(type, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def chat(chat_id)
|
|
86
|
+
@mutex.synchronize { @chats[chat_id.to_s] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_turn(chat_id:, input:, display_input: nil, context: nil)
|
|
90
|
+
chat = fetch_chat(chat_id)
|
|
91
|
+
turn = Turn.new(
|
|
92
|
+
id: SecureRandom.uuid,
|
|
93
|
+
chat_id: chat.id,
|
|
94
|
+
input: input,
|
|
95
|
+
display_input: display_input.nil? ? input.to_s : display_input.to_s,
|
|
96
|
+
context: context || {},
|
|
97
|
+
status: "queued",
|
|
98
|
+
cancellation: Cancellation.new,
|
|
99
|
+
created_at: Time.now.utc.iso8601(3),
|
|
100
|
+
events: [],
|
|
101
|
+
next_sequence: 1,
|
|
102
|
+
mutex: Mutex.new
|
|
103
|
+
)
|
|
104
|
+
@mutex.synchronize { @turns[turn.id] = turn }
|
|
105
|
+
chat.queue << turn.id
|
|
106
|
+
ensure_worker(chat)
|
|
107
|
+
emit_event(turn, "turnQueued", { status: "queued" })
|
|
108
|
+
turn
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cancel_turn(turn_id:)
|
|
112
|
+
turn = fetch_turn(turn_id)
|
|
113
|
+
queued = turn.mutex.synchronize do
|
|
114
|
+
turn.cancellation.cancel!
|
|
115
|
+
turn.status == "queued"
|
|
116
|
+
end
|
|
117
|
+
emit_event(turn, "turnCancelRequested", {})
|
|
118
|
+
finish_turn(turn, "canceled") if queued
|
|
119
|
+
turn
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def turn_status(turn_id:)
|
|
123
|
+
fetch_turn(turn_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def turn_events(turn_id:, after_sequence: 0)
|
|
127
|
+
turn = fetch_turn(turn_id)
|
|
128
|
+
turn.mutex.synchronize do
|
|
129
|
+
turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def list_turns(chat_id: nil, active: false)
|
|
134
|
+
turns = @mutex.synchronize { @turns.values.dup }
|
|
135
|
+
turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
|
|
136
|
+
turns.select! { |turn| %w[queued running].include?(turn.status) } if active
|
|
137
|
+
turns
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def shutdown
|
|
141
|
+
chats = @mutex.synchronize { @chats.values.dup }
|
|
142
|
+
chats.each do |chat|
|
|
143
|
+
chat.queue << WORKER_STOP if chat.worker&.alive?
|
|
144
|
+
chat.worker&.join(0.2)
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Converts normalized image attachment hashes into the input shape accepted
|
|
150
|
+
# by plugin chat drivers. RPC and transport frontends can normalize their
|
|
151
|
+
# own boundary formats before calling this helper.
|
|
152
|
+
def input_with_attachments(input, attachments)
|
|
153
|
+
attachments = Array(attachments)
|
|
154
|
+
return input.to_s if attachments.empty?
|
|
155
|
+
|
|
156
|
+
[{ type: "text", text: input.to_s }] + attachments.map do |attachment|
|
|
157
|
+
{
|
|
158
|
+
type: "image",
|
|
159
|
+
media_type: attachment.fetch(:mimeType) { attachment.fetch("mimeType") },
|
|
160
|
+
data: attachment.fetch(:data) { attachment.fetch("data") },
|
|
161
|
+
alt: attachment[:alt] || attachment["alt"]
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def plugin_registry
|
|
169
|
+
return @plugin_registry_provider.call if @plugin_registry_provider
|
|
170
|
+
|
|
171
|
+
@plugin_registry ||= PluginRegistry.load
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def chat_for(type, scope_key:, descriptor:, workspace_root:)
|
|
175
|
+
scope_key = normalize_scope_key(scope_key)
|
|
176
|
+
chat_id = chat_id_for(type, scope_key)
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
@chats[chat_id] ||= begin
|
|
179
|
+
descriptor = {
|
|
180
|
+
"kind" => "plugin",
|
|
181
|
+
"plugin_tab_type" => type.id,
|
|
182
|
+
"label" => type.title,
|
|
183
|
+
"scope_key" => scope_key
|
|
184
|
+
}.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
|
|
188
|
+
|
|
189
|
+
Chat.new(
|
|
190
|
+
id: chat_id,
|
|
191
|
+
type: type,
|
|
192
|
+
driver: driver,
|
|
193
|
+
queue: Queue.new,
|
|
194
|
+
scope_key: scope_key,
|
|
195
|
+
descriptor: descriptor,
|
|
196
|
+
workspace_root: workspace_root
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_scope_key(scope_key)
|
|
203
|
+
value = scope_key.to_s
|
|
204
|
+
value.empty? ? "default" : value
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def chat_id_for(type, scope_key)
|
|
208
|
+
return type.id if type.singleton == :global
|
|
209
|
+
return type.id if scope_key == "default"
|
|
210
|
+
|
|
211
|
+
"#{type.id}:#{Digest::SHA256.hexdigest(scope_key)[0, 16]}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fetch_chat(chat_id)
|
|
215
|
+
chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fetch_turn(turn_id)
|
|
219
|
+
@mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ensure_worker(chat)
|
|
223
|
+
return if chat.worker&.alive?
|
|
224
|
+
|
|
225
|
+
chat.worker = Thread.new do
|
|
226
|
+
loop do
|
|
227
|
+
turn_id = chat.queue.pop
|
|
228
|
+
break if turn_id.equal?(WORKER_STOP)
|
|
229
|
+
|
|
230
|
+
turn = fetch_turn(turn_id)
|
|
231
|
+
run_turn(chat, turn) unless turn.cancellation.cancelled?
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
chat.worker.report_on_exception = false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def run_turn(chat, turn)
|
|
238
|
+
turn.mutex.synchronize do
|
|
239
|
+
return if terminal?(turn)
|
|
240
|
+
|
|
241
|
+
turn.status = "running"
|
|
242
|
+
turn.started_at = Time.now.utc.iso8601(3)
|
|
243
|
+
end
|
|
244
|
+
chat.running_turn_id = turn.id
|
|
245
|
+
emit_event(turn, "turnStarted", { status: "running" })
|
|
246
|
+
submit_options = {
|
|
247
|
+
display_input: turn.display_input,
|
|
248
|
+
cancellation: turn.cancellation
|
|
249
|
+
}
|
|
250
|
+
if !turn.context.empty? && accepts_context?(chat.driver)
|
|
251
|
+
submit_options[:context] = turn.context
|
|
252
|
+
end
|
|
253
|
+
chat.driver.submit(turn.input, **submit_options) do |event|
|
|
254
|
+
handle_driver_event(chat, turn, event)
|
|
255
|
+
end
|
|
256
|
+
finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
|
|
257
|
+
rescue Cancellation::CancelledError
|
|
258
|
+
finish_turn(turn, "canceled")
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
|
|
261
|
+
finish_turn(turn, "failed")
|
|
262
|
+
ensure
|
|
263
|
+
chat.running_turn_id = nil if chat.running_turn_id == turn.id
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def accepts_context?(driver)
|
|
267
|
+
driver.method(:submit).parameters.any? do |kind, name|
|
|
268
|
+
kind == :keyrest || ((kind == :key || kind == :keyreq) && name == :context)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def handle_driver_event(chat, turn, event)
|
|
273
|
+
notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
|
|
274
|
+
|
|
275
|
+
type, payload = case event
|
|
276
|
+
when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
|
|
277
|
+
when Events::ReasoningBoundary then ["reasoningBoundary", {}]
|
|
278
|
+
when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
|
|
279
|
+
when Events::AssistantMessage then ["assistantMessage", { message: normalized_assistant_message(event.message) }]
|
|
280
|
+
when Events::Retry then ["modelRetry", retry_event_payload(event)]
|
|
281
|
+
when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
|
|
282
|
+
when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
|
|
283
|
+
when Events::Answer then ["answer", { content: event.content }]
|
|
284
|
+
end
|
|
285
|
+
emit_event(turn, type, payload) if type
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def notify_plugin_tab_transcript_event(chat, event)
|
|
289
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
290
|
+
|
|
291
|
+
context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: chat.workspace_root)
|
|
292
|
+
plugin_registry.notify_transcript_event(event, context)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalized_assistant_message(message)
|
|
296
|
+
@message_normalizer ? @message_normalizer.call(message) : message
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def retry_event_payload(event)
|
|
300
|
+
{
|
|
301
|
+
provider: event.provider,
|
|
302
|
+
model: event.model,
|
|
303
|
+
attempt: event.attempt,
|
|
304
|
+
maxAttempts: event.max_attempts,
|
|
305
|
+
delaySeconds: event.delay_seconds,
|
|
306
|
+
error: event.error,
|
|
307
|
+
requestBytes: event.request_bytes
|
|
308
|
+
}.compact
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def tool_call_payload(tool_call)
|
|
312
|
+
{
|
|
313
|
+
toolCallId: ToolCall.id(tool_call),
|
|
314
|
+
toolName: ToolCall.name(tool_call),
|
|
315
|
+
args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call))
|
|
316
|
+
}.compact
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def tool_result_payload(tool_call, content)
|
|
320
|
+
{
|
|
321
|
+
toolCallId: ToolCall.id(tool_call),
|
|
322
|
+
toolName: ToolCall.name(tool_call),
|
|
323
|
+
result: {
|
|
324
|
+
content: content.to_s,
|
|
325
|
+
isError: content.to_s.start_with?("Unknown", "Invalid", "Research tool failed")
|
|
326
|
+
}
|
|
327
|
+
}.compact
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def finish_turn(turn, status)
|
|
331
|
+
event = turn.mutex.synchronize do
|
|
332
|
+
next nil if terminal?(turn)
|
|
333
|
+
|
|
334
|
+
turn.status = status
|
|
335
|
+
turn.finished_at = Time.now.utc.iso8601(3)
|
|
336
|
+
append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
|
|
337
|
+
end
|
|
338
|
+
notify_event(event) if event
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def emit_event(turn, type, payload)
|
|
342
|
+
event = turn.mutex.synchronize { append_event(turn, type, payload) }
|
|
343
|
+
notify_event(event)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def append_event(turn, type, payload)
|
|
347
|
+
event = {
|
|
348
|
+
sequence: turn.next_sequence,
|
|
349
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
350
|
+
chatId: turn.chat_id,
|
|
351
|
+
turnId: turn.id,
|
|
352
|
+
type: type,
|
|
353
|
+
payload: payload
|
|
354
|
+
}
|
|
355
|
+
turn.next_sequence += 1
|
|
356
|
+
turn.events << event
|
|
357
|
+
turn.events.shift while turn.events.length > EVENT_LIMIT
|
|
358
|
+
event
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def notify_event(event)
|
|
362
|
+
listeners = @mutex.synchronize { @event_listeners.dup }
|
|
363
|
+
listeners.each do |listener|
|
|
364
|
+
listener.call(event)
|
|
365
|
+
rescue StandardError
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def terminal?(turn)
|
|
371
|
+
%w[completed failed canceled].include?(turn.status)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "config_files"
|
|
2
2
|
require_relative "deep_copy"
|
|
3
3
|
require_relative "hooks"
|
|
4
|
+
require_relative "transport"
|
|
4
5
|
|
|
5
6
|
# Namespace for the Kward CLI agent runtime.
|
|
6
7
|
module Kward
|
|
@@ -36,7 +37,11 @@ module Kward
|
|
|
36
37
|
|
|
37
38
|
# Registered plugin-owned tab runtime. Its factory receives a
|
|
38
39
|
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
39
|
-
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
|
|
40
|
+
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transport, :local, :transcript_events, :path, :handler, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
# Registered external transport. The factory receives a transport host and
|
|
43
|
+
# configuration when the transport is started, not while plugins load.
|
|
44
|
+
TransportType = Struct.new(:id, :name, :capabilities, :execution_profile, :path, :handler, keyword_init: true)
|
|
40
45
|
|
|
41
46
|
# Read-only event passed to plugin transcript observers.
|
|
42
47
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
@@ -269,21 +274,38 @@ module Kward
|
|
|
269
274
|
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
270
275
|
end
|
|
271
276
|
|
|
272
|
-
# Registers a plugin-owned
|
|
273
|
-
#
|
|
277
|
+
# Registers a plugin-owned chat type. `id` is a durable identifier used
|
|
278
|
+
# in persisted tab layouts and transport chat handles and must not change.
|
|
274
279
|
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
275
280
|
#
|
|
276
281
|
# @param name [String] command name used by `/tab open <name>`
|
|
277
282
|
# @param id [String] stable persisted tab type identifier
|
|
278
283
|
# @param title [String] default tab label
|
|
279
284
|
# @param singleton [Symbol] `:global` for one shared plugin runtime
|
|
285
|
+
# @param rpc [Boolean] expose this chat through trusted local RPC
|
|
286
|
+
# @param transport [Boolean] allow external transport adapters to target this chat
|
|
287
|
+
# @param local [Boolean] expose this chat as an interactive local tab
|
|
280
288
|
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
281
289
|
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
282
290
|
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
283
291
|
# @return [void]
|
|
284
292
|
# @api public
|
|
285
|
-
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
|
|
286
|
-
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
|
|
293
|
+
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, &block)
|
|
294
|
+
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transport: transport, local: local, transcript_events: transcript_events, path: @path, &block)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Registers an external messaging or event transport. The factory is
|
|
298
|
+
# called when the transport runtime starts.
|
|
299
|
+
#
|
|
300
|
+
# @param name [String] human-readable transport name
|
|
301
|
+
# @param id [String] stable transport identifier
|
|
302
|
+
# @param capabilities [Hash, Transport::Capabilities] supported features
|
|
303
|
+
# @yieldparam host [Object] transport host
|
|
304
|
+
# @yieldparam config [Object] transport configuration
|
|
305
|
+
# @return [void]
|
|
306
|
+
# @api public
|
|
307
|
+
def transport(name, id:, capabilities: nil, execution_profile: nil, &block)
|
|
308
|
+
@registry.register_transport(name, id: id, capabilities: capabilities, execution_profile: execution_profile, path: @path, &block)
|
|
287
309
|
end
|
|
288
310
|
end
|
|
289
311
|
|
|
@@ -291,20 +313,25 @@ module Kward
|
|
|
291
313
|
class << self
|
|
292
314
|
attr_accessor :loading_registry, :loading_path
|
|
293
315
|
|
|
294
|
-
def load(paths:
|
|
295
|
-
|
|
316
|
+
def load(paths: nil, reserved_commands: [], warning_sink: nil)
|
|
317
|
+
warning_sink ||= ConfigFiles.warning_sink
|
|
318
|
+
paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
|
|
319
|
+
registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
|
|
296
320
|
paths.each { |path| registry.load_file(path) }
|
|
297
321
|
registry
|
|
298
322
|
end
|
|
299
323
|
end
|
|
300
324
|
|
|
301
325
|
# Creates an object for trusted plugin loading and dispatch.
|
|
302
|
-
def initialize(reserved_commands: [])
|
|
326
|
+
def initialize(reserved_commands: [], warning_sink: nil)
|
|
303
327
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
328
|
+
@warning_sink = warning_sink
|
|
304
329
|
@commands = {}
|
|
305
330
|
@interactive_commands = {}
|
|
306
331
|
@tab_types = {}
|
|
307
332
|
@tab_types_by_id = {}
|
|
333
|
+
@transports = {}
|
|
334
|
+
@transports_by_id = {}
|
|
308
335
|
@footer = nil
|
|
309
336
|
@footer_path = nil
|
|
310
337
|
@transcript_event_handlers = []
|
|
@@ -347,6 +374,22 @@ module Kward
|
|
|
347
374
|
@tab_types_by_id[id.to_s]
|
|
348
375
|
end
|
|
349
376
|
|
|
377
|
+
def transport_tab_types
|
|
378
|
+
@tab_types.values.select(&:transport)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def transports
|
|
382
|
+
@transports.values
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def transport_for(name)
|
|
386
|
+
@transports[name.to_s]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def transport_for_id(id)
|
|
390
|
+
@transports_by_id[id.to_s]
|
|
391
|
+
end
|
|
392
|
+
|
|
350
393
|
def footer_renderer
|
|
351
394
|
@footer
|
|
352
395
|
end
|
|
@@ -379,7 +422,7 @@ module Kward
|
|
|
379
422
|
rendered = entry[:renderer].call(context)
|
|
380
423
|
parts << rendered.to_s unless rendered.to_s.empty?
|
|
381
424
|
rescue StandardError => e
|
|
382
|
-
|
|
425
|
+
emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
|
|
383
426
|
end
|
|
384
427
|
parts.empty? ? nil : parts.join("\n\n")
|
|
385
428
|
end
|
|
@@ -391,7 +434,7 @@ module Kward
|
|
|
391
434
|
@transcript_event_handlers.each do |entry|
|
|
392
435
|
entry[:handler].call(transcript_event, context)
|
|
393
436
|
rescue StandardError => e
|
|
394
|
-
|
|
437
|
+
emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
|
|
395
438
|
end
|
|
396
439
|
nil
|
|
397
440
|
end
|
|
@@ -404,7 +447,7 @@ module Kward
|
|
|
404
447
|
Kernel.load(path, true)
|
|
405
448
|
@paths << path
|
|
406
449
|
rescue StandardError => e
|
|
407
|
-
|
|
450
|
+
emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
|
|
408
451
|
ensure
|
|
409
452
|
self.class.loading_registry = previous_registry
|
|
410
453
|
self.class.loading_path = previous_path
|
|
@@ -422,11 +465,11 @@ module Kward
|
|
|
422
465
|
raise "Plugin command /#{name} requires a handler" unless handler
|
|
423
466
|
|
|
424
467
|
if @reserved_commands.include?(name)
|
|
425
|
-
|
|
468
|
+
emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
|
|
426
469
|
return nil
|
|
427
470
|
end
|
|
428
471
|
if @commands.key?(name)
|
|
429
|
-
|
|
472
|
+
emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
|
|
430
473
|
return nil
|
|
431
474
|
end
|
|
432
475
|
|
|
@@ -445,11 +488,11 @@ module Kward
|
|
|
445
488
|
raise "Interactive command /#{name} requires a handler" unless handler
|
|
446
489
|
|
|
447
490
|
if @reserved_commands.include?(name) || @commands.key?(name)
|
|
448
|
-
|
|
491
|
+
emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
|
|
449
492
|
return nil
|
|
450
493
|
end
|
|
451
494
|
if @interactive_commands.key?(name)
|
|
452
|
-
|
|
495
|
+
emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
|
|
453
496
|
return nil
|
|
454
497
|
end
|
|
455
498
|
|
|
@@ -464,7 +507,7 @@ module Kward
|
|
|
464
507
|
)
|
|
465
508
|
end
|
|
466
509
|
|
|
467
|
-
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
|
|
510
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, path: nil, &handler)
|
|
468
511
|
name = name.to_s
|
|
469
512
|
id = id.to_s
|
|
470
513
|
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
@@ -472,23 +515,46 @@ module Kward
|
|
|
472
515
|
raise "Plugin tab type #{name} requires a handler" unless handler
|
|
473
516
|
|
|
474
517
|
if @tab_types.key?(name) || @tab_types_by_id.key?(id)
|
|
475
|
-
|
|
518
|
+
emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
|
|
476
519
|
return nil
|
|
477
520
|
end
|
|
478
521
|
|
|
479
|
-
tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
522
|
+
tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transport: transport == true, local: local == true, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
480
523
|
@tab_types[name] = tab_type
|
|
481
524
|
@tab_types_by_id[id] = tab_type
|
|
482
525
|
end
|
|
483
526
|
|
|
527
|
+
def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
|
|
528
|
+
name = name.to_s
|
|
529
|
+
id = id.to_s
|
|
530
|
+
raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
531
|
+
raise "Plugin transport id is required" if id.empty?
|
|
532
|
+
raise "Plugin transport #{name} requires a handler" unless handler
|
|
533
|
+
|
|
534
|
+
if @transports.key?(name) || @transports_by_id.key?(id)
|
|
535
|
+
emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
|
|
536
|
+
return nil
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
capabilities = normalize_transport_capabilities(capabilities)
|
|
540
|
+
execution_profile = normalize_execution_profile(execution_profile)
|
|
541
|
+
transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
|
|
542
|
+
@transports[name] = transport
|
|
543
|
+
@transports_by_id[id] = transport
|
|
544
|
+
end
|
|
545
|
+
|
|
484
546
|
def register_footer(path: nil, &renderer)
|
|
485
547
|
raise "Plugin footer requires a renderer" unless renderer
|
|
486
548
|
|
|
487
|
-
|
|
549
|
+
emit_warning "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
|
|
488
550
|
@footer = renderer
|
|
489
551
|
@footer_path = path
|
|
490
552
|
end
|
|
491
553
|
|
|
554
|
+
def emit_warning(message)
|
|
555
|
+
@warning_sink ? @warning_sink.call(message) : warn(message)
|
|
556
|
+
end
|
|
557
|
+
|
|
492
558
|
def register_transcript_event(path: nil, &handler)
|
|
493
559
|
raise "Plugin transcript event requires a handler" unless handler
|
|
494
560
|
|
|
@@ -520,6 +586,20 @@ module Kward
|
|
|
520
586
|
|
|
521
587
|
private
|
|
522
588
|
|
|
589
|
+
def normalize_execution_profile(profile)
|
|
590
|
+
return nil if profile.nil?
|
|
591
|
+
return profile if profile.is_a?(Transport::ExecutionProfile)
|
|
592
|
+
raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def normalize_transport_capabilities(capabilities)
|
|
596
|
+
return Transport.capabilities if capabilities.nil?
|
|
597
|
+
return capabilities if capabilities.is_a?(Transport::Capabilities)
|
|
598
|
+
raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
|
|
599
|
+
|
|
600
|
+
Transport.capabilities(**capabilities.transform_keys(&:to_sym))
|
|
601
|
+
end
|
|
602
|
+
|
|
523
603
|
def transcript_event_for(event)
|
|
524
604
|
case event.class.name
|
|
525
605
|
when "Kward::Events::ReasoningDelta"
|
data/lib/kward/private_file.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "json"
|
|
3
|
+
require "tempfile"
|
|
3
4
|
|
|
4
5
|
# Namespace for the Kward CLI agent runtime.
|
|
5
6
|
module Kward
|
|
@@ -9,12 +10,17 @@ module Kward
|
|
|
9
10
|
|
|
10
11
|
def write_json(path, data)
|
|
11
12
|
path = File.expand_path(path)
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
directory = File.dirname(path)
|
|
14
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
15
|
+
|
|
16
|
+
Tempfile.create([".#{File.basename(path)}.", ".tmp"], directory, mode: 0o600) do |file|
|
|
14
17
|
file.write(JSON.pretty_generate(data))
|
|
15
18
|
file.write("\n")
|
|
19
|
+
file.flush
|
|
20
|
+
file.fsync
|
|
21
|
+
file.close
|
|
22
|
+
File.rename(file.path, path)
|
|
16
23
|
end
|
|
17
|
-
File.chmod(0o600, path)
|
|
18
24
|
end
|
|
19
25
|
end
|
|
20
26
|
end
|
|
@@ -37,6 +37,7 @@ module Kward
|
|
|
37
37
|
def tool_approval_title(tool_name)
|
|
38
38
|
case tool_name.to_s
|
|
39
39
|
when "run_shell_command" then "Shell command"
|
|
40
|
+
when "git_commit" then "Git commit"
|
|
40
41
|
when "write_file" then "Write file"
|
|
41
42
|
when "edit_file" then "Edit file"
|
|
42
43
|
when "fetch_content", "fetch_raw" then "Network request"
|
|
@@ -48,6 +49,7 @@ module Kward
|
|
|
48
49
|
def tool_approval_details(tool_name, args)
|
|
49
50
|
action = case tool_name.to_s
|
|
50
51
|
when "run_shell_command" then "run this shell command"
|
|
52
|
+
when "git_commit" then "create this Git commit"
|
|
51
53
|
when "write_file" then "write this file"
|
|
52
54
|
when "edit_file" then "edit this file"
|
|
53
55
|
when "read_file", "read_skill" then "read these resources"
|