letsdo 0.1.0 → 0.2.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.
@@ -1,260 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
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.
6
+ # Runs pi in --mode json and hands events to the output streamer.
28
7
  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"
8
+ COMMAND = 'pi'
9
+ MODE = 'json'
10
+ MESSAGE_UPDATE = 'message_update'
11
+ TOOL_EXECUTION_START = 'tool_execution_start'
12
+ TOOL_EXECUTION_END = 'tool_execution_end'
13
+ AGENT_END = 'agent_end'
14
+ end
15
+ end
16
+
17
+ require_relative 'pi_runner/events'
18
+ require_relative 'pi_runner/process'
35
19
 
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)
20
+ module Letsdo
21
+ # Pi process spawn, stream drain, and pause/resume signals.
22
+ class PiRunner
23
+ include PiRunnerEvents
24
+ include PiRunnerProcess
25
+
26
+ def initialize(prompt:, streamer:, **opts)
42
27
  @prompt = prompt
43
- @flags = flags
44
28
  @streamer = streamer
45
- @command = command
29
+ @flags = opts.fetch(:flags, [])
30
+ @command = opts.fetch(:command, COMMAND)
46
31
  @pending_tools = {}
47
- @debug = debug.nil? ? ENV["LETSDO_DEBUG"] == "1" : debug
32
+ @reaped_status = nil
33
+ @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
48
34
  end
49
35
 
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
36
  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)
37
+ out_r = spawn_pi
38
+ drain_stream(out_r)
39
+ finish_run
97
40
  ensure
98
41
  @pid = nil
42
+ @reaped_status = nil
99
43
  end
100
44
 
101
45
  def debug(message)
102
46
  warn("[letsdo] pi: #{message}") if @debug
103
47
  end
104
48
 
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
49
  def terminate_now
109
50
  pid = @pid
110
- send_signal("TERM", pid) if pid
51
+ send_signal('TERM', pid) if pid
111
52
  end
112
53
 
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)
54
+ def pause
120
55
  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
56
+ send_signal('STOP', pid) if pid
165
57
  end
166
58
 
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
59
+ def resume
60
+ pid = @pid
61
+ send_signal('CONT', pid) if pid
215
62
  end
216
63
 
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
64
+ private
225
65
 
226
- # Ignores lines that are not valid JSON events.
227
- def parse_event(line)
228
- JSON.parse(line)
229
- rescue JSON::ParserError
230
- nil
66
+ def spawn_pi
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
+ out_r
231
73
  end
232
74
 
233
- def wait_status(pid)
234
- _, status = Process.wait2(pid)
235
- status
75
+ def drain_stream(out_r)
76
+ read_pi_stream(out_r)
77
+ rescue Letsdo::Stopped
78
+ debug('stopped by signal, terminating pi')
79
+ terminate
80
+ wait_status(@pid)
81
+ @streamer.finish
82
+ raise
83
+ ensure
84
+ close_pipe(out_r)
85
+ flush_pending_tools
236
86
  end
237
87
 
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
88
+ def close_pipe(out_r)
89
+ out_r.close
90
+ rescue IOError
243
91
  nil
244
92
  end
245
93
 
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
94
+ def finish_run
95
+ status = wait_status(@pid)
96
+ @streamer.finish
97
+ debug("exit status=#{status.inspect} code=#{exit_code(status)}")
98
+ exit_code(status)
258
99
  end
259
100
  end
260
- end
101
+ end
@@ -1,10 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+
3
5
  module Letsdo
4
6
  # Access to agent prompts: the agents/<name>.md directory in the project
5
7
  # root. A new agent = a new agents/<name>.md file, no code changes needed.
6
8
  class PromptStore
7
- AGENTS_DIR = "agents"
9
+ AGENTS_DIR = 'agents'
10
+
11
+ # Whether a name may be used as an agent prompt file name. Refuses
12
+ # path separators (no writes outside agents/ via traversal) and the
13
+ # dot names. Shared by create_agent and the CLI so both agree.
14
+ #
15
+ # @param name [String] agent name
16
+ # @return [Boolean]
17
+ def self.unsafe_name?(name)
18
+ name == '.' || name == '..' || name.match?(%r{[/\\]})
19
+ end
8
20
 
9
21
  # @param root [String] project root (agents/ lives there)
10
22
  def initialize(root:)
@@ -15,25 +27,52 @@ module Letsdo
15
27
  #
16
28
  # @return [Array<String>]
17
29
  def list
18
- Dir.glob(File.join(agents_dir, "*.md")).sort.map { |path| File.basename(path, ".md") }
30
+ Dir.glob(File.join(agents_dir, '*.md')).sort.map { |path| File.basename(path, '.md') }
19
31
  end
20
32
 
21
- # Reads an agent prompt.
33
+ # Reads an agent prompt. There are no unknown agents: when
34
+ # agents/<name>.md is missing, the caller falls back to the built-in
35
+ # default prompt (Letsdo::DefaultPrompt) and announces it (see CLI).
22
36
  #
23
37
  # @param name [String] agent name
24
- # @return [String] contents of agents/<name>.md
25
- # @raise [UnknownAgentError] if there is no such agent
38
+ # @return [String, nil] contents of agents/<name>.md, nil when missing
26
39
  def read(name)
27
- path = File.join(agents_dir, "#{name}.md")
28
- raise UnknownAgentError, name unless File.file?(path)
40
+ path = agent_path(name)
41
+ return nil unless File.file?(path)
29
42
 
30
43
  File.read(path)
31
44
  end
32
45
 
46
+ # Absolute path of an agent's prompt file, whether or not it exists.
47
+ #
48
+ # @param name [String] agent name
49
+ # @return [String] <root>/agents/<name>.md (resolved to an absolute path)
50
+ def agent_path(name)
51
+ File.expand_path(File.join(agents_dir, "#{name}.md"))
52
+ end
53
+
54
+ # Creates agents/<name>.md with the given content. Never raises and
55
+ # never writes anything when the file already exists or the name is
56
+ # unsafe (contains "/" or "\\", or is "."/"..") — returns false in
57
+ # both cases. Otherwise mkdir_p the agents/ dir, writes the content
58
+ # and returns true. Creation always stays inside agents/.
59
+ #
60
+ # @param name [String] agent name (also the would-be file name)
61
+ # @param content [String] prompt text to write
62
+ # @return [Boolean] true when the file was created, false otherwise
63
+ def create_agent(name, content)
64
+ return false if self.class.unsafe_name?(name)
65
+ return false if File.exist?(agent_path(name))
66
+
67
+ FileUtils.mkdir_p(agents_dir)
68
+ File.write(agent_path(name), content)
69
+ true
70
+ end
71
+
33
72
  private
34
73
 
35
74
  def agents_dir
36
75
  File.join(@root, AGENTS_DIR)
37
76
  end
38
77
  end
39
- end
78
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
3
+ require 'stringio'
4
4
 
5
5
  module Letsdo
6
6
  module Tui
@@ -21,7 +21,7 @@ module Letsdo
21
21
  POLL_TIMEOUT = 0.1
22
22
 
23
23
  KEY_BY_VALUE = {
24
- "p" => :p, "r" => :r, "q" => :q,
24
+ 'p' => :p, 'r' => :r, 'q' => :q,
25
25
  "\u0003" => :ctrl_c,
26
26
  "\e[A" => :up, "\eOA" => :up,
27
27
  "\e[B" => :down, "\eOB" => :down,
@@ -37,7 +37,7 @@ module Letsdo
37
37
  # @param stdin [IO] the keyboard stream (a terminal when engaged)
38
38
  # @param poll_timeout [Numeric] nil-poll interval for non-tty inputs
39
39
  def initialize(stdin:, poll_timeout: POLL_TIMEOUT)
40
- require "tty-reader"
40
+ require 'tty-reader'
41
41
 
42
42
  @stdin = stdin
43
43
  @poll_timeout = poll_timeout
@@ -90,4 +90,4 @@ module Letsdo
90
90
  end
91
91
  end
92
92
  end
93
- end
93
+ end
@@ -19,14 +19,14 @@ module Letsdo
19
19
  # Default maximum number of complete lines kept in the buffer.
20
20
  MAX_LINES = 2_000
21
21
  # The run-boundary separator line.
22
- DIVIDER = "" * 40
22
+ DIVIDER = '' * 40
23
23
 
24
24
  # @param max_lines [Integer] maximum number of complete lines kept;
25
25
  # older lines are dropped
26
26
  def initialize(max_lines: MAX_LINES)
27
27
  @mutex = Mutex.new
28
28
  @lines = []
29
- @pending = +""
29
+ @pending = +''
30
30
  @max_lines = max_lines
31
31
  @version = 0
32
32
  end
@@ -108,4 +108,4 @@ module Letsdo
108
108
  end
109
109
  end
110
110
  end
111
- end
111
+ end
@@ -94,4 +94,4 @@ module Letsdo
94
94
  end
95
95
  end
96
96
  end
97
- end
97
+ end