kward 0.71.0 → 0.73.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +288 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +451 -57
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +67 -1
|
@@ -22,6 +22,40 @@ module Kward
|
|
|
22
22
|
when "redraw"
|
|
23
23
|
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
24
24
|
[true, nil]
|
|
25
|
+
when "git"
|
|
26
|
+
handle_git_command(agent)
|
|
27
|
+
[true, nil]
|
|
28
|
+
when "diff"
|
|
29
|
+
open_session_diff
|
|
30
|
+
[true, nil]
|
|
31
|
+
when "files"
|
|
32
|
+
open_project_files_browser
|
|
33
|
+
[true, nil]
|
|
34
|
+
when "shell"
|
|
35
|
+
run_ekwsh(agent)
|
|
36
|
+
[true, nil]
|
|
37
|
+
when "scratchpad"
|
|
38
|
+
open_scratchpad_command(argument)
|
|
39
|
+
[true, nil]
|
|
40
|
+
when "pty"
|
|
41
|
+
run_interactive_pty_command(argument, agent)
|
|
42
|
+
[true, nil]
|
|
43
|
+
when "workers"
|
|
44
|
+
unless experimental_workers_enabled?
|
|
45
|
+
runtime_output("Workers are experimental. Start Kward with --experimental-workers to enable /workers.")
|
|
46
|
+
return [true, nil]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
[true, handle_workers_command(argument, agent, session_store)]
|
|
50
|
+
when "queue"
|
|
51
|
+
unless experimental_workers_enabled?
|
|
52
|
+
runtime_output("Worker queues are experimental. Start Kward with --experimental-workers to enable /queue.")
|
|
53
|
+
return [true, nil]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
[true, handle_worker_queue_command(argument, agent, session_store)]
|
|
57
|
+
when "tab"
|
|
58
|
+
[true, handle_tab_command(argument, session_store)]
|
|
25
59
|
when "settings"
|
|
26
60
|
configure_settings(agent.conversation)
|
|
27
61
|
[true, nil]
|
|
@@ -93,7 +127,9 @@ module Kward
|
|
|
93
127
|
run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
|
|
94
128
|
[true, nil]
|
|
95
129
|
else
|
|
96
|
-
if
|
|
130
|
+
if interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
|
|
131
|
+
run_interactive_command(name, argument, agent)
|
|
132
|
+
elsif plugin_command_for(name)
|
|
97
133
|
run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
|
|
98
134
|
else
|
|
99
135
|
[false, nil]
|
|
@@ -105,9 +141,55 @@ module Kward
|
|
|
105
141
|
PromptCommands.parse(command) || [nil, ""]
|
|
106
142
|
end
|
|
107
143
|
|
|
144
|
+
def open_session_diff
|
|
145
|
+
unless @active_session&.path
|
|
146
|
+
runtime_output("No active persisted session.")
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
content = SessionDiff.content_from_session_file(@active_session.path)
|
|
151
|
+
if content.empty?
|
|
152
|
+
runtime_output("No file changes recorded in this session.")
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if @prompt.respond_to?(:open_modal_diff_viewer)
|
|
157
|
+
@prompt.open_modal_diff_viewer("Session diff", content)
|
|
158
|
+
elsif @prompt.respond_to?(:open_diff_viewer)
|
|
159
|
+
@prompt.open_diff_viewer("Session diff", content)
|
|
160
|
+
else
|
|
161
|
+
runtime_output(content)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def open_scratchpad_command(argument)
|
|
166
|
+
if @prompt.respond_to?(:scratchpad)
|
|
167
|
+
@prompt.scratchpad(scratchpad_language_argument(argument))
|
|
168
|
+
else
|
|
169
|
+
runtime_output("The scratchpad is only available in the interactive prompt.")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def scratchpad_language_argument(argument)
|
|
174
|
+
value = argument.to_s.strip.downcase
|
|
175
|
+
return :text if value.empty? || value == "text"
|
|
176
|
+
return :markdown if ["markdown", "md"].include?(value)
|
|
177
|
+
return :ruby if ["ruby", "rb"].include?(value)
|
|
178
|
+
|
|
179
|
+
:text
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def open_project_files_browser
|
|
183
|
+
if @prompt.respond_to?(:open_project_browser)
|
|
184
|
+
@prompt.open_project_browser
|
|
185
|
+
else
|
|
186
|
+
runtime_output("The project file browser is only available in the interactive prompt.")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
108
190
|
# Writes the status output for the terminal CLI flow.
|
|
109
191
|
def print_status
|
|
110
|
-
lines = [
|
|
192
|
+
lines = ["Kward status"]
|
|
111
193
|
lines << ""
|
|
112
194
|
lines << auto_compaction_status_line
|
|
113
195
|
if @active_session
|
|
@@ -136,6 +218,550 @@ module Kward
|
|
|
136
218
|
nil
|
|
137
219
|
end
|
|
138
220
|
|
|
221
|
+
def handle_worker_queue_command(argument, agent, session_store)
|
|
222
|
+
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
223
|
+
case action
|
|
224
|
+
when nil, "", "status", "list"
|
|
225
|
+
show_worker_queue
|
|
226
|
+
when "add", "enqueue"
|
|
227
|
+
enqueue_active_tab(value, agent)
|
|
228
|
+
when "open", "show"
|
|
229
|
+
open_worker_queue_job(session_store, value)
|
|
230
|
+
when "run", "start"
|
|
231
|
+
run_worker_queue(session_store)
|
|
232
|
+
when "resume"
|
|
233
|
+
value.to_s.strip.empty? ? run_worker_queue(session_store) : resume_worker_queue_job(session_store, value)
|
|
234
|
+
when "suspend", "pause"
|
|
235
|
+
suspend_worker_queue_job(session_store, value)
|
|
236
|
+
when "next"
|
|
237
|
+
run_next_worker_queue_job(session_store)
|
|
238
|
+
else
|
|
239
|
+
runtime_output("Usage: /queue [add|list|status|run|suspend <id>|resume <id>]")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def worker_queue_store
|
|
244
|
+
@worker_queue_store ||= Workers::QueueStore.new
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def enqueue_active_tab(title, agent)
|
|
248
|
+
session = @active_session
|
|
249
|
+
unless session&.path
|
|
250
|
+
runtime_output("No active persisted session to queue.")
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
job = worker_queue_store.enqueue(
|
|
255
|
+
title: worker_queue_title(title, session, agent),
|
|
256
|
+
session_path: session.path,
|
|
257
|
+
workspace_root: session.cwd || current_workspace_root
|
|
258
|
+
)
|
|
259
|
+
runtime_output("Queued worker #{job.id}: #{job.title}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def worker_queue_title(title, session, agent)
|
|
263
|
+
explicit = title.to_s.strip
|
|
264
|
+
return explicit unless explicit.empty?
|
|
265
|
+
|
|
266
|
+
session_name = session&.name.to_s.strip
|
|
267
|
+
return session_name unless session_name.empty?
|
|
268
|
+
|
|
269
|
+
last_user = if agent&.respond_to?(:conversation)
|
|
270
|
+
agent.conversation.messages.reverse.find { |message| MessageAccess.role(message) == "user" }
|
|
271
|
+
end
|
|
272
|
+
content = MessageAccess.content(last_user).to_s.strip.gsub(/\s+/, " ")
|
|
273
|
+
content.empty? ? "Queued worker" : content[0, 80]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def open_worker_queue_job(session_store, id)
|
|
277
|
+
unless session_store
|
|
278
|
+
runtime_output("Worker queue requires persisted sessions.")
|
|
279
|
+
return nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
id = id.to_s.strip
|
|
283
|
+
return runtime_output("Usage: /queue open <id>") if id.empty?
|
|
284
|
+
|
|
285
|
+
record = worker_queue_store.find(id)
|
|
286
|
+
return runtime_output("Unknown worker job: #{id}") unless record
|
|
287
|
+
|
|
288
|
+
load_session(session_store, record.fetch("session_path"), message: "Showing queued worker #{record.fetch('id')}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def run_worker_queue(session_store)
|
|
292
|
+
unless session_store
|
|
293
|
+
runtime_output("Worker queue requires persisted sessions.")
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
results = worker_queue_runner(session_store).run_all
|
|
298
|
+
if results.empty?
|
|
299
|
+
runtime_output("Worker queue has no queued jobs.")
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
summary = results.map { |record| "#{record.fetch('id')} #{record.fetch('status')}" }.join(", ")
|
|
304
|
+
runtime_output("Worker queue run finished: #{summary}")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def run_next_worker_queue_job(session_store)
|
|
308
|
+
unless session_store
|
|
309
|
+
runtime_output("Worker queue requires persisted sessions.")
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
record = worker_queue_runner(session_store).run_next
|
|
314
|
+
if record
|
|
315
|
+
runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
|
|
316
|
+
else
|
|
317
|
+
runtime_output("Worker queue has no queued jobs.")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def suspend_worker_queue_job(session_store, id)
|
|
322
|
+
unless session_store
|
|
323
|
+
runtime_output("Worker queue requires persisted sessions.")
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
id = id.to_s.strip
|
|
328
|
+
return runtime_output("Usage: /queue suspend <id>") if id.empty?
|
|
329
|
+
|
|
330
|
+
record = worker_queue_runner(session_store).suspend(id)
|
|
331
|
+
runtime_output("Worker #{record.fetch('id')} suspended.")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def resume_worker_queue_job(session_store, id)
|
|
335
|
+
unless session_store
|
|
336
|
+
runtime_output("Worker queue requires persisted sessions.")
|
|
337
|
+
return
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
id = id.to_s.strip
|
|
341
|
+
return runtime_output("Usage: /queue resume <id>") if id.empty?
|
|
342
|
+
|
|
343
|
+
record = worker_queue_runner(session_store).resume(id)
|
|
344
|
+
runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def worker_queue_runner(session_store)
|
|
348
|
+
Workers::QueueRunner.new(
|
|
349
|
+
queue_store: worker_queue_store,
|
|
350
|
+
session_store: session_store,
|
|
351
|
+
client_factory: -> { Client.new },
|
|
352
|
+
prompt: @prompt,
|
|
353
|
+
workspace_root: current_workspace_root,
|
|
354
|
+
provider: current_model_provider,
|
|
355
|
+
model: current_model_id,
|
|
356
|
+
reasoning_effort: current_reasoning_effort,
|
|
357
|
+
write_lock: (@worker_write_lock ||= Workers::WriteLock.new)
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def show_worker_queue
|
|
362
|
+
jobs = worker_queue_store.list
|
|
363
|
+
if jobs.empty?
|
|
364
|
+
runtime_output("Worker queue is empty.")
|
|
365
|
+
return
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
lines = ["Worker queue:"]
|
|
369
|
+
jobs.each do |job|
|
|
370
|
+
details = [job.fetch("id"), "[#{job.fetch('status')}]"]
|
|
371
|
+
details << "##{job['position']}" if job["position"]
|
|
372
|
+
details << job.fetch("title")
|
|
373
|
+
details << "commit #{job['commit_sha']}" unless job["commit_sha"].to_s.empty?
|
|
374
|
+
details << "error: #{job['error']}" unless job["error"].to_s.empty?
|
|
375
|
+
lines << "- #{details.join(' ')}"
|
|
376
|
+
end
|
|
377
|
+
runtime_output(lines.join("\n"))
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def handle_workers_command(argument, agent, session_store)
|
|
381
|
+
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
382
|
+
replacement_agent = case action
|
|
383
|
+
when nil, ""
|
|
384
|
+
open_worker_menu(agent, session_store)
|
|
385
|
+
when "list"
|
|
386
|
+
open_worker_list(agent, session_store)
|
|
387
|
+
when "new", "do"
|
|
388
|
+
prompt_for_worker_request(agent, value)
|
|
389
|
+
nil
|
|
390
|
+
else
|
|
391
|
+
runtime_output("Usage: /workers | /workers new | /workers list")
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
replacement_agent?(replacement_agent) ? replacement_agent : nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def worker_store
|
|
398
|
+
@worker_store ||= Workers::Store.new
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def worker_manager(agent)
|
|
402
|
+
workspace_root = interactive_workspace_root(agent)
|
|
403
|
+
return @worker_manager if @worker_manager && @worker_manager_workspace_root == workspace_root
|
|
404
|
+
|
|
405
|
+
@worker_manager_workspace_root = workspace_root
|
|
406
|
+
@worker_manager = Workers::Manager.new(
|
|
407
|
+
client_factory: -> { Client.new },
|
|
408
|
+
prompt: @prompt,
|
|
409
|
+
workspace_root: workspace_root,
|
|
410
|
+
session_store: interactive_session_store(agent),
|
|
411
|
+
provider: current_model_provider,
|
|
412
|
+
model: current_model_id,
|
|
413
|
+
reasoning_effort: current_reasoning_effort,
|
|
414
|
+
write_lock: (@worker_write_lock ||= Workers::WriteLock.new),
|
|
415
|
+
worker_store: worker_store,
|
|
416
|
+
write_lane_available: -> { !@foreground_turn_active }
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def open_worker_menu(agent, session_store)
|
|
421
|
+
return runtime_output("Usage: /workers | /workers new | /workers list") unless @prompt.respond_to?(:select)
|
|
422
|
+
|
|
423
|
+
choice = @prompt.select(
|
|
424
|
+
"Workers",
|
|
425
|
+
["New worker", "List workers"],
|
|
426
|
+
title: "Workers",
|
|
427
|
+
custom: false
|
|
428
|
+
)
|
|
429
|
+
case choice
|
|
430
|
+
when "New worker"
|
|
431
|
+
prompt_for_worker_request(agent)
|
|
432
|
+
when "List workers"
|
|
433
|
+
open_worker_list(agent, session_store)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def prompt_for_worker_request(agent, topic = nil)
|
|
438
|
+
topic = @prompt.ask("Worker task>") if topic.to_s.strip.empty? && @prompt.respond_to?(:ask)
|
|
439
|
+
send_worker_request(topic, agent) unless topic.to_s.strip.empty?
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def send_worker_request(topic, agent)
|
|
443
|
+
if topic.to_s.strip.empty?
|
|
444
|
+
runtime_output("Usage: /workers new <task>")
|
|
445
|
+
return
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
worker = worker_manager(agent).start(role: "request", prompt: topic)
|
|
449
|
+
runtime_output("Worker #{worker.id} started: #{worker.title}")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def open_worker_list(agent, session_store, title: "Workers", empty_message: "No workers in the pipeline.")
|
|
453
|
+
return runtime_output(empty_message) unless @prompt.respond_to?(:select)
|
|
454
|
+
|
|
455
|
+
jobs = worker_jobs(agent)
|
|
456
|
+
if jobs.empty?
|
|
457
|
+
runtime_output(empty_message)
|
|
458
|
+
return
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
labels = jobs.map { |job| worker_choice_label(job) }
|
|
462
|
+
choice = @prompt.select("Select worker", labels, title: title, custom: false)
|
|
463
|
+
return unless choice
|
|
464
|
+
|
|
465
|
+
selected = jobs[labels.index(choice)]
|
|
466
|
+
open_worker_actions(selected, agent, session_store) if selected
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def worker_jobs(agent)
|
|
470
|
+
runtime_worker_ids = @worker_manager ? @worker_manager.list.map(&:id) : []
|
|
471
|
+
persisted_workers = worker_store.list.reject { |job| runtime_worker_ids.include?(job["id"]) }
|
|
472
|
+
live_workers = @worker_manager ? @worker_manager.list.map(&:to_h) : []
|
|
473
|
+
[implementation_worker_job(agent)].compact + persisted_workers + live_workers
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def implementation_worker_job(agent)
|
|
477
|
+
remember_implementation_worker(agent) if implementation_agent?(agent)
|
|
478
|
+
path = @implementation_worker_session_path || @active_session&.path
|
|
479
|
+
return nil if path.to_s.empty?
|
|
480
|
+
|
|
481
|
+
{
|
|
482
|
+
"id" => "implementation",
|
|
483
|
+
"title" => @implementation_worker_title || @active_session&.name || "Implementation",
|
|
484
|
+
"role" => "implementation",
|
|
485
|
+
"status" => implementation_agent?(agent) ? "active" : "idle",
|
|
486
|
+
"session_path" => path
|
|
487
|
+
}
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def implementation_agent?(agent)
|
|
491
|
+
@active_worker_role.to_s.empty? || @active_worker_role == "implementation"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def remember_implementation_worker(agent)
|
|
495
|
+
return unless @active_session&.path
|
|
496
|
+
return unless implementation_agent?(agent)
|
|
497
|
+
|
|
498
|
+
@implementation_worker_session_path = @active_session.path
|
|
499
|
+
@implementation_worker_title = @active_session.name || "Implementation"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def open_worker_actions(job, _agent, session_store)
|
|
503
|
+
return open_implementation_actions(job, session_store) if job["id"] == "implementation"
|
|
504
|
+
|
|
505
|
+
open_background_worker_actions(job, session_store)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def open_implementation_actions(job, session_store)
|
|
509
|
+
actions = ["Show", "Back to list"]
|
|
510
|
+
choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
|
|
511
|
+
case choice
|
|
512
|
+
when "Show"
|
|
513
|
+
load_implementation_session(session_store, job)
|
|
514
|
+
when "Back to list"
|
|
515
|
+
open_worker_list(nil, session_store)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def handle_request_worker_input(input, agent, session_store)
|
|
520
|
+
return [false, nil] unless @active_worker_role == "request"
|
|
521
|
+
|
|
522
|
+
worker = visible_request_worker(agent)
|
|
523
|
+
return [false, nil] unless worker
|
|
524
|
+
|
|
525
|
+
text = input.to_s.strip
|
|
526
|
+
return [true, nil] if text.empty?
|
|
527
|
+
return [true, proceed_request_worker(worker.to_h, agent, session_store)] if proceed_request_input?(text)
|
|
528
|
+
|
|
529
|
+
runtime_output("Worker #{worker.id} is a read-only request review. Reply yes/proceed to queue implementation, or use /workers to switch workers.")
|
|
530
|
+
[true, nil]
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def visible_request_worker(agent)
|
|
534
|
+
worker = @visible_worker
|
|
535
|
+
return worker if worker&.role == "request" && worker.status == "ready"
|
|
536
|
+
|
|
537
|
+
id = @visible_worker_id.to_s
|
|
538
|
+
return nil if id.empty? || id == "implementation"
|
|
539
|
+
|
|
540
|
+
worker = @worker_manager&.find(id)
|
|
541
|
+
return worker if worker&.role == "request" && worker.status == "ready"
|
|
542
|
+
|
|
543
|
+
job = worker_store.find(id)
|
|
544
|
+
return nil unless job && job["role"] == "request" && job["status"] == "ready"
|
|
545
|
+
return nil if job["session_path"].to_s.empty? || !session_matches_agent?(job["session_path"], agent)
|
|
546
|
+
|
|
547
|
+
Workers::Worker.new(
|
|
548
|
+
id: job.fetch("id"),
|
|
549
|
+
title: job.fetch("title"),
|
|
550
|
+
role: job.fetch("role"),
|
|
551
|
+
workspace_root: job["workspace_root"] || current_workspace_root,
|
|
552
|
+
status: job.fetch("status"),
|
|
553
|
+
prompt: job["prompt"]
|
|
554
|
+
).tap { |restored| restored.update_status("ready", report: job["report"], error: job["error"]) }
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def session_matches_agent?(path, agent)
|
|
558
|
+
return true unless agent.respond_to?(:conversation)
|
|
559
|
+
return false unless @active_session&.path
|
|
560
|
+
|
|
561
|
+
File.expand_path(path) == File.expand_path(@active_session.path)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def proceed_request_input?(input)
|
|
565
|
+
input.downcase.strip.match?(/\A(?:y|yes|yeah|yep|sure|ok|okay|go ahead|proceed|continue|implement|do it|please do|make it so)\b/)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def proceed_request_worker(job, agent, session_store)
|
|
569
|
+
return runtime_output("Worker #{job.fetch('id')} is not ready to proceed.") unless request_ready?(job)
|
|
570
|
+
|
|
571
|
+
release_implementation_writer
|
|
572
|
+
manager = worker_manager(agent || build_session_agent_for_worker(job, session_store))
|
|
573
|
+
worker = manager.continue(
|
|
574
|
+
job.fetch("id"),
|
|
575
|
+
role: "implementation",
|
|
576
|
+
prompt: implementation_prompt_for_request(job),
|
|
577
|
+
title: "Implement #{job.fetch('title')}"
|
|
578
|
+
)
|
|
579
|
+
runtime_output("Worker #{worker.id} queued from request #{job.fetch('id')}: #{worker.title}")
|
|
580
|
+
wait_for_worker_session(worker)
|
|
581
|
+
load_worker_session(session_store, worker.session.path, worker.to_h, worker: worker) if worker.session&.path
|
|
582
|
+
rescue StandardError => e
|
|
583
|
+
runtime_output("Error: #{e.message}")
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def wait_for_worker_session(worker, timeout: 1.0)
|
|
588
|
+
deadline = Time.now + timeout
|
|
589
|
+
until worker.session&.path || Time.now >= deadline
|
|
590
|
+
sleep 0.02
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def build_session_agent_for_worker(job, session_store)
|
|
595
|
+
conversation = Conversation.new(workspace_root: job["workspace_root"] || session_store&.cwd || current_workspace_root)
|
|
596
|
+
build_worker_agent(conversation, role: "request")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def request_ready?(job)
|
|
600
|
+
job["role"] == "request" && job["status"] == "ready"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def implementation_prompt_for_request(job)
|
|
604
|
+
<<~PROMPT
|
|
605
|
+
The user reviewed and approved this Kward request. Continue in the write-capable implementation lane.
|
|
606
|
+
|
|
607
|
+
Original request:
|
|
608
|
+
#{job["prompt"]}
|
|
609
|
+
|
|
610
|
+
Request review:
|
|
611
|
+
#{job["report"].to_s.empty? ? "No saved review text is available. Use the request session transcript for context if needed." : job["report"]}
|
|
612
|
+
|
|
613
|
+
Implement the approved next step. Make the smallest correct change, preserve existing style, and run focused verification when practical.
|
|
614
|
+
If you change files, commit the changes and report the commit hash. If no file changes are needed, explain why.
|
|
615
|
+
PROMPT
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def open_background_worker_actions(job, session_store)
|
|
619
|
+
actions = ["Show"]
|
|
620
|
+
actions << "Proceed" if request_ready?(job)
|
|
621
|
+
actions << "Cancel" if %w[queued running].include?(job["status"])
|
|
622
|
+
actions << "Dismiss"
|
|
623
|
+
actions << "Back to list"
|
|
624
|
+
choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
|
|
625
|
+
case choice
|
|
626
|
+
when "Show"
|
|
627
|
+
worker = @worker_manager&.find(job.fetch("id"))
|
|
628
|
+
path = job["session_path"] || worker&.session&.path
|
|
629
|
+
return runtime_output("Worker #{job.fetch('id')} session is not ready yet.") unless path
|
|
630
|
+
|
|
631
|
+
load_worker_session(session_store, path, job, worker: worker)
|
|
632
|
+
when "Proceed"
|
|
633
|
+
proceed_request_worker(job, nil, session_store)
|
|
634
|
+
when "Cancel"
|
|
635
|
+
@worker_manager&.cancel(job.fetch("id"))
|
|
636
|
+
runtime_output("Worker #{job.fetch('id')} cancelled.")
|
|
637
|
+
when "Dismiss"
|
|
638
|
+
dismiss_worker(job.fetch("id"))
|
|
639
|
+
runtime_output("Worker #{job.fetch('id')} dismissed.")
|
|
640
|
+
when "Back to list"
|
|
641
|
+
open_worker_list(nil, session_store)
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def dismiss_worker(id)
|
|
646
|
+
@worker_manager&.archive(id)
|
|
647
|
+
rescue ArgumentError
|
|
648
|
+
nil
|
|
649
|
+
ensure
|
|
650
|
+
worker_store.archive(id)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def load_implementation_session(session_store, job)
|
|
654
|
+
return runtime_output("Implementation session unavailable.") unless session_store
|
|
655
|
+
|
|
656
|
+
stop_live_worker_view
|
|
657
|
+
@active_worker_role = "implementation"
|
|
658
|
+
set_visible_worker("implementation", status: "active")
|
|
659
|
+
load_session(session_store, job.fetch("session_path"), message: "Showing implementation worker")
|
|
660
|
+
rescue StandardError => e
|
|
661
|
+
runtime_output("Error: #{e.message}")
|
|
662
|
+
nil
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def worker_choice_label(job)
|
|
666
|
+
error = job["status"] == "failed" && !job["error"].to_s.empty? ? " — #{job['error']}" : ""
|
|
667
|
+
"#{job.fetch('id')} [#{job.fetch('role')}/#{job.fetch('status')}] #{job.fetch('title')}#{error}"
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def load_worker_session(session_store, path, job, worker: nil)
|
|
671
|
+
unless session_store
|
|
672
|
+
runtime_output(worker_report_text(job))
|
|
673
|
+
return nil
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
release_implementation_writer
|
|
677
|
+
agent = load_session(session_store, path, message: "Showing worker #{job.fetch('id')}")
|
|
678
|
+
release_implementation_writer
|
|
679
|
+
role = visible_session_role(job)
|
|
680
|
+
agent = build_worker_agent(agent.conversation, role: role)
|
|
681
|
+
@active_worker_role = role
|
|
682
|
+
set_visible_worker(job.fetch("id"), status: job["status"], worker: worker)
|
|
683
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
684
|
+
start_live_worker_view(worker, agent) if live_worker?(worker)
|
|
685
|
+
agent
|
|
686
|
+
rescue StandardError => e
|
|
687
|
+
runtime_output("Error: #{e.message}")
|
|
688
|
+
nil
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def visible_session_role(job)
|
|
692
|
+
return "read_only" if job["id"] != "implementation" && job["role"] == "implementation"
|
|
693
|
+
|
|
694
|
+
job["role"] || "request"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def live_worker?(worker)
|
|
698
|
+
worker && %w[queued running].include?(worker.status)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def start_live_worker_view(worker, agent)
|
|
702
|
+
return unless prompt_interface?
|
|
703
|
+
|
|
704
|
+
stop_live_worker_view
|
|
705
|
+
renderer = live_worker_renderer(worker)
|
|
706
|
+
@live_worker_view = Workers::LiveView.new(worker: worker, agent: agent, renderer: renderer).start
|
|
707
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
708
|
+
runtime_output("Watching worker #{worker.id}; the view will update until it finishes.")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def stop_live_worker_view
|
|
712
|
+
@live_worker_view&.stop
|
|
713
|
+
@live_worker_view = nil
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def live_worker_renderer(worker)
|
|
717
|
+
markdown_chunks = []
|
|
718
|
+
stream_state = {
|
|
719
|
+
streamed: false,
|
|
720
|
+
last_flush: monotonic_now,
|
|
721
|
+
stream_block_open: false,
|
|
722
|
+
markdown_streams: {},
|
|
723
|
+
defer_assistant_streaming: false
|
|
724
|
+
}
|
|
725
|
+
lambda do |event, agent|
|
|
726
|
+
if event == :flush
|
|
727
|
+
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
|
|
728
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
729
|
+
next
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
|
|
733
|
+
handle_live_worker_event(event, markdown_chunks, stream_state)
|
|
734
|
+
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
|
|
735
|
+
rescue StandardError => e
|
|
736
|
+
runtime_output("Worker view error: #{e.message}")
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def handle_live_worker_event(event, markdown_chunks, stream_state)
|
|
741
|
+
case event
|
|
742
|
+
when Events::AssistantMessage
|
|
743
|
+
return if stream_state[:streamed]
|
|
744
|
+
|
|
745
|
+
render_assistant_message(event.message)
|
|
746
|
+
else
|
|
747
|
+
handle_interactive_event(event, markdown_chunks, stream_state)
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def worker_finished?(worker)
|
|
752
|
+
%w[ready failed cancelled archived].include?(worker.status)
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def worker_report_text(job)
|
|
756
|
+
lines = ["Worker #{job.fetch('id')} [#{job.fetch('status')}] #{job.fetch('title')}", ""]
|
|
757
|
+
if job["report"].to_s.empty?
|
|
758
|
+
lines << (job["error"].to_s.empty? ? "No report yet." : "Error: #{job['error']}")
|
|
759
|
+
else
|
|
760
|
+
lines << job["report"]
|
|
761
|
+
end
|
|
762
|
+
lines.join("\n")
|
|
763
|
+
end
|
|
764
|
+
|
|
139
765
|
end
|
|
140
766
|
end
|
|
141
767
|
end
|