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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +288 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. 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 plugin_command_for(name)
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 = [STATUS_MESSAGE]
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