kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. 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