letsdo 0.1.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.
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Letsdo
6
+ # Runs pi in --mode json and hands events to the output streamer as they
7
+ # are generated. The pi exit code is propagated outward.
8
+ #
9
+ # pi --mode json streams events line by line: each line is parsed as JSON
10
+ # and reacted to:
11
+ # message_update (assistantMessageEvent):
12
+ # text_delta - a fragment of the agent's answer text → stdout;
13
+ # toolcall_start - the model started emitting a tool call,
14
+ # id→name is remembered (fallback, see below);
15
+ # tool_execution_start - a tool started executing: name and full
16
+ # arguments are available (e.g. bash command text);
17
+ # tool_execution_end - a tool finished: result (output) and isError;
18
+ # agent_end - the end of the run.
19
+ #
20
+ # The "HH:MM:SS ⚙ name: arguments" header is printed at execution start,
21
+ # the result and "✓/✖ name" completion line — at completion. If for some
22
+ # reason no tool_execution_* events arrive (older pi versions, etc.), "⚙ name"
23
+ # placeholders from remembered toolcall_start events are printed at the end
24
+ # of the run.
25
+ #
26
+ # pi stdout (events) is read by us; pi stderr (its own log) is inherited
27
+ # and goes to our stderr.
28
+ class PiRunner
29
+ COMMAND = "pi"
30
+ MODE = "json"
31
+ MESSAGE_UPDATE = "message_update"
32
+ TOOL_EXECUTION_START = "tool_execution_start"
33
+ TOOL_EXECUTION_END = "tool_execution_end"
34
+ AGENT_END = "agent_end"
35
+
36
+ # @param prompt [String] agent prompt text
37
+ # @param flags [Array<String>] extra pi flags
38
+ # @param streamer [OutputStreamer] where to print output
39
+ # @param command [String] the pi command (overridable for tests)
40
+ # @param debug [Boolean, nil] trace [letsdo] lines to stderr; nil = LETSDO_DEBUG
41
+ def initialize(prompt:, flags: [], streamer:, command: COMMAND, debug: nil)
42
+ @prompt = prompt
43
+ @flags = flags
44
+ @streamer = streamer
45
+ @command = command
46
+ @pending_tools = {}
47
+ @debug = debug.nil? ? ENV["LETSDO_DEBUG"] == "1" : debug
48
+ end
49
+
50
+ # Runs pi and waits for completion.
51
+ #
52
+ # pi is spawned in its own process group so the orchestrator can stop it
53
+ # (signal handlers interrupt the loop, not the pi child directly): a stop
54
+ # terminates the whole group via #terminate.
55
+ #
56
+ # The event stream is read on the main thread. A stop signal arrives as
57
+ # Letsdo::Stopped raised by the trap (see Letsdo::AgentLoop#on_signal):
58
+ # the raise interrupts the blocking read directly — no reader threads,
59
+ # polls or flags. Note for future work on this file: CRuby 4.0 (M:N
60
+ # threads) here does not execute traps while the main thread is in
61
+ # Thread#join, defers them with another thread blocked on IO, and does
62
+ # not wake IO.select on pipe data — the raise-in-trap approach avoids
63
+ # all of it.
64
+ #
65
+ # @return [Integer] pi exit code (128+signal if pi was killed by a signal)
66
+ def run
67
+ cmd = [@command, "--mode", MODE, *@flags, @prompt]
68
+ out_r, out_w = IO.pipe
69
+ @pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
70
+ out_w.close
71
+ debug("spawned pid=#{@pid} (own group)")
72
+
73
+ begin
74
+ read_pi_stream(out_r)
75
+ rescue Letsdo::Stopped
76
+ # The signal handler already sent SIGTERM to the pi group; make sure
77
+ # it is gone (grace loop here runs in the main context, not a trap)
78
+ # and reap it before propagating the stop.
79
+ debug("stopped by signal, terminating pi")
80
+ terminate
81
+ status = wait_status(@pid)
82
+ @streamer.finish
83
+ raise
84
+ ensure
85
+ begin
86
+ out_r.close
87
+ rescue IOError
88
+ nil
89
+ end
90
+ flush_pending_tools
91
+ end
92
+
93
+ status = wait_status(@pid)
94
+ @streamer.finish
95
+ debug("exit status=#{status.inspect} code=#{exit_code(status)}")
96
+ exit_code(status)
97
+ ensure
98
+ @pid = nil
99
+ end
100
+
101
+ def debug(message)
102
+ warn("[letsdo] pi: #{message}") if @debug
103
+ end
104
+
105
+ # One-shot SIGTERM to the pi process group for a signal handler: no
106
+ # waits, sleeps or IO — safe inside a trap. The caller reaps the child
107
+ # afterwards (see #run).
108
+ def terminate_now
109
+ pid = @pid
110
+ send_signal("TERM", pid) if pid
111
+ end
112
+
113
+ # Stops a running pi: SIGTERM to its process group, then SIGKILL after
114
+ # the grace period if it did not exit. Safe to call when the run already
115
+ # finished (no-op).
116
+ #
117
+ # @param signal [String] the first signal to send
118
+ # @param grace [Float] seconds to wait before falling back to SIGKILL
119
+ def terminate(signal: "TERM", grace: 3.0, tick: 0.05)
120
+ pid = @pid
121
+ return true unless pid
122
+
123
+ send_signal(signal, pid)
124
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
125
+ loop do
126
+ break unless alive?(pid)
127
+
128
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
129
+ send_signal("KILL", pid)
130
+ break
131
+ end
132
+ sleep(tick)
133
+ end
134
+ true
135
+ end
136
+
137
+ private
138
+
139
+ # Reads the pi event stream until EOF. Runs on the main thread; a stop
140
+ # signal interrupts it via Letsdo::Stopped raised from the trap.
141
+ def read_pi_stream(out_r)
142
+ out_r.each_line { |line| handle_line(line) }
143
+ debug("stream: EOF")
144
+ end
145
+
146
+ # Parses one line of the pi event stream and passes it to the streamer.
147
+ def handle_line(line)
148
+ line = line.strip
149
+ return if line.empty?
150
+
151
+ event = parse_event(line)
152
+ return unless event
153
+
154
+ case event["type"]
155
+ when MESSAGE_UPDATE
156
+ handle_message_update(event)
157
+ when TOOL_EXECUTION_START
158
+ @pending_tools.delete(event["toolCallId"])
159
+ @streamer.tool_start(event["toolName"] || "tool", args: event["args"])
160
+ when TOOL_EXECUTION_END
161
+ handle_tool_execution_end(event)
162
+ when AGENT_END
163
+ flush_pending_tools
164
+ end
165
+ end
166
+
167
+ # Handles an assistant message update event.
168
+ def handle_message_update(event)
169
+ payload = event["assistantMessageEvent"]
170
+ return unless payload
171
+
172
+ case payload["type"]
173
+ when "text_delta"
174
+ delta = payload["delta"]
175
+ @streamer.text_delta(delta) if delta && !delta.empty?
176
+ when "toolcall_start"
177
+ # The call has just started to be generated: the name is known
178
+ # immediately, arguments will arrive with the execution start
179
+ # (tool_execution_start).
180
+ id = event["id"] || payload["id"]
181
+ name = event["toolName"] || payload["toolName"] || "tool"
182
+ @pending_tools[id] = name unless id.nil?
183
+ end
184
+ end
185
+
186
+ # Tool execution result: text from result.content plus the error flag.
187
+ # An empty error text is replaced with a clear wording. The completion
188
+ # line is always printed (even for an empty result), so that the action
189
+ # completion is visible in the service output.
190
+ def handle_tool_execution_end(event)
191
+ name = event["toolName"] || "tool"
192
+ text = result_text(event["result"])
193
+ error = event["isError"] == true
194
+ text = "tool failed with an error" if (text.nil? || text.empty?) && error
195
+ @streamer.tool_result(name, text, error: error)
196
+ end
197
+
198
+ # Collects the result text from {type: "text"} content blocks.
199
+ # Image blocks and other types do not get into the text.
200
+ def result_text(result)
201
+ return nil unless result.is_a?(Hash)
202
+
203
+ content = result["content"]
204
+ return nil unless content.is_a?(Array)
205
+
206
+ parts = content.filter_map do |block|
207
+ next nil unless block.is_a?(Hash)
208
+
209
+ text = block["text"]
210
+ text if text.is_a?(String) && !text.empty? &&
211
+ (block["type"] == "text" || !block.key?("type"))
212
+ end
213
+ text = parts.join
214
+ text.empty? ? nil : text
215
+ end
216
+
217
+ # Placeholders for calls without execution events (old pi, etc.):
218
+ # prints "⚙ name" without arguments. Called both on agent_end and in
219
+ # the ensure block after reading the stream; clear protects against
220
+ # duplicates.
221
+ def flush_pending_tools
222
+ @pending_tools.each_value { |name| @streamer.tool_start(name) }
223
+ @pending_tools.clear
224
+ end
225
+
226
+ # Ignores lines that are not valid JSON events.
227
+ def parse_event(line)
228
+ JSON.parse(line)
229
+ rescue JSON::ParserError
230
+ nil
231
+ end
232
+
233
+ def wait_status(pid)
234
+ _, status = Process.wait2(pid)
235
+ status
236
+ end
237
+
238
+ # Sends a signal to the pi process group. Missing/killed groups are
239
+ # silently ignored.
240
+ def send_signal(signal, pid)
241
+ Process.kill(signal, -pid)
242
+ rescue Errno::ESRCH, Errno::EPERM
243
+ nil
244
+ end
245
+
246
+ # Whether the pi process still exists (does not reap it).
247
+ def alive?(pid)
248
+ Process.kill(0, pid)
249
+ true
250
+ rescue Errno::ESRCH, Errno::EPERM
251
+ false
252
+ end
253
+
254
+ def exit_code(status)
255
+ return status.exitstatus if status.exitstatus
256
+
257
+ status.termsig ? 128 + status.termsig : 1
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Access to agent prompts: the agents/<name>.md directory in the project
5
+ # root. A new agent = a new agents/<name>.md file, no code changes needed.
6
+ class PromptStore
7
+ AGENTS_DIR = "agents"
8
+
9
+ # @param root [String] project root (agents/ lives there)
10
+ def initialize(root:)
11
+ @root = root
12
+ end
13
+
14
+ # Sorted list of agent names (file names without extension).
15
+ #
16
+ # @return [Array<String>]
17
+ def list
18
+ Dir.glob(File.join(agents_dir, "*.md")).sort.map { |path| File.basename(path, ".md") }
19
+ end
20
+
21
+ # Reads an agent prompt.
22
+ #
23
+ # @param name [String] agent name
24
+ # @return [String] contents of agents/<name>.md
25
+ # @raise [UnknownAgentError] if there is no such agent
26
+ def read(name)
27
+ path = File.join(agents_dir, "#{name}.md")
28
+ raise UnknownAgentError, name unless File.file?(path)
29
+
30
+ File.read(path)
31
+ end
32
+
33
+ private
34
+
35
+ def agents_dir
36
+ File.join(@root, AGENTS_DIR)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Letsdo
6
+ module Tui
7
+ # Non-blocking keyboard input for the TUI: wraps tty-reader and maps
8
+ # escape sequences to plain symbols (:up, :down, :page_up, :page_down,
9
+ # :home, :end, :p, :r, :q, :ctrl_c). Returns nil when no key is waiting
10
+ # within the poll timeout, so the caller can repaint on a 1s timer.
11
+ #
12
+ # Ctrl-C is read as a key (:ctrl_c → quit) instead of raising: the
13
+ # tty-reader raw mode disables the terminal's SIGINT generation, and
14
+ # external SIGINT/SIGTERM still go through the loop's signal path.
15
+ #
16
+ # Tests inject a pipe with the raw escape bytes — tty-reader's
17
+ # mode helpers no-op on non-tty inputs, so no real TTY is needed.
18
+ # tty-reader's unused echo stream is a StringIO (stdlib, required here).
19
+ class Input
20
+ # Poll timeout for readable input, seconds.
21
+ POLL_TIMEOUT = 0.1
22
+
23
+ KEY_BY_VALUE = {
24
+ "p" => :p, "r" => :r, "q" => :q,
25
+ "\u0003" => :ctrl_c,
26
+ "\e[A" => :up, "\eOA" => :up,
27
+ "\e[B" => :down, "\eOB" => :down,
28
+ "\e[5~" => :page_up,
29
+ "\e[6~" => :page_down,
30
+ "\e[H" => :home, "\e[1~" => :home, "\e[7~" => :home, "\eOH" => :home,
31
+ "\e[F" => :end, "\e[4~" => :end, "\e[8~" => :end, "\eOF" => :end
32
+ }.freeze
33
+
34
+ # The keyboard stream (exposed so the session can wrap it in raw mode).
35
+ attr_reader :stdin
36
+
37
+ # @param stdin [IO] the keyboard stream (a terminal when engaged)
38
+ # @param poll_timeout [Numeric] nil-poll interval for non-tty inputs
39
+ def initialize(stdin:, poll_timeout: POLL_TIMEOUT)
40
+ require "tty-reader"
41
+
42
+ @stdin = stdin
43
+ @poll_timeout = poll_timeout
44
+ @reader = TTY::Reader.new(input: stdin, output: StringIO.new,
45
+ interrupt: :noop, track_history: false)
46
+ end
47
+
48
+ # The next key, or nil when nothing was pressed in time.
49
+ #
50
+ # @return [Symbol, nil]
51
+ def next_key
52
+ return nil unless ready?
53
+
54
+ value = @reader.read_keypress(echo: false, raw: false, nonblock: false)
55
+ return nil if value.nil?
56
+
57
+ # tty-reader 0.9 only continues CSI (`\e[…`); SS3 (`\eO…`) stops
58
+ # after `\eO`. Pull the final byte so `\eOA` / `\eOH` / `\eOF` map.
59
+ if value == "\eO" && (final = ss3_final_byte)
60
+ value += final
61
+ end
62
+
63
+ KEY_BY_VALUE[value]
64
+ end
65
+
66
+ private
67
+
68
+ # Whether a key is available now (bounded wait). Real terminals use
69
+ # wait_readable; otherwise eof?.
70
+ def ready?
71
+ if @stdin.respond_to?(:wait_readable)
72
+ @stdin.wait_readable(@poll_timeout)
73
+ else
74
+ !@stdin.eof?
75
+ end
76
+ end
77
+
78
+ def ss3_final_byte
79
+ return nil unless byte_waiting?
80
+
81
+ @stdin.getc
82
+ end
83
+
84
+ def byte_waiting?
85
+ if @stdin.respond_to?(:wait_readable)
86
+ @stdin.wait_readable(0)
87
+ else
88
+ !@stdin.eof?
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # The combined, thread-safe log behind the TUI stream.
6
+ #
7
+ # The TUI uses one buffer for everything the agent produces: answer
8
+ # text deltas, tool lines, loop service messages — in arrival order,
9
+ # exactly what Letsdo::OutputStreamer emits. The streamer (main thread)
10
+ # appends via #write/#puts; the TUI input thread reads a snapshot via
11
+ # #lines for rendering. Both operations are mutex-protected.
12
+ #
13
+ # Lines are capped (oldest dropped); a partial last line (the agent's
14
+ # in-progress text) is kept separately so rendering can show the live
15
+ # tail. #divider appends a run-boundary separator. A monotonically
16
+ # increasing version lets the renderer skip repaints when nothing new
17
+ # arrived.
18
+ class LogBuffer
19
+ # Default maximum number of complete lines kept in the buffer.
20
+ MAX_LINES = 2_000
21
+ # The run-boundary separator line.
22
+ DIVIDER = "─" * 40
23
+
24
+ # @param max_lines [Integer] maximum number of complete lines kept;
25
+ # older lines are dropped
26
+ def initialize(max_lines: MAX_LINES)
27
+ @mutex = Mutex.new
28
+ @lines = []
29
+ @pending = +""
30
+ @max_lines = max_lines
31
+ @version = 0
32
+ end
33
+
34
+ # Appends raw text (IO-compatible, used by Letsdo::OutputStreamer).
35
+ #
36
+ # @param text [String] the next chunk of the stream
37
+ def write(text)
38
+ return if text.nil? || text.empty?
39
+
40
+ @mutex.synchronize { append(text) }
41
+ self
42
+ end
43
+
44
+ # Appends a complete line with a trailing newline (IO-compatible,
45
+ # used by Letsdo::AgentLoop service messages).
46
+ #
47
+ # @param text [String, nil] the line or nil for an empty line
48
+ def puts(text = nil)
49
+ write(text.nil? ? "\n" : "#{text}\n")
50
+ self
51
+ end
52
+
53
+ # IO-compatible no-op: an in-memory buffer never needs flushing.
54
+ def flush
55
+ self
56
+ end
57
+
58
+ # Appends a run-boundary divider line (skipped when the buffer is
59
+ # still empty — there is no boundary yet).
60
+ def divider
61
+ @mutex.synchronize do
62
+ @lines << DIVIDER unless @lines.empty? && @pending.empty?
63
+ @version += 1
64
+ end
65
+ self
66
+ end
67
+
68
+ # The current buffer content for rendering: complete lines plus the
69
+ # pending partial line, and the current version.
70
+ #
71
+ # @return [Array(Array<String>, Integer)] lines and version
72
+ def lines
73
+ @mutex.synchronize do
74
+ snapshot = @lines.dup
75
+ snapshot << @pending.dup unless @pending.empty?
76
+ [snapshot, @version]
77
+ end
78
+ end
79
+
80
+ # Monotonic change counter. The input thread compares this to the
81
+ # last rendered version so it can skip repaints when the log is idle.
82
+ #
83
+ # @return [Integer]
84
+ def version
85
+ @mutex.synchronize { @version }
86
+ end
87
+
88
+ private
89
+
90
+ # Splits the raw chunk on newlines: everything before the last
91
+ # newline becomes complete lines, the trailing fragment stays
92
+ # pending. The pending fragment is moved to the completed list as
93
+ # soon as a newline arrives, so line order is preserved.
94
+ def append(text)
95
+ chunks = text.split("\n", -1)
96
+ @pending << chunks.shift
97
+ chunks.each do |chunk|
98
+ @lines << @pending
99
+ @pending = chunk
100
+ end
101
+ trim_lines
102
+ @version += 1
103
+ end
104
+
105
+ def trim_lines
106
+ excess = @lines.length - @max_lines
107
+ @lines.shift(excess) if excess.positive?
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # The metrics facade for the TUI header, fed by the loop driver.
6
+ #
7
+ # Data sources (TASK-38 architecture survey):
8
+ # done - number of completed agent runs in this session,
9
+ # counted from the loop's run_finished events;
10
+ # left - latest open-task count from the backlog provider
11
+ # (the loop reports it per iteration; 'r' forces an
12
+ # immediate re-query);
13
+ # current task - the task the agent is running now, started by
14
+ # run_started and elapsed on a monotonic clock;
15
+ # session timer - monotonic time since this facade was created;
16
+ # identity - agent name and assignee handle (the header's
17
+ # "name (@handle)" line).
18
+ #
19
+ # All state changes happen on the loop thread; snapshots are read by
20
+ # the TUI input thread while the loop runs — hence the mutex. The
21
+ # facade is deliberately generic: Letsdo::Loop stays untouched, the
22
+ # events come from Letsdo::AgentLoop (wrapped_provider/wrapped_run).
23
+ #
24
+ # An optional on_run_start hook lets the caller (CLI) mark run
25
+ # boundaries in the log when a run starts.
26
+ class Metrics
27
+ # An immutable snapshot of the header metrics at some moment.
28
+ Snapshot = Struct.new(:name, :handle, :done, :left, :session_seconds,
29
+ :current_task, :current_task_seconds, keyword_init: true)
30
+
31
+ # @param name [String] agent name (CLI argument)
32
+ # @param handle [String] assignee handle (e.g. "@developer")
33
+ # @param clock [Proc] monotonic clock, callable → seconds; injected
34
+ # in tests
35
+ # @param on_run_start [Proc, nil] called with the task label when a
36
+ # run starts (the CLI uses it to insert a log divider)
37
+ def initialize(name:, handle:, clock: nil, on_run_start: nil)
38
+ @name = name
39
+ @handle = handle
40
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
41
+ @on_run_start = on_run_start
42
+ @mutex = Mutex.new
43
+ @done = 0
44
+ @left = nil
45
+ @current_task = nil
46
+ @current_started = nil
47
+ @session_started = @clock.call
48
+ end
49
+
50
+ # An agent run started for a task: records the task label and the
51
+ # monotonic run start time.
52
+ #
53
+ # @param task [String] task label
54
+ def run_started(task)
55
+ @on_run_start&.call(task)
56
+ @mutex.synchronize do
57
+ @current_task = task
58
+ @current_started = @clock.call
59
+ end
60
+ end
61
+
62
+ # An agent run finished: increments the done counter and clears the
63
+ # current-task state.
64
+ def run_finished
65
+ @mutex.synchronize do
66
+ @done += 1
67
+ @current_task = nil
68
+ @current_started = nil
69
+ end
70
+ end
71
+
72
+ # The latest open-task count from the backlog provider.
73
+ #
74
+ # @param count [Integer, nil] number of open tasks; nil = the backlog
75
+ # state is unreadable
76
+ def provider_result(count)
77
+ @mutex.synchronize { @left = count }
78
+ end
79
+
80
+ # A point-in-time snapshot of all header metrics.
81
+ #
82
+ # @return [Snapshot]
83
+ def snapshot
84
+ now = @clock.call
85
+ @mutex.synchronize do
86
+ current_seconds = @current_started ? now - @current_started : nil
87
+ Snapshot.new(
88
+ name: @name, handle: @handle, done: @done, left: @left,
89
+ session_seconds: now - @session_started,
90
+ current_task: @current_task,
91
+ current_task_seconds: current_seconds
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end