kward 0.76.0 → 0.77.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +75 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/rpc.md +0 -22
  17. data/doc/security.md +180 -0
  18. data/doc/usage.md +15 -32
  19. data/lib/kward/ansi.rb +2 -2
  20. data/lib/kward/cancellation.rb +2 -2
  21. data/lib/kward/cli/commands.rb +1 -3
  22. data/lib/kward/cli/interactive_turn.rb +0 -17
  23. data/lib/kward/cli/plugins.rb +2 -7
  24. data/lib/kward/cli/prompt_interface.rb +1 -0
  25. data/lib/kward/cli/runtime_helpers.rb +0 -38
  26. data/lib/kward/cli/sessions.rb +14 -5
  27. data/lib/kward/cli/settings.rb +22 -0
  28. data/lib/kward/cli/slash_commands.rb +1 -561
  29. data/lib/kward/cli/tabs.rb +14 -2
  30. data/lib/kward/cli.rb +1 -17
  31. data/lib/kward/compactor.rb +3 -3
  32. data/lib/kward/config_files.rb +18 -0
  33. data/lib/kward/ekwsh.rb +0 -4
  34. data/lib/kward/hooks/catalog.rb +0 -5
  35. data/lib/kward/image_attachments.rb +1 -1
  36. data/lib/kward/pan/index.html.erb +459 -64
  37. data/lib/kward/pan/kward_logo.png +0 -0
  38. data/lib/kward/pan/server.rb +248 -23
  39. data/lib/kward/plugin_registry.rb +18 -0
  40. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  41. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  42. data/lib/kward/prompt_interface/layout.rb +0 -4
  43. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  44. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  46. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  47. data/lib/kward/prompt_interface.rb +18 -1
  48. data/lib/kward/prompts/commands.rb +1 -3
  49. data/lib/kward/prompts.rb +20 -0
  50. data/lib/kward/rpc/server.rb +13 -41
  51. data/lib/kward/rpc/session_manager.rb +75 -25
  52. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  53. data/lib/kward/rpc/tool_metadata.rb +23 -0
  54. data/lib/kward/rpc/transcript_normalizer.rb +4 -0
  55. data/lib/kward/session_store.rb +55 -16
  56. data/lib/kward/skills/registry.rb +21 -1
  57. data/lib/kward/tools/base.rb +14 -0
  58. data/lib/kward/tools/registry.rb +11 -16
  59. data/lib/kward/tools/search/code.rb +1 -1
  60. data/lib/kward/tools/search/web.rb +1 -1
  61. data/lib/kward/tools/tool_call.rb +0 -4
  62. data/lib/kward/transcript_export.rb +1 -1
  63. data/lib/kward/version.rb +1 -1
  64. data/templates/default/kward_navigation.rb +5 -1
  65. data/templates/default/layout/html/layout.erb +0 -2
  66. metadata +5 -12
  67. data/lib/kward/workers/git_guard.rb +0 -93
  68. data/lib/kward/workers/job.rb +0 -99
  69. data/lib/kward/workers/live_view.rb +0 -49
  70. data/lib/kward/workers/manager.rb +0 -328
  71. data/lib/kward/workers/queue_runner.rb +0 -166
  72. data/lib/kward/workers/queue_store.rb +0 -112
  73. data/lib/kward/workers/store.rb +0 -72
  74. data/lib/kward/workers/tool_policy.rb +0 -23
  75. data/lib/kward/workers/worker.rb +0 -82
  76. data/lib/kward/workers/write_lock.rb +0 -38
  77. data/lib/kward/workers.rb +0 -10
@@ -46,20 +46,6 @@ module Kward
46
46
  when "pty"
47
47
  run_interactive_pty_command(argument, agent)
48
48
  [true, nil]
49
- when "workers"
50
- unless experimental_workers_enabled?
51
- runtime_output("Workers are experimental. Start Kward with --experimental-workers to enable /workers.")
52
- return [true, nil]
53
- end
54
-
55
- [true, handle_workers_command(argument, agent, session_store)]
56
- when "queue"
57
- unless experimental_workers_enabled?
58
- runtime_output("Worker queues are experimental. Start Kward with --experimental-workers to enable /queue.")
59
- return [true, nil]
60
- end
61
-
62
- [true, handle_worker_queue_command(argument, agent, session_store)]
63
49
  when "tab"
64
50
  [true, handle_tab_command(argument, session_store)]
65
51
  when "settings"
@@ -136,7 +122,7 @@ module Kward
136
122
 
137
123
  path = argument.to_s.strip
138
124
  if path.empty?
139
- sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
125
+ sessions = run_busy_local_command_and_requeue { recent_sessions(session_store, tree: true) }
140
126
  path = select_session_path_from_sessions(sessions, session_store: session_store)
141
127
  end
142
128
 
@@ -255,552 +241,6 @@ module Kward
255
241
  nil
256
242
  end
257
243
 
258
- def handle_worker_queue_command(argument, agent, session_store)
259
- action, value = argument.to_s.strip.split(/\s+/, 2)
260
- case action
261
- when nil, "", "status", "list"
262
- show_worker_queue
263
- when "add", "enqueue"
264
- enqueue_active_tab(value, agent)
265
- when "open", "show"
266
- open_worker_queue_job(session_store, value)
267
- when "run", "start"
268
- run_worker_queue(session_store)
269
- when "resume"
270
- value.to_s.strip.empty? ? run_worker_queue(session_store) : resume_worker_queue_job(session_store, value)
271
- when "suspend", "pause"
272
- suspend_worker_queue_job(session_store, value)
273
- when "next"
274
- run_next_worker_queue_job(session_store)
275
- else
276
- runtime_output("Usage: /queue [add|list|status|run|suspend <id>|resume <id>]")
277
- end
278
- end
279
-
280
- def worker_queue_store
281
- @worker_queue_store ||= Workers::QueueStore.new
282
- end
283
-
284
- def enqueue_active_tab(title, agent)
285
- session = @active_session
286
- unless session&.path
287
- runtime_output("No active persisted session to queue.")
288
- return
289
- end
290
-
291
- job = worker_queue_store.enqueue(
292
- title: worker_queue_title(title, session, agent),
293
- session_path: session.path,
294
- workspace_root: session.cwd || current_workspace_root
295
- )
296
- runtime_output("Queued worker #{job.id}: #{job.title}")
297
- end
298
-
299
- def worker_queue_title(title, session, agent)
300
- explicit = title.to_s.strip
301
- return explicit unless explicit.empty?
302
-
303
- session_name = session&.name.to_s.strip
304
- return session_name unless session_name.empty?
305
-
306
- last_user = if agent&.respond_to?(:conversation)
307
- agent.conversation.messages.reverse.find { |message| MessageAccess.role(message) == "user" }
308
- end
309
- content = MessageAccess.content(last_user).to_s.strip.gsub(/\s+/, " ")
310
- content.empty? ? "Queued worker" : content[0, 80]
311
- end
312
-
313
- def open_worker_queue_job(session_store, id)
314
- unless session_store
315
- runtime_output("Worker queue requires persisted sessions.")
316
- return nil
317
- end
318
-
319
- id = id.to_s.strip
320
- return runtime_output("Usage: /queue open <id>") if id.empty?
321
-
322
- record = worker_queue_store.find(id)
323
- return runtime_output("Unknown worker job: #{id}") unless record
324
-
325
- load_session(session_store, record.fetch("session_path"), message: "Showing queued worker #{record.fetch('id')}")
326
- end
327
-
328
- def run_worker_queue(session_store)
329
- unless session_store
330
- runtime_output("Worker queue requires persisted sessions.")
331
- return
332
- end
333
-
334
- results = worker_queue_runner(session_store).run_all
335
- if results.empty?
336
- runtime_output("Worker queue has no queued jobs.")
337
- return
338
- end
339
-
340
- summary = results.map { |record| "#{record.fetch('id')} #{record.fetch('status')}" }.join(", ")
341
- runtime_output("Worker queue run finished: #{summary}")
342
- end
343
-
344
- def run_next_worker_queue_job(session_store)
345
- unless session_store
346
- runtime_output("Worker queue requires persisted sessions.")
347
- return
348
- end
349
-
350
- record = worker_queue_runner(session_store).run_next
351
- if record
352
- runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
353
- else
354
- runtime_output("Worker queue has no queued jobs.")
355
- end
356
- end
357
-
358
- def suspend_worker_queue_job(session_store, id)
359
- unless session_store
360
- runtime_output("Worker queue requires persisted sessions.")
361
- return
362
- end
363
-
364
- id = id.to_s.strip
365
- return runtime_output("Usage: /queue suspend <id>") if id.empty?
366
-
367
- record = worker_queue_runner(session_store).suspend(id)
368
- runtime_output("Worker #{record.fetch('id')} suspended.")
369
- end
370
-
371
- def resume_worker_queue_job(session_store, id)
372
- unless session_store
373
- runtime_output("Worker queue requires persisted sessions.")
374
- return
375
- end
376
-
377
- id = id.to_s.strip
378
- return runtime_output("Usage: /queue resume <id>") if id.empty?
379
-
380
- record = worker_queue_runner(session_store).resume(id)
381
- runtime_output("Worker #{record.fetch('id')} finished with status #{record.fetch('status')}.")
382
- end
383
-
384
- def worker_queue_runner(session_store)
385
- Workers::QueueRunner.new(
386
- queue_store: worker_queue_store,
387
- session_store: session_store,
388
- client_factory: -> { Client.new },
389
- prompt: @prompt,
390
- workspace_root: current_workspace_root,
391
- provider: current_model_provider,
392
- model: current_model_id,
393
- reasoning_effort: current_reasoning_effort,
394
- write_lock: (@worker_write_lock ||= Workers::WriteLock.new)
395
- )
396
- end
397
-
398
- def show_worker_queue
399
- jobs = worker_queue_store.list
400
- if jobs.empty?
401
- runtime_output("Worker queue is empty.")
402
- return
403
- end
404
-
405
- lines = ["Worker queue:"]
406
- jobs.each do |job|
407
- details = [job.fetch("id"), "[#{job.fetch('status')}]"]
408
- details << "##{job['position']}" if job["position"]
409
- details << job.fetch("title")
410
- details << "commit #{job['commit_sha']}" unless job["commit_sha"].to_s.empty?
411
- details << "error: #{job['error']}" unless job["error"].to_s.empty?
412
- lines << "- #{details.join(' ')}"
413
- end
414
- runtime_output(lines.join("\n"))
415
- end
416
-
417
- def handle_workers_command(argument, agent, session_store)
418
- action, value = argument.to_s.strip.split(/\s+/, 2)
419
- replacement_agent = case action
420
- when nil, ""
421
- open_worker_menu(agent, session_store)
422
- when "list"
423
- open_worker_list(agent, session_store)
424
- when "new", "do"
425
- prompt_for_worker_request(agent, value)
426
- nil
427
- else
428
- runtime_output("Usage: /workers | /workers new | /workers list")
429
- nil
430
- end
431
- replacement_agent?(replacement_agent) ? replacement_agent : nil
432
- end
433
-
434
- def worker_store
435
- @worker_store ||= Workers::Store.new
436
- end
437
-
438
- def worker_manager(agent)
439
- workspace_root = interactive_workspace_root(agent)
440
- return @worker_manager if @worker_manager && @worker_manager_workspace_root == workspace_root
441
-
442
- @worker_manager_workspace_root = workspace_root
443
- @worker_manager = Workers::Manager.new(
444
- client_factory: -> { Client.new },
445
- prompt: @prompt,
446
- workspace_root: workspace_root,
447
- session_store: interactive_session_store(agent),
448
- provider: current_model_provider,
449
- model: current_model_id,
450
- reasoning_effort: current_reasoning_effort,
451
- write_lock: (@worker_write_lock ||= Workers::WriteLock.new),
452
- worker_store: worker_store,
453
- write_lane_available: -> { !@foreground_turn_active },
454
- hook_manager: lifecycle_hook_manager(agent.conversation),
455
- hook_context: lifecycle_hook_context(agent.conversation)
456
- )
457
- end
458
-
459
- def open_worker_menu(agent, session_store)
460
- return runtime_output("Usage: /workers | /workers new | /workers list") unless @prompt.respond_to?(:select)
461
-
462
- choice = @prompt.select(
463
- "Workers",
464
- ["New worker", "List workers"],
465
- title: "Workers",
466
- custom: false
467
- )
468
- case choice
469
- when "New worker"
470
- prompt_for_worker_request(agent)
471
- when "List workers"
472
- open_worker_list(agent, session_store)
473
- end
474
- end
475
-
476
- def prompt_for_worker_request(agent, topic = nil)
477
- topic = @prompt.ask("Worker task>") if topic.to_s.strip.empty? && @prompt.respond_to?(:ask)
478
- send_worker_request(topic, agent) unless topic.to_s.strip.empty?
479
- end
480
-
481
- def send_worker_request(topic, agent)
482
- if topic.to_s.strip.empty?
483
- runtime_output("Usage: /workers new <task>")
484
- return
485
- end
486
-
487
- worker = worker_manager(agent).start(role: "request", prompt: topic)
488
- runtime_output("Worker #{worker.id} started: #{worker.title}")
489
- end
490
-
491
- def open_worker_list(agent, session_store, title: "Workers", empty_message: "No workers in the pipeline.")
492
- return runtime_output(empty_message) unless @prompt.respond_to?(:select)
493
-
494
- jobs = worker_jobs
495
- if jobs.empty?
496
- runtime_output(empty_message)
497
- return
498
- end
499
-
500
- labels = jobs.map { |job| worker_choice_label(job) }
501
- choice = @prompt.select("Select worker", labels, title: title, custom: false)
502
- return unless choice
503
-
504
- selected = jobs[labels.index(choice)]
505
- open_worker_actions(selected, agent, session_store) if selected
506
- end
507
-
508
- def worker_jobs
509
- runtime_worker_ids = @worker_manager ? @worker_manager.list.map(&:id) : []
510
- persisted_workers = worker_store.list.reject { |job| runtime_worker_ids.include?(job["id"]) }
511
- live_workers = @worker_manager ? @worker_manager.list.map(&:to_h) : []
512
- [implementation_worker_job].compact + persisted_workers + live_workers
513
- end
514
-
515
- def implementation_worker_job
516
- remember_implementation_worker if implementation_agent?
517
- path = @implementation_worker_session_path || @active_session&.path
518
- return nil if path.to_s.empty?
519
-
520
- {
521
- "id" => "implementation",
522
- "title" => @implementation_worker_title || @active_session&.name || "Implementation",
523
- "role" => "implementation",
524
- "status" => implementation_agent? ? "active" : "idle",
525
- "session_path" => path
526
- }
527
- end
528
-
529
- def implementation_agent?
530
- @active_worker_role.to_s.empty? || @active_worker_role == "implementation"
531
- end
532
-
533
- def remember_implementation_worker
534
- return unless @active_session&.path
535
- return unless implementation_agent?
536
-
537
- @implementation_worker_session_path = @active_session.path
538
- @implementation_worker_title = @active_session.name || "Implementation"
539
- end
540
-
541
- def open_worker_actions(job, _agent, session_store)
542
- return open_implementation_actions(job, session_store) if job["id"] == "implementation"
543
-
544
- open_background_worker_actions(job, session_store)
545
- end
546
-
547
- def open_implementation_actions(job, session_store)
548
- actions = ["Show", "Back to list"]
549
- choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
550
- case choice
551
- when "Show"
552
- load_implementation_session(session_store, job)
553
- when "Back to list"
554
- open_worker_list(nil, session_store)
555
- end
556
- end
557
-
558
- def handle_request_worker_input(input, agent, session_store)
559
- return [false, nil] unless @active_worker_role == "request"
560
-
561
- worker = visible_request_worker(agent)
562
- return [false, nil] unless worker
563
-
564
- text = input.to_s.strip
565
- return [true, nil] if text.empty?
566
- return [true, proceed_request_worker(worker.to_h, agent, session_store)] if proceed_request_input?(text)
567
-
568
- runtime_output("Worker #{worker.id} is a read-only request review. Reply yes/proceed to queue implementation, or use /workers to switch workers.")
569
- [true, nil]
570
- end
571
-
572
- def visible_request_worker(agent)
573
- worker = @visible_worker
574
- return worker if worker&.role == "request" && worker.status == "ready"
575
-
576
- id = @visible_worker_id.to_s
577
- return nil if id.empty? || id == "implementation"
578
-
579
- worker = @worker_manager&.find(id)
580
- return worker if worker&.role == "request" && worker.status == "ready"
581
-
582
- job = worker_store.find(id)
583
- return nil unless job && job["role"] == "request" && job["status"] == "ready"
584
- return nil if job["session_path"].to_s.empty? || !session_matches_agent?(job["session_path"], agent)
585
-
586
- Workers::Worker.new(
587
- id: job.fetch("id"),
588
- title: job.fetch("title"),
589
- role: job.fetch("role"),
590
- workspace_root: job["workspace_root"] || current_workspace_root,
591
- status: job.fetch("status"),
592
- prompt: job["prompt"]
593
- ).tap { |restored| restored.update_status("ready", report: job["report"], error: job["error"]) }
594
- end
595
-
596
- def session_matches_agent?(path, agent)
597
- return true unless agent.respond_to?(:conversation)
598
- return false unless @active_session&.path
599
-
600
- File.expand_path(path) == File.expand_path(@active_session.path)
601
- end
602
-
603
- def proceed_request_input?(input)
604
- 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/)
605
- end
606
-
607
- def proceed_request_worker(job, agent, session_store)
608
- return runtime_output("Worker #{job.fetch('id')} is not ready to proceed.") unless request_ready?(job)
609
-
610
- release_implementation_writer
611
- manager = worker_manager(agent || build_session_agent_for_worker(job, session_store))
612
- worker = manager.continue(
613
- job.fetch("id"),
614
- role: "implementation",
615
- prompt: implementation_prompt_for_request(job),
616
- title: "Implement #{job.fetch('title')}"
617
- )
618
- runtime_output("Worker #{worker.id} queued from request #{job.fetch('id')}: #{worker.title}")
619
- wait_for_worker_session(worker)
620
- load_worker_session(session_store, worker.session.path, worker.to_h, worker: worker) if worker.session&.path
621
- rescue StandardError => e
622
- runtime_output("Error: #{e.message}")
623
- nil
624
- end
625
-
626
- def wait_for_worker_session(worker, timeout: 1.0)
627
- deadline = Time.now + timeout
628
- until worker.session&.path || Time.now >= deadline
629
- sleep 0.02
630
- end
631
- end
632
-
633
- def build_session_agent_for_worker(job, session_store)
634
- conversation = Conversation.new(workspace_root: job["workspace_root"] || session_store&.cwd || current_workspace_root)
635
- build_worker_agent(conversation, role: "request")
636
- end
637
-
638
- def request_ready?(job)
639
- job["role"] == "request" && job["status"] == "ready"
640
- end
641
-
642
- def implementation_prompt_for_request(job)
643
- <<~PROMPT
644
- The user reviewed and approved this Kward request. Continue in the write-capable implementation lane.
645
-
646
- Original request:
647
- #{job["prompt"]}
648
-
649
- Request review:
650
- #{job["report"].to_s.empty? ? "No saved review text is available. Use the request session transcript for context if needed." : job["report"]}
651
-
652
- Implement the approved next step. Make the smallest correct change, preserve existing style, and run focused verification when practical.
653
- If you change files, commit the changes and report the commit hash. If no file changes are needed, explain why.
654
- PROMPT
655
- end
656
-
657
- def open_background_worker_actions(job, session_store)
658
- actions = ["Show"]
659
- actions << "Proceed" if request_ready?(job)
660
- actions << "Cancel" if %w[queued running].include?(job["status"])
661
- actions << "Dismiss"
662
- actions << "Back to list"
663
- choice = @prompt.select("#{job.fetch('id')} — #{job.fetch('title')}", actions, title: "Worker", custom: false)
664
- case choice
665
- when "Show"
666
- worker = @worker_manager&.find(job.fetch("id"))
667
- path = job["session_path"] || worker&.session&.path
668
- return runtime_output("Worker #{job.fetch('id')} session is not ready yet.") unless path
669
-
670
- load_worker_session(session_store, path, job, worker: worker)
671
- when "Proceed"
672
- proceed_request_worker(job, nil, session_store)
673
- when "Cancel"
674
- @worker_manager&.cancel(job.fetch("id"))
675
- runtime_output("Worker #{job.fetch('id')} cancelled.")
676
- when "Dismiss"
677
- dismiss_worker(job.fetch("id"))
678
- runtime_output("Worker #{job.fetch('id')} dismissed.")
679
- when "Back to list"
680
- open_worker_list(nil, session_store)
681
- end
682
- end
683
-
684
- def dismiss_worker(id)
685
- @worker_manager&.archive(id)
686
- rescue ArgumentError
687
- nil
688
- ensure
689
- worker_store.archive(id)
690
- end
691
-
692
- def load_implementation_session(session_store, job)
693
- return runtime_output("Implementation session unavailable.") unless session_store
694
-
695
- stop_live_worker_view
696
- @active_worker_role = "implementation"
697
- set_visible_worker("implementation", status: "active")
698
- load_session(session_store, job.fetch("session_path"), message: "Showing implementation worker")
699
- rescue StandardError => e
700
- runtime_output("Error: #{e.message}")
701
- nil
702
- end
703
-
704
- def worker_choice_label(job)
705
- error = job["status"] == "failed" && !job["error"].to_s.empty? ? " — #{job['error']}" : ""
706
- "#{job.fetch('id')} [#{job.fetch('role')}/#{job.fetch('status')}] #{job.fetch('title')}#{error}"
707
- end
708
-
709
- def load_worker_session(session_store, path, job, worker: nil)
710
- unless session_store
711
- runtime_output(worker_report_text(job))
712
- return nil
713
- end
714
-
715
- release_implementation_writer
716
- agent = load_session(session_store, path, message: "Showing worker #{job.fetch('id')}")
717
- release_implementation_writer
718
- role = visible_session_role(job)
719
- agent = build_worker_agent(agent.conversation, role: role)
720
- @active_worker_role = role
721
- set_visible_worker(job.fetch("id"), status: job["status"], worker: worker)
722
- @prompt.redraw if @prompt.respond_to?(:redraw)
723
- start_live_worker_view(worker, agent) if live_worker?(worker)
724
- agent
725
- rescue StandardError => e
726
- runtime_output("Error: #{e.message}")
727
- nil
728
- end
729
-
730
- def visible_session_role(job)
731
- return "read_only" if job["id"] != "implementation" && job["role"] == "implementation"
732
-
733
- job["role"] || "request"
734
- end
735
-
736
- def live_worker?(worker)
737
- worker && %w[queued running].include?(worker.status)
738
- end
739
-
740
- def start_live_worker_view(worker, agent)
741
- return unless prompt_interface?
742
-
743
- stop_live_worker_view
744
- renderer = live_worker_renderer(worker)
745
- @live_worker_view = Workers::LiveView.new(worker: worker, agent: agent, renderer: renderer).start
746
- @prompt.redraw if @prompt.respond_to?(:redraw)
747
- runtime_output("Watching worker #{worker.id}; the view will update until it finishes.")
748
- end
749
-
750
- def stop_live_worker_view
751
- @live_worker_view&.stop
752
- @live_worker_view = nil
753
- end
754
-
755
- def live_worker_renderer(worker)
756
- markdown_chunks = []
757
- stream_state = {
758
- streamed: false,
759
- last_flush: monotonic_now,
760
- stream_block_open: false,
761
- markdown_streams: {},
762
- defer_assistant_streaming: false
763
- }
764
- lambda do |event, agent|
765
- if event == :flush
766
- flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
767
- @prompt.redraw if @prompt.respond_to?(:redraw)
768
- next
769
- end
770
-
771
- notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
772
- handle_live_worker_event(event, markdown_chunks, stream_state)
773
- flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: worker_finished?(worker))
774
- rescue StandardError => e
775
- runtime_output("Worker view error: #{e.message}")
776
- end
777
- end
778
-
779
- def handle_live_worker_event(event, markdown_chunks, stream_state)
780
- case event
781
- when Events::AssistantMessage
782
- return if stream_state[:streamed]
783
-
784
- render_assistant_message(event.message)
785
- else
786
- handle_interactive_event(event, markdown_chunks, stream_state)
787
- end
788
- end
789
-
790
- def worker_finished?(worker)
791
- %w[ready failed cancelled archived].include?(worker.status)
792
- end
793
-
794
- def worker_report_text(job)
795
- lines = ["Worker #{job.fetch('id')} [#{job.fetch('status')}] #{job.fetch('title')}", ""]
796
- if job["report"].to_s.empty?
797
- lines << (job["error"].to_s.empty? ? "No report yet." : "Error: #{job['error']}")
798
- else
799
- lines << job["report"]
800
- end
801
- lines.join("\n")
802
- end
803
-
804
244
  end
805
245
  end
806
246
  end
@@ -404,7 +404,6 @@ module Kward
404
404
  end
405
405
 
406
406
  def start_tab_turn(tab, input, display_input: nil)
407
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
408
407
  prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
409
408
  print_user_transcript(input, display_input: display_input) if prompt_interface?
410
409
  tab.status = "queued"
@@ -489,7 +488,7 @@ module Kward
489
488
  tab.queued_inputs << "/exit"
490
489
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
491
490
  when String
492
- handle_tab_busy_input(tab, poll_result)
491
+ return { tab_action: :busy_command } if handle_tab_busy_input(tab, poll_result) == :active_tab_changed
493
492
  end
494
493
  return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
495
494
  return :tab_idle if tab.idle?
@@ -512,6 +511,15 @@ module Kward
512
511
  tab.queued_inputs << input
513
512
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
514
513
  return
514
+ elsif busy_tab_command?(input)
515
+ handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
516
+ unless active_tab.equal?(tab)
517
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
518
+ return :active_tab_changed
519
+ end
520
+
521
+ restore_busy_input_prompt
522
+ return
515
523
  elsif slash_command_input?(input)
516
524
  # Slash commands are local control actions. Running or queuing them
517
525
  # from the busy composer is surprising because the state they act on
@@ -532,6 +540,10 @@ module Kward
532
540
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
533
541
  end
534
542
 
543
+ def busy_tab_command?(input)
544
+ input.to_s.strip.match?(%r{\A/tab(?:\s|\z)})
545
+ end
546
+
535
547
  def refresh_active_tab
536
548
  tab = active_tab
537
549
  return unless tab
data/lib/kward/cli.rb CHANGED
@@ -40,7 +40,6 @@ require_relative "session_tree_renderer"
40
40
  require_relative "starter_pack_installer"
41
41
  require_relative "steering"
42
42
  require_relative "update_check"
43
- require_relative "workers"
44
43
  require_relative "tools/tool_call"
45
44
  require_relative "tools/registry"
46
45
  require_relative "telemetry/stats"
@@ -114,8 +113,6 @@ module Kward
114
113
  @prompt_delimited = false
115
114
  @requested_mode = "auto"
116
115
  @skip_config = false
117
- @experimental_workers = false
118
- @foreground_turn_active = false
119
116
  @pending_reasoning_config = nil
120
117
  @pending_reasoning_config_mutex = Mutex.new
121
118
  @color_enabled = ANSI.enabled?($stdout)
@@ -253,7 +250,7 @@ module Kward
253
250
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
254
251
 
255
252
  ensure_client!
256
- Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
253
+ Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
257
254
  return
258
255
  end
259
256
 
@@ -473,25 +470,16 @@ module Kward
473
470
  end
474
471
  end
475
472
  next if handled
476
- request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
477
- if request_handled
478
- if replacement_agent?(request_replacement)
479
- agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
480
- end
481
- next
482
- end
483
473
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
484
474
 
485
475
  flush_pending_reasoning_config(conversation: agent.conversation)
486
476
  expanded_input = expand_prompt_template(input)
487
477
  display_input = display_input || input if expanded_input
488
478
  input = expanded_input || input
489
- agent = refresh_implementation_writer(agent)
490
479
  @footer_conversation = agent.conversation
491
480
  begin
492
481
  @rewind_return_leaf_id = nil
493
482
  auto_name_active_session(display_input || input)
494
- @foreground_turn_active = true if @active_worker_role == "implementation"
495
483
  if active_tab
496
484
  submit_tab_input(active_tab, input, display_input: display_input)
497
485
  pending_inputs = []
@@ -503,9 +491,6 @@ module Kward
503
491
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
504
492
  rescue StandardError => e
505
493
  runtime_output("Error: #{e.message}")
506
- ensure
507
- @foreground_turn_active = false if @active_worker_role == "implementation"
508
- release_implementation_writer if @active_worker_role == "implementation"
509
494
  end
510
495
  end
511
496
 
@@ -518,7 +503,6 @@ module Kward
518
503
  ensure
519
504
  begin
520
505
  stop_tabs if respond_to?(:stop_tabs, true)
521
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
522
506
  @prompt.close if prompt_interface?
523
507
  ensure
524
508
  cleanup_unused_sessions