agentilda 1.0.3 → 2.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +8 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +78 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +22 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +61 -15
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli/autocomplete/command"
4
+ require "tty-screen"
4
5
 
5
6
  # One file per command, `subcommands/` under the prefixed ones. Base carries
6
7
  # the shared flags and plumbing, so it loads first; the rest only meet each
@@ -21,6 +22,10 @@ require_relative "cli/version/version"
21
22
  require_relative "cli/states/states"
22
23
  require_relative "cli/agents/subcommands/list"
23
24
  require_relative "cli/agents/subcommands/describe"
25
+ require_relative "cli/worktree/worktree"
26
+ require_relative "cli/mail/mail"
27
+ require_relative "cli/mail/subcommands/send"
28
+ require_relative "cli/mail/subcommands/read"
24
29
 
25
30
  module Agentilda
26
31
  # The command line. Every command is a thin shell over one library class:
@@ -37,6 +42,37 @@ module Agentilda
37
42
  module CLI
38
43
  extend Dry::CLI::Registry
39
44
 
45
+ # What `agentilda -h` prints above and below the command list. Rendering,
46
+ # wrapping and colour belong to dry-cli-help; only the words live here.
47
+ # dry-cli-help 0.2 configures once per process, not per registry, which
48
+ # suits a gem with exactly one registry.
49
+ Dry::CLI::Help.configure do
50
+ title "agentilda — Agentic Specification-Driven Development v#{Agentilda::VERSION}"
51
+
52
+ description <<~TEXT
53
+ Drives the agentic flow: spec → plan → build → review → tune/fix → approve.
54
+
55
+ Keeps the .plans folders in step with GitHub pull requests and Linear issues.
56
+ The final merge and deploy stay manual.
57
+ TEXT
58
+
59
+ epilogue <<~TEXT.chomp
60
+ Global flags: -C, --no-color disables colour in every command.
61
+
62
+ Documentation: https://github.com/kigster/agentilda
63
+ TEXT
64
+
65
+ # The bare program prints this screen, and asking for help is not a failure.
66
+ exit_code_without_arguments 0
67
+
68
+ # Wrap at column 90, or two short of the terminal when it is narrower.
69
+ width [90, TTY::Screen.width - 2].min
70
+
71
+ group "Plans", "create", "list-plans", "index", "resync", "unblock", "worktree"
72
+ group "Agents", "run", "agents", "describe", "mail"
73
+ group "Reference", "docs", "states", "linear", "completion", "version"
74
+ end
75
+
40
76
  register "create", Create, aliases: %w[new c]
41
77
  # Renamed from `status`, which read as "is the tool OK?" rather than "what
42
78
  # plans are there?". The old spellings stay registered: other repos, agent
@@ -44,6 +80,7 @@ module Agentilda
44
80
  register "list-plans", ListPlans, aliases: %w[status st]
45
81
  register "run", Run
46
82
  register "unblock", Unblock
83
+ register "worktree", Worktree
47
84
  register "docs", Docs
48
85
  register "states", States, aliases: %w[diagram]
49
86
  register "index", Index, aliases: %w[idx]
@@ -69,6 +106,12 @@ module Agentilda
69
106
  prefix.register "projects", Linear::Projects
70
107
  end
71
108
 
109
+ # What a paired agent shells out to between steps. See {Agentilda::Mailbox}.
110
+ register "mail" do |prefix|
111
+ prefix.register "send", Mail::Send
112
+ prefix.register "read", Mail::Read
113
+ end
114
+
72
115
  register "completion", ::Dry::CLI::Autocomplete::Command[::Agentilda::CLI]
73
116
  end
74
117
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # The advisory clock one invocation runs against.
5
+ #
6
+ # It never terminates the agent on its own. It writes into the control file
7
+ # the agent polls: a warning ten and five minutes out, a wrap-up request at
8
+ # one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
9
+ # is killed, and that is the executor's act on this clock's say-so, so the
10
+ # agent has every chance to write its ledger line first.
11
+ #
12
+ # `now` is injectable so the moments can be tested without sleeping.
13
+ class Clock
14
+ # Seconds before the deadline, and what the control file says then.
15
+ # A moment already in the past when the clock starts is skipped: a five
16
+ # minute agent told "10 minutes left" at t=0 has been lied to.
17
+ WARNINGS = [
18
+ [600, "WARN: 10 minutes left"],
19
+ [300, "WARN: 5 minutes left"],
20
+ [60, "WRAP_UP: 1 minute left, write to disk now"]
21
+ ].freeze
22
+
23
+ # Seconds after STOP before {#on_expire} fires.
24
+ GRACE = 60
25
+
26
+ # @param seconds [Integer] the budget
27
+ # @param control [String] the invocation's control file
28
+ # @param on_expire [Proc] called once, GRACE seconds after STOP
29
+ # @param now [Proc] a monotonic clock reading
30
+ def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
31
+ @control = control
32
+ @on_expire = on_expire
33
+ @now = now
34
+ @mutex = Mutex.new
35
+ arm(seconds)
36
+ end
37
+
38
+ # @return [Integer] whole seconds left, floored at zero
39
+ def remaining = [@deadline - @now.call, 0].max.round
40
+
41
+ # @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
42
+ attr_reader :phase
43
+
44
+ # Evaluate every moment once. Idempotent within a phase.
45
+ #
46
+ # @return [void]
47
+ def tick
48
+ @mutex.synchronize do
49
+ left = @deadline - @now.call
50
+ WARNINGS.each do |before, text|
51
+ next if @fired.include?(before) || left > before
52
+
53
+ @fired << before
54
+ Control.write(@control, text)
55
+ @phase = before == 60 ? :wrap_up : :warned
56
+ end
57
+ if left <= 0 && !@fired.include?(:stop)
58
+ @fired << :stop
59
+ Control.write(@control, Control::STOP)
60
+ @phase = :stopped
61
+ end
62
+ if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
63
+ @fired << :expired
64
+ @phase = :expired
65
+ @on_expire.call
66
+ end
67
+ end
68
+ end
69
+
70
+ # @return [self]
71
+ def start
72
+ @thread ||= Thread.new do
73
+ Thread.current.report_on_exception = false
74
+ loop do
75
+ tick
76
+ break if phase == :expired
77
+
78
+ sleep(1)
79
+ end
80
+ end
81
+ self
82
+ end
83
+
84
+ # @return [void]
85
+ def stop
86
+ @thread&.kill
87
+ @thread = nil
88
+ end
89
+
90
+ # Ten more minutes, or however many. The file is emptied so the next poll
91
+ # reads a reprieve, and every moment is re-armed against the new deadline.
92
+ #
93
+ # @param seconds [Integer]
94
+ # @return [void]
95
+ def extend!(seconds)
96
+ @mutex.synchronize do
97
+ @deadline += seconds
98
+ @fired = []
99
+ @phase = :calm
100
+ Control.write(@control, "")
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # @param seconds [Integer]
107
+ # @return [void]
108
+ def arm(seconds)
109
+ @deadline = @now.call + seconds
110
+ @fired = WARNINGS.map(&:first).select { |before| before >= seconds }
111
+ @phase = :calm
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # What the keys do to the screen, and to the running agents.
5
+ #
6
+ # Selection and the dialog live here, never on the screen: the screen
7
+ # draws whatever board it is handed, and this decides what the board
8
+ # says. Pending changes are visible only in the dialog and take effect
9
+ # only on ENTER, so a stray `k` kills nobody.
10
+ class Console
11
+ # Seconds each `x` adds.
12
+ EXTENSION = 600
13
+
14
+ # @param screen [Agentilda::Screen::Ratatui]
15
+ def initialize(screen:)
16
+ @screen = screen
17
+ @dispatcher = nil
18
+ @board = nil
19
+ @selected = nil
20
+ @dialog = false
21
+ @help = false
22
+ @about = false
23
+ @pending = { kill: false, extend: 0 }
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # @return [String, nil] the selected row's key
28
+ attr_reader :selected
29
+
30
+ # @return [Hash] `{kill: Boolean, extend: Integer}`
31
+ attr_reader :pending
32
+
33
+ # @return [Agentilda::Screen::Ratatui]
34
+ attr_reader :screen
35
+
36
+ # @return [Boolean]
37
+ def dialog? = @dialog
38
+
39
+ # @return [Boolean]
40
+ def help? = @help
41
+
42
+ # @return [Boolean]
43
+ def about? = @about
44
+
45
+ # The dispatcher exists only once the run starts, after the keyboard is
46
+ # already listening, so it arrives late.
47
+ #
48
+ # @param dispatcher [Agentilda::Dispatcher]
49
+ # @return [void]
50
+ def attach(dispatcher) = @dispatcher = dispatcher
51
+
52
+ # Called by the dispatcher each tick.
53
+ #
54
+ # @param board [Agentilda::Board]
55
+ # @return [void]
56
+ def paint(board)
57
+ @mutex.synchronize do
58
+ @board = board
59
+ @selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
60
+ @screen.draw(board.with(selected: @selected,
61
+ dialog: (dialog_text if @dialog),
62
+ help: (help_text if @help),
63
+ about: (about_text if @about)))
64
+ end
65
+ end
66
+
67
+ # @return [void]
68
+ def select_next = move(1)
69
+
70
+ # @return [void]
71
+ def select_prev = move(-1)
72
+
73
+ # @return [void]
74
+ def toggle_kill
75
+ select_next unless @selected
76
+ return unless @selected
77
+
78
+ @pending[:kill] = !@pending[:kill]
79
+ @dialog = true
80
+ end
81
+
82
+ # @return [void]
83
+ def extend
84
+ select_next unless @selected
85
+ return unless @selected
86
+
87
+ @pending[:extend] += EXTENSION
88
+ @dialog = true
89
+ end
90
+
91
+ # ENTER.
92
+ #
93
+ # @return [void]
94
+ def apply
95
+ return unless @dialog && @selected && @dispatcher
96
+
97
+ @dispatcher.kill(@selected) if @pending[:kill]
98
+ @dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
99
+ UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
100
+ close_dialog
101
+ end
102
+
103
+ # ESC: the dialog first, then the help, then the about screen, then the
104
+ # selection.
105
+ #
106
+ # @return [void]
107
+ def escape
108
+ if @dialog
109
+ close_dialog
110
+ elsif @help
111
+ @help = false
112
+ elsif @about
113
+ @about = false
114
+ else
115
+ @selected = nil
116
+ end
117
+ end
118
+
119
+ # @return [void]
120
+ def toggle_help = @help = !@help
121
+
122
+ # @return [void]
123
+ def toggle_about = @about = !@about
124
+
125
+ private
126
+
127
+ # @return [void]
128
+ def close_dialog
129
+ @dialog = false
130
+ @pending = { kill: false, extend: 0 }
131
+ end
132
+
133
+ # @param step [Integer]
134
+ # @return [void]
135
+ def move(step)
136
+ keys = (@board&.rows || []).select(&:running?).map(&:key)
137
+ return @selected = nil if keys.empty?
138
+
139
+ index = keys.index(@selected)
140
+ @selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
141
+ end
142
+
143
+ # @return [String]
144
+ def dialog_text
145
+ row = @board&.rows&.find { |r| r.key == @selected }
146
+ who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
147
+ extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
148
+ "#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
149
+ end
150
+
151
+ # @return [String]
152
+ def help_text = Keyboard.new(input: $stdin).help
153
+
154
+ # @return [String]
155
+ def about_text = Keyboard.new(input: $stdin).about
156
+ end
157
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Agentilda
4
6
  # The side channel between a keypress and a running agent.
5
7
  #
@@ -16,6 +18,13 @@ module Agentilda
16
18
  WRAP_UP = "WRAP_UP"
17
19
  STOP = "STOP"
18
20
 
21
+ # Ctrl-C: STOP, and the run will be started again, so the agent leaves
22
+ # a resume note before it goes. The prompt says what that note is.
23
+ INTERRUPT = "INTERRUPT"
24
+
25
+ # The clock's warnings start with this; the agent's prompt explains them.
26
+ WARN = "WARN"
27
+
19
28
  # Seconds between {#quit!} and the harness terminating whatever is still
20
29
  # running. Long enough to write files and a handoff note; short enough
21
30
  # that q means quit rather than "quit eventually".
@@ -24,6 +33,7 @@ module Agentilda
24
33
  @mutex = Mutex.new
25
34
  @files = []
26
35
  @quit = false
36
+ @interrupted = false
27
37
  @deadline = nil
28
38
 
29
39
  class << self
@@ -52,6 +62,18 @@ module Agentilda
52
62
  FileUtils.rm_f(path)
53
63
  end
54
64
 
65
+ # One line into one file, from the clock. Not a broadcast: a warning is
66
+ # about one agent's own deadline.
67
+ #
68
+ # @param path [String]
69
+ # @param text [String]
70
+ # @return [void]
71
+ def write(path, text)
72
+ File.write(path, text.empty? ? "" : "#{text}\n")
73
+ rescue SystemCallError
74
+ # The invocation finished and released its file; nothing to warn.
75
+ end
76
+
55
77
  # `w`: every running agent is asked to finish the essential remainder
56
78
  # as fast as it can. The loop itself keeps going.
57
79
  #
@@ -78,9 +100,49 @@ module Agentilda
78
100
  }
79
101
  end
80
102
 
103
+ # Ctrl-C, the first time: {#quit!}, except that every running agent is
104
+ # told the run is being interrupted rather than stopped, so it writes
105
+ # down where it got to. A restart then reads that note instead of
106
+ # redoing, or half-redoing, the work.
107
+ #
108
+ # @return [void]
109
+ def interrupt!
110
+ broadcast(INTERRUPT)
111
+ @mutex.synchronize {
112
+ @quit = true
113
+ @interrupted = true
114
+ @deadline ||= UI.monotonic + GRACE
115
+ }
116
+ end
117
+
81
118
  # @return [Boolean] whether `q` has been pressed
82
119
  def quit? = @mutex.synchronize { @quit }
83
120
 
121
+ # @return [Boolean] whether Ctrl-C has been pressed
122
+ def interrupted? = @mutex.synchronize { @interrupted }
123
+
124
+ # Ctrl-C where no keyboard reads it: a run with no terminal to put in
125
+ # raw mode still gets the graceful first press and the hard second.
126
+ #
127
+ # A trap handler may not take a mutex, so the first press hands
128
+ # {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
129
+ #
130
+ # @yieldreturn [Object]
131
+ # @return [Object] the block's value
132
+ def on_interrupt
133
+ previous = Signal.trap("INT") do
134
+ # A plain read, not {#interrupted?}: a trap handler may not lock.
135
+ raise Interrupt if @interrupted || @pending_interrupt
136
+
137
+ @pending_interrupt = true
138
+ Thread.new { interrupt! }
139
+ end
140
+ yield
141
+ ensure
142
+ @pending_interrupt = false
143
+ Signal.trap("INT", previous || "DEFAULT")
144
+ end
145
+
84
146
  # @return [Boolean] whether the grace period after `q` has run out
85
147
  def overdue?
86
148
  @mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
@@ -95,6 +157,7 @@ module Agentilda
95
157
  @files.each { |f| FileUtils.rm_f(f) }
96
158
  @files.clear
97
159
  @quit = false
160
+ @interrupted = false
98
161
  @deadline = nil
99
162
  }
100
163
  end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry/monads"
4
+ require "fileutils"
5
+
6
+ require "agentilda/status"
7
+
3
8
  module Agentilda
4
9
  # Mints new plan folders. Backs `/spec-create`.
5
10
  #
@@ -52,8 +57,8 @@ module Agentilda
52
57
  return [] unless File.directory?(dir)
53
58
 
54
59
  Dir.children(dir)
55
- .select { |c| File.directory?(File.join(dir, c)) }
56
- .filter_map { |c| Ordinal.from_dirname(c) }
60
+ .select { |c| File.directory?(File.join(dir, c)) }
61
+ .filter_map { |c| Ordinal.from_dirname(c) }
57
62
  end
58
63
  end
59
64
 
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # The ratatui dashboard for agent work that is not a `run`: `unblock`
5
+ # handing plans to lando-broker, `create` drafting a spec.md. `run` has a
6
+ # {Dispatcher} to build its {Board}; this builds one from the items
7
+ # {UI.concurrently} hands it, so every agent a person waits on is drawn
8
+ # the same way, with the same keys.
9
+ #
10
+ # It stands in for the dispatcher behind {Console}, which is why it
11
+ # answers {#kill} and {#extend}: a row whose work passed its
12
+ # {Tracker#handle} on to {Executor#call} can be killed or given more time.
13
+ class Dashboard
14
+ # Seconds between frames handed to the screen.
15
+ REFRESH = 0.25
16
+
17
+ # Seconds a finished row stays on screen, so a fast agent is still seen.
18
+ LINGER = 3
19
+
20
+ # One item's row, and the {UI::Line} its work streams progress through.
21
+ # Nothing is drawn by the line itself; the dashboard reads it each frame.
22
+ class Tracker < UI::Line
23
+ # @return [String]
24
+ attr_reader :key
25
+
26
+ # @return [Agentilda::Executor::Handle] handed to {Executor#call} by a
27
+ # caller that wants the kill and extend keys to reach its agent.
28
+ # Not `@handle`: {UI::Line} keeps its dry-cli-ui line under that name.
29
+ def handle = @executor_handle
30
+
31
+ # @return [Symbol] :running, :done or :failed
32
+ attr_reader :state
33
+
34
+ # @return [Float, nil] monotonic reading of when it stopped running
35
+ attr_reader :finished_at
36
+
37
+ # @param key [String]
38
+ # @param fields [Hash] plan, status, agent and round, for the log and the row
39
+ # @param file [String] what the row's feature column says
40
+ # @param message [String] shown until the agent says something
41
+ # @param timeout [Integer, nil]
42
+ def initialize(key:, fields:, file:, message:, timeout: nil)
43
+ super(fields:, timeout:)
44
+ @key = key
45
+ @file = file
46
+ @message = message
47
+ @executor_handle = Executor::Handle.new
48
+ @state = :running
49
+ @update = nil
50
+ @history = []
51
+ @finished_at = nil
52
+ end
53
+
54
+ # @param update [Agentilda::Transcript::Progress]
55
+ # @return [void]
56
+ def call(update)
57
+ super
58
+ @update = update
59
+ return unless update.activity
60
+
61
+ @message = update.activity
62
+ @history = Board::Row.remember(@history, @message)
63
+ end
64
+
65
+ # @return [void]
66
+ def done
67
+ super
68
+ finish(:done)
69
+ end
70
+
71
+ # @param reason [String]
72
+ # @return [void]
73
+ def failed(reason)
74
+ super
75
+ @message = reason
76
+ @history = Board::Row.remember(@history, reason)
77
+ finish(:failed)
78
+ end
79
+
80
+ # @return [Agentilda::Board::Row]
81
+ def row
82
+ Board::Row.new(key:,
83
+ at: Time.now,
84
+ ordinal: @fields[:plan].to_s,
85
+ file: @file,
86
+ agent: @fields[:agent].to_s,
87
+ role: @fields[:agent].to_s,
88
+ round: @fields[:round] || 1,
89
+ rounds: @fields[:round] || 1,
90
+ model: "default",
91
+ remaining: handle.remaining || remaining,
92
+ phase: handle.phase,
93
+ up: @update&.up.to_i,
94
+ down: @update&.down.to_i,
95
+ message: @message,
96
+ state:,
97
+ elapsed: seconds.round,
98
+ subagents: @update&.subagents.to_i,
99
+ history: @history)
100
+ end
101
+
102
+ private
103
+
104
+ # @param state [Symbol]
105
+ # @return [void]
106
+ def finish(state)
107
+ @state = state
108
+ @finished_at = UI.monotonic
109
+ end
110
+ end
111
+
112
+ # Opens the screen, runs the block, and always closes it again.
113
+ #
114
+ # @param root [String] shown in the bottom bar
115
+ # @param screen [Agentilda::Screen::Ratatui]
116
+ # @yieldparam dashboard [Agentilda::Dashboard]
117
+ # @return [Object] the block's value
118
+ def self.open(root: Dir.pwd, screen: Screen::Ratatui.new)
119
+ dashboard = new(root:, screen:)
120
+ dashboard.start
121
+ begin
122
+ yield dashboard
123
+ ensure
124
+ dashboard.stop
125
+ end
126
+ end
127
+
128
+ # @param root [String]
129
+ # @param screen [Agentilda::Screen::Ratatui]
130
+ def initialize(root:, screen:)
131
+ @root = root
132
+ @screen = screen
133
+ @console = Console.new(screen:)
134
+ @console.attach(self)
135
+ @trackers = []
136
+ @lock = Mutex.new
137
+ @started_at = UI.monotonic
138
+ @closing = false
139
+ end
140
+
141
+ # @return [void]
142
+ def start
143
+ # ratatui owns the terminal's input, so the keyboard is fed from its
144
+ # loop rather than reading STDIN on a thread of its own.
145
+ @keyboard = Keyboard.new(sink: @console)
146
+ @screen.attach_keyboard(@keyboard)
147
+ @screen.open
148
+ @painter = Thread.new do
149
+ Thread.current.report_on_exception = false
150
+ until @closing
151
+ paint
152
+ sleep(REFRESH)
153
+ end
154
+ end
155
+ end
156
+
157
+ # @return [void]
158
+ def stop
159
+ @closing = true
160
+ @painter&.join
161
+ paint
162
+ @screen.close
163
+ end
164
+
165
+ # @param key [String]
166
+ # @param fields [Hash]
167
+ # @param file [String]
168
+ # @param message [String]
169
+ # @param timeout [Integer, nil]
170
+ # @return [Tracker]
171
+ def track(key:, fields: {}, file: "", message: "starting", timeout: nil)
172
+ Tracker.new(key:, fields:, file:, message:, timeout:).tap do |tracker|
173
+ @lock.synchronize { @trackers << tracker }
174
+ end
175
+ end
176
+
177
+ # @return [void]
178
+ def paint = @console.paint(board)
179
+
180
+ # @return [Agentilda::Board]
181
+ def board
182
+ rows = visible.map(&:row)
183
+ running = rows.select(&:running?)
184
+ Board.new(started_at: @started_at,
185
+ status: Control.quit? ? :quitting : :running,
186
+ plans: rows.map(&:ordinal).reject(&:empty?).uniq,
187
+ up: rows.sum(&:up),
188
+ down: rows.sum(&:down),
189
+ rows:,
190
+ root: @root,
191
+ running: running.size,
192
+ live_up: running.sum(&:up),
193
+ live_down: running.sum(&:down))
194
+ end
195
+
196
+ # @param key [String]
197
+ # @return [void]
198
+ def kill(key) = find(key)&.handle&.kill!
199
+
200
+ # @param key [String]
201
+ # @param seconds [Integer]
202
+ # @return [void]
203
+ def extend(key, seconds) = find(key)&.handle&.extend!(seconds)
204
+
205
+ private
206
+
207
+ # @return [Array<Tracker>] running, plus whatever finished a moment ago
208
+ def visible
209
+ now = UI.monotonic
210
+ @lock.synchronize do
211
+ @trackers.reject { |t| t.finished_at && now - t.finished_at > LINGER }
212
+ end
213
+ end
214
+
215
+ # @param key [String]
216
+ # @return [Tracker, nil]
217
+ def find(key) = @lock.synchronize { @trackers.find { |t| t.key == key } }
218
+ end
219
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/status"
4
+
3
5
  module Agentilda
4
6
  # Telling "this belongs to no specification" apart from "nobody could tell".
5
7
  #